mirror of
https://github.com/wassname/talk.git
synced 2026-07-22 13:00:29 +08:00
[CORL-687] Webhooks (#2738)
* feat: initial webhook impl * feat: added support for key rotation * feat: harmonized fetcher * feat: added expired secrets cleaning * feat: event system refactor * feat: added story event * feat: simplfiied webhook handler * feat: added ref's to locations where user events can be added * feat: added UI to support webhooks * fix: renaming some Webhook -> WebhookEndpoint * fix: review comments to adjuist flow * feat: added localizations * fix: linting, updated snapshots * fix: adapted for new fluent * fix: rearranged folders * fix: linting * feat: added webhooks documentation * feat: improved toc generation * feat: added some tests to webhooks * fix: chain transition hooks * feat: added tests around webhook ui * fix: renamed events * fix: adjusted circle markdown linting * fix: adjusted doctoc script call * review: review fixes * review: review comments * review: adjusted signing secret confirmation * review: adjusted styles to harmonize button usage * fix: updated snapshots and tests * review: move form out of webhooks Moved the form out of the webhooks by relocating the layout used for the route associated with the configure routes. * fix: fixed bugs and snapshots with tests * feat: revised slack message format to use block api * fix: fixed a small text bug Co-authored-by: Vinh <vinh@vinh.tech> Co-authored-by: Kim Gardner <kgardnr@gmail.com>
This commit is contained in:
co-authored by
Vinh
Kim Gardner
parent
34ba2da88d
commit
e42c2b925d
@@ -17,7 +17,7 @@ export type GraphMiddlewareOptions = Pick<
|
||||
| "pubsub"
|
||||
| "tenantCache"
|
||||
| "metrics"
|
||||
| "notifierQueue"
|
||||
| "broker"
|
||||
>;
|
||||
|
||||
export const graphQLHandler = ({
|
||||
|
||||
@@ -15,9 +15,9 @@ import { HTMLErrorHandler } from "coral-server/app/middleware/error";
|
||||
import { notFoundMiddleware } from "coral-server/app/middleware/notFound";
|
||||
import { createPassport } from "coral-server/app/middleware/passport";
|
||||
import { Config } from "coral-server/config";
|
||||
import CoralEventListenerBroker from "coral-server/events/publisher";
|
||||
import logger from "coral-server/logger";
|
||||
import { MailerQueue } from "coral-server/queue/tasks/mailer";
|
||||
import { NotifierQueue } from "coral-server/queue/tasks/notifier";
|
||||
import { ScraperQueue } from "coral-server/queue/tasks/scraper";
|
||||
import { I18n } from "coral-server/services/i18n";
|
||||
import { JWTSigningConfig } from "coral-server/services/jwt";
|
||||
@@ -41,7 +41,6 @@ export interface AppOptions {
|
||||
mailerQueue: MailerQueue;
|
||||
metrics?: Metrics;
|
||||
mongo: Db;
|
||||
notifierQueue: NotifierQueue;
|
||||
parent: Express;
|
||||
persistedQueriesRequired: boolean;
|
||||
persistedQueryCache: PersistedQueryCache;
|
||||
@@ -52,6 +51,7 @@ export interface AppOptions {
|
||||
signingConfig: JWTSigningConfig;
|
||||
tenantCache: TenantCache;
|
||||
migrationManager: MigrationManager;
|
||||
broker: CoralEventListenerBroker;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import fetch from "node-fetch";
|
||||
import { URL } from "url";
|
||||
|
||||
import { ensureNoEndSlash } from "coral-common/utils";
|
||||
import { createFetch } from "coral-server/services/fetch";
|
||||
|
||||
/**
|
||||
* Configuration that Coral is expecting.
|
||||
@@ -25,6 +25,12 @@ interface DiscoveryRawConfiguration {
|
||||
jwks_uri: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* fetch provides a single source for managing the fetching operations for
|
||||
* discovery.
|
||||
*/
|
||||
const fetch = createFetch({ name: "OIDC" });
|
||||
|
||||
/**
|
||||
* discover will discover the configuration for the issuer.
|
||||
*
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Db } from "mongodb";
|
||||
|
||||
import { validate } from "coral-server/app/request/body";
|
||||
import { IntegrationDisabled, TokenInvalidError } from "coral-server/errors";
|
||||
import { SSOAuthIntegration, SSOKey } from "coral-server/models/settings";
|
||||
import { Secret, SSOAuthIntegration } from "coral-server/models/settings";
|
||||
import { Tenant } from "coral-server/models/tenant";
|
||||
import {
|
||||
retrieveUserWithProfile,
|
||||
@@ -169,7 +169,7 @@ export function getRelevantSSOKeys(
|
||||
tokenString: string,
|
||||
now: Date,
|
||||
kid?: string
|
||||
): SSOKey[] {
|
||||
): Secret[] {
|
||||
// Collect all the current valid keys.
|
||||
const keys = integration.keys.filter(k => {
|
||||
if (k.inactiveAt && now >= k.inactiveAt) {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# events
|
||||
|
||||
This is the events package for Coral.
|
||||
|
||||
## Adding new events
|
||||
|
||||
You can add new events by adding to the `events.ts` file. Each event must export
|
||||
a `{ eventName }Payload` type and a `{ eventName }` Coral Event.
|
||||
|
||||
## Adding new event listeners
|
||||
|
||||
You can add a new event listener by adding to the `listeners/` folder. These
|
||||
events must implement the `CoralEventListener` abstract class. You can then
|
||||
register this listener in the `src/core/server/index.ts` file by registering
|
||||
it on the broker.
|
||||
@@ -0,0 +1,51 @@
|
||||
import uuid from "uuid/v4";
|
||||
|
||||
import logger from "coral-server/logger";
|
||||
|
||||
import { CoralEventPublisherBroker } from "./publisher";
|
||||
import { CoralEventType } from "./types";
|
||||
|
||||
export interface CoralEventPayload<
|
||||
T extends CoralEventType = CoralEventType,
|
||||
U extends {} = {}
|
||||
> {
|
||||
/**
|
||||
* id is the identifier for this specific event. Every copy of this unique
|
||||
* event will share the same identifier.
|
||||
*/
|
||||
readonly id: string;
|
||||
|
||||
/**
|
||||
* type identifies this particular event.
|
||||
*/
|
||||
readonly type: T;
|
||||
|
||||
/**
|
||||
* data stores the underlying content of the event.
|
||||
*/
|
||||
readonly data: Readonly<U>;
|
||||
|
||||
/**
|
||||
* createdAt is the date that this event was published at.
|
||||
*/
|
||||
readonly createdAt: Date;
|
||||
}
|
||||
|
||||
export function createCoralEvent<T extends CoralEventPayload>(type: T["type"]) {
|
||||
return {
|
||||
publish: async (broker: CoralEventPublisherBroker, data: T["data"]) => {
|
||||
const event: CoralEventPayload = {
|
||||
id: uuid(),
|
||||
createdAt: new Date(),
|
||||
data,
|
||||
type,
|
||||
};
|
||||
|
||||
logger.trace(
|
||||
{ eventType: event.type, eventID: event.id },
|
||||
"publishing event"
|
||||
);
|
||||
await broker.emit(event);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
CommentCreatedInput,
|
||||
CommentEnteredModerationQueueInput,
|
||||
CommentFeaturedInput,
|
||||
CommentLeftModerationQueueInput,
|
||||
CommentReleasedInput,
|
||||
CommentReplyCreatedInput,
|
||||
CommentStatusUpdatedInput,
|
||||
} from "coral-server/graph/resolvers/Subscription";
|
||||
|
||||
import { CoralEventPayload, createCoralEvent } from "./event";
|
||||
import { CoralEventType } from "./types";
|
||||
|
||||
export type CommentEnteredModerationQueueCoralEventPayload = CoralEventPayload<
|
||||
CoralEventType.COMMENT_ENTERED_MODERATION_QUEUE,
|
||||
CommentEnteredModerationQueueInput
|
||||
>;
|
||||
|
||||
export const CommentEnteredModerationQueueCoralEvent = createCoralEvent<
|
||||
CommentEnteredModerationQueueCoralEventPayload
|
||||
>(CoralEventType.COMMENT_ENTERED_MODERATION_QUEUE);
|
||||
|
||||
export type CommentLeftModerationQueueCoralEventPayload = CoralEventPayload<
|
||||
CoralEventType.COMMENT_LEFT_MODERATION_QUEUE,
|
||||
CommentLeftModerationQueueInput
|
||||
>;
|
||||
|
||||
export const CommentLeftModerationQueueCoralEvent = createCoralEvent<
|
||||
CommentLeftModerationQueueCoralEventPayload
|
||||
>(CoralEventType.COMMENT_LEFT_MODERATION_QUEUE);
|
||||
|
||||
export type CommentStatusUpdatedCoralEventPayload = CoralEventPayload<
|
||||
CoralEventType.COMMENT_STATUS_UPDATED,
|
||||
CommentStatusUpdatedInput
|
||||
>;
|
||||
|
||||
export const CommentStatusUpdatedCoralEvent = createCoralEvent<
|
||||
CommentStatusUpdatedCoralEventPayload
|
||||
>(CoralEventType.COMMENT_STATUS_UPDATED);
|
||||
|
||||
export type CommentReplyCreatedCoralEventPayload = CoralEventPayload<
|
||||
CoralEventType.COMMENT_REPLY_CREATED,
|
||||
CommentReplyCreatedInput
|
||||
>;
|
||||
|
||||
export const CommentReplyCreatedCoralEvent = createCoralEvent<
|
||||
CommentReplyCreatedCoralEventPayload
|
||||
>(CoralEventType.COMMENT_REPLY_CREATED);
|
||||
|
||||
export type CommentCreatedCoralEventPayload = CoralEventPayload<
|
||||
CoralEventType.COMMENT_CREATED,
|
||||
CommentCreatedInput
|
||||
>;
|
||||
|
||||
export const CommentCreatedCoralEvent = createCoralEvent<
|
||||
CommentCreatedCoralEventPayload
|
||||
>(CoralEventType.COMMENT_CREATED);
|
||||
|
||||
export type CommentFeaturedCoralEventPayload = CoralEventPayload<
|
||||
CoralEventType.COMMENT_FEATURED,
|
||||
CommentFeaturedInput
|
||||
>;
|
||||
|
||||
export const CommentFeaturedCoralEvent = createCoralEvent<
|
||||
CommentFeaturedCoralEventPayload
|
||||
>(CoralEventType.COMMENT_FEATURED);
|
||||
|
||||
export type CommentReleasedCoralEventPayload = CoralEventPayload<
|
||||
CoralEventType.COMMENT_RELEASED,
|
||||
CommentReleasedInput
|
||||
>;
|
||||
|
||||
export const CommentReleasedCoralEvent = createCoralEvent<
|
||||
CommentReleasedCoralEventPayload
|
||||
>(CoralEventType.COMMENT_RELEASED);
|
||||
|
||||
export type StoryCreatedCoralEventPayload = CoralEventPayload<
|
||||
CoralEventType.STORY_CREATED,
|
||||
{
|
||||
storyID: string;
|
||||
storyURL: string;
|
||||
}
|
||||
>;
|
||||
|
||||
export const StoryCreatedCoralEvent = createCoralEvent<
|
||||
StoryCreatedCoralEventPayload
|
||||
>(CoralEventType.STORY_CREATED);
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./types";
|
||||
export * from "./events";
|
||||
@@ -0,0 +1,49 @@
|
||||
import { NotifierQueue } from "coral-server/queue/tasks/notifier";
|
||||
import { categories } from "coral-server/services/notifications/categories";
|
||||
|
||||
import {
|
||||
CommentFeaturedCoralEventPayload,
|
||||
CommentReplyCreatedCoralEventPayload,
|
||||
CommentStatusUpdatedCoralEventPayload,
|
||||
} from "../events";
|
||||
import { CoralEventListener, CoralEventPublisherFactory } from "../publisher";
|
||||
import { CoralEventType } from "../types";
|
||||
|
||||
export type NotifierCoralEventListenerPayloads =
|
||||
| CommentFeaturedCoralEventPayload
|
||||
| CommentStatusUpdatedCoralEventPayload
|
||||
| CommentReplyCreatedCoralEventPayload;
|
||||
|
||||
export class NotifierCoralEventListener
|
||||
implements CoralEventListener<NotifierCoralEventListenerPayloads> {
|
||||
public readonly name = "notifier";
|
||||
|
||||
private readonly queue: NotifierQueue;
|
||||
|
||||
constructor(queue: NotifierQueue) {
|
||||
this.queue = queue;
|
||||
}
|
||||
|
||||
/**
|
||||
* events are the events that this listener handles. These are parsed from the
|
||||
* notification categories.
|
||||
*/
|
||||
public readonly events = categories.reduce(
|
||||
(events, category) => {
|
||||
for (const event of category.events) {
|
||||
if (!events.includes(event)) {
|
||||
events.push(event);
|
||||
}
|
||||
}
|
||||
|
||||
return events;
|
||||
},
|
||||
[] as CoralEventType[]
|
||||
);
|
||||
|
||||
public initialize: CoralEventPublisherFactory<
|
||||
NotifierCoralEventListenerPayloads
|
||||
> = ({ tenant: { id } }) => async input => {
|
||||
await this.queue.add({ tenantID: id, input });
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import striptags from "striptags";
|
||||
|
||||
import { reconstructTenantURL } from "coral-server/app/url";
|
||||
import GraphContext from "coral-server/graph/context";
|
||||
import logger from "coral-server/logger";
|
||||
import { getLatestRevision } from "coral-server/models/comment";
|
||||
import { getStoryTitle, getURLWithCommentID } from "coral-server/models/story";
|
||||
import { createFetch } from "coral-server/services/fetch";
|
||||
|
||||
import { GQLMODERATION_QUEUE } from "coral-server/graph/schema/__generated__/types";
|
||||
|
||||
import {
|
||||
CommentEnteredModerationQueueCoralEventPayload,
|
||||
CommentFeaturedCoralEventPayload,
|
||||
} from "../events";
|
||||
import { CoralEventListener, CoralEventPublisherFactory } from "../publisher";
|
||||
import { CoralEventType } from "../types";
|
||||
|
||||
type SlackCoralEventListenerPayloads =
|
||||
| CommentFeaturedCoralEventPayload
|
||||
| CommentEnteredModerationQueueCoralEventPayload;
|
||||
|
||||
type Trigger = "reported" | "pending" | "featured";
|
||||
|
||||
export class SlackCoralEventListener
|
||||
implements CoralEventListener<SlackCoralEventListenerPayloads> {
|
||||
public readonly name = "slack";
|
||||
public readonly events = [
|
||||
CoralEventType.COMMENT_FEATURED,
|
||||
CoralEventType.COMMENT_ENTERED_MODERATION_QUEUE,
|
||||
];
|
||||
private readonly fetch = createFetch({ name: "slack" });
|
||||
|
||||
private payloadTrigger(
|
||||
payload: SlackCoralEventListenerPayloads
|
||||
): Trigger | null {
|
||||
switch (payload.type) {
|
||||
case CoralEventType.COMMENT_ENTERED_MODERATION_QUEUE:
|
||||
if (payload.data.queue === GQLMODERATION_QUEUE.REPORTED) {
|
||||
return "reported";
|
||||
} else if (payload.data.queue === GQLMODERATION_QUEUE.PENDING) {
|
||||
return "pending";
|
||||
}
|
||||
break;
|
||||
case CoralEventType.COMMENT_FEATURED:
|
||||
return "featured";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* postMessage will prepare and send the incoming Slack webhook.
|
||||
*
|
||||
* @param ctx context of the request
|
||||
* @param message the message prefix for the request
|
||||
* @param payload payload for the event that occurred
|
||||
* @param hookURL url to the Slack webhook that we should send the message to
|
||||
*/
|
||||
private async postMessage(
|
||||
{ loaders, config, tenant, req }: GraphContext,
|
||||
message: string,
|
||||
payload: SlackCoralEventListenerPayloads,
|
||||
hookURL: string
|
||||
) {
|
||||
// Get the comment.
|
||||
const comment = await loaders.Comments.comment.load(payload.data.commentID);
|
||||
if (!comment || !comment.authorID) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the story.
|
||||
const story = await loaders.Stories.story.load(payload.data.storyID);
|
||||
if (!story) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the author.
|
||||
const author = await loaders.Users.user.load(comment.authorID);
|
||||
if (!author) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get some properties about the event.
|
||||
const storyTitle = getStoryTitle(story);
|
||||
const moderateLink = reconstructTenantURL(
|
||||
config,
|
||||
tenant,
|
||||
req,
|
||||
`/admin/moderate/comment/${comment.id}`
|
||||
);
|
||||
const commentLink = getURLWithCommentID(story.url, comment.id);
|
||||
|
||||
// Replace HTML link breaks with newlines.
|
||||
const body = striptags(getLatestRevision(comment).body);
|
||||
|
||||
// Send the post to the Slack URL. We don't wrap this in a try/catch because
|
||||
// it's handled in the calling function.
|
||||
const res = await this.fetch(hookURL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
blocks: [
|
||||
{
|
||||
type: "section",
|
||||
text: {
|
||||
type: "mrkdwn",
|
||||
text: `${message} on *<${story.url}|${storyTitle}>*`,
|
||||
},
|
||||
},
|
||||
{ type: "divider" },
|
||||
{
|
||||
type: "section",
|
||||
text: {
|
||||
type: "plain_text",
|
||||
text: body,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "context",
|
||||
elements: [
|
||||
{
|
||||
type: "mrkdwn",
|
||||
text: `Authored by *${author.username}* | <${moderateLink}|Go to Moderation> | <${commentLink}|See Comment>`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{ type: "divider" },
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
// Check that the request was completed successfully.
|
||||
if (!res.ok) {
|
||||
throw new Error(`slack returned non-200 status code: ${res.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
private getMessage(trigger: Trigger): string {
|
||||
switch (trigger) {
|
||||
case "featured":
|
||||
return "This comment has been featured";
|
||||
case "pending":
|
||||
return "This comment is pending";
|
||||
case "reported":
|
||||
return "This comment has been reported";
|
||||
default:
|
||||
throw new Error("invalid trigger");
|
||||
}
|
||||
}
|
||||
|
||||
public initialize: CoralEventPublisherFactory<
|
||||
SlackCoralEventListenerPayloads
|
||||
> = ctx => async payload => {
|
||||
const {
|
||||
tenant: { id: tenantID, slack },
|
||||
} = ctx;
|
||||
|
||||
if (
|
||||
// If slack is not defined,
|
||||
!slack ||
|
||||
// Or there are no slack channels,
|
||||
slack.channels.length === 0 ||
|
||||
// Or each channel isn't enabled or configured right.
|
||||
slack.channels.every(c => !c.enabled || !c.hookURL)
|
||||
) {
|
||||
// Exit out then.
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the trigger that is associated with this payload.
|
||||
const trigger = this.payloadTrigger(payload);
|
||||
if (!trigger) {
|
||||
return;
|
||||
}
|
||||
|
||||
// For each channel that is enabled with configuration.
|
||||
for (const channel of slack.channels) {
|
||||
if (!channel.enabled || !channel.hookURL) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
// If featured comments are, and it's a featured comment,
|
||||
(channel.triggers.featuredComments && trigger === "featured") ||
|
||||
// Or reported comments are, and it's a reported comment,
|
||||
(channel.triggers.reportedComments && trigger === "reported") ||
|
||||
// Or pending comments are, and it's a pending comment,
|
||||
(channel.triggers.pendingComments && trigger === "pending")
|
||||
) {
|
||||
try {
|
||||
// Post the message to slack.
|
||||
await this.postMessage(
|
||||
ctx,
|
||||
this.getMessage(trigger),
|
||||
payload,
|
||||
channel.hookURL
|
||||
);
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
{ err, tenantID, payload, channel },
|
||||
"could not post the comment to slack"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { createSubscriptionChannelName } from "coral-server/graph/resolvers/Subscription/helpers";
|
||||
import { SUBSCRIPTION_CHANNELS } from "coral-server/graph/resolvers/Subscription/types";
|
||||
|
||||
import {
|
||||
CommentCreatedCoralEventPayload,
|
||||
CommentEnteredModerationQueueCoralEventPayload,
|
||||
CommentFeaturedCoralEventPayload,
|
||||
CommentLeftModerationQueueCoralEventPayload,
|
||||
CommentReleasedCoralEventPayload,
|
||||
CommentReplyCreatedCoralEventPayload,
|
||||
CommentStatusUpdatedCoralEventPayload,
|
||||
} from "../events";
|
||||
import { CoralEventListener, CoralEventPublisherFactory } from "../publisher";
|
||||
import { CoralEventType } from "../types";
|
||||
|
||||
type SubscriptionCoralEventListenerPayloads =
|
||||
| CommentEnteredModerationQueueCoralEventPayload
|
||||
| CommentLeftModerationQueueCoralEventPayload
|
||||
| CommentStatusUpdatedCoralEventPayload
|
||||
| CommentReplyCreatedCoralEventPayload
|
||||
| CommentCreatedCoralEventPayload
|
||||
| CommentFeaturedCoralEventPayload
|
||||
| CommentReleasedCoralEventPayload;
|
||||
|
||||
export class SubscriptionCoralEventListener
|
||||
implements CoralEventListener<SubscriptionCoralEventListenerPayloads> {
|
||||
public readonly name = "subscription";
|
||||
public readonly events = [
|
||||
CoralEventType.COMMENT_ENTERED_MODERATION_QUEUE,
|
||||
CoralEventType.COMMENT_LEFT_MODERATION_QUEUE,
|
||||
CoralEventType.COMMENT_STATUS_UPDATED,
|
||||
CoralEventType.COMMENT_REPLY_CREATED,
|
||||
CoralEventType.COMMENT_CREATED,
|
||||
CoralEventType.COMMENT_FEATURED,
|
||||
CoralEventType.COMMENT_RELEASED,
|
||||
];
|
||||
|
||||
private translate(
|
||||
type: SubscriptionCoralEventListenerPayloads["type"]
|
||||
): SUBSCRIPTION_CHANNELS {
|
||||
switch (type) {
|
||||
case CoralEventType.COMMENT_ENTERED_MODERATION_QUEUE:
|
||||
return SUBSCRIPTION_CHANNELS.COMMENT_ENTERED_MODERATION_QUEUE;
|
||||
case CoralEventType.COMMENT_LEFT_MODERATION_QUEUE:
|
||||
return SUBSCRIPTION_CHANNELS.COMMENT_LEFT_MODERATION_QUEUE;
|
||||
case CoralEventType.COMMENT_STATUS_UPDATED:
|
||||
return SUBSCRIPTION_CHANNELS.COMMENT_STATUS_UPDATED;
|
||||
case CoralEventType.COMMENT_REPLY_CREATED:
|
||||
return SUBSCRIPTION_CHANNELS.COMMENT_REPLY_CREATED;
|
||||
case CoralEventType.COMMENT_CREATED:
|
||||
return SUBSCRIPTION_CHANNELS.COMMENT_CREATED;
|
||||
case CoralEventType.COMMENT_FEATURED:
|
||||
return SUBSCRIPTION_CHANNELS.COMMENT_FEATURED;
|
||||
case CoralEventType.COMMENT_RELEASED:
|
||||
return SUBSCRIPTION_CHANNELS.COMMENT_RELEASED;
|
||||
}
|
||||
}
|
||||
|
||||
private trigger(
|
||||
tenantID: string,
|
||||
type: SubscriptionCoralEventListenerPayloads["type"]
|
||||
) {
|
||||
return createSubscriptionChannelName(tenantID, this.translate(type));
|
||||
}
|
||||
|
||||
public initialize: CoralEventPublisherFactory<
|
||||
SubscriptionCoralEventListenerPayloads
|
||||
> = ({ clientID, pubsub, tenant: { id } }) => async ({ type, data }) => {
|
||||
await pubsub.publish(this.trigger(id, type), {
|
||||
...data,
|
||||
clientID,
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import logger from "coral-server/logger";
|
||||
import { WebhookQueue } from "coral-server/queue/tasks/webhook";
|
||||
|
||||
import { GQLWEBHOOK_EVENT_NAME } from "coral-server/graph/schema/__generated__/types";
|
||||
|
||||
import { StoryCreatedCoralEventPayload } from "../events";
|
||||
import { CoralEventListener, CoralEventPublisherFactory } from "../publisher";
|
||||
import { CoralEventType } from "../types";
|
||||
|
||||
export type WebhookCoralEventListenerPayloads = StoryCreatedCoralEventPayload;
|
||||
|
||||
export class WebhookCoralEventListener
|
||||
implements CoralEventListener<WebhookCoralEventListenerPayloads> {
|
||||
public readonly name = "webhook";
|
||||
public readonly events = [CoralEventType.STORY_CREATED];
|
||||
|
||||
private readonly queue: WebhookQueue;
|
||||
|
||||
constructor(queue: WebhookQueue) {
|
||||
this.queue = queue;
|
||||
}
|
||||
|
||||
public initialize: CoralEventPublisherFactory<
|
||||
WebhookCoralEventListenerPayloads
|
||||
> = ({ id: contextID, tenant }) => async event => {
|
||||
const log = logger.child(
|
||||
{
|
||||
tenantID: tenant.id,
|
||||
contextID,
|
||||
eventType: event.type,
|
||||
},
|
||||
true
|
||||
);
|
||||
|
||||
// Based on the incoming event, determine which endpoints we should send.
|
||||
const endpoints = tenant.webhooks.endpoints.filter(endpoint => {
|
||||
// If the endpoint is disabled, don't include it.
|
||||
if (!endpoint.enabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If all notifications have been enabled for this endpoint, include it.
|
||||
if (endpoint.all) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If this event name is specifically listed, include it. We have to do
|
||||
// some nasty casting here to address the fact that the types don't
|
||||
// technically overlap.
|
||||
if (
|
||||
endpoint.events.includes(
|
||||
(event.type as unknown) as GQLWEBHOOK_EVENT_NAME
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
// Log some important details.
|
||||
if (endpoints.length === 0) {
|
||||
log.debug("no endpoints matched for event");
|
||||
return;
|
||||
}
|
||||
|
||||
log.debug(
|
||||
{ endpoints: endpoints.length },
|
||||
"matched endpoints that will receive event"
|
||||
);
|
||||
|
||||
// For each of these endpoints that need a delivery of these notifications,
|
||||
// queue up the job that will send it.
|
||||
await Promise.all(
|
||||
endpoints.map(endpoint =>
|
||||
this.queue.add({
|
||||
tenantID: tenant.id,
|
||||
contextID,
|
||||
endpointID: endpoint.id,
|
||||
event,
|
||||
})
|
||||
)
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
/* eslint-disable max-classes-per-file */
|
||||
|
||||
import GraphContext from "coral-server/graph/context";
|
||||
import logger from "coral-server/logger";
|
||||
|
||||
import { CoralEventPayload } from "./event";
|
||||
import { CoralEventType } from "./types";
|
||||
|
||||
export type CoralEventPublisher<T extends CoralEventPayload = any> = (
|
||||
payload: T
|
||||
) => Promise<void>;
|
||||
|
||||
export type CoralEventPublisherFactory<T extends CoralEventPayload = any> = (
|
||||
ctx: GraphContext
|
||||
) => CoralEventPublisher<T>;
|
||||
|
||||
export abstract class CoralEventListener<T extends CoralEventPayload = any> {
|
||||
/**
|
||||
* name is the name of the listener used for identification in logs.
|
||||
*/
|
||||
public abstract readonly name: string;
|
||||
|
||||
/**
|
||||
* events is the array of event types that this listener should listen for.
|
||||
*/
|
||||
public abstract readonly events: CoralEventType[];
|
||||
|
||||
/**
|
||||
* initialize is a function that when
|
||||
*/
|
||||
public abstract initialize: CoralEventPublisherFactory<T>;
|
||||
}
|
||||
|
||||
export class CoralEventPublisherBroker {
|
||||
private readonly ctx: GraphContext;
|
||||
private readonly events: Set<CoralEventType>;
|
||||
private readonly listeners: CoralEventListener[];
|
||||
private registry?: Map<CoralEventType, CoralEventPublisher[]>;
|
||||
|
||||
constructor(
|
||||
ctx: GraphContext,
|
||||
events: Set<CoralEventType>,
|
||||
listeners: CoralEventListener[]
|
||||
) {
|
||||
this.ctx = ctx;
|
||||
this.events = events;
|
||||
this.listeners = listeners;
|
||||
}
|
||||
|
||||
private initialize() {
|
||||
const registry = new Map<CoralEventType, CoralEventPublisher[]>();
|
||||
|
||||
// Iterate over the listeners to initialize them.
|
||||
for (const listener of this.listeners) {
|
||||
// Initialize this listener.
|
||||
const publisher = listener.initialize(this.ctx);
|
||||
|
||||
// Associate the publisher with each of the events.
|
||||
for (const event of listener.events) {
|
||||
// Get the current publishers associated with this event.
|
||||
const publishers = registry.get(event) || [];
|
||||
|
||||
// Add this publisher to the array.
|
||||
publishers.push(publisher);
|
||||
|
||||
// Update this item in the registry.
|
||||
registry.set(event, publishers);
|
||||
}
|
||||
}
|
||||
|
||||
return registry;
|
||||
}
|
||||
|
||||
public emit = (payload: CoralEventPayload) => {
|
||||
// Check to see if this event is even registered.
|
||||
if (!this.events.has(payload.type)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Lazily create the registry.
|
||||
if (!this.registry) {
|
||||
this.registry = this.initialize();
|
||||
}
|
||||
|
||||
// Get the current publishers for this event. We can assert that this is
|
||||
// found because the event was checked in the above events set. If the event
|
||||
// did not exist in the events set, then it does not have an associated
|
||||
// registry entry.
|
||||
const publishers = this.registry.get(payload.type)!;
|
||||
|
||||
// Begin resolving these publishers.
|
||||
return Promise.all(publishers.map(publisher => publisher(payload)));
|
||||
};
|
||||
}
|
||||
|
||||
export default class CoralEventListenerBroker {
|
||||
private readonly events = new Set<CoralEventType>();
|
||||
private readonly listeners: CoralEventListener[] = [];
|
||||
|
||||
public instance = (ctx: GraphContext) =>
|
||||
new CoralEventPublisherBroker(ctx, this.events, this.listeners);
|
||||
|
||||
public register(listener: CoralEventListener) {
|
||||
if (listener.events.length === 0) {
|
||||
logger.warn(
|
||||
{ listenerName: listener.name },
|
||||
"listener was registered without any events"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.trace(
|
||||
{ listenerName: listener.name, listenerEvents: listener.events },
|
||||
"registering listener for events"
|
||||
);
|
||||
|
||||
// Add this listener to this listener set.
|
||||
this.listeners.push(listener);
|
||||
|
||||
// Add each event to the set of registered events.
|
||||
for (const event of listener.events) {
|
||||
this.events.add(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export enum CoralEventType {
|
||||
COMMENT_ENTERED_MODERATION_QUEUE = "COMMENT_ENTERED_MODERATION_QUEUE",
|
||||
COMMENT_LEFT_MODERATION_QUEUE = "COMMENT_LEFT_MODERATION_QUEUE",
|
||||
COMMENT_STATUS_UPDATED = "COMMENT_STATUS_UPDATED",
|
||||
COMMENT_REPLY_CREATED = "COMMENT_REPLY_CREATED",
|
||||
COMMENT_CREATED = "COMMENT_CREATED",
|
||||
COMMENT_FEATURED = "COMMENT_FEATURED",
|
||||
COMMENT_RELEASED = "COMMENT_RELEASED",
|
||||
STORY_CREATED = "STORY_CREATED",
|
||||
}
|
||||
@@ -4,21 +4,18 @@ import uuid from "uuid";
|
||||
|
||||
import { LanguageCode } from "coral-common/helpers/i18n/locales";
|
||||
import { Config } from "coral-server/config";
|
||||
import {
|
||||
createPublisher,
|
||||
Publisher,
|
||||
} from "coral-server/graph/subscriptions/publisher";
|
||||
import CoralEventListenerBroker, {
|
||||
CoralEventPublisherBroker,
|
||||
} from "coral-server/events/publisher";
|
||||
import logger, { Logger } from "coral-server/logger";
|
||||
import { PersistedQuery } from "coral-server/models/queries";
|
||||
import { Tenant } from "coral-server/models/tenant";
|
||||
import { User } from "coral-server/models/user";
|
||||
import { MailerQueue } from "coral-server/queue/tasks/mailer";
|
||||
import { NotifierQueue } from "coral-server/queue/tasks/notifier";
|
||||
import { ScraperQueue } from "coral-server/queue/tasks/scraper";
|
||||
import { I18n } from "coral-server/services/i18n";
|
||||
import { JWTSigningConfig } from "coral-server/services/jwt";
|
||||
import { AugmentedRedis } from "coral-server/services/redis";
|
||||
import createSlackPublisher from "coral-server/services/slack/publisher";
|
||||
import TenantCache from "coral-server/services/tenant/cache";
|
||||
import { Request } from "coral-server/types/express";
|
||||
|
||||
@@ -41,16 +38,17 @@ export interface GraphContextOptions {
|
||||
i18n: I18n;
|
||||
mailerQueue: MailerQueue;
|
||||
mongo: Db;
|
||||
notifierQueue: NotifierQueue;
|
||||
pubsub: RedisPubSub;
|
||||
redis: AugmentedRedis;
|
||||
scraperQueue: ScraperQueue;
|
||||
tenant: Tenant;
|
||||
tenantCache: TenantCache;
|
||||
broker: CoralEventListenerBroker;
|
||||
}
|
||||
|
||||
export default class GraphContext {
|
||||
public readonly config: Config;
|
||||
public readonly broker: CoralEventPublisherBroker;
|
||||
public readonly disableCaching: boolean;
|
||||
public readonly i18n: I18n;
|
||||
public readonly id: string;
|
||||
@@ -61,7 +59,6 @@ export default class GraphContext {
|
||||
public readonly mongo: Db;
|
||||
public readonly mutators: ReturnType<typeof mutators>;
|
||||
public readonly now: Date;
|
||||
public readonly publisher: Publisher;
|
||||
public readonly pubsub: RedisPubSub;
|
||||
public readonly redis: AugmentedRedis;
|
||||
public readonly scraperQueue: ScraperQueue;
|
||||
@@ -100,18 +97,7 @@ export default class GraphContext {
|
||||
this.signingConfig = options.signingConfig;
|
||||
this.clientID = options.clientID;
|
||||
|
||||
this.publisher = createPublisher({
|
||||
pubsub: this.pubsub,
|
||||
slackPublisher: createSlackPublisher(
|
||||
this.mongo,
|
||||
this.config,
|
||||
this.tenant
|
||||
),
|
||||
notifierQueue: options.notifierQueue,
|
||||
tenantID: this.tenant.id,
|
||||
clientID: this.clientID,
|
||||
});
|
||||
|
||||
this.broker = options.broker.instance(this);
|
||||
this.loaders = loaders(this);
|
||||
this.mutators = mutators(this);
|
||||
}
|
||||
|
||||
@@ -81,7 +81,14 @@ const primeStoriesFromConnection = (ctx: GraphContext) => (
|
||||
export default (ctx: GraphContext) => ({
|
||||
findOrCreate: new DataLoader(
|
||||
createManyBatchLoadFn((input: FindOrCreateStory) =>
|
||||
findOrCreate(ctx.mongo, ctx.tenant, input, ctx.scraperQueue, ctx.now)
|
||||
findOrCreate(
|
||||
ctx.mongo,
|
||||
ctx.tenant,
|
||||
ctx.broker,
|
||||
input,
|
||||
ctx.scraperQueue,
|
||||
ctx.now
|
||||
)
|
||||
),
|
||||
{
|
||||
// TODO: (wyattjoh) see if there's something we can do to improve the cache key
|
||||
|
||||
@@ -12,7 +12,7 @@ export const Actions = (ctx: GraphContext) => ({
|
||||
ctx.mongo,
|
||||
ctx.redis,
|
||||
ctx.config,
|
||||
ctx.publisher,
|
||||
ctx.broker,
|
||||
ctx.tenant,
|
||||
input.commentID,
|
||||
input.commentRevisionID,
|
||||
@@ -24,7 +24,7 @@ export const Actions = (ctx: GraphContext) => ({
|
||||
ctx.mongo,
|
||||
ctx.redis,
|
||||
ctx.config,
|
||||
ctx.publisher,
|
||||
ctx.broker,
|
||||
ctx.tenant,
|
||||
input.commentID,
|
||||
input.commentRevisionID,
|
||||
|
||||
@@ -45,7 +45,7 @@ export const Comments = (ctx: GraphContext) => ({
|
||||
ctx.mongo,
|
||||
ctx.redis,
|
||||
ctx.config,
|
||||
ctx.publisher,
|
||||
ctx.broker,
|
||||
ctx.tenant,
|
||||
ctx.user!,
|
||||
{ authorID: ctx.user!.id, ...comment },
|
||||
@@ -68,7 +68,7 @@ export const Comments = (ctx: GraphContext) => ({
|
||||
ctx.mongo,
|
||||
ctx.redis,
|
||||
ctx.config,
|
||||
ctx.publisher,
|
||||
ctx.broker,
|
||||
ctx.tenant,
|
||||
ctx.user!,
|
||||
{
|
||||
@@ -92,7 +92,7 @@ export const Comments = (ctx: GraphContext) => ({
|
||||
createReaction(
|
||||
ctx.mongo,
|
||||
ctx.redis,
|
||||
ctx.publisher,
|
||||
ctx.broker,
|
||||
ctx.tenant,
|
||||
ctx.user!,
|
||||
{
|
||||
@@ -102,7 +102,7 @@ export const Comments = (ctx: GraphContext) => ({
|
||||
ctx.now
|
||||
),
|
||||
removeReaction: ({ commentID }: GQLRemoveCommentReactionInput) =>
|
||||
removeReaction(ctx.mongo, ctx.redis, ctx.publisher, ctx.tenant, ctx.user!, {
|
||||
removeReaction(ctx.mongo, ctx.redis, ctx.broker, ctx.tenant, ctx.user!, {
|
||||
commentID,
|
||||
}),
|
||||
createDontAgree: ({
|
||||
@@ -113,7 +113,7 @@ export const Comments = (ctx: GraphContext) => ({
|
||||
createDontAgree(
|
||||
ctx.mongo,
|
||||
ctx.redis,
|
||||
ctx.publisher,
|
||||
ctx.broker,
|
||||
ctx.tenant,
|
||||
ctx.user!,
|
||||
{
|
||||
@@ -128,14 +128,9 @@ export const Comments = (ctx: GraphContext) => ({
|
||||
ctx.now
|
||||
),
|
||||
removeDontAgree: ({ commentID }: GQLRemoveCommentDontAgreeInput) =>
|
||||
removeDontAgree(
|
||||
ctx.mongo,
|
||||
ctx.redis,
|
||||
ctx.publisher,
|
||||
ctx.tenant,
|
||||
ctx.user!,
|
||||
{ commentID }
|
||||
),
|
||||
removeDontAgree(ctx.mongo, ctx.redis, ctx.broker, ctx.tenant, ctx.user!, {
|
||||
commentID,
|
||||
}),
|
||||
createFlag: ({
|
||||
commentID,
|
||||
commentRevisionID,
|
||||
@@ -145,7 +140,7 @@ export const Comments = (ctx: GraphContext) => ({
|
||||
createFlag(
|
||||
ctx.mongo,
|
||||
ctx.redis,
|
||||
ctx.publisher,
|
||||
ctx.broker,
|
||||
ctx.tenant,
|
||||
ctx.user!,
|
||||
{
|
||||
@@ -179,7 +174,7 @@ export const Comments = (ctx: GraphContext) => ({
|
||||
ctx.mongo,
|
||||
ctx.redis,
|
||||
ctx.config,
|
||||
ctx.publisher,
|
||||
ctx.broker,
|
||||
ctx.tenant,
|
||||
commentID,
|
||||
commentRevisionID,
|
||||
@@ -190,7 +185,7 @@ export const Comments = (ctx: GraphContext) => ({
|
||||
)
|
||||
.then(comment => {
|
||||
// Publish that the comment was featured.
|
||||
publishCommentFeatured(ctx.publisher, comment);
|
||||
publishCommentFeatured(ctx.broker, comment);
|
||||
|
||||
// Return it to the next step.
|
||||
return comment;
|
||||
|
||||
@@ -2,19 +2,33 @@ import GraphContext from "coral-server/graph/context";
|
||||
import { Tenant } from "coral-server/models/tenant";
|
||||
import {
|
||||
createAnnouncement,
|
||||
createWebhookEndpoint,
|
||||
deleteAnnouncement,
|
||||
deleteWebhookEndpoint,
|
||||
disableFeatureFlag,
|
||||
disableWebhookEndpoint,
|
||||
enableFeatureFlag,
|
||||
enableWebhookEndpoint,
|
||||
regenerateSSOKey,
|
||||
rotateWebhookEndpointSecret,
|
||||
update,
|
||||
updateWebhookEndpoint,
|
||||
} from "coral-server/services/tenant";
|
||||
|
||||
import {
|
||||
GQLCreateAnnouncementInput,
|
||||
GQLCreateWebhookEndpointInput,
|
||||
GQLDeleteWebhookEndpointInput,
|
||||
GQLDisableWebhookEndpointInput,
|
||||
GQLEnableWebhookEndpointInput,
|
||||
GQLFEATURE_FLAG,
|
||||
GQLRotateWebhookEndpointSecretInput,
|
||||
GQLUpdateSettingsInput,
|
||||
GQLUpdateWebhookEndpointInput,
|
||||
} from "coral-server/graph/schema/__generated__/types";
|
||||
|
||||
import { WithoutMutationID } from "./util";
|
||||
|
||||
export const Settings = ({
|
||||
mongo,
|
||||
redis,
|
||||
@@ -23,7 +37,9 @@ export const Settings = ({
|
||||
config,
|
||||
now,
|
||||
}: GraphContext) => ({
|
||||
update: (input: GQLUpdateSettingsInput): Promise<Tenant | null> =>
|
||||
update: (
|
||||
input: WithoutMutationID<GQLUpdateSettingsInput>
|
||||
): Promise<Tenant | null> =>
|
||||
update(mongo, redis, tenantCache, config, tenant, input.settings),
|
||||
regenerateSSOKey: (): Promise<Tenant | null> =>
|
||||
regenerateSSOKey(mongo, redis, tenantCache, tenant, now),
|
||||
@@ -35,4 +51,42 @@ export const Settings = ({
|
||||
createAnnouncement(mongo, redis, tenantCache, tenant, input, now),
|
||||
deleteAnnouncement: () =>
|
||||
deleteAnnouncement(mongo, redis, tenantCache, tenant),
|
||||
createWebhookEndpoint: (
|
||||
input: WithoutMutationID<GQLCreateWebhookEndpointInput>
|
||||
) =>
|
||||
createWebhookEndpoint(
|
||||
mongo,
|
||||
redis,
|
||||
config,
|
||||
tenantCache,
|
||||
tenant,
|
||||
input,
|
||||
now
|
||||
),
|
||||
enableWebhookEndpoint: (
|
||||
input: WithoutMutationID<GQLEnableWebhookEndpointInput>
|
||||
) => enableWebhookEndpoint(mongo, redis, tenantCache, tenant, input.id),
|
||||
disableWebhookEndpoint: (
|
||||
input: WithoutMutationID<GQLDisableWebhookEndpointInput>
|
||||
) => disableWebhookEndpoint(mongo, redis, tenantCache, tenant, input.id),
|
||||
updateWebhookEndpoint: ({
|
||||
id,
|
||||
...input
|
||||
}: WithoutMutationID<GQLUpdateWebhookEndpointInput>) =>
|
||||
updateWebhookEndpoint(mongo, redis, config, tenantCache, tenant, id, input),
|
||||
deleteWebhookEndpoint: (
|
||||
input: WithoutMutationID<GQLDeleteWebhookEndpointInput>
|
||||
) => deleteWebhookEndpoint(mongo, redis, tenantCache, tenant, input.id),
|
||||
rotateWebhookEndpointSecret: (
|
||||
input: WithoutMutationID<GQLRotateWebhookEndpointSecretInput>
|
||||
) =>
|
||||
rotateWebhookEndpointSecret(
|
||||
mongo,
|
||||
redis,
|
||||
tenantCache,
|
||||
tenant,
|
||||
input.id,
|
||||
input.inactiveIn,
|
||||
now
|
||||
),
|
||||
});
|
||||
|
||||
@@ -32,6 +32,7 @@ export const Stories = (ctx: GraphContext) => ({
|
||||
create(
|
||||
ctx.mongo,
|
||||
ctx.tenant,
|
||||
ctx.broker,
|
||||
ctx.config,
|
||||
input.story.id,
|
||||
input.story.url,
|
||||
|
||||
@@ -86,7 +86,7 @@ export const storyModerationInputResolver = (
|
||||
*
|
||||
* @param source the source of the type, not used
|
||||
* @param args the args of the type, not used
|
||||
* @param ctx the TenantContext that will be used to get the shared counts
|
||||
* @param ctx the GraphContext that will be used to get the shared counts
|
||||
*/
|
||||
export const sharedModerationInputResolver = async (
|
||||
source: any,
|
||||
@@ -106,7 +106,7 @@ export const sharedModerationInputResolver = async (
|
||||
*
|
||||
* @param source the source of the payload, not used
|
||||
* @param args the args of the payload containing potentially a Story ID
|
||||
* @param ctx the TenantContext for which we can use to retrieve the shared data
|
||||
* @param ctx the GraphContext for which we can use to retrieve the shared data
|
||||
*/
|
||||
export const moderationQueuesResolver: QueryToModerationQueuesResolver = async (
|
||||
source,
|
||||
|
||||
@@ -33,9 +33,13 @@ export const Mutation: Required<GQLMutationTypeResolver<void>> = {
|
||||
user: await ctx.mutators.Users.updateNotificationSettings(input),
|
||||
clientMutationId,
|
||||
}),
|
||||
updateSettings: async (source, { input }, ctx) => ({
|
||||
updateSettings: async (
|
||||
source,
|
||||
{ input: { clientMutationId, ...input } },
|
||||
ctx
|
||||
) => ({
|
||||
settings: await ctx.mutators.Settings.update(input),
|
||||
clientMutationId: input.clientMutationId,
|
||||
clientMutationId,
|
||||
}),
|
||||
createCommentReaction: async (source, { input }, ctx) => ({
|
||||
comment: await ctx.mutators.Comments.createReaction(input),
|
||||
@@ -252,4 +256,52 @@ export const Mutation: Required<GQLMutationTypeResolver<void>> = {
|
||||
site: await ctx.mutators.Sites.update(input),
|
||||
clientMutationId: input.clientMutationId,
|
||||
}),
|
||||
createWebhookEndpoint: async (
|
||||
source,
|
||||
{ input: { clientMutationId, ...input } },
|
||||
ctx
|
||||
) => ({
|
||||
...(await ctx.mutators.Settings.createWebhookEndpoint(input)),
|
||||
clientMutationId,
|
||||
}),
|
||||
updateWebhookEndpoint: async (
|
||||
source,
|
||||
{ input: { clientMutationId, ...input } },
|
||||
ctx
|
||||
) => ({
|
||||
endpoint: await ctx.mutators.Settings.updateWebhookEndpoint(input),
|
||||
clientMutationId,
|
||||
}),
|
||||
disableWebhookEndpoint: async (
|
||||
source,
|
||||
{ input: { clientMutationId, ...input } },
|
||||
ctx
|
||||
) => ({
|
||||
endpoint: await ctx.mutators.Settings.disableWebhookEndpoint(input),
|
||||
clientMutationId,
|
||||
}),
|
||||
enableWebhookEndpoint: async (
|
||||
source,
|
||||
{ input: { clientMutationId, ...input } },
|
||||
ctx
|
||||
) => ({
|
||||
endpoint: await ctx.mutators.Settings.enableWebhookEndpoint(input),
|
||||
clientMutationId,
|
||||
}),
|
||||
deleteWebhookEndpoint: async (
|
||||
source,
|
||||
{ input: { clientMutationId, ...input } },
|
||||
ctx
|
||||
) => ({
|
||||
endpoint: await ctx.mutators.Settings.deleteWebhookEndpoint(input),
|
||||
clientMutationId,
|
||||
}),
|
||||
rotateWebhookEndpointSecret: async (
|
||||
source,
|
||||
{ input: { clientMutationId, ...input } },
|
||||
ctx
|
||||
) => ({
|
||||
endpoint: await ctx.mutators.Settings.rotateWebhookEndpointSecret(input),
|
||||
clientMutationId,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { getWebhookEndpoint } from "coral-server/models/tenant";
|
||||
|
||||
import { GQLQueryTypeResolver } from "coral-server/graph/schema/__generated__/types";
|
||||
|
||||
import { moderationQueuesResolver } from "./ModerationQueues";
|
||||
@@ -25,4 +27,5 @@ export const Query: Required<GQLQueryTypeResolver<void>> = {
|
||||
ctx.loaders.Stories.activeStories(limit),
|
||||
sites: (source, args, ctx) => ctx.loaders.Sites.connection(args),
|
||||
site: (source, { id }, ctx) => (id ? ctx.loaders.Sites.site.load(id) : null),
|
||||
webhookEndpoint: (source, { id }, ctx) => getWebhookEndpoint(ctx.tenant, id),
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ import * as settings from "coral-server/models/settings";
|
||||
|
||||
import { GQLSSOAuthIntegrationTypeResolver } from "coral-server/graph/schema/__generated__/types";
|
||||
|
||||
function getActiveSSOKey(keys: settings.SSOKey[]) {
|
||||
function getActiveSSOKey(keys: settings.Secret[]) {
|
||||
// Any key that has been rotated cannot be the active key.
|
||||
return keys.find(key => !key.rotatedAt);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
import {
|
||||
GQLFEATURE_FLAG,
|
||||
GQLSettingsTypeResolver,
|
||||
GQLWEBHOOK_EVENT_NAME,
|
||||
} from "coral-server/graph/schema/__generated__/types";
|
||||
|
||||
const filterValidFeatureFlags = () => {
|
||||
@@ -27,4 +28,5 @@ export const Settings: GQLSettingsTypeResolver<Tenant> = {
|
||||
const sites = await ctx.loaders.Sites.connection({});
|
||||
return sites.edges.length > 1;
|
||||
},
|
||||
webhookEvents: () => Object.values(GQLWEBHOOK_EVENT_NAME),
|
||||
};
|
||||
|
||||
@@ -17,3 +17,13 @@ export const Subscription: GQLSubscriptionTypeResolver = {
|
||||
commentFeatured,
|
||||
commentReleased,
|
||||
};
|
||||
|
||||
export { CommentFeaturedInput } from "./commentFeatured";
|
||||
export { CommentCreatedInput } from "./commentCreated";
|
||||
export {
|
||||
CommentEnteredModerationQueueInput,
|
||||
} from "./commentEnteredModerationQueue";
|
||||
export { CommentLeftModerationQueueInput } from "./commentLeftModerationQueue";
|
||||
export { CommentReleasedInput } from "./commentReleased";
|
||||
export { CommentReplyCreatedInput } from "./commentReplyCreated";
|
||||
export { CommentStatusUpdatedInput } from "./commentStatusUpdated";
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import * as tenant from "coral-server/models/tenant";
|
||||
|
||||
import { GQLWebhookEndpointTypeResolver } from "coral-server/graph/schema/__generated__/types";
|
||||
|
||||
export const WebhookEndpoint: GQLWebhookEndpointTypeResolver<
|
||||
tenant.Endpoint
|
||||
> = {
|
||||
signingSecret: ({ signingSecrets }) =>
|
||||
signingSecrets[signingSecrets.length - 1],
|
||||
};
|
||||
@@ -50,6 +50,7 @@ import { User } from "./User";
|
||||
import { UsernameHistory } from "./UsernameHistory";
|
||||
import { UsernameStatus } from "./UsernameStatus";
|
||||
import { UserStatus } from "./UserStatus";
|
||||
import { WebhookEndpoint } from "./WebhookEndpoint";
|
||||
|
||||
const Resolvers: GQLResolver = {
|
||||
ApproveCommentPayload,
|
||||
@@ -101,6 +102,7 @@ const Resolvers: GQLResolver = {
|
||||
UserStatus,
|
||||
Settings,
|
||||
SlackConfiguration,
|
||||
WebhookEndpoint,
|
||||
};
|
||||
|
||||
export default Resolvers;
|
||||
|
||||
@@ -1002,7 +1002,7 @@ type SlackChannel {
|
||||
triggers are the filters of types of comments that will be sent to
|
||||
the correlated channel
|
||||
"""
|
||||
triggers: SlackChannelTriggers
|
||||
triggers: SlackChannelTriggers!
|
||||
}
|
||||
|
||||
################################################################################
|
||||
@@ -1204,6 +1204,79 @@ type StaffConfiguration {
|
||||
label: String!
|
||||
}
|
||||
|
||||
type WebhookDelivery {
|
||||
success: Boolean!
|
||||
status: Int!
|
||||
statusText: String!
|
||||
request: String!
|
||||
response: String!
|
||||
createdAt: Time!
|
||||
}
|
||||
|
||||
enum WEBHOOK_EVENT_NAME {
|
||||
STORY_CREATED
|
||||
}
|
||||
|
||||
"""
|
||||
TODO: merge with SSOKey with PR #2732
|
||||
"""
|
||||
type Secret {
|
||||
"""
|
||||
secret is the actual underlying secret used to verify the tokens with.
|
||||
"""
|
||||
secret: String!
|
||||
|
||||
"""
|
||||
createdAt is the date that the key was created at.
|
||||
"""
|
||||
createdAt: Time!
|
||||
}
|
||||
|
||||
type WebhookEndpoint {
|
||||
"""
|
||||
id is the unique identifier for this specific endpoint.
|
||||
"""
|
||||
id: ID!
|
||||
|
||||
"""
|
||||
enabled when true will enable events to be sent to this endpoint.
|
||||
"""
|
||||
enabled: Boolean!
|
||||
|
||||
"""
|
||||
url is the URL that we will POST event data to.
|
||||
"""
|
||||
url: String!
|
||||
|
||||
"""
|
||||
signingSecret is the current secret used to sign the events sent out.
|
||||
"""
|
||||
signingSecret: Secret!
|
||||
|
||||
"""
|
||||
deliveries store the deliveries for each event sent for the last 50 events.
|
||||
"""
|
||||
deliveries: [WebhookDelivery!]!
|
||||
|
||||
"""
|
||||
all is true when all events are subscribed to.
|
||||
"""
|
||||
all: Boolean!
|
||||
|
||||
"""
|
||||
events are the specific event names that this endpoint is configured to send
|
||||
for.
|
||||
"""
|
||||
events: [WEBHOOK_EVENT_NAME!]!
|
||||
}
|
||||
|
||||
type WebhookConfiguration {
|
||||
"""
|
||||
endpoints is all the configured endpoints that should receive events.
|
||||
"""
|
||||
endpoints: [WebhookEndpoint!]!
|
||||
}
|
||||
|
||||
"""
|
||||
NewCommenterConfiguration specifies the features that apply to new commenters
|
||||
"""
|
||||
@@ -1266,6 +1339,16 @@ type Settings {
|
||||
"""
|
||||
domain: String! @auth(roles: [ADMIN])
|
||||
|
||||
"""
|
||||
webhooks store the webhook configuration.
|
||||
"""
|
||||
webhooks: WebhookConfiguration! @auth(roles: [ADMIN])
|
||||
|
||||
"""
|
||||
webhookEvents returns all the events that can trigger webhooks.
|
||||
"""
|
||||
webhookEvents: [WEBHOOK_EVENT_NAME!]! @auth(roles: [ADMIN])
|
||||
|
||||
"""
|
||||
staticURI if configured, is the static URI used to serve static files from.
|
||||
"""
|
||||
@@ -2891,6 +2974,11 @@ type Query {
|
||||
activeStories(limit: Int = 10 @constraint(max: 25)): [Story!]!
|
||||
@auth(roles: [ADMIN])
|
||||
@rate(max: 2, seconds: 1)
|
||||
|
||||
"""
|
||||
webhookEndpint will return a specific WebhookEndpoint if it exists.
|
||||
"""
|
||||
webhookEndpoint(id: ID!): WebhookEndpoint @auth(roles: [ADMIN])
|
||||
}
|
||||
|
||||
################################################################################
|
||||
@@ -4768,6 +4856,212 @@ type DeleteModeratorNotePayload {
|
||||
user: User!
|
||||
}
|
||||
|
||||
##################
|
||||
# createWebhookEndpoint
|
||||
##################
|
||||
|
||||
input CreateWebhookEndpointInput {
|
||||
"""
|
||||
clientMutationId is required for Relay support.
|
||||
"""
|
||||
clientMutationId: String!
|
||||
|
||||
"""
|
||||
url is the URL that Coral will POST event data to.
|
||||
"""
|
||||
url: String!
|
||||
|
||||
"""
|
||||
all is true when all events are subscribed to.
|
||||
"""
|
||||
all: Boolean!
|
||||
|
||||
"""
|
||||
events are the specific event names that this endpoint is configured to send
|
||||
for.
|
||||
"""
|
||||
events: [WEBHOOK_EVENT_NAME!]!
|
||||
}
|
||||
|
||||
type CreateWebhookEndpointPayload {
|
||||
"""
|
||||
clientMutationId is required for Relay support.
|
||||
"""
|
||||
clientMutationId: String!
|
||||
|
||||
"""
|
||||
endpoint is the endpoint that we just created.
|
||||
"""
|
||||
endpoint: WebhookEndpoint!
|
||||
|
||||
"""
|
||||
settings is the updated settings also containing the new endpoint.
|
||||
"""
|
||||
settings: Settings!
|
||||
}
|
||||
|
||||
##################
|
||||
# updateWebhookEndpoint
|
||||
##################
|
||||
|
||||
input UpdateWebhookEndpointInput {
|
||||
"""
|
||||
clientMutationId is required for Relay support.
|
||||
"""
|
||||
clientMutationId: String!
|
||||
|
||||
"""
|
||||
id is the ID of the WebhookEndpoint being updated.
|
||||
"""
|
||||
id: ID!
|
||||
|
||||
"""
|
||||
url is the URL that Coral will POST event data to.
|
||||
"""
|
||||
url: String
|
||||
|
||||
"""
|
||||
all is true when all events are subscribed to.
|
||||
"""
|
||||
all: Boolean
|
||||
|
||||
"""
|
||||
events are the specific event names that this endpoint is configured to send
|
||||
for.
|
||||
"""
|
||||
events: [WEBHOOK_EVENT_NAME!]
|
||||
}
|
||||
|
||||
type UpdateWebhookEndpointPayload {
|
||||
"""
|
||||
clientMutationId is required for Relay support.
|
||||
"""
|
||||
clientMutationId: String!
|
||||
|
||||
"""
|
||||
endpoint is the endpoint that we just created.
|
||||
"""
|
||||
endpoint: WebhookEndpoint!
|
||||
}
|
||||
|
||||
##################
|
||||
# rotateWebhookEndpointSecret
|
||||
##################
|
||||
|
||||
input RotateWebhookEndpointSecretInput {
|
||||
"""
|
||||
clientMutationId is required for Relay support.
|
||||
"""
|
||||
clientMutationId: String!
|
||||
|
||||
"""
|
||||
id is the ID of the WebhookEndpoint being updated.
|
||||
"""
|
||||
id: ID!
|
||||
|
||||
"""
|
||||
inactiveIn is the number of seconds that the current active Secret should be
|
||||
kept active.
|
||||
"""
|
||||
inactiveIn: Int!
|
||||
}
|
||||
|
||||
type RotateWebhookEndpointSecretPayload {
|
||||
"""
|
||||
clientMutationId is required for Relay support.
|
||||
"""
|
||||
clientMutationId: String!
|
||||
|
||||
"""
|
||||
endpoint is the endpoint that we just updated.
|
||||
"""
|
||||
endpoint: WebhookEndpoint
|
||||
}
|
||||
|
||||
##################
|
||||
# disableWebhookEndpoint
|
||||
##################
|
||||
|
||||
input DisableWebhookEndpointInput {
|
||||
"""
|
||||
clientMutationId is required for Relay support.
|
||||
"""
|
||||
clientMutationId: String!
|
||||
|
||||
"""
|
||||
id is the ID of the WebhookEndpoint being disabled.
|
||||
"""
|
||||
id: ID!
|
||||
}
|
||||
|
||||
type DisableWebhookEndpointPayload {
|
||||
"""
|
||||
clientMutationId is required for Relay support.
|
||||
"""
|
||||
clientMutationId: String!
|
||||
|
||||
"""
|
||||
endpoint is the endpoint that we just disabled.
|
||||
"""
|
||||
endpoint: WebhookEndpoint
|
||||
}
|
||||
|
||||
##################
|
||||
# enableWebhookEndpoint
|
||||
##################
|
||||
|
||||
input EnableWebhookEndpointInput {
|
||||
"""
|
||||
clientMutationId is required for Relay support.
|
||||
"""
|
||||
clientMutationId: String!
|
||||
|
||||
"""
|
||||
id is the ID of the WebhookEndpoint being enabled.
|
||||
"""
|
||||
id: ID!
|
||||
}
|
||||
|
||||
type EnableWebhookEndpointPayload {
|
||||
"""
|
||||
clientMutationId is required for Relay support.
|
||||
"""
|
||||
clientMutationId: String!
|
||||
|
||||
"""
|
||||
endpoint is the endpoint that we just enabled.
|
||||
"""
|
||||
endpoint: WebhookEndpoint
|
||||
}
|
||||
|
||||
##################
|
||||
# deleteWebhookEndpoint
|
||||
##################
|
||||
|
||||
input DeleteWebhookEndpointInput {
|
||||
"""
|
||||
clientMutationId is required for Relay support.
|
||||
"""
|
||||
clientMutationId: String!
|
||||
|
||||
"""
|
||||
id is the ID of the WebhookEndpoint being deleted.
|
||||
"""
|
||||
id: ID!
|
||||
}
|
||||
|
||||
type DeleteWebhookEndpointPayload {
|
||||
"""
|
||||
clientMutationId is required for Relay support.
|
||||
"""
|
||||
clientMutationId: String!
|
||||
|
||||
"""
|
||||
endpoint is the endpoint that we just deleted.
|
||||
"""
|
||||
endpoint: WebhookEndpoint
|
||||
}
|
||||
|
||||
##################
|
||||
# setEmail
|
||||
##################
|
||||
@@ -5899,6 +6193,49 @@ type Mutation {
|
||||
createSite(input: CreateSiteInput!): CreateSitePayload! @auth(roles: [ADMIN])
|
||||
|
||||
updateSite(input: UpdateSiteInput!): UpdateSitePayload! @auth(roles: [ADMIN])
|
||||
|
||||
"""
|
||||
createWebhookEndpoint will create a new WebhookEndpoint.
|
||||
"""
|
||||
createWebhookEndpoint(
|
||||
input: CreateWebhookEndpointInput!
|
||||
): CreateWebhookEndpointPayload! @auth(roles: [ADMIN])
|
||||
|
||||
"""
|
||||
updateWebhookEndpoint will update a WebhookEndpoint.
|
||||
"""
|
||||
updateWebhookEndpoint(
|
||||
input: UpdateWebhookEndpointInput!
|
||||
): UpdateWebhookEndpointPayload! @auth(roles: [ADMIN])
|
||||
|
||||
"""
|
||||
enableWebhookEndpoint will enable a WebhookEndpoint to recieve new events.
|
||||
"""
|
||||
enableWebhookEndpoint(
|
||||
input: EnableWebhookEndpointInput!
|
||||
): EnableWebhookEndpointPayload! @auth(roles: [ADMIN])
|
||||
|
||||
"""
|
||||
disableWebhookEndpoint will disable a WebhookEndpoint from recieving new
|
||||
events.
|
||||
"""
|
||||
disableWebhookEndpoint(
|
||||
input: DisableWebhookEndpointInput!
|
||||
): DisableWebhookEndpointPayload! @auth(roles: [ADMIN])
|
||||
|
||||
"""
|
||||
deleteWebhookEndpoint will delete a WebhookEndpoint.
|
||||
"""
|
||||
deleteWebhookEndpoint(
|
||||
input: DeleteWebhookEndpointInput!
|
||||
): DeleteWebhookEndpointPayload! @auth(roles: [ADMIN])
|
||||
|
||||
"""
|
||||
rotateWebhookEndpointSecret will roll the current active secret to a new key.
|
||||
"""
|
||||
rotateWebhookEndpointSecret(
|
||||
input: RotateWebhookEndpointSecretInput!
|
||||
): RotateWebhookEndpointSecretPayload! @auth(roles: [ADMIN])
|
||||
}
|
||||
|
||||
##################
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
import { RedisPubSub } from "graphql-redis-subscriptions";
|
||||
|
||||
import { createSubscriptionChannelName } from "coral-server/graph/resolvers/Subscription/helpers";
|
||||
import { SUBSCRIPTION_INPUT } from "coral-server/graph/resolvers/Subscription/types";
|
||||
import logger from "coral-server/logger";
|
||||
import { NotifierQueue } from "coral-server/queue/tasks/notifier";
|
||||
import { SlackPublisher } from "coral-server/services/slack/publisher";
|
||||
|
||||
export type Publisher = (input: SUBSCRIPTION_INPUT) => Promise<void>;
|
||||
|
||||
export interface PublisherOptions {
|
||||
pubsub: RedisPubSub;
|
||||
slackPublisher: SlackPublisher;
|
||||
notifierQueue: NotifierQueue;
|
||||
tenantID: string;
|
||||
clientID?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* createPublisher will create a new Publisher that can be used to send events
|
||||
* over the pubsub broker to facilitate live updates and notifications.
|
||||
*
|
||||
* TODO: Update
|
||||
*
|
||||
* @param options options object
|
||||
* @param options.pubsub the pubsub broker to be used to facilitate the publish action
|
||||
* @param options.slackPublisher the slack publisher instance
|
||||
* @param options.notifierQueue the queue
|
||||
* @param options.tenantID the ID of the Tenant where the event will be published with
|
||||
* @param options.clientID the ID of the client to de-duplicate mutation responses
|
||||
*/
|
||||
export const createPublisher = ({
|
||||
pubsub,
|
||||
slackPublisher,
|
||||
notifierQueue,
|
||||
tenantID,
|
||||
clientID,
|
||||
}: PublisherOptions): Publisher => async input => {
|
||||
const { channel, payload } = input;
|
||||
|
||||
logger.trace({ channel, tenantID, clientID }, "publishing event");
|
||||
|
||||
// Start the publishing operation out to all affected subscribers.
|
||||
await Promise.all([
|
||||
// Publish to the underlying pubsub system for subscriptions.
|
||||
pubsub.publish(createSubscriptionChannelName(tenantID, channel), {
|
||||
...payload,
|
||||
clientID,
|
||||
}),
|
||||
|
||||
slackPublisher(channel, payload),
|
||||
|
||||
// Notify the notifications queue so we can offload notification processing
|
||||
// to it.
|
||||
notifierQueue.add({ tenantID, input }),
|
||||
]);
|
||||
};
|
||||
@@ -37,6 +37,11 @@ import {
|
||||
} from "coral-server/services/redis";
|
||||
import TenantCache from "coral-server/services/tenant/cache";
|
||||
|
||||
import { NotifierCoralEventListener } from "./events/listeners/notifier";
|
||||
import { SlackCoralEventListener } from "./events/listeners/slack";
|
||||
import { SubscriptionCoralEventListener } from "./events/listeners/subscription";
|
||||
import { WebhookCoralEventListener } from "./events/listeners/webhook";
|
||||
import CoralEventListenerBroker from "./events/publisher";
|
||||
import { isInstalled } from "./services/tenant";
|
||||
|
||||
export interface ServerOptions {
|
||||
@@ -108,6 +113,12 @@ class Server {
|
||||
// migrationManager is the manager for performing migrations on Coral.
|
||||
private migrationManager: MigrationManager;
|
||||
|
||||
/**
|
||||
* broker stores a reference to all of the listeners that can be used in
|
||||
* conjunction with an event to publish activity occurring inside Coral.
|
||||
*/
|
||||
private broker: CoralEventListenerBroker;
|
||||
|
||||
constructor(options: ServerOptions) {
|
||||
this.parentApp = express();
|
||||
|
||||
@@ -190,6 +201,7 @@ class Server {
|
||||
this.tasks = await createQueue({
|
||||
config: this.config,
|
||||
mongo: this.mongo,
|
||||
redis: this.redis,
|
||||
tenantCache: this.tenantCache,
|
||||
i18n: this.i18n,
|
||||
signingConfig: this.signingConfig,
|
||||
@@ -201,6 +213,13 @@ class Server {
|
||||
createRedisClient(this.config)
|
||||
);
|
||||
|
||||
// Setup the broker.
|
||||
this.broker = new CoralEventListenerBroker();
|
||||
this.broker.register(new NotifierCoralEventListener(this.tasks.notifier));
|
||||
this.broker.register(new SlackCoralEventListener());
|
||||
this.broker.register(new SubscriptionCoralEventListener());
|
||||
this.broker.register(new WebhookCoralEventListener(this.tasks.webhook));
|
||||
|
||||
// Setup the metrics collectors.
|
||||
collectDefaultMetrics({ timeout: 5000 });
|
||||
}
|
||||
@@ -233,6 +252,7 @@ class Server {
|
||||
this.tasks.mailer.process();
|
||||
this.tasks.scraper.process();
|
||||
this.tasks.notifier.process();
|
||||
this.tasks.webhook.process();
|
||||
|
||||
// Start up the cron job processors.
|
||||
this.scheduledTasks = startScheduledTasks({
|
||||
@@ -323,6 +343,7 @@ class Server {
|
||||
|
||||
const options: AppOptions = {
|
||||
parent,
|
||||
broker: this.broker,
|
||||
pubsub: this.pubsub,
|
||||
mongo: this.mongo,
|
||||
redis: this.redis,
|
||||
@@ -333,7 +354,6 @@ class Server {
|
||||
i18n: this.i18n,
|
||||
mailerQueue: this.tasks.mailer,
|
||||
scraperQueue: this.tasks.scraper,
|
||||
notifierQueue: this.tasks.notifier,
|
||||
disableClientRoutes,
|
||||
persistedQueryCache: this.persistedQueryCache,
|
||||
persistedQueriesRequired:
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./settings";
|
||||
export * from "./secret";
|
||||
@@ -0,0 +1,44 @@
|
||||
export interface Secret {
|
||||
/**
|
||||
* kid is the identifier for the key used when verifying tokens issued by the
|
||||
* provider.
|
||||
*/
|
||||
kid: string;
|
||||
|
||||
/**
|
||||
* secret is the actual underlying secret used to verify the tokens with.
|
||||
*/
|
||||
secret: string;
|
||||
|
||||
/**
|
||||
* createdAt is the date that the key was created at.
|
||||
*/
|
||||
createdAt: Date;
|
||||
|
||||
/**
|
||||
* rotatedAt is the time that the token was rotated out.
|
||||
*/
|
||||
rotatedAt?: Date;
|
||||
|
||||
/**
|
||||
* inactiveAt is the date that the token can no longer be used to validate
|
||||
* tokens.
|
||||
*/
|
||||
inactiveAt?: Date;
|
||||
}
|
||||
|
||||
export function isSecretExpired({ inactiveAt }: Secret, now = new Date()) {
|
||||
if (inactiveAt && inactiveAt <= now) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function filterExpiredSecrets(now = new Date()) {
|
||||
return (secret: Secret) => isSecretExpired(secret, now);
|
||||
}
|
||||
|
||||
export function filterActiveSecrets(now = new Date()) {
|
||||
return (secret: Secret) => !isSecretExpired(secret, now);
|
||||
}
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
GQLSettings,
|
||||
} from "coral-server/graph/schema/__generated__/types";
|
||||
|
||||
import { Secret } from "./secret";
|
||||
|
||||
export type LiveConfiguration = Omit<GQLLiveConfiguration, "configurable">;
|
||||
|
||||
export type EmailConfiguration = GQLEmailConfiguration;
|
||||
@@ -38,40 +40,11 @@ export type FacebookAuthIntegration = Omit<
|
||||
"callbackURL" | "redirectURL"
|
||||
>;
|
||||
|
||||
export interface SSOKey {
|
||||
/**
|
||||
* kid is the identifier for the key used when verifying tokens issued by the
|
||||
* provider.
|
||||
*/
|
||||
kid: string;
|
||||
|
||||
/**
|
||||
* secret is the actual underlying secret used to verify the tokens with.
|
||||
*/
|
||||
secret: string;
|
||||
|
||||
/**
|
||||
* createdAt is the date that the key was created at.
|
||||
*/
|
||||
createdAt: Date;
|
||||
|
||||
/**
|
||||
* rotatedAt is the time that the token was rotated out.
|
||||
*/
|
||||
rotatedAt?: Date;
|
||||
|
||||
/**
|
||||
* inactiveAt is the date that the token can no longer be used to validate
|
||||
* tokens.
|
||||
*/
|
||||
inactiveAt?: Date;
|
||||
}
|
||||
|
||||
export interface SSOAuthIntegration {
|
||||
enabled: boolean;
|
||||
allowRegistration: boolean;
|
||||
targetFilter: GQLAuthenticationTargetFilter;
|
||||
keys: SSOKey[];
|
||||
keys: Secret[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -93,24 +93,27 @@ export interface UpsertStoryInput {
|
||||
siteID: string;
|
||||
}
|
||||
|
||||
export interface UpsertStoryResult {
|
||||
story: Story;
|
||||
wasUpserted: boolean;
|
||||
}
|
||||
|
||||
export async function upsertStory(
|
||||
mongo: Db,
|
||||
tenantID: string,
|
||||
{ id = uuid.v4(), url, siteID }: UpsertStoryInput,
|
||||
now = new Date()
|
||||
) {
|
||||
): Promise<UpsertStoryResult> {
|
||||
// Create the story, optionally sourcing the id from the input, additionally
|
||||
// porting in the tenantID.
|
||||
const update: { $setOnInsert: Story } = {
|
||||
$setOnInsert: {
|
||||
id,
|
||||
url,
|
||||
siteID,
|
||||
tenantID,
|
||||
createdAt: now,
|
||||
commentCounts: createEmptyRelatedCommentCounts(),
|
||||
settings: {},
|
||||
},
|
||||
const story: Story = {
|
||||
id,
|
||||
url,
|
||||
tenantID,
|
||||
siteID,
|
||||
createdAt: now,
|
||||
commentCounts: createEmptyRelatedCommentCounts(),
|
||||
settings: {},
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -121,18 +124,26 @@ export async function upsertStory(
|
||||
url,
|
||||
tenantID,
|
||||
},
|
||||
update,
|
||||
{ $setOnInsert: story },
|
||||
{
|
||||
// Create the object if it doesn't already exist.
|
||||
upsert: true,
|
||||
|
||||
// False to return the updated document instead of the original
|
||||
// document.
|
||||
returnOriginal: false,
|
||||
// True to return the original document instead of the updated document.
|
||||
// This will ensure that when an upsert operation adds a new Story, it
|
||||
// should return null.
|
||||
returnOriginal: true,
|
||||
}
|
||||
);
|
||||
|
||||
return result.value || null;
|
||||
return {
|
||||
// The story will either be found (via `result.value`) or upserted (via
|
||||
// `story`).
|
||||
story: result.value || story,
|
||||
|
||||
// The story was upserted if the value isn't provided.
|
||||
wasUpserted: !result.value,
|
||||
};
|
||||
} catch (err) {
|
||||
// Evaluate the error, if it is in regards to violating the unique index,
|
||||
// then return a duplicate Story error.
|
||||
@@ -172,13 +183,18 @@ export interface FindOrCreateStoryInput {
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface FindOrCreateStoryResult {
|
||||
story: Story | null;
|
||||
wasUpserted: boolean;
|
||||
}
|
||||
|
||||
export async function findOrCreateStory(
|
||||
mongo: Db,
|
||||
tenantID: string,
|
||||
{ id, url }: FindOrCreateStoryInput,
|
||||
siteID: string | null,
|
||||
now = new Date()
|
||||
) {
|
||||
): Promise<FindOrCreateStoryResult> {
|
||||
if (id) {
|
||||
if (url && siteID) {
|
||||
// The URL was specified, this is an upsert operation.
|
||||
@@ -194,8 +210,14 @@ export async function findOrCreateStory(
|
||||
);
|
||||
}
|
||||
|
||||
// The URL and siteID were not specified, this is a lookup operation.
|
||||
return retrieveStory(mongo, tenantID, id);
|
||||
// The URL was not specified, this is a lookup operation.
|
||||
const story = await retrieveStory(mongo, tenantID, id);
|
||||
|
||||
// Return the result object.
|
||||
return {
|
||||
story,
|
||||
wasUpserted: false,
|
||||
};
|
||||
}
|
||||
|
||||
// The ID was not specified, this is an upsert operation. Check to see that
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
GQLStaffConfiguration,
|
||||
} from "coral-server/graph/schema/__generated__/types";
|
||||
|
||||
import { SSOKey } from "../settings";
|
||||
import { Secret } from "../settings";
|
||||
import { Tenant } from "./tenant";
|
||||
|
||||
export const getDefaultReactionConfiguration = (
|
||||
@@ -39,12 +39,12 @@ export function generateRandomString(size: number, drift = 5) {
|
||||
.toString("hex");
|
||||
}
|
||||
|
||||
export function generateSSOKey(createdAt: Date): SSOKey {
|
||||
export function generateSecret(prefix: string, createdAt: Date): Secret {
|
||||
// Generate a new key. We generate a key of minimum length 32 up to 37 bytes,
|
||||
// as 16 was the minimum length recommended.
|
||||
//
|
||||
// Reference: https://security.stackexchange.com/a/96176
|
||||
const secret = generateRandomString(32, 5);
|
||||
const secret = prefix + "_" + generateRandomString(32, 5);
|
||||
const kid = generateRandomString(8, 3);
|
||||
|
||||
return { kid, secret, createdAt };
|
||||
@@ -67,3 +67,10 @@ export function hasFeatureFlag(
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function getWebhookEndpoint(
|
||||
tenant: Pick<Tenant, "webhooks">,
|
||||
endpointID: string
|
||||
) {
|
||||
return tenant.webhooks.endpoints.find(e => e.id === endpointID) || null;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,8 @@ import TIME from "coral-common/time";
|
||||
import { DeepPartial, Omit, Sub } from "coral-common/types";
|
||||
import { isBeforeDate } from "coral-common/utils";
|
||||
import { dotize } from "coral-common/utils/dotize";
|
||||
import { Settings } from "coral-server/models/settings";
|
||||
import logger from "coral-server/logger";
|
||||
import { Secret, Settings } from "coral-server/models/settings";
|
||||
import { I18n } from "coral-server/services/i18n";
|
||||
import { tenants as collection } from "coral-server/services/mongodb/collections";
|
||||
|
||||
@@ -18,12 +19,14 @@ import {
|
||||
GQLFEATURE_FLAG,
|
||||
GQLMODERATION_MODE,
|
||||
GQLSettings,
|
||||
GQLWEBHOOK_EVENT_NAME,
|
||||
} from "coral-server/graph/schema/__generated__/types";
|
||||
|
||||
import {
|
||||
generateSSOKey,
|
||||
generateSecret,
|
||||
getDefaultReactionConfiguration,
|
||||
getDefaultStaffConfiguration,
|
||||
getWebhookEndpoint,
|
||||
} from "./helpers";
|
||||
|
||||
/**
|
||||
@@ -38,6 +41,49 @@ export interface TenantResource {
|
||||
readonly tenantID: string;
|
||||
}
|
||||
|
||||
export interface Endpoint {
|
||||
/**
|
||||
* id is the unique identifier for this specific endpoint.
|
||||
*/
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* enabled when true will enable events to be sent to this endpoint.
|
||||
*/
|
||||
enabled: boolean;
|
||||
|
||||
/**
|
||||
* url is the URL that we will POST event data to.
|
||||
*/
|
||||
url: string;
|
||||
|
||||
/**
|
||||
* signingSecret is the secret used to sign the events sent out.
|
||||
*/
|
||||
signingSecrets: Secret[];
|
||||
|
||||
/**
|
||||
* all when true indicates that all events should trigger.
|
||||
*/
|
||||
all: boolean;
|
||||
|
||||
/**
|
||||
* events is the array of events that will trigger the delivery of an
|
||||
* event.
|
||||
*/
|
||||
events: GQLWEBHOOK_EVENT_NAME[];
|
||||
|
||||
/**
|
||||
* createdAt is the date that this endpoint was created.
|
||||
*/
|
||||
createdAt: Date;
|
||||
|
||||
/**
|
||||
* modifiedAt is the date that this Endpoint was last modified at.
|
||||
*/
|
||||
modifiedAt?: Date;
|
||||
}
|
||||
|
||||
export interface TenantSettings
|
||||
extends Pick<GQLSettings, "domain" | "organization"> {
|
||||
readonly id: string;
|
||||
@@ -51,6 +97,16 @@ export interface TenantSettings
|
||||
* featureFlags is the set of flags enabled on this Tenant.
|
||||
*/
|
||||
featureFlags?: GQLFEATURE_FLAG[];
|
||||
|
||||
/**
|
||||
* webhooks stores the configurations for this Tenant's webhook rules.
|
||||
*/
|
||||
webhooks: {
|
||||
/**
|
||||
* endpoints is all the configured endpoints that should receive events.
|
||||
*/
|
||||
endpoints: Endpoint[];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -112,6 +168,9 @@ export async function createTenant(
|
||||
enabled: false,
|
||||
},
|
||||
editCommentWindowLength: 30 * TIME.SECOND,
|
||||
webhooks: {
|
||||
endpoints: [],
|
||||
},
|
||||
charCount: {
|
||||
enabled: false,
|
||||
},
|
||||
@@ -138,7 +197,7 @@ export async function createTenant(
|
||||
stream: true,
|
||||
},
|
||||
// TODO: [CORL-754] (wyattjoh) remove this in favor of generating this when needed
|
||||
keys: [generateSSOKey(now)],
|
||||
keys: [generateSecret("ssosec", now)],
|
||||
},
|
||||
oidc: {
|
||||
enabled: false,
|
||||
@@ -294,9 +353,11 @@ export async function updateTenant(
|
||||
{ id },
|
||||
// Only update fields that have been updated.
|
||||
{ $set },
|
||||
// False to return the updated document instead of the original
|
||||
// document.
|
||||
{ returnOriginal: false }
|
||||
{
|
||||
// False to return the updated document instead of the original
|
||||
// document.
|
||||
returnOriginal: false,
|
||||
}
|
||||
);
|
||||
|
||||
return result.value || null;
|
||||
@@ -309,7 +370,7 @@ export async function updateTenant(
|
||||
*/
|
||||
export async function createTenantSSOKey(mongo: Db, id: string, now: Date) {
|
||||
// Construct the new key.
|
||||
const key = generateSSOKey(now);
|
||||
const key = generateSecret("ssosec", now);
|
||||
|
||||
// Update the Tenant with this new key.
|
||||
const result = await collection(mongo).findOneAndUpdate(
|
||||
@@ -466,3 +527,243 @@ export function retrieveAnnouncementIfEnabled(
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function rollTenantWebhookEndpointSecret(
|
||||
mongo: Db,
|
||||
id: string,
|
||||
endpointID: string,
|
||||
inactiveAt: Date,
|
||||
now: Date
|
||||
) {
|
||||
// Create the new secret.
|
||||
const secret = generateSecret("whsec", now);
|
||||
|
||||
// Update the Tenant with this new secret.
|
||||
let result = await collection(mongo).findOneAndUpdate(
|
||||
{ id },
|
||||
{
|
||||
$push: { "webhooks.endpoints.$[endpoint].signingSecrets": secret },
|
||||
},
|
||||
{
|
||||
// False to return the updated document instead of the original
|
||||
// document.
|
||||
returnOriginal: false,
|
||||
arrayFilters: [
|
||||
// Select the endpoint we're updating.
|
||||
{ "endpoint.id": endpointID },
|
||||
],
|
||||
}
|
||||
);
|
||||
if (!result.value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Grab the endpoint we just modified.
|
||||
const endpoint = getWebhookEndpoint(result.value, endpointID);
|
||||
if (!endpoint) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get the secrets we need to deactivate...
|
||||
const secretKIDsToDeprecate = endpoint.signingSecrets
|
||||
// By excluding the last one (the one we just pushed)...
|
||||
.splice(0, endpoint.signingSecrets.length - 1)
|
||||
// And only finding keys that have not been rotated yet.
|
||||
.filter(s => !s.rotatedAt)
|
||||
// And get their kid's.
|
||||
.map(s => s.kid);
|
||||
if (secretKIDsToDeprecate.length > 0) {
|
||||
logger.trace(
|
||||
{ kids: secretKIDsToDeprecate },
|
||||
"deprecating old signingSecrets"
|
||||
);
|
||||
|
||||
// Deactivate the old keys.
|
||||
result = await collection(mongo).findOneAndUpdate(
|
||||
{ id },
|
||||
{
|
||||
$set: {
|
||||
"webhooks.endpoints.$[endpoint].signingSecrets.$[signingSecret].inactiveAt": inactiveAt,
|
||||
"webhooks.endpoints.$[endpoint].signingSecrets.$[signingSecret].rotatedAt": now,
|
||||
},
|
||||
},
|
||||
{
|
||||
arrayFilters: [
|
||||
// Select the endpoint we're updating.
|
||||
{ "endpoint.id": endpointID },
|
||||
// Select any signing secrets with the given ids.
|
||||
{ "signingSecret.kid": { $in: secretKIDsToDeprecate } },
|
||||
],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
|
||||
export interface CreateTenantWebhookEndpointInput {
|
||||
url: string;
|
||||
all: boolean;
|
||||
events: GQLWEBHOOK_EVENT_NAME[];
|
||||
}
|
||||
|
||||
export async function createTenantWebhookEndpoint(
|
||||
mongo: Db,
|
||||
id: string,
|
||||
input: CreateTenantWebhookEndpointInput,
|
||||
now: Date
|
||||
) {
|
||||
// Create the new endpoint.
|
||||
const endpoint: Endpoint = {
|
||||
...input,
|
||||
id: uuid(),
|
||||
enabled: true,
|
||||
signingSecrets: [generateSecret("whsec", now)],
|
||||
createdAt: now,
|
||||
};
|
||||
|
||||
// Update the Tenant with this new endpoint.
|
||||
const result = await collection(mongo).findOneAndUpdate(
|
||||
{ id },
|
||||
{ $push: { "webhooks.endpoints": endpoint } },
|
||||
{
|
||||
// False to return the updated document instead of the original
|
||||
// document.
|
||||
returnOriginal: false,
|
||||
}
|
||||
);
|
||||
if (!result.value) {
|
||||
const tenant = await retrieveTenant(mongo, id);
|
||||
if (!tenant) {
|
||||
return {
|
||||
endpoint: null,
|
||||
tenant: null,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error("update failed for an unexpected reason");
|
||||
}
|
||||
|
||||
return {
|
||||
endpoint,
|
||||
tenant: result.value,
|
||||
};
|
||||
}
|
||||
|
||||
export interface UpdateTenantWebhookEndpointInput {
|
||||
enabled?: boolean;
|
||||
url?: string;
|
||||
all?: boolean;
|
||||
events?: GQLWEBHOOK_EVENT_NAME[];
|
||||
}
|
||||
|
||||
export async function updateTenantWebhookEndpoint(
|
||||
mongo: Db,
|
||||
id: string,
|
||||
endpointID: string,
|
||||
update: UpdateTenantWebhookEndpointInput
|
||||
) {
|
||||
const $set = dotize(
|
||||
{ "webhooks.endpoints.$[endpoint]": update },
|
||||
{ embedArrays: true }
|
||||
);
|
||||
|
||||
// Check to see if there is any updates that will be made.
|
||||
if (isEmpty($set)) {
|
||||
// No updates need to be made, abort here and just return the tenant.
|
||||
return retrieveTenant(mongo, id);
|
||||
}
|
||||
|
||||
// Perform the actual update operation.
|
||||
const result = await collection(mongo).findOneAndUpdate(
|
||||
{ id },
|
||||
{ $set },
|
||||
{
|
||||
// False to return the updated document instead of the original
|
||||
// document.
|
||||
returnOriginal: false,
|
||||
arrayFilters: [{ "endpoint.id": endpointID }],
|
||||
}
|
||||
);
|
||||
if (!result.value) {
|
||||
const tenant = await retrieveTenant(mongo, id);
|
||||
if (!tenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const endpoint = getWebhookEndpoint(tenant, endpointID);
|
||||
if (!endpoint) {
|
||||
throw new Error(
|
||||
`endpoint not found with id: ${endpointID} on tenant: ${id}`
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error("update failed for an unexpected reason");
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
|
||||
export async function deleteEndpointSecrets(
|
||||
mongo: Db,
|
||||
id: string,
|
||||
endpointID: string,
|
||||
kids: string[]
|
||||
) {
|
||||
const result = await collection(mongo).findOneAndUpdate(
|
||||
{ id },
|
||||
{
|
||||
$pull: {
|
||||
"webhooks.endpoints.$[endpoint].signingSecrets": { kid: { $in: kids } },
|
||||
},
|
||||
},
|
||||
{ returnOriginal: false, arrayFilters: [{ "endpoint.id": endpointID }] }
|
||||
);
|
||||
if (!result.value) {
|
||||
const tenant = await retrieveTenant(mongo, id);
|
||||
if (!tenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const endpoint = getWebhookEndpoint(tenant, endpointID);
|
||||
if (!endpoint) {
|
||||
throw new Error(
|
||||
`endpoint not found with id: ${endpointID} on tenant: ${id}`
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error("update failed for an unexpected reason");
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
|
||||
export async function deleteTenantWebhookEndpoint(
|
||||
mongo: Db,
|
||||
id: string,
|
||||
endpointID: string
|
||||
) {
|
||||
const result = await collection(mongo).findOneAndUpdate(
|
||||
{ id },
|
||||
{
|
||||
$pull: {
|
||||
"webhooks.endpoints": { id: endpointID },
|
||||
},
|
||||
},
|
||||
{
|
||||
// False to return the updated document instead of the original
|
||||
// document.
|
||||
returnOriginal: false,
|
||||
}
|
||||
);
|
||||
if (!result.value) {
|
||||
const tenant = await retrieveTenant(mongo, id);
|
||||
if (!tenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
throw new Error("update failed for an unexpected reason");
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
|
||||
@@ -576,7 +576,7 @@ export async function findOrCreateUser(
|
||||
const user = await findOrCreateUserInput(tenantID, input, now);
|
||||
|
||||
try {
|
||||
await collection(mongo).findOneAndUpdate(
|
||||
const result = await collection(mongo).findOneAndUpdate(
|
||||
{
|
||||
tenantID,
|
||||
profiles: {
|
||||
@@ -588,12 +588,18 @@ export async function findOrCreateUser(
|
||||
},
|
||||
{ $setOnInsert: user },
|
||||
{
|
||||
// False to return the updated document instead of the original
|
||||
// document.
|
||||
returnOriginal: false,
|
||||
// True to return the original document instead of the updated document.
|
||||
// This will ensure that when an upsert operation adds a new User, it
|
||||
// should return null.
|
||||
returnOriginal: true,
|
||||
upsert: true,
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
user: result.value || user,
|
||||
wasUpserted: !result.value,
|
||||
};
|
||||
} catch (err) {
|
||||
// Evaluate the error, if it is in regards to violating the unique index,
|
||||
// then return a duplicate User error.
|
||||
@@ -607,8 +613,6 @@ export async function findOrCreateUser(
|
||||
|
||||
throw err;
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
export type CreateUserInput = FindOrCreateUserInput;
|
||||
|
||||
@@ -4,9 +4,11 @@ import Logger from "bunyan";
|
||||
import TIME from "coral-common/time";
|
||||
import logger from "coral-server/logger";
|
||||
|
||||
export interface TaskOptions<T, U = any> {
|
||||
export type JobProcessor<T, U = void> = (job: Job<T>) => Promise<U>;
|
||||
|
||||
export interface TaskOptions<T, U = void> {
|
||||
jobName: string;
|
||||
jobProcessor: (job: Job<T>) => Promise<U>;
|
||||
jobProcessor: JobProcessor<T, U>;
|
||||
jobOptions?: Queue.JobOptions;
|
||||
queue: Queue.QueueOptions;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Queue from "bull";
|
||||
import { Redis } from "ioredis";
|
||||
import { Db } from "mongodb";
|
||||
|
||||
import { Config } from "coral-server/config";
|
||||
@@ -10,6 +11,7 @@ import TenantCache from "coral-server/services/tenant/cache";
|
||||
import { createMailerTask, MailerQueue } from "./tasks/mailer";
|
||||
import { createNotifierTask, NotifierQueue } from "./tasks/notifier";
|
||||
import { createScraperTask, ScraperQueue } from "./tasks/scraper";
|
||||
import { createWebhookTask, WebhookQueue } from "./tasks/webhook";
|
||||
|
||||
const createQueueOptions = async (
|
||||
config: Config
|
||||
@@ -47,12 +49,14 @@ export interface QueueOptions {
|
||||
tenantCache: TenantCache;
|
||||
i18n: I18n;
|
||||
signingConfig: JWTSigningConfig;
|
||||
redis: Redis;
|
||||
}
|
||||
|
||||
export interface TaskQueue {
|
||||
mailer: MailerQueue;
|
||||
scraper: ScraperQueue;
|
||||
notifier: NotifierQueue;
|
||||
webhook: WebhookQueue;
|
||||
}
|
||||
|
||||
export async function createQueue(options: QueueOptions): Promise<TaskQueue> {
|
||||
@@ -67,11 +71,13 @@ export async function createQueue(options: QueueOptions): Promise<TaskQueue> {
|
||||
mailerQueue: mailer,
|
||||
...options,
|
||||
});
|
||||
const webhook = createWebhookTask(queueOptions, options);
|
||||
|
||||
// Return the tasks + client.
|
||||
return {
|
||||
mailer,
|
||||
scraper,
|
||||
notifier,
|
||||
webhook,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import Queue from "bull";
|
||||
import { groupBy } from "lodash";
|
||||
import { Db } from "mongodb";
|
||||
|
||||
import { Config } from "coral-server/config";
|
||||
import { SUBSCRIPTION_CHANNELS } from "coral-server/graph/resolvers/Subscription/types";
|
||||
import logger from "coral-server/logger";
|
||||
import { CoralEventType } from "coral-server/events";
|
||||
import Task from "coral-server/queue/Task";
|
||||
import { MailerQueue } from "coral-server/queue/tasks/mailer";
|
||||
import { JWTSigningConfig } from "coral-server/services/jwt";
|
||||
@@ -29,34 +27,32 @@ interface Options {
|
||||
* that could be sent to users.
|
||||
*/
|
||||
export class NotifierQueue {
|
||||
private registry: Record<SUBSCRIPTION_CHANNELS, NotificationCategory[]>;
|
||||
private task: Task<NotifierData>;
|
||||
|
||||
constructor(queue: Queue.QueueOptions, options: Options) {
|
||||
const registry = new Map<CoralEventType, NotificationCategory[]>();
|
||||
|
||||
// Notification categories have been grouped by their event name so that
|
||||
// each event emitted need only access the associated notification once.
|
||||
this.registry = groupBy(categories, "event") as Record<
|
||||
SUBSCRIPTION_CHANNELS,
|
||||
NotificationCategory[]
|
||||
>;
|
||||
for (const category of categories) {
|
||||
for (const event of category.events as CoralEventType[]) {
|
||||
let handlers = registry.get(event);
|
||||
if (!handlers) {
|
||||
handlers = [];
|
||||
}
|
||||
handlers.push(category);
|
||||
registry.set(event, handlers);
|
||||
}
|
||||
}
|
||||
|
||||
this.task = new Task({
|
||||
jobName: JOB_NAME,
|
||||
jobProcessor: createJobProcessor({ registry: this.registry, ...options }),
|
||||
jobProcessor: createJobProcessor({ registry, ...options }),
|
||||
queue,
|
||||
});
|
||||
}
|
||||
|
||||
public async add(data: NotifierData) {
|
||||
// Get all the handlers that are active for this channel.
|
||||
const c = this.registry[data.input.channel];
|
||||
if (!c || c.length === 0) {
|
||||
logger.debug(
|
||||
{ channel: data.input.channel },
|
||||
"no notifications registered on this channel"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
return this.task.add(data);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { SUBSCRIPTION_INPUT } from "coral-server/graph/resolvers/Subscription/types";
|
||||
import { GQLDIGEST_FREQUENCY } from "coral-server/graph/schema/__generated__/types";
|
||||
import { CoralEventPayload } from "coral-server/events/event";
|
||||
import logger from "coral-server/logger";
|
||||
import { NotificationCategory } from "coral-server/services/notifications/categories";
|
||||
import NotificationContext from "coral-server/services/notifications/context";
|
||||
import { Notification } from "coral-server/services/notifications/notification";
|
||||
|
||||
import { GQLDIGEST_FREQUENCY } from "coral-server/graph/schema/__generated__/types";
|
||||
|
||||
import { MailerQueue } from "../mailer";
|
||||
import { DigestibleTemplate } from "../mailer/templates";
|
||||
import { CategoryNotification } from "./processor";
|
||||
@@ -52,11 +53,11 @@ export const filterSuperseded = (
|
||||
export const handleHandlers = async (
|
||||
ctx: NotificationContext,
|
||||
categories: NotificationCategory[],
|
||||
input: SUBSCRIPTION_INPUT
|
||||
payload: CoralEventPayload
|
||||
): Promise<CategoryNotification[]> => {
|
||||
const notifications: Array<CategoryNotification | null> = await Promise.all(
|
||||
categories.map(async category => {
|
||||
const notification = await category.process(ctx, input.payload);
|
||||
const notification = await category.process(ctx, payload);
|
||||
if (!notification) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -2,10 +2,8 @@ import { Job } from "bull";
|
||||
import { Db } from "mongodb";
|
||||
|
||||
import { Config } from "coral-server/config";
|
||||
import {
|
||||
SUBSCRIPTION_CHANNELS,
|
||||
SUBSCRIPTION_INPUT,
|
||||
} from "coral-server/graph/resolvers/Subscription/types";
|
||||
import { CoralEventType } from "coral-server/events";
|
||||
import { NotifierCoralEventListenerPayloads } from "coral-server/events/listeners/notifier";
|
||||
import logger from "coral-server/logger";
|
||||
import { MailerQueue } from "coral-server/queue/tasks/mailer";
|
||||
import { JWTSigningConfig } from "coral-server/services/jwt";
|
||||
@@ -27,14 +25,14 @@ export const JOB_NAME = "notifications";
|
||||
*/
|
||||
export interface NotifierData {
|
||||
tenantID: string;
|
||||
input: SUBSCRIPTION_INPUT;
|
||||
input: NotifierCoralEventListenerPayloads;
|
||||
}
|
||||
|
||||
interface Options {
|
||||
mailerQueue: MailerQueue;
|
||||
mongo: Db;
|
||||
config: Config;
|
||||
registry: Record<SUBSCRIPTION_CHANNELS, NotificationCategory[]>;
|
||||
registry: Map<CoralEventType, NotificationCategory[]>;
|
||||
tenantCache: TenantCache;
|
||||
signingConfig: JWTSigningConfig;
|
||||
}
|
||||
@@ -83,7 +81,7 @@ export const createJobProcessor = ({
|
||||
|
||||
try {
|
||||
// Get all the handlers that are active for this channel.
|
||||
const categories = registry[input.channel];
|
||||
const categories = registry.get(input.type);
|
||||
if (!categories || categories.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import Queue from "bull";
|
||||
|
||||
import Task from "coral-server/queue/Task";
|
||||
|
||||
import {
|
||||
createJobProcessor,
|
||||
JOB_NAME,
|
||||
WebhookData,
|
||||
WebhookProcessorOptions,
|
||||
} from "./processor";
|
||||
|
||||
export type WebhookQueue = Task<WebhookData>;
|
||||
|
||||
export const createWebhookTask = (
|
||||
queue: Queue.QueueOptions,
|
||||
options: WebhookProcessorOptions
|
||||
) =>
|
||||
new Task({
|
||||
jobName: JOB_NAME,
|
||||
jobProcessor: createJobProcessor(options),
|
||||
queue,
|
||||
});
|
||||
@@ -0,0 +1,262 @@
|
||||
import crypto from "crypto";
|
||||
import { Redis } from "ioredis";
|
||||
import { Db } from "mongodb";
|
||||
import getNow from "performance-now";
|
||||
|
||||
import { Config } from "coral-server/config";
|
||||
import { CoralEventPayload } from "coral-server/events/event";
|
||||
import logger from "coral-server/logger";
|
||||
import {
|
||||
filterActiveSecrets,
|
||||
filterExpiredSecrets,
|
||||
} from "coral-server/models/settings";
|
||||
import {
|
||||
deleteEndpointSecrets,
|
||||
Endpoint,
|
||||
getWebhookEndpoint,
|
||||
} from "coral-server/models/tenant";
|
||||
import { JobProcessor } from "coral-server/queue/Task";
|
||||
import { createFetch, FetchOptions } from "coral-server/services/fetch";
|
||||
import { disableWebhookEndpoint } from "coral-server/services/tenant";
|
||||
import TenantCache from "coral-server/services/tenant/cache";
|
||||
|
||||
export const JOB_NAME = "webhook";
|
||||
|
||||
// The count of failures on a webhook delivery before we disable the endpoint.
|
||||
const MAXIMUM_FAILURE_COUNT = 10;
|
||||
|
||||
// The number of webhook attempts that should be retained for debugging.
|
||||
const MAXIMUM_EVENT_ATTEMPTS_LOG_SIZE = 50;
|
||||
|
||||
export interface WebhookProcessorOptions {
|
||||
config: Config;
|
||||
mongo: Db;
|
||||
redis: Redis;
|
||||
tenantCache: TenantCache;
|
||||
}
|
||||
|
||||
export interface WebhookData {
|
||||
contextID: string;
|
||||
endpointID: string;
|
||||
tenantID: string;
|
||||
event: CoralEventPayload;
|
||||
}
|
||||
|
||||
export interface WebhookDelivery {
|
||||
id: string;
|
||||
name: string;
|
||||
success: boolean;
|
||||
status: number;
|
||||
statusText: string;
|
||||
request: string;
|
||||
response: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* generateSignature will generate a signature used to assist clients to
|
||||
* validate that the request came from Coral.
|
||||
*
|
||||
* @param secret the secret used to sign the body with
|
||||
* @param body the body to use when signing
|
||||
*/
|
||||
export function generateSignature(secret: string, body: string) {
|
||||
return crypto
|
||||
.createHmac("sha256", secret)
|
||||
.update(body)
|
||||
.digest()
|
||||
.toString("hex");
|
||||
}
|
||||
|
||||
export function generateSignatures(
|
||||
endpoint: Pick<Endpoint, "signingSecrets">,
|
||||
body: string,
|
||||
now: Date
|
||||
) {
|
||||
// For each of the signatures, we only want to sign the body with secrets that
|
||||
// are still active.
|
||||
return endpoint.signingSecrets
|
||||
.filter(filterActiveSecrets(now))
|
||||
.map(({ secret }) => generateSignature(secret, body))
|
||||
.map(signature => `sha256=${signature}`)
|
||||
.join(",");
|
||||
}
|
||||
|
||||
export function generateFetchOptions(
|
||||
endpoint: Pick<Endpoint, "signingSecrets">,
|
||||
data: CoralEventPayload,
|
||||
now: Date
|
||||
): FetchOptions {
|
||||
// Serialize the body and signature to include in the request.
|
||||
const body = JSON.stringify(data, null, 2);
|
||||
const signature = generateSignatures(endpoint, body, now);
|
||||
|
||||
const headers: Record<string, any> = {
|
||||
"Content-Type": "application/json",
|
||||
"X-Coral-Event": data.type,
|
||||
"X-Coral-Signature": signature,
|
||||
};
|
||||
|
||||
return {
|
||||
method: "POST",
|
||||
headers,
|
||||
body,
|
||||
};
|
||||
}
|
||||
|
||||
export function createJobProcessor({
|
||||
mongo,
|
||||
tenantCache,
|
||||
redis,
|
||||
}: WebhookProcessorOptions): JobProcessor<WebhookData> {
|
||||
// Create the fetcher that will orchestrate sending the actual webhooks.
|
||||
const fetch = createFetch({ name: "Webhook" });
|
||||
|
||||
return async job => {
|
||||
const { tenantID, endpointID, contextID, event } = job.data;
|
||||
|
||||
const log = logger.child(
|
||||
{
|
||||
eventID: event.id,
|
||||
contextID,
|
||||
jobID: job.id,
|
||||
jobName: JOB_NAME,
|
||||
tenantID,
|
||||
endpointID,
|
||||
},
|
||||
true
|
||||
);
|
||||
|
||||
// Get the referenced tenant so we can get the endpoint details.
|
||||
const tenant = await tenantCache.retrieveByID(tenantID);
|
||||
if (!tenant) {
|
||||
log.error("referenced tenant was not found");
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the referenced endpoint.
|
||||
const endpoint = getWebhookEndpoint(tenant, endpointID);
|
||||
if (!endpoint) {
|
||||
log.error("referenced endpoint was not found");
|
||||
return;
|
||||
}
|
||||
|
||||
// If the endpoint is disabled, don't bother processing it.
|
||||
if (!endpoint.enabled) {
|
||||
log.warn("endpoint was disabled, skipping sending");
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the current date.
|
||||
const now = new Date();
|
||||
|
||||
// Get the fetch options.
|
||||
const options = generateFetchOptions(endpoint, event, now);
|
||||
|
||||
// Send the request.
|
||||
const startedSendingAt = getNow();
|
||||
const res = await fetch(endpoint.url, options);
|
||||
const took = getNow() - startedSendingAt;
|
||||
if (res.ok) {
|
||||
log.info(
|
||||
{ took, responseStatus: res.status },
|
||||
"finished sending webhook"
|
||||
);
|
||||
} else {
|
||||
log.warn(
|
||||
{ took, responseStatus: res.status },
|
||||
"failed to deliver webhook"
|
||||
);
|
||||
}
|
||||
|
||||
// Grab the response from the webhook, we'll want to save this in the recent
|
||||
// attempts.
|
||||
const response = await res.text();
|
||||
|
||||
// Collect the delivery information.
|
||||
const delivery: WebhookDelivery = {
|
||||
id: event.id,
|
||||
name: event.type,
|
||||
success: res.ok,
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
// We only serialize the body as a string.
|
||||
request: options.body as string,
|
||||
response,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
// Record the delivery.
|
||||
const endpointDeliveriesKey = `${tenantID}:endpointDeliveries:${endpointID}`;
|
||||
const endpointFailuresKey = `${tenantID}:endpointFailures:${endpointID}`;
|
||||
let [, , [, failuresString]] = await redis
|
||||
.multi()
|
||||
// Push the attempt into the list.
|
||||
.rpush(endpointDeliveriesKey, JSON.stringify(delivery))
|
||||
// Trim the list to the 50 most recent attempts.
|
||||
.ltrim(endpointDeliveriesKey, 0, MAXIMUM_EVENT_ATTEMPTS_LOG_SIZE - 1)
|
||||
// Get the current failure count.
|
||||
.get(endpointFailuresKey)
|
||||
// Execute the queued operations.
|
||||
.exec();
|
||||
|
||||
let failures = failuresString ? parseInt(failuresString, 10) : null;
|
||||
if (res.ok && failures && failures > 0) {
|
||||
// The webhook delivery was a success, and there were previous failures.
|
||||
// Remove the failures record.
|
||||
await redis.del(endpointFailuresKey);
|
||||
} else if (!res.ok) {
|
||||
// Record the failed attempt.
|
||||
failuresString = await redis.incr(endpointFailuresKey);
|
||||
|
||||
// If the failure count is higher than the allowed maximum, disable the
|
||||
// endpoint.
|
||||
failures = failuresString ? parseInt(failuresString, 10) : null;
|
||||
if (failures && failures >= MAXIMUM_FAILURE_COUNT) {
|
||||
log.warn(
|
||||
{ failures, maxFailures: MAXIMUM_FAILURE_COUNT },
|
||||
"maximum failures reached, disabling endpoint"
|
||||
);
|
||||
|
||||
await disableWebhookEndpoint(
|
||||
mongo,
|
||||
redis,
|
||||
tenantCache,
|
||||
tenant,
|
||||
endpointID
|
||||
);
|
||||
} else {
|
||||
// TODO: (wyattjoh) maybe schedule a retry?
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the expired secrets in the next tick so that it does not affect
|
||||
// the sending performance of this job, and errors do not impact the
|
||||
// sending.
|
||||
const expiredSigningSecrets = endpoint.signingSecrets.filter(
|
||||
filterExpiredSecrets(now)
|
||||
);
|
||||
if (expiredSigningSecrets.length > 0) {
|
||||
process.nextTick(() => {
|
||||
deleteEndpointSecrets(
|
||||
mongo,
|
||||
tenantID,
|
||||
endpoint.id,
|
||||
expiredSigningSecrets.map(s => s.kid)
|
||||
)
|
||||
.then(() => {
|
||||
log.info(
|
||||
{ secrets: expiredSigningSecrets.length },
|
||||
"removed expired secrets from endpoint"
|
||||
);
|
||||
})
|
||||
.catch(err => {
|
||||
log.error(
|
||||
{ err },
|
||||
"an error occurred when trying to remove expired secrets"
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { Db } from "mongodb";
|
||||
|
||||
import { Omit } from "coral-common/types";
|
||||
import { CommentNotFoundError } from "coral-server/errors";
|
||||
import { Publisher } from "coral-server/graph/subscriptions/publisher";
|
||||
import { CoralEventPublisherBroker } from "coral-server/events/publisher";
|
||||
import {
|
||||
ACTION_TYPE,
|
||||
CreateActionInput,
|
||||
@@ -76,7 +76,7 @@ export async function addCommentActionCounts(
|
||||
async function addCommentAction(
|
||||
mongo: Db,
|
||||
redis: AugmentedRedis,
|
||||
publisher: Publisher,
|
||||
broker: CoralEventPublisherBroker,
|
||||
tenant: Tenant,
|
||||
input: Omit<CreateActionInput, "storyID" | "siteID">,
|
||||
now = new Date()
|
||||
@@ -116,7 +116,7 @@ async function addCommentAction(
|
||||
});
|
||||
|
||||
// Publish changes to the event publisher.
|
||||
await publishChanges(publisher, {
|
||||
await publishChanges(broker, {
|
||||
...counts,
|
||||
before: oldComment,
|
||||
after: updatedComment,
|
||||
@@ -131,7 +131,7 @@ async function addCommentAction(
|
||||
export async function removeCommentAction(
|
||||
mongo: Db,
|
||||
redis: AugmentedRedis,
|
||||
publisher: Publisher,
|
||||
broker: CoralEventPublisherBroker,
|
||||
tenant: Tenant,
|
||||
input: Omit<RemoveActionInput, "commentRevisionID" | "reason">
|
||||
): Promise<Readonly<Comment>> {
|
||||
@@ -191,7 +191,7 @@ export async function removeCommentAction(
|
||||
});
|
||||
|
||||
// Publish changes to the event publisher.
|
||||
await publishChanges(publisher, {
|
||||
await publishChanges(broker, {
|
||||
...counts,
|
||||
before: oldComment,
|
||||
after: updatedComment,
|
||||
@@ -211,7 +211,7 @@ export type CreateCommentReaction = Pick<
|
||||
export async function createReaction(
|
||||
mongo: Db,
|
||||
redis: AugmentedRedis,
|
||||
publish: Publisher,
|
||||
broker: CoralEventPublisherBroker,
|
||||
tenant: Tenant,
|
||||
author: User,
|
||||
input: CreateCommentReaction,
|
||||
@@ -220,7 +220,7 @@ export async function createReaction(
|
||||
return addCommentAction(
|
||||
mongo,
|
||||
redis,
|
||||
publish,
|
||||
broker,
|
||||
tenant,
|
||||
{
|
||||
actionType: ACTION_TYPE.REACTION,
|
||||
@@ -237,12 +237,12 @@ export type RemoveCommentReaction = Pick<RemoveActionInput, "commentID">;
|
||||
export async function removeReaction(
|
||||
mongo: Db,
|
||||
redis: AugmentedRedis,
|
||||
publisher: Publisher,
|
||||
broker: CoralEventPublisherBroker,
|
||||
tenant: Tenant,
|
||||
author: User,
|
||||
input: RemoveCommentReaction
|
||||
) {
|
||||
return removeCommentAction(mongo, redis, publisher, tenant, {
|
||||
return removeCommentAction(mongo, redis, broker, tenant, {
|
||||
actionType: ACTION_TYPE.REACTION,
|
||||
commentID: input.commentID,
|
||||
userID: author.id,
|
||||
@@ -257,7 +257,7 @@ export type CreateCommentDontAgree = Pick<
|
||||
export async function createDontAgree(
|
||||
mongo: Db,
|
||||
redis: AugmentedRedis,
|
||||
publish: Publisher,
|
||||
broker: CoralEventPublisherBroker,
|
||||
tenant: Tenant,
|
||||
author: User,
|
||||
input: CreateCommentDontAgree,
|
||||
@@ -266,7 +266,7 @@ export async function createDontAgree(
|
||||
return addCommentAction(
|
||||
mongo,
|
||||
redis,
|
||||
publish,
|
||||
broker,
|
||||
tenant,
|
||||
{
|
||||
actionType: ACTION_TYPE.DONT_AGREE,
|
||||
@@ -284,12 +284,12 @@ export type RemoveCommentDontAgree = Pick<RemoveActionInput, "commentID">;
|
||||
export async function removeDontAgree(
|
||||
mongo: Db,
|
||||
redis: AugmentedRedis,
|
||||
publisher: Publisher,
|
||||
broker: CoralEventPublisherBroker,
|
||||
tenant: Tenant,
|
||||
author: User,
|
||||
input: RemoveCommentDontAgree
|
||||
) {
|
||||
return removeCommentAction(mongo, redis, publisher, tenant, {
|
||||
return removeCommentAction(mongo, redis, broker, tenant, {
|
||||
actionType: ACTION_TYPE.DONT_AGREE,
|
||||
commentID: input.commentID,
|
||||
userID: author.id,
|
||||
@@ -306,7 +306,7 @@ export type CreateCommentFlag = Pick<
|
||||
export async function createFlag(
|
||||
mongo: Db,
|
||||
redis: AugmentedRedis,
|
||||
publish: Publisher,
|
||||
broker: CoralEventPublisherBroker,
|
||||
tenant: Tenant,
|
||||
author: User,
|
||||
input: CreateCommentFlag,
|
||||
@@ -315,7 +315,7 @@ export async function createFlag(
|
||||
return addCommentAction(
|
||||
mongo,
|
||||
redis,
|
||||
publish,
|
||||
broker,
|
||||
tenant,
|
||||
{
|
||||
actionType: ACTION_TYPE.FLAG,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { isNil } from "lodash";
|
||||
import fetch from "node-fetch";
|
||||
import path from "path";
|
||||
import { URL } from "url";
|
||||
|
||||
@@ -18,6 +17,7 @@ import {
|
||||
IntermediatePhaseResult,
|
||||
ModerationPhaseContext,
|
||||
} from "coral-server/services/comments/pipeline";
|
||||
import { createFetch } from "coral-server/services/fetch";
|
||||
|
||||
import {
|
||||
GQLCOMMENT_FLAG_REASON,
|
||||
@@ -26,6 +26,11 @@ import {
|
||||
GQLPerspectiveExternalIntegration,
|
||||
} from "coral-server/graph/schema/__generated__/types";
|
||||
|
||||
/**
|
||||
* fetch is the phase hook fetcher used to communicate with the Perspective API.
|
||||
*/
|
||||
const fetch = createFetch({ name: "Hooks" });
|
||||
|
||||
export const toxic: IntermediateModerationPhase = async ({
|
||||
tenant,
|
||||
nudge,
|
||||
|
||||
@@ -1,153 +1,131 @@
|
||||
import { SUBSCRIPTION_CHANNELS } from "coral-server/graph/resolvers/Subscription/types";
|
||||
import { Publisher } from "coral-server/graph/subscriptions/publisher";
|
||||
import { Comment, hasPublishedStatus } from "coral-server/models/comment";
|
||||
import { CommentModerationQueueCounts } from "coral-server/models/comment/counts";
|
||||
import {
|
||||
CommentCreatedCoralEvent,
|
||||
CommentEnteredModerationQueueCoralEvent,
|
||||
CommentFeaturedCoralEvent,
|
||||
CommentLeftModerationQueueCoralEvent,
|
||||
CommentReleasedCoralEvent,
|
||||
CommentReplyCreatedCoralEvent,
|
||||
CommentStatusUpdatedCoralEvent,
|
||||
} from "coral-server/events";
|
||||
import { CoralEventPublisherBroker } from "coral-server/events/publisher";
|
||||
import {
|
||||
Comment,
|
||||
CommentModerationQueueCounts,
|
||||
hasPublishedStatus,
|
||||
} from "coral-server/models/comment";
|
||||
|
||||
import {
|
||||
GQLCOMMENT_STATUS,
|
||||
GQLMODERATION_QUEUE,
|
||||
} from "coral-server/graph/schema/__generated__/types";
|
||||
|
||||
export function publishCommentStatusChanges(
|
||||
publish: Publisher,
|
||||
export async function publishCommentStatusChanges(
|
||||
broker: CoralEventPublisherBroker,
|
||||
oldStatus: GQLCOMMENT_STATUS,
|
||||
newStatus: GQLCOMMENT_STATUS,
|
||||
commentID: string,
|
||||
moderatorID: string | null
|
||||
) {
|
||||
if (oldStatus !== newStatus) {
|
||||
publish({
|
||||
channel: SUBSCRIPTION_CHANNELS.COMMENT_STATUS_UPDATED,
|
||||
payload: {
|
||||
newStatus,
|
||||
oldStatus,
|
||||
commentID,
|
||||
moderatorID,
|
||||
},
|
||||
await CommentStatusUpdatedCoralEvent.publish(broker, {
|
||||
newStatus,
|
||||
oldStatus,
|
||||
commentID,
|
||||
moderatorID,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function publishCommentReplyCreated(
|
||||
publish: Publisher,
|
||||
export async function publishCommentReplyCreated(
|
||||
broker: CoralEventPublisherBroker,
|
||||
comment: Pick<Comment, "id" | "status" | "ancestorIDs">
|
||||
) {
|
||||
if (comment.ancestorIDs.length > 0 && hasPublishedStatus(comment)) {
|
||||
publish({
|
||||
channel: SUBSCRIPTION_CHANNELS.COMMENT_REPLY_CREATED,
|
||||
payload: {
|
||||
ancestorIDs: comment.ancestorIDs,
|
||||
commentID: comment.id,
|
||||
},
|
||||
await CommentReplyCreatedCoralEvent.publish(broker, {
|
||||
ancestorIDs: comment.ancestorIDs,
|
||||
commentID: comment.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function publishCommentCreated(
|
||||
publish: Publisher,
|
||||
export async function publishCommentCreated(
|
||||
broker: CoralEventPublisherBroker,
|
||||
comment: Pick<Comment, "id" | "storyID" | "parentID" | "status">
|
||||
) {
|
||||
if (!comment.parentID && hasPublishedStatus(comment)) {
|
||||
publish({
|
||||
channel: SUBSCRIPTION_CHANNELS.COMMENT_CREATED,
|
||||
payload: {
|
||||
commentID: comment.id,
|
||||
storyID: comment.storyID,
|
||||
},
|
||||
await CommentCreatedCoralEvent.publish(broker, {
|
||||
commentID: comment.id,
|
||||
storyID: comment.storyID,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function publishCommentReleased(
|
||||
publish: Publisher,
|
||||
export async function publishCommentReleased(
|
||||
broker: CoralEventPublisherBroker,
|
||||
comment: Pick<Comment, "id" | "storyID" | "parentID" | "status">
|
||||
) {
|
||||
if (!comment.parentID && hasPublishedStatus(comment)) {
|
||||
publish({
|
||||
channel: SUBSCRIPTION_CHANNELS.COMMENT_RELEASED,
|
||||
payload: {
|
||||
commentID: comment.id,
|
||||
storyID: comment.storyID,
|
||||
},
|
||||
await CommentReleasedCoralEvent.publish(broker, {
|
||||
commentID: comment.id,
|
||||
storyID: comment.storyID,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function publishCommentFeatured(
|
||||
publish: Publisher,
|
||||
export async function publishCommentFeatured(
|
||||
broker: CoralEventPublisherBroker,
|
||||
comment: Pick<Comment, "id" | "status" | "storyID">
|
||||
) {
|
||||
if (hasPublishedStatus(comment)) {
|
||||
publish({
|
||||
channel: SUBSCRIPTION_CHANNELS.COMMENT_FEATURED,
|
||||
payload: {
|
||||
commentID: comment.id,
|
||||
storyID: comment.storyID,
|
||||
},
|
||||
await CommentFeaturedCoralEvent.publish(broker, {
|
||||
commentID: comment.id,
|
||||
storyID: comment.storyID,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function publishModerationQueueChanges(
|
||||
publish: Publisher,
|
||||
export async function publishModerationQueueChanges(
|
||||
broker: CoralEventPublisherBroker,
|
||||
moderationQueue: Pick<CommentModerationQueueCounts, "queues">,
|
||||
comment: Pick<Comment, "id" | "storyID">
|
||||
) {
|
||||
if (moderationQueue.queues.pending === 1) {
|
||||
publish({
|
||||
channel: SUBSCRIPTION_CHANNELS.COMMENT_ENTERED_MODERATION_QUEUE,
|
||||
payload: {
|
||||
queue: GQLMODERATION_QUEUE.PENDING,
|
||||
commentID: comment.id,
|
||||
storyID: comment.storyID,
|
||||
},
|
||||
await CommentEnteredModerationQueueCoralEvent.publish(broker, {
|
||||
queue: GQLMODERATION_QUEUE.PENDING,
|
||||
commentID: comment.id,
|
||||
storyID: comment.storyID,
|
||||
});
|
||||
} else if (moderationQueue.queues.pending === -1) {
|
||||
publish({
|
||||
channel: SUBSCRIPTION_CHANNELS.COMMENT_LEFT_MODERATION_QUEUE,
|
||||
payload: {
|
||||
queue: GQLMODERATION_QUEUE.PENDING,
|
||||
commentID: comment.id,
|
||||
storyID: comment.storyID,
|
||||
},
|
||||
await CommentLeftModerationQueueCoralEvent.publish(broker, {
|
||||
queue: GQLMODERATION_QUEUE.PENDING,
|
||||
commentID: comment.id,
|
||||
storyID: comment.storyID,
|
||||
});
|
||||
}
|
||||
if (moderationQueue.queues.reported === 1) {
|
||||
publish({
|
||||
channel: SUBSCRIPTION_CHANNELS.COMMENT_ENTERED_MODERATION_QUEUE,
|
||||
payload: {
|
||||
queue: GQLMODERATION_QUEUE.REPORTED,
|
||||
commentID: comment.id,
|
||||
storyID: comment.storyID,
|
||||
},
|
||||
await CommentEnteredModerationQueueCoralEvent.publish(broker, {
|
||||
queue: GQLMODERATION_QUEUE.REPORTED,
|
||||
commentID: comment.id,
|
||||
storyID: comment.storyID,
|
||||
});
|
||||
} else if (moderationQueue.queues.reported === -1) {
|
||||
publish({
|
||||
channel: SUBSCRIPTION_CHANNELS.COMMENT_LEFT_MODERATION_QUEUE,
|
||||
payload: {
|
||||
queue: GQLMODERATION_QUEUE.REPORTED,
|
||||
commentID: comment.id,
|
||||
storyID: comment.storyID,
|
||||
},
|
||||
await CommentLeftModerationQueueCoralEvent.publish(broker, {
|
||||
queue: GQLMODERATION_QUEUE.REPORTED,
|
||||
commentID: comment.id,
|
||||
storyID: comment.storyID,
|
||||
});
|
||||
}
|
||||
if (moderationQueue.queues.unmoderated === 1) {
|
||||
publish({
|
||||
channel: SUBSCRIPTION_CHANNELS.COMMENT_ENTERED_MODERATION_QUEUE,
|
||||
payload: {
|
||||
queue: GQLMODERATION_QUEUE.UNMODERATED,
|
||||
commentID: comment.id,
|
||||
storyID: comment.storyID,
|
||||
},
|
||||
await CommentEnteredModerationQueueCoralEvent.publish(broker, {
|
||||
queue: GQLMODERATION_QUEUE.UNMODERATED,
|
||||
commentID: comment.id,
|
||||
storyID: comment.storyID,
|
||||
});
|
||||
} else if (moderationQueue.queues.unmoderated === -1) {
|
||||
publish({
|
||||
channel: SUBSCRIPTION_CHANNELS.COMMENT_LEFT_MODERATION_QUEUE,
|
||||
payload: {
|
||||
queue: GQLMODERATION_QUEUE.UNMODERATED,
|
||||
commentID: comment.id,
|
||||
storyID: comment.storyID,
|
||||
},
|
||||
await CommentLeftModerationQueueCoralEvent.publish(broker, {
|
||||
queue: GQLMODERATION_QUEUE.UNMODERATED,
|
||||
commentID: comment.id,
|
||||
storyID: comment.storyID,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import http from "http";
|
||||
import https from "https";
|
||||
import { capitalize } from "lodash";
|
||||
import fetch, { RequestInit, Response } from "node-fetch";
|
||||
import { URL } from "url";
|
||||
|
||||
import { version } from "coral-common/version";
|
||||
|
||||
import abortAfter from "./abortAfter";
|
||||
|
||||
export type Fetch = (url: string, options?: FetchOptions) => Promise<Response>;
|
||||
|
||||
export interface CreateFetchOptions {
|
||||
/**
|
||||
* name is the string that is attached to the `User-Agent` header as:
|
||||
*
|
||||
* `Coral ${name}/${version}`
|
||||
*/
|
||||
name: string;
|
||||
}
|
||||
|
||||
export type FetchOptions = RequestInit & {
|
||||
/**
|
||||
* timeout is the number of seconds that the request will wait for a response
|
||||
* before timing out.
|
||||
*/
|
||||
timeout?: number;
|
||||
};
|
||||
|
||||
export const createFetch = ({ name }: CreateFetchOptions): Fetch => {
|
||||
// Create HTTP agents to improve connection performance.
|
||||
const agents = {
|
||||
https: new https.Agent({
|
||||
keepAlive: true,
|
||||
}),
|
||||
http: new http.Agent({
|
||||
keepAlive: true,
|
||||
}),
|
||||
};
|
||||
|
||||
// agent will select the correct agent to use for reusing the agent.
|
||||
const agent = (url: URL) =>
|
||||
url.protocol === "http:" ? agents.http : agents.https;
|
||||
|
||||
// defaultHeaders are the headers attached to each request (unless they are
|
||||
// overridden).
|
||||
const defaultHeaders = {
|
||||
"User-Agent": `Coral ${capitalize(name)}/${version}`,
|
||||
};
|
||||
|
||||
// Return the actual fetcher that just uses fetch under the hood.
|
||||
return async (
|
||||
url: string,
|
||||
{
|
||||
headers = {},
|
||||
// Default to 10 seconds for the timeout.
|
||||
timeout = 10000,
|
||||
...options
|
||||
}: FetchOptions = {}
|
||||
) => {
|
||||
// Abort the scrape request after the timeout is reached.
|
||||
const abort = abortAfter(timeout);
|
||||
|
||||
try {
|
||||
// Perform the actual fetch operation.
|
||||
const res = await fetch(url, {
|
||||
agent,
|
||||
headers: {
|
||||
...defaultHeaders,
|
||||
...headers,
|
||||
},
|
||||
// Attach the controller signal to abort the request after the timeout
|
||||
// is reached.
|
||||
signal: abort.controller.signal,
|
||||
// Merge in the passed options.
|
||||
...options,
|
||||
});
|
||||
|
||||
return res;
|
||||
} finally {
|
||||
clearTimeout(abort.timeout);
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./fetch";
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Db } from "mongodb";
|
||||
|
||||
import { SSOKey } from "coral-server/models/settings";
|
||||
import { generateSSOKey, Tenant } from "coral-server/models/tenant";
|
||||
import { Secret } from "coral-server/models/settings";
|
||||
import { generateSecret, Tenant } from "coral-server/models/tenant";
|
||||
import Migration from "coral-server/services/migrate/migration";
|
||||
import collections from "coral-server/services/mongodb/collections";
|
||||
|
||||
@@ -48,14 +48,14 @@ export default class extends Migration {
|
||||
}
|
||||
|
||||
// Store the keys in an array.
|
||||
const keys: SSOKey[] = [];
|
||||
const keys: Secret[] = [];
|
||||
|
||||
// Check to see if a key is set.
|
||||
const sso = tenant.auth.integrations.sso;
|
||||
|
||||
if (sso.key && sso.keyGeneratedAt) {
|
||||
// Create the new SSOKey based on this data.
|
||||
const key = generateSSOKey(sso.keyGeneratedAt);
|
||||
const key = generateSecret("ssosec", sso.keyGeneratedAt);
|
||||
|
||||
// Set the secret of the sso key to the secret of the current set key.
|
||||
key.secret = sso.key;
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Db } from "mongodb";
|
||||
|
||||
import Migration from "coral-server/services/migrate/migration";
|
||||
import collections from "coral-server/services/mongodb/collections";
|
||||
|
||||
import { MigrationError } from "../error";
|
||||
|
||||
export default class extends Migration {
|
||||
public async up(mongo: Db, id: string) {
|
||||
await collections.tenants(mongo).updateOne(
|
||||
{ id, webhooks: null },
|
||||
{
|
||||
$set: {
|
||||
webhooks: {
|
||||
endpoints: [],
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public async test(mongo: Db, id: string) {
|
||||
// Ensure that the tenant has the webhooks set.
|
||||
const tenant = await collections.tenants(mongo).findOne({ id });
|
||||
if (!tenant) {
|
||||
throw new MigrationError(
|
||||
id,
|
||||
"could not find the specified tenant",
|
||||
"tenants",
|
||||
[id]
|
||||
);
|
||||
}
|
||||
|
||||
if (!tenant.webhooks) {
|
||||
throw new MigrationError(
|
||||
id,
|
||||
"tenant did not have webhooks set",
|
||||
"tenants",
|
||||
[id]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async down(mongo: Db, id: string) {
|
||||
await collections
|
||||
.tenants(mongo)
|
||||
.updateOne({ id }, { $unset: { webhooks: "" } });
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { DateTime } from "luxon";
|
||||
import { Db } from "mongodb";
|
||||
|
||||
import { SSOKey } from "coral-server/models/settings";
|
||||
import { Secret } from "coral-server/models/settings";
|
||||
import Migration from "coral-server/services/migrate/migration";
|
||||
import collections from "coral-server/services/mongodb/collections";
|
||||
|
||||
@@ -15,16 +15,16 @@ interface OldSSOKey {
|
||||
deletedAt?: Date;
|
||||
}
|
||||
|
||||
function isOldSSOKey(key: SSOKey | OldSSOKey): key is OldSSOKey {
|
||||
function isOldSSOKey(key: Secret | OldSSOKey): key is OldSSOKey {
|
||||
if (!key) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ((key as SSOKey).inactiveAt) {
|
||||
if ((key as Secret).inactiveAt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((key as SSOKey).rotatedAt) {
|
||||
if ((key as Secret).rotatedAt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ export default class extends Migration {
|
||||
|
||||
// Transform the keys into the new format.
|
||||
const keys: OldSSOKey[] = tenant.auth.integrations.sso.keys.map(
|
||||
(key: OldSSOKey | SSOKey): OldSSOKey =>
|
||||
(key: OldSSOKey | Secret): OldSSOKey =>
|
||||
!isOldSSOKey(key)
|
||||
? {
|
||||
kid: key.kid,
|
||||
@@ -100,8 +100,8 @@ export default class extends Migration {
|
||||
}
|
||||
|
||||
// Transform the keys into the new format.
|
||||
const keys: SSOKey[] = tenant.auth.integrations.sso.keys.map(
|
||||
(key): SSOKey => ({
|
||||
const keys: Secret[] = tenant.auth.integrations.sso.keys.map(
|
||||
(key): Secret => ({
|
||||
kid: key.kid,
|
||||
secret: key.secret || "<deleted>",
|
||||
createdAt: key.createdAt,
|
||||
|
||||
@@ -8,10 +8,10 @@ import { staffReply } from "./staffReply";
|
||||
* categories stores all the notification categories in a flat list.
|
||||
*/
|
||||
const categories: NotificationCategory[] = [
|
||||
...reply,
|
||||
...staffReply,
|
||||
...featured,
|
||||
...moderation,
|
||||
reply,
|
||||
staffReply,
|
||||
moderation,
|
||||
featured,
|
||||
];
|
||||
|
||||
export default categories;
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import {
|
||||
SUBSCRIPTION_CHANNELS,
|
||||
SUBSCRIPTION_INPUT,
|
||||
} from "coral-server/graph/resolvers/Subscription/types";
|
||||
import { CoralEventPayload } from "coral-server/events/event";
|
||||
|
||||
import NotificationContext from "../context";
|
||||
import { Notification } from "../notification";
|
||||
@@ -10,7 +7,7 @@ import { Notification } from "../notification";
|
||||
* NotificationCategory define the Category that is used to define a
|
||||
* Notification type.
|
||||
*/
|
||||
export interface NotificationCategory {
|
||||
export interface NotificationCategory<T extends CoralEventPayload = any> {
|
||||
/**
|
||||
* name is the actual name of the notification that can be used to define the
|
||||
* other category names that are superseded by this one.
|
||||
@@ -23,14 +20,14 @@ export interface NotificationCategory {
|
||||
*/
|
||||
process: (
|
||||
ctx: NotificationContext,
|
||||
input: SUBSCRIPTION_INPUT["payload"]
|
||||
payload: T
|
||||
) => Promise<Notification | null>;
|
||||
|
||||
/**
|
||||
* event is the subscription event that when fired, will trigger this
|
||||
* events is the subscription event that when fired, will trigger this
|
||||
* notification processor to be called.
|
||||
*/
|
||||
event: SUBSCRIPTION_CHANNELS;
|
||||
events: T["type"][];
|
||||
|
||||
/**
|
||||
* digestOrder, when provided, allows the custom ordering of notifications in
|
||||
|
||||
@@ -1,63 +1,58 @@
|
||||
import { CommentFeaturedInput } from "coral-server/graph/resolvers/Subscription/commentFeatured";
|
||||
import { SUBSCRIPTION_CHANNELS } from "coral-server/graph/resolvers/Subscription/types";
|
||||
import {
|
||||
CommentFeaturedCoralEventPayload,
|
||||
CoralEventType,
|
||||
} from "coral-server/events";
|
||||
import { hasPublishedStatus } from "coral-server/models/comment";
|
||||
|
||||
import { getStoryTitle, getURLWithCommentID } from "coral-server/models/story";
|
||||
import NotificationContext from "../context";
|
||||
import { Notification } from "../notification";
|
||||
import { NotificationCategory } from "./category";
|
||||
|
||||
async function processor(
|
||||
ctx: NotificationContext,
|
||||
input: CommentFeaturedInput
|
||||
): Promise<Notification | null> {
|
||||
// Get the comment that was featured.
|
||||
const comment = await ctx.comments.load(input.commentID);
|
||||
if (!comment || (!hasPublishedStatus(comment) || !comment.authorID)) {
|
||||
return null;
|
||||
}
|
||||
export const featured: NotificationCategory<
|
||||
CommentFeaturedCoralEventPayload
|
||||
> = {
|
||||
name: "featured",
|
||||
process: async (ctx, input) => {
|
||||
// Get the comment that was featured.
|
||||
const comment = await ctx.comments.load(input.data.commentID);
|
||||
if (!comment || !hasPublishedStatus(comment) || !comment.authorID) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get the comment's author.
|
||||
const author = await ctx.users.load(comment.authorID);
|
||||
if (!author) {
|
||||
return null;
|
||||
}
|
||||
// Get the comment's author.
|
||||
const author = await ctx.users.load(comment.authorID);
|
||||
if (!author) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check to see if the user has this notification type enabled.
|
||||
if (!author.notifications.onFeatured) {
|
||||
return null;
|
||||
}
|
||||
// Check to see if the user has this notification type enabled.
|
||||
if (!author.notifications.onFeatured) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get the story that this was written on.
|
||||
const story = await ctx.stories.load(comment.storyID);
|
||||
if (!story) {
|
||||
return null;
|
||||
}
|
||||
// Get the story that this was written on.
|
||||
const story = await ctx.stories.load(comment.storyID);
|
||||
if (!story) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Generate the unsubscribe URL.
|
||||
const unsubscribeURL = await ctx.generateUnsubscribeURL(author);
|
||||
// Generate the unsubscribe URL.
|
||||
const unsubscribeURL = await ctx.generateUnsubscribeURL(author);
|
||||
|
||||
return {
|
||||
userID: author.id,
|
||||
template: {
|
||||
name: "notification/on-featured",
|
||||
context: {
|
||||
commentPermalink: getURLWithCommentID(story.url, comment.id),
|
||||
storyTitle: getStoryTitle(story),
|
||||
storyURL: story.url,
|
||||
organizationName: ctx.tenant.organization.name,
|
||||
organizationURL: ctx.tenant.organization.url,
|
||||
unsubscribeURL,
|
||||
return {
|
||||
userID: author.id,
|
||||
template: {
|
||||
name: "notification/on-featured",
|
||||
context: {
|
||||
commentPermalink: getURLWithCommentID(story.url, comment.id),
|
||||
storyTitle: getStoryTitle(story),
|
||||
storyURL: story.url,
|
||||
organizationName: ctx.tenant.organization.name,
|
||||
organizationURL: ctx.tenant.organization.url,
|
||||
unsubscribeURL,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const featured: NotificationCategory[] = [
|
||||
{
|
||||
name: "featured",
|
||||
process: processor,
|
||||
event: SUBSCRIPTION_CHANNELS.COMMENT_FEATURED,
|
||||
digestOrder: 30,
|
||||
};
|
||||
},
|
||||
];
|
||||
events: [CoralEventType.COMMENT_FEATURED],
|
||||
digestOrder: 30,
|
||||
};
|
||||
|
||||
@@ -1,84 +1,80 @@
|
||||
import { CommentStatusUpdatedInput } from "coral-server/graph/resolvers/Subscription/commentStatusUpdated";
|
||||
import { SUBSCRIPTION_CHANNELS } from "coral-server/graph/resolvers/Subscription/types";
|
||||
import {
|
||||
CommentStatusUpdatedCoralEventPayload,
|
||||
CoralEventType,
|
||||
} from "coral-server/events";
|
||||
import { hasModeratorStatus } from "coral-server/models/comment";
|
||||
import { getURLWithCommentID } from "coral-server/models/story";
|
||||
|
||||
import { GQLCOMMENT_STATUS } from "coral-server/graph/schema/__generated__/types";
|
||||
import { getURLWithCommentID } from "coral-server/models/story";
|
||||
import NotificationContext from "../context";
|
||||
import { Notification } from "../notification";
|
||||
|
||||
import { NotificationCategory } from "./category";
|
||||
|
||||
async function processor(
|
||||
ctx: NotificationContext,
|
||||
input: CommentStatusUpdatedInput
|
||||
): Promise<Notification | null> {
|
||||
// Check to see if this comment was previously in a moderation status.
|
||||
if (!hasModeratorStatus({ status: input.oldStatus })) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Load the comment in question.
|
||||
const comment = await ctx.comments.load(input.commentID);
|
||||
if (!comment || !comment.authorID) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get the comment author.
|
||||
const author = await ctx.users.load(comment.authorID);
|
||||
if (!author) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check to see if this user has notifications enabled.
|
||||
if (!author.notifications.onModeration) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Generate the unsubscribe URL.
|
||||
const unsubscribeURL = await ctx.generateUnsubscribeURL(author);
|
||||
|
||||
// Check to see which template we should use.
|
||||
if (comment.status === GQLCOMMENT_STATUS.APPROVED) {
|
||||
// Get the story that this was written on.
|
||||
const story = await ctx.stories.load(comment.storyID);
|
||||
if (!story) {
|
||||
export const moderation: NotificationCategory<
|
||||
CommentStatusUpdatedCoralEventPayload
|
||||
> = {
|
||||
name: "moderation",
|
||||
process: async (ctx, input) => {
|
||||
// Check to see if this comment was previously in a moderation status.
|
||||
if (!hasModeratorStatus({ status: input.data.oldStatus })) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
userID: author.id,
|
||||
template: {
|
||||
name: "notification/on-comment-approved",
|
||||
context: {
|
||||
commentPermalink: getURLWithCommentID(story.url, comment.id),
|
||||
organizationName: ctx.tenant.organization.name,
|
||||
organizationURL: ctx.tenant.organization.url,
|
||||
unsubscribeURL,
|
||||
},
|
||||
},
|
||||
};
|
||||
} else if (comment.status === GQLCOMMENT_STATUS.REJECTED) {
|
||||
return {
|
||||
userID: author.id,
|
||||
template: {
|
||||
name: "notification/on-comment-rejected",
|
||||
context: {
|
||||
organizationName: ctx.tenant.organization.name,
|
||||
organizationURL: ctx.tenant.organization.url,
|
||||
unsubscribeURL,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
// Load the comment in question.
|
||||
const comment = await ctx.comments.load(input.data.commentID);
|
||||
if (!comment || !comment.authorID) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
// Get the comment author.
|
||||
const author = await ctx.users.load(comment.authorID);
|
||||
if (!author) {
|
||||
return null;
|
||||
}
|
||||
|
||||
export const moderation: NotificationCategory[] = [
|
||||
{
|
||||
name: "moderation",
|
||||
process: processor,
|
||||
event: SUBSCRIPTION_CHANNELS.COMMENT_STATUS_UPDATED,
|
||||
digestOrder: 30,
|
||||
// Check to see if this user has notifications enabled.
|
||||
if (!author.notifications.onModeration) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Generate the unsubscribe URL.
|
||||
const unsubscribeURL = await ctx.generateUnsubscribeURL(author);
|
||||
|
||||
// Check to see which template we should use.
|
||||
if (comment.status === GQLCOMMENT_STATUS.APPROVED) {
|
||||
// Get the story that this was written on.
|
||||
const story = await ctx.stories.load(comment.storyID);
|
||||
if (!story) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
userID: author.id,
|
||||
template: {
|
||||
name: "notification/on-comment-approved",
|
||||
context: {
|
||||
commentPermalink: getURLWithCommentID(story.url, comment.id),
|
||||
organizationName: ctx.tenant.organization.name,
|
||||
organizationURL: ctx.tenant.organization.url,
|
||||
unsubscribeURL,
|
||||
},
|
||||
},
|
||||
};
|
||||
} else if (comment.status === GQLCOMMENT_STATUS.REJECTED) {
|
||||
return {
|
||||
userID: author.id,
|
||||
template: {
|
||||
name: "notification/on-comment-rejected",
|
||||
context: {
|
||||
organizationName: ctx.tenant.organization.name,
|
||||
organizationURL: ctx.tenant.organization.url,
|
||||
unsubscribeURL,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
];
|
||||
events: [CoralEventType.COMMENT_STATUS_UPDATED],
|
||||
digestOrder: 30,
|
||||
};
|
||||
|
||||
@@ -1,112 +1,95 @@
|
||||
import { CommentReplyCreatedInput } from "coral-server/graph/resolvers/Subscription/commentReplyCreated";
|
||||
import { CommentStatusUpdatedInput } from "coral-server/graph/resolvers/Subscription/commentStatusUpdated";
|
||||
import { SUBSCRIPTION_CHANNELS } from "coral-server/graph/resolvers/Subscription/types";
|
||||
import {
|
||||
CommentReplyCreatedCoralEventPayload,
|
||||
CommentStatusUpdatedCoralEventPayload,
|
||||
CoralEventType,
|
||||
} from "coral-server/events";
|
||||
import { hasPublishedStatus } from "coral-server/models/comment";
|
||||
import { getStoryTitle, getURLWithCommentID } from "coral-server/models/story";
|
||||
|
||||
import NotificationContext from "../context";
|
||||
import { Notification } from "../notification";
|
||||
import { NotificationCategory } from "./category";
|
||||
|
||||
async function processor(
|
||||
ctx: NotificationContext,
|
||||
input: CommentReplyCreatedInput
|
||||
): Promise<Notification | null> {
|
||||
const comment = await ctx.comments.load(input.commentID);
|
||||
if (!comment || !hasPublishedStatus(comment) || !comment.authorID) {
|
||||
return null;
|
||||
}
|
||||
type Payloads =
|
||||
| CommentReplyCreatedCoralEventPayload
|
||||
| CommentStatusUpdatedCoralEventPayload;
|
||||
|
||||
// Check to see if this is a reply to an existing comment.
|
||||
if (!comment.parentID) {
|
||||
return null;
|
||||
}
|
||||
export const reply: NotificationCategory<Payloads> = {
|
||||
name: "reply",
|
||||
process: async (ctx, input) => {
|
||||
const comment = await ctx.comments.load(input.data.commentID);
|
||||
if (!comment || !hasPublishedStatus(comment)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get the parent comment.
|
||||
const parent = await ctx.comments.load(comment.parentID);
|
||||
if (!parent || !hasPublishedStatus(parent) || !parent.authorID) {
|
||||
return null;
|
||||
}
|
||||
// TODO: evaluate storing a history of comment statuses so we can ensure we don't double send.
|
||||
|
||||
// Get the parent comment's author.
|
||||
const [author, parentAuthor] = await ctx.users.loadMany([
|
||||
comment.authorID,
|
||||
parent.authorID,
|
||||
]);
|
||||
if (!author || !parentAuthor) {
|
||||
return null;
|
||||
}
|
||||
// Check to see if this is a reply to an existing comment.
|
||||
if (!comment.parentID || !comment.authorID) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check to see if the target user has notifications enabled for this type.
|
||||
if (!parentAuthor.notifications.onReply) {
|
||||
return null;
|
||||
}
|
||||
// Get the parent comment.
|
||||
const parent = await ctx.comments.load(comment.parentID);
|
||||
if (!parent || !hasPublishedStatus(parent) || !parent.authorID) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check to see if this is yourself replying to yourself, if that's the case
|
||||
// don't send a notification.
|
||||
if (parentAuthor.id === author.id) {
|
||||
return null;
|
||||
}
|
||||
// Get the parent comment's author.
|
||||
const [author, parentAuthor] = await ctx.users.loadMany([
|
||||
comment.authorID,
|
||||
parent.authorID,
|
||||
]);
|
||||
if (!author || !parentAuthor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check to see if this user is ignoring the user who replied to their
|
||||
// comment.
|
||||
if (parentAuthor.ignoredUsers.some(user => user.id === author.id)) {
|
||||
return null;
|
||||
}
|
||||
// Check to see if the target user has notifications enabled for this type.
|
||||
if (!parentAuthor.notifications.onReply) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get the story that this was written on.
|
||||
const story = await ctx.stories.load(comment.storyID);
|
||||
if (!story) {
|
||||
return null;
|
||||
}
|
||||
// Check to see if this is yourself replying to yourself, if that's the case
|
||||
// don't send a notification.
|
||||
if (parentAuthor.id === author.id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Generate the unsubscribe URL.
|
||||
const unsubscribeURL = await ctx.generateUnsubscribeURL(parentAuthor);
|
||||
// Check to see if this user is ignoring the user who replied to their
|
||||
// comment.
|
||||
if (parentAuthor.ignoredUsers.some(user => user.id === author.id)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// The user does have notifications for replied comments enabled, queue the
|
||||
// notification to be sent.
|
||||
return {
|
||||
userID: parentAuthor.id,
|
||||
template: {
|
||||
name: "notification/on-reply",
|
||||
context: {
|
||||
// We know that the user had a username because they wrote a comment!
|
||||
authorUsername: author.username!,
|
||||
commentPermalink: getURLWithCommentID(story.url, comment.id),
|
||||
storyTitle: getStoryTitle(story),
|
||||
storyURL: story.url,
|
||||
organizationName: ctx.tenant.organization.name,
|
||||
organizationURL: ctx.tenant.organization.url,
|
||||
unsubscribeURL,
|
||||
// Get the story that this was written on.
|
||||
const story = await ctx.stories.load(comment.storyID);
|
||||
if (!story) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Generate the unsubscribe URL.
|
||||
const unsubscribeURL = await ctx.generateUnsubscribeURL(parentAuthor);
|
||||
|
||||
// The user does have notifications for replied comments enabled, queue the
|
||||
// notification to be sent.
|
||||
return {
|
||||
userID: parentAuthor.id,
|
||||
template: {
|
||||
name: "notification/on-reply",
|
||||
context: {
|
||||
// We know that the user had a username because they wrote a comment!
|
||||
authorUsername: author.username!,
|
||||
commentPermalink: getURLWithCommentID(story.url, comment.id),
|
||||
storyTitle: getStoryTitle(story),
|
||||
storyURL: story.url,
|
||||
organizationName: ctx.tenant.organization.name,
|
||||
organizationURL: ctx.tenant.organization.url,
|
||||
unsubscribeURL,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const reply: NotificationCategory[] = [
|
||||
{
|
||||
name: "reply",
|
||||
process: processor,
|
||||
event: SUBSCRIPTION_CHANNELS.COMMENT_REPLY_CREATED,
|
||||
digestOrder: 30,
|
||||
};
|
||||
},
|
||||
{
|
||||
name: "reply",
|
||||
process: async (ctx, input: CommentStatusUpdatedInput) => {
|
||||
const comment = await ctx.comments.load(input.commentID);
|
||||
if (!comment || !hasPublishedStatus(comment)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// TODO: evaluate storing a history of comment statuses so we can ensure we don't double send.
|
||||
|
||||
// We've checked the status, let the processing continue!
|
||||
return processor(ctx, {
|
||||
commentID: comment.id,
|
||||
ancestorIDs: comment.ancestorIDs,
|
||||
});
|
||||
},
|
||||
event: SUBSCRIPTION_CHANNELS.COMMENT_STATUS_UPDATED,
|
||||
digestOrder: 30,
|
||||
},
|
||||
];
|
||||
events: [
|
||||
CoralEventType.COMMENT_STATUS_UPDATED,
|
||||
CoralEventType.COMMENT_REPLY_CREATED,
|
||||
],
|
||||
digestOrder: 30,
|
||||
};
|
||||
|
||||
@@ -1,114 +1,102 @@
|
||||
import { CommentReplyCreatedInput } from "coral-server/graph/resolvers/Subscription/commentReplyCreated";
|
||||
import { CommentStatusUpdatedInput } from "coral-server/graph/resolvers/Subscription/commentStatusUpdated";
|
||||
import { SUBSCRIPTION_CHANNELS } from "coral-server/graph/resolvers/Subscription/types";
|
||||
import {
|
||||
CommentReplyCreatedCoralEventPayload,
|
||||
CommentStatusUpdatedCoralEventPayload,
|
||||
CoralEventType,
|
||||
} from "coral-server/events";
|
||||
import { hasPublishedStatus } from "coral-server/models/comment";
|
||||
import { getStoryTitle, getURLWithCommentID } from "coral-server/models/story";
|
||||
import { hasStaffRole } from "coral-server/models/user/helpers";
|
||||
|
||||
import NotificationContext from "../context";
|
||||
import { Notification } from "../notification";
|
||||
import { NotificationCategory } from "./category";
|
||||
|
||||
async function processor(
|
||||
ctx: NotificationContext,
|
||||
input: CommentReplyCreatedInput
|
||||
): Promise<Notification | null> {
|
||||
const comment = await ctx.comments.load(input.commentID);
|
||||
if (!comment || !hasPublishedStatus(comment) || !comment.authorID) {
|
||||
return null;
|
||||
}
|
||||
type Payloads =
|
||||
| CommentStatusUpdatedCoralEventPayload
|
||||
| CommentReplyCreatedCoralEventPayload;
|
||||
|
||||
// Check to see if this is a reply to an existing comment.
|
||||
if (!comment.parentID) {
|
||||
return null;
|
||||
}
|
||||
export const staffReply: NotificationCategory<Payloads> = {
|
||||
name: "staffReply",
|
||||
process: async (ctx, input) => {
|
||||
const comment = await ctx.comments.load(input.data.commentID);
|
||||
if (!comment || !hasPublishedStatus(comment)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get the parent comment.
|
||||
const parent = await ctx.comments.load(comment.parentID);
|
||||
if (!parent || !hasPublishedStatus(parent) || !parent.authorID) {
|
||||
return null;
|
||||
}
|
||||
// TODO: evaluate storing a history of comment statuses so we can ensure we don't double send.
|
||||
|
||||
// Get the parent comment's author.
|
||||
const [author, parentAuthor] = await ctx.users.loadMany([
|
||||
comment.authorID,
|
||||
parent.authorID,
|
||||
]);
|
||||
if (!author || !parentAuthor) {
|
||||
return null;
|
||||
}
|
||||
// Check to see if this is a reply to an existing comment.
|
||||
if (!comment.parentID) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check to see if the author was a staff member.
|
||||
if (!hasStaffRole(author)) {
|
||||
return null;
|
||||
}
|
||||
// Get the parent comment.
|
||||
const parent = await ctx.comments.load(comment.parentID);
|
||||
if (
|
||||
!parent ||
|
||||
!hasPublishedStatus(parent) ||
|
||||
!parent.authorID ||
|
||||
!comment.authorID
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check to see if the target user has notifications enabled for this type.
|
||||
if (!parentAuthor.notifications.onStaffReplies) {
|
||||
return null;
|
||||
}
|
||||
// Get the parent comment's author.
|
||||
const [author, parentAuthor] = await ctx.users.loadMany([
|
||||
comment.authorID,
|
||||
parent.authorID,
|
||||
]);
|
||||
if (!author || !parentAuthor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check to see if this is yourself replying to yourself, if that's the case
|
||||
// don't send a notification.
|
||||
if (parentAuthor.id === author.id) {
|
||||
return null;
|
||||
}
|
||||
// Check to see if the author was a staff member.
|
||||
if (!hasStaffRole(author)) {
|
||||
// This is a handler for staff replies only.
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get the story that this was written on.
|
||||
const story = await ctx.stories.load(comment.storyID);
|
||||
if (!story) {
|
||||
return null;
|
||||
}
|
||||
// Check to see if the target user has notifications enabled for this type.
|
||||
if (!parentAuthor.notifications.onStaffReplies) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Generate the unsubscribe URL.
|
||||
const unsubscribeURL = await ctx.generateUnsubscribeURL(parentAuthor);
|
||||
// Check to see if this is yourself replying to yourself, if that's the case
|
||||
// don't send a notification.
|
||||
if (parentAuthor.id === author.id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// The user does have notifications for replied comments enabled, queue the
|
||||
// notification to be sent.
|
||||
return {
|
||||
userID: parentAuthor.id,
|
||||
template: {
|
||||
name: "notification/on-staff-reply",
|
||||
context: {
|
||||
// We know that the user had a username because they wrote a comment!
|
||||
authorUsername: author.username!,
|
||||
commentPermalink: getURLWithCommentID(story.url, comment.id),
|
||||
storyTitle: getStoryTitle(story),
|
||||
storyURL: story.url,
|
||||
organizationName: ctx.tenant.organization.name,
|
||||
organizationURL: ctx.tenant.organization.url,
|
||||
unsubscribeURL,
|
||||
// Get the story that this was written on.
|
||||
const story = await ctx.stories.load(comment.storyID);
|
||||
if (!story) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Generate the unsubscribe URL.
|
||||
const unsubscribeURL = await ctx.generateUnsubscribeURL(parentAuthor);
|
||||
|
||||
// The user does have notifications for replied comments enabled, queue the
|
||||
// notification to be sent.
|
||||
return {
|
||||
userID: parentAuthor.id,
|
||||
template: {
|
||||
name: "notification/on-staff-reply",
|
||||
context: {
|
||||
// We know that the user had a username because they wrote a comment!
|
||||
authorUsername: author.username!,
|
||||
commentPermalink: getURLWithCommentID(story.url, comment.id),
|
||||
storyTitle: getStoryTitle(story),
|
||||
storyURL: story.url,
|
||||
organizationName: ctx.tenant.organization.name,
|
||||
organizationURL: ctx.tenant.organization.url,
|
||||
unsubscribeURL,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const staffReply: NotificationCategory[] = [
|
||||
{
|
||||
name: "staffReply",
|
||||
process: processor,
|
||||
event: SUBSCRIPTION_CHANNELS.COMMENT_REPLY_CREATED,
|
||||
digestOrder: 30,
|
||||
supersedesCategories: ["reply"],
|
||||
};
|
||||
},
|
||||
{
|
||||
name: "staffReply",
|
||||
process: async (ctx, input: CommentStatusUpdatedInput) => {
|
||||
const comment = await ctx.comments.load(input.commentID);
|
||||
if (!comment || !hasPublishedStatus(comment)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// TODO: evaluate storing a history of comment statuses so we can ensure we don't double send.
|
||||
|
||||
// We've checked the status, let the processing continue!
|
||||
return processor(ctx, {
|
||||
commentID: comment.id,
|
||||
ancestorIDs: comment.ancestorIDs,
|
||||
});
|
||||
},
|
||||
event: SUBSCRIPTION_CHANNELS.COMMENT_STATUS_UPDATED,
|
||||
digestOrder: 30,
|
||||
supersedesCategories: ["reply"],
|
||||
},
|
||||
];
|
||||
events: [
|
||||
CoralEventType.COMMENT_STATUS_UPDATED,
|
||||
CoralEventType.COMMENT_REPLY_CREATED,
|
||||
],
|
||||
digestOrder: 30,
|
||||
supersedesCategories: ["reply"],
|
||||
};
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
import Logger from "bunyan";
|
||||
import DataLoader from "dataloader";
|
||||
import { Db } from "mongodb";
|
||||
|
||||
import { Config } from "coral-server/config";
|
||||
import { Comment, retrieveManyComments } from "coral-server/models/comment";
|
||||
import { retrieveManyStories, Story } from "coral-server/models/story";
|
||||
import { Tenant } from "coral-server/models/tenant";
|
||||
import { retrieveManyUsers, User } from "coral-server/models/user";
|
||||
import { Request } from "coral-server/types/express";
|
||||
|
||||
interface Options {
|
||||
mongo: Db;
|
||||
tenant: Pick<Tenant, "id" | "domain">;
|
||||
config: Config;
|
||||
req?: Request;
|
||||
}
|
||||
|
||||
class SlackContext {
|
||||
public readonly mongo: Db;
|
||||
public readonly tenant: Pick<Tenant, "id" | "domain">;
|
||||
public readonly logger: Logger;
|
||||
public readonly config: Config;
|
||||
public readonly req?: Request;
|
||||
|
||||
public readonly comments: DataLoader<
|
||||
string,
|
||||
Readonly<Comment> | null
|
||||
> = new DataLoader(commentIDs =>
|
||||
retrieveManyComments(this.mongo, this.tenant.id, commentIDs)
|
||||
);
|
||||
|
||||
public readonly stories: DataLoader<
|
||||
string,
|
||||
Readonly<Story> | null
|
||||
> = new DataLoader(storyIDs =>
|
||||
retrieveManyStories(this.mongo, this.tenant.id, storyIDs)
|
||||
);
|
||||
|
||||
public readonly users: DataLoader<
|
||||
string,
|
||||
Readonly<User> | null
|
||||
> = new DataLoader(userIDs =>
|
||||
retrieveManyUsers(this.mongo, this.tenant.id, userIDs)
|
||||
);
|
||||
|
||||
constructor({ mongo, tenant, config, req }: Options) {
|
||||
this.mongo = mongo;
|
||||
this.tenant = tenant;
|
||||
this.config = config;
|
||||
this.req = req;
|
||||
}
|
||||
}
|
||||
|
||||
export default SlackContext;
|
||||
@@ -1,3 +0,0 @@
|
||||
export { default as slackPublisher } from "./publisher";
|
||||
export * from "./publisher";
|
||||
export * from "./context";
|
||||
@@ -1,207 +0,0 @@
|
||||
import { Db } from "mongodb";
|
||||
|
||||
import { reconstructTenantURL } from "coral-server/app/url";
|
||||
import { Config } from "coral-server/config";
|
||||
import { CommentCreatedInput } from "coral-server/graph/resolvers/Subscription/commentCreated";
|
||||
import { CommentEnteredModerationQueueInput } from "coral-server/graph/resolvers/Subscription/commentEnteredModerationQueue";
|
||||
import { CommentFeaturedInput } from "coral-server/graph/resolvers/Subscription/commentFeatured";
|
||||
import { CommentLeftModerationQueueInput } from "coral-server/graph/resolvers/Subscription/commentLeftModerationQueue";
|
||||
import { CommentReleasedInput } from "coral-server/graph/resolvers/Subscription/commentReleased";
|
||||
import { CommentReplyCreatedInput } from "coral-server/graph/resolvers/Subscription/commentReplyCreated";
|
||||
import { CommentStatusUpdatedInput } from "coral-server/graph/resolvers/Subscription/commentStatusUpdated";
|
||||
import { SUBSCRIPTION_CHANNELS } from "coral-server/graph/resolvers/Subscription/types";
|
||||
import logger from "coral-server/logger";
|
||||
import { getLatestRevision } from "coral-server/models/comment/helpers";
|
||||
import {
|
||||
getStoryTitle,
|
||||
getURLWithCommentID,
|
||||
} from "coral-server/models/story/helpers";
|
||||
import { Tenant } from "coral-server/models/tenant";
|
||||
|
||||
import { GQLMODERATION_QUEUE } from "coral-server/graph/schema/__generated__/types";
|
||||
|
||||
import SlackContext from "./context";
|
||||
|
||||
type Payload =
|
||||
| CommentEnteredModerationQueueInput
|
||||
| CommentLeftModerationQueueInput
|
||||
| CommentStatusUpdatedInput
|
||||
| CommentReplyCreatedInput
|
||||
| CommentCreatedInput
|
||||
| CommentFeaturedInput
|
||||
| CommentReleasedInput;
|
||||
|
||||
function isFeatured(channel: SUBSCRIPTION_CHANNELS) {
|
||||
return channel === SUBSCRIPTION_CHANNELS.COMMENT_FEATURED;
|
||||
}
|
||||
|
||||
function isReported(channel: SUBSCRIPTION_CHANNELS, payload: Payload) {
|
||||
return (
|
||||
channel === SUBSCRIPTION_CHANNELS.COMMENT_ENTERED_MODERATION_QUEUE &&
|
||||
(payload as CommentEnteredModerationQueueInput).queue ===
|
||||
GQLMODERATION_QUEUE.REPORTED
|
||||
);
|
||||
}
|
||||
|
||||
function isPending(channel: SUBSCRIPTION_CHANNELS, payload: Payload) {
|
||||
return (
|
||||
channel === SUBSCRIPTION_CHANNELS.COMMENT_ENTERED_MODERATION_QUEUE &&
|
||||
(payload as CommentEnteredModerationQueueInput).queue ===
|
||||
GQLMODERATION_QUEUE.PENDING
|
||||
);
|
||||
}
|
||||
|
||||
function createModerationLink(ctx: SlackContext, commentID: string) {
|
||||
return reconstructTenantURL(
|
||||
ctx.config,
|
||||
ctx.tenant,
|
||||
ctx.req,
|
||||
`/admin/moderate/comment/${commentID}`
|
||||
);
|
||||
}
|
||||
|
||||
async function postCommentToSlack(
|
||||
ctx: SlackContext,
|
||||
message: string,
|
||||
commentID: string,
|
||||
hookURL: string
|
||||
) {
|
||||
const comment = await ctx.comments.load(commentID);
|
||||
if (comment === null || !comment.authorID) {
|
||||
return;
|
||||
}
|
||||
const author = await ctx.users.load(comment.authorID);
|
||||
if (author === null) {
|
||||
return;
|
||||
}
|
||||
const story = await ctx.stories.load(comment.storyID);
|
||||
if (story === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get some properties about the event.
|
||||
const storyTitle = getStoryTitle(story);
|
||||
const commentBody = getLatestRevision(comment).body;
|
||||
const moderateLink = createModerationLink(ctx, commentID);
|
||||
const commentLink = getURLWithCommentID(story.url, comment.id);
|
||||
|
||||
// Replace HTML link breaks with newlines.
|
||||
const body = commentBody.replace(/<br\/?>/g, "\n");
|
||||
|
||||
const data = {
|
||||
text: `${message} on *<${story.url}|${storyTitle}>*`,
|
||||
attachments: [
|
||||
{
|
||||
text: body,
|
||||
footer: `Authored by *${author.username}* | <${moderateLink}|Go to Moderation> | <${commentLink}|See Comment>`,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
try {
|
||||
// Send the post to the Slack URL.
|
||||
const res = await fetch(hookURL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
logger.error({ res }, "error sending Slack comment");
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error({ err }, "error sending Slack comment");
|
||||
}
|
||||
}
|
||||
|
||||
export type SlackPublisher = (
|
||||
channel: SUBSCRIPTION_CHANNELS,
|
||||
payload: Payload
|
||||
) => Promise<void>;
|
||||
|
||||
function createSlackPublisher(
|
||||
mongo: Db,
|
||||
config: Config,
|
||||
tenant: Tenant
|
||||
): SlackPublisher {
|
||||
if (
|
||||
!tenant.slack ||
|
||||
!tenant.slack.channels ||
|
||||
tenant.slack.channels.length === 0
|
||||
) {
|
||||
return async () => {
|
||||
// noop
|
||||
};
|
||||
}
|
||||
|
||||
const { channels } = tenant.slack;
|
||||
|
||||
return async (channel: SUBSCRIPTION_CHANNELS, payload: Payload) => {
|
||||
const ctx = new SlackContext({ mongo, config, tenant });
|
||||
|
||||
try {
|
||||
const reported = isReported(channel, payload);
|
||||
const pending = isPending(channel, payload);
|
||||
const featured = isFeatured(channel);
|
||||
|
||||
// If the comment doesn't match any filter, then we don't need to send
|
||||
// anything.
|
||||
if (!reported && !pending && !featured) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { commentID } = payload;
|
||||
|
||||
for (const ch of channels) {
|
||||
if (!ch) {
|
||||
return;
|
||||
}
|
||||
if (!ch.enabled) {
|
||||
return;
|
||||
}
|
||||
const { hookURL } = ch;
|
||||
if (!hookURL) {
|
||||
return;
|
||||
}
|
||||
const { triggers } = ch;
|
||||
if (!triggers) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Add ticket to add back all comments option (including approved)
|
||||
|
||||
if (triggers.reportedComments && reported) {
|
||||
await postCommentToSlack(
|
||||
ctx,
|
||||
"This comment has been reported",
|
||||
commentID,
|
||||
hookURL
|
||||
);
|
||||
} else if (triggers.pendingComments && pending) {
|
||||
await postCommentToSlack(
|
||||
ctx,
|
||||
"This comment is pending",
|
||||
commentID,
|
||||
hookURL
|
||||
);
|
||||
} else if (triggers.featuredComments && featured) {
|
||||
await postCommentToSlack(
|
||||
ctx,
|
||||
"This comment has been featured",
|
||||
commentID,
|
||||
hookURL
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
{ err, tenantID: tenant.id, channel, payload },
|
||||
"could not handle comment in Slack publisher"
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default createSlackPublisher;
|
||||
@@ -4,6 +4,8 @@ import { Db } from "mongodb";
|
||||
import isNonNullArray from "coral-common/helpers/isNonNullArray";
|
||||
import { Config } from "coral-server/config";
|
||||
import { StoryURLInvalidError } from "coral-server/errors";
|
||||
import { StoryCreatedCoralEvent } from "coral-server/events";
|
||||
import { CoralEventPublisherBroker } from "coral-server/events/publisher";
|
||||
import logger from "coral-server/logger";
|
||||
import {
|
||||
mergeCommentActionCounts,
|
||||
@@ -53,6 +55,7 @@ export type FindOrCreateStory = FindOrCreateStoryInput;
|
||||
export async function findOrCreate(
|
||||
mongo: Db,
|
||||
tenant: Tenant,
|
||||
broker: CoralEventPublisherBroker,
|
||||
input: FindOrCreateStory,
|
||||
scraper: ScraperQueue,
|
||||
now = new Date()
|
||||
@@ -70,11 +73,24 @@ export async function findOrCreate(
|
||||
siteID = site.id;
|
||||
}
|
||||
|
||||
const story = await findOrCreateStory(mongo, tenant.id, input, siteID, now);
|
||||
const { story, wasUpserted } = await findOrCreateStory(
|
||||
mongo,
|
||||
tenant.id,
|
||||
input,
|
||||
siteID,
|
||||
now
|
||||
);
|
||||
if (!story) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (wasUpserted) {
|
||||
StoryCreatedCoralEvent.publish(broker, {
|
||||
storyID: story.id,
|
||||
storyURL: story.url,
|
||||
});
|
||||
}
|
||||
|
||||
if (tenant.stories.scraping.enabled && !story.metadata && !story.scrapedAt) {
|
||||
// If the scraper has not scraped this story, and we have no metadata, we
|
||||
// need to scrape it now!
|
||||
@@ -160,6 +176,7 @@ export type CreateStory = Partial<
|
||||
export async function create(
|
||||
mongo: Db,
|
||||
tenant: Tenant,
|
||||
broker: CoralEventPublisherBroker,
|
||||
config: Config,
|
||||
storyID: string,
|
||||
storyURL: string,
|
||||
@@ -182,7 +199,7 @@ export async function create(
|
||||
}
|
||||
|
||||
// Create the story in the database.
|
||||
let newStory = await createStory(
|
||||
let story = await createStory(
|
||||
mongo,
|
||||
tenant.id,
|
||||
storyID,
|
||||
@@ -193,10 +210,15 @@ export async function create(
|
||||
if (!metadata && tenant.stories.scraping.enabled) {
|
||||
// If the scraper has not scraped this story and story metadata was not
|
||||
// provided, we need to scrape it now!
|
||||
newStory = await scrape(mongo, config, tenant.id, newStory.id, storyURL);
|
||||
story = await scrape(mongo, config, tenant.id, story.id, storyURL);
|
||||
}
|
||||
|
||||
return newStory;
|
||||
StoryCreatedCoralEvent.publish(broker, {
|
||||
storyID: story.id,
|
||||
storyURL: story.url,
|
||||
});
|
||||
|
||||
return story;
|
||||
}
|
||||
|
||||
export type UpdateStory = UpdateStoryInput;
|
||||
|
||||
@@ -5,19 +5,17 @@ import descriptionScraper from "metascraper-description";
|
||||
import imageScraper from "metascraper-image";
|
||||
import titleScraper from "metascraper-title";
|
||||
import { Db } from "mongodb";
|
||||
import fetch, { RequestInit } from "node-fetch";
|
||||
import ProxyAgent from "proxy-agent";
|
||||
|
||||
import { version } from "coral-common/version";
|
||||
import { Config } from "coral-server/config";
|
||||
import { ScrapeFailed } from "coral-server/errors";
|
||||
import logger from "coral-server/logger";
|
||||
import { retrieveStory, updateStory } from "coral-server/models/story";
|
||||
import { retrieveTenant } from "coral-server/models/tenant";
|
||||
import { createFetch, Fetch, FetchOptions } from "coral-server/services/fetch";
|
||||
|
||||
import { GQLStoryMetadata } from "coral-server/graph/schema/__generated__/types";
|
||||
|
||||
import abortAfter from "./abortAfter";
|
||||
import { modifiedScraper } from "./rules/modified";
|
||||
import { publishedScraper } from "./rules/published";
|
||||
import { sectionScraper } from "./rules/section";
|
||||
@@ -32,8 +30,10 @@ export type Rule = Record<
|
||||
class Scraper {
|
||||
private readonly rules: Rule[];
|
||||
private readonly log: Logger;
|
||||
private readonly fetch: Fetch;
|
||||
|
||||
constructor(rules: Rule[]) {
|
||||
this.fetch = createFetch({ name: "Scraper" });
|
||||
this.rules = rules;
|
||||
this.log = logger.child({ taskName: "scraper" }, true);
|
||||
}
|
||||
@@ -85,27 +85,25 @@ class Scraper {
|
||||
|
||||
public async download(
|
||||
url: string,
|
||||
abortAfterMilliseconds: number,
|
||||
timeout: number,
|
||||
customUserAgent?: string,
|
||||
proxyURL?: string
|
||||
) {
|
||||
const log = this.log.child({ storyURL: url }, true);
|
||||
|
||||
// Abort the scrape request after the timeout is reached.
|
||||
const { controller, timeout } = abortAfter(abortAfterMilliseconds);
|
||||
|
||||
const options: RequestInit = {
|
||||
headers: {
|
||||
"User-Agent": customUserAgent || `Talk Scraper/${version}`,
|
||||
},
|
||||
signal: controller.signal,
|
||||
};
|
||||
const options: FetchOptions = { timeout };
|
||||
if (customUserAgent) {
|
||||
options.headers = {
|
||||
...options.headers,
|
||||
"User-Agent": customUserAgent,
|
||||
};
|
||||
}
|
||||
|
||||
if (proxyURL) {
|
||||
// Force the type here because there's a slight mismatch.
|
||||
options.agent = (new ProxyAgent(
|
||||
proxyURL
|
||||
) as unknown) as RequestInit["agent"];
|
||||
) as unknown) as FetchOptions["agent"];
|
||||
log.debug("using proxy for scrape");
|
||||
}
|
||||
|
||||
@@ -113,8 +111,8 @@ class Scraper {
|
||||
log.debug("starting scrape of Story");
|
||||
|
||||
try {
|
||||
const res = await fetch(url, options);
|
||||
if (!res.ok || res.status !== 200) {
|
||||
const res = await this.fetch(url, options);
|
||||
if (!res.ok) {
|
||||
log.warn(
|
||||
{ statusCode: res.status, statusText: res.statusText },
|
||||
"scrape failed with non-200 status code"
|
||||
@@ -129,8 +127,6 @@ class Scraper {
|
||||
return html;
|
||||
} catch (err) {
|
||||
throw new ScrapeFailed(url, err);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,14 +194,12 @@ export async function scrape(
|
||||
|
||||
// This typecast is needed because the custom `ms` format does not return the
|
||||
// desired `number` type even though that's the only type it can output.
|
||||
const abortAfterMilliseconds = (config.get(
|
||||
"scrape_timeout"
|
||||
) as unknown) as number;
|
||||
const timeout = (config.get("scrape_timeout") as unknown) as number;
|
||||
|
||||
// Get the metadata from the scraped html.
|
||||
const metadata = await scraper.scrape(
|
||||
storyURL,
|
||||
abortAfterMilliseconds,
|
||||
timeout,
|
||||
tenant.stories.scraping.customUserAgent,
|
||||
tenant.stories.scraping.proxyURL
|
||||
);
|
||||
|
||||
@@ -14,12 +14,19 @@ import {
|
||||
createTenantAnnouncement,
|
||||
CreateTenantInput,
|
||||
createTenantSSOKey,
|
||||
createTenantWebhookEndpoint,
|
||||
CreateTenantWebhookEndpointInput,
|
||||
deleteTenantAnnouncement,
|
||||
deleteTenantWebhookEndpoint,
|
||||
disableTenantFeatureFlag,
|
||||
enableTenantFeatureFlag,
|
||||
getWebhookEndpoint,
|
||||
rollTenantWebhookEndpointSecret,
|
||||
rotateTenantSSOKey,
|
||||
Tenant,
|
||||
updateTenant,
|
||||
updateTenantWebhookEndpoint,
|
||||
UpdateTenantWebhookEndpointInput,
|
||||
} from "coral-server/models/tenant";
|
||||
import { I18n } from "coral-server/services/i18n";
|
||||
|
||||
@@ -27,6 +34,7 @@ import {
|
||||
GQLFEATURE_FLAG,
|
||||
GQLSettingsInput,
|
||||
GQLSettingsWordListInput,
|
||||
GQLWEBHOOK_EVENT_NAME,
|
||||
} from "coral-server/graph/schema/__generated__/types";
|
||||
|
||||
import TenantCache from "./cache";
|
||||
@@ -203,6 +211,258 @@ export async function discoverOIDCConfiguration(issuerString: string) {
|
||||
return discover(issuer);
|
||||
}
|
||||
|
||||
interface WebhookEndpointInput {
|
||||
url: string;
|
||||
all: boolean;
|
||||
events: GQLWEBHOOK_EVENT_NAME[];
|
||||
}
|
||||
|
||||
export function validateWebhookEndpointInput(
|
||||
config: Config,
|
||||
input: WebhookEndpointInput
|
||||
) {
|
||||
// Check to see that this URL is valid and has a https:// scheme if in
|
||||
// production mode.
|
||||
const url = new URL(input.url);
|
||||
if (config.get("env") === "production" && url.protocol !== "https:") {
|
||||
throw new Error(`invalid scheme provided in production: ${url.protocol}`);
|
||||
}
|
||||
|
||||
// Ensure that either the "all" or "events" is provided but not both.
|
||||
if (input.all && input.events.length > 0) {
|
||||
throw new Error("both all events and specific events were requested");
|
||||
}
|
||||
}
|
||||
|
||||
export async function createWebhookEndpoint(
|
||||
mongo: Db,
|
||||
redis: Redis,
|
||||
config: Config,
|
||||
cache: TenantCache,
|
||||
tenant: Tenant,
|
||||
input: CreateTenantWebhookEndpointInput,
|
||||
now: Date
|
||||
) {
|
||||
// Validate the input.
|
||||
validateWebhookEndpointInput(config, input);
|
||||
|
||||
// Looks good in create this, send it off to be created.
|
||||
const result = await createTenantWebhookEndpoint(
|
||||
mongo,
|
||||
tenant.id,
|
||||
input,
|
||||
now
|
||||
);
|
||||
if (!result.tenant) {
|
||||
throw new Error("could not create the tenant endpoint, tenant not found");
|
||||
}
|
||||
|
||||
// Update the tenant cache.
|
||||
await cache.update(redis, result.tenant);
|
||||
|
||||
return {
|
||||
endpoint: result.endpoint,
|
||||
settings: result.tenant,
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateWebhookEndpoint(
|
||||
mongo: Db,
|
||||
redis: Redis,
|
||||
config: Config,
|
||||
cache: TenantCache,
|
||||
tenant: Tenant,
|
||||
endpointID: string,
|
||||
input: UpdateTenantWebhookEndpointInput
|
||||
) {
|
||||
// Find the endpoint.
|
||||
let endpoint = getWebhookEndpoint(tenant, endpointID);
|
||||
if (!endpoint) {
|
||||
throw new Error("referenced endpoint was not found on tenant");
|
||||
}
|
||||
|
||||
// Extract the input.
|
||||
const {
|
||||
url = endpoint.url,
|
||||
all = endpoint.all,
|
||||
events = endpoint.events,
|
||||
} = input;
|
||||
|
||||
// Validate the input.
|
||||
validateWebhookEndpointInput(config, {
|
||||
url,
|
||||
all,
|
||||
events,
|
||||
});
|
||||
|
||||
const updatedTenant = await updateTenantWebhookEndpoint(
|
||||
mongo,
|
||||
tenant.id,
|
||||
endpointID,
|
||||
input
|
||||
);
|
||||
if (!updatedTenant) {
|
||||
throw new Error("tenant not found");
|
||||
}
|
||||
|
||||
// Update the tenant cache.
|
||||
await cache.update(redis, updatedTenant);
|
||||
|
||||
// Find the updated endpoint.
|
||||
endpoint = getWebhookEndpoint(updatedTenant, endpointID);
|
||||
if (!endpoint) {
|
||||
throw new Error("referenced endpoint was not found on tenant");
|
||||
}
|
||||
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
export async function enableWebhookEndpoint(
|
||||
mongo: Db,
|
||||
redis: Redis,
|
||||
cache: TenantCache,
|
||||
tenant: Tenant,
|
||||
endpointID: string
|
||||
) {
|
||||
// Find the endpoint.
|
||||
let endpoint = getWebhookEndpoint(tenant, endpointID);
|
||||
if (!endpoint) {
|
||||
throw new Error("referenced endpoint was not found on tenant");
|
||||
}
|
||||
|
||||
// Endpoint is already enabled.
|
||||
if (endpoint.enabled === true) {
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
const updatedTenant = await updateTenantWebhookEndpoint(
|
||||
mongo,
|
||||
tenant.id,
|
||||
endpointID,
|
||||
{ enabled: true }
|
||||
);
|
||||
if (!updatedTenant) {
|
||||
throw new Error("tenant not found");
|
||||
}
|
||||
|
||||
// Update the tenant cache.
|
||||
await cache.update(redis, updatedTenant);
|
||||
|
||||
// Find the updated endpoint.
|
||||
endpoint = getWebhookEndpoint(updatedTenant, endpointID);
|
||||
if (!endpoint) {
|
||||
throw new Error("referenced endpoint was not found on tenant");
|
||||
}
|
||||
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
export async function disableWebhookEndpoint(
|
||||
mongo: Db,
|
||||
redis: Redis,
|
||||
cache: TenantCache,
|
||||
tenant: Tenant,
|
||||
endpointID: string
|
||||
) {
|
||||
// Find the endpoint.
|
||||
let endpoint = getWebhookEndpoint(tenant, endpointID);
|
||||
if (!endpoint) {
|
||||
throw new Error("referenced endpoint was not found on tenant");
|
||||
}
|
||||
|
||||
// Endpoint is already disabled.
|
||||
if (endpoint.enabled === false) {
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
const updatedTenant = await updateTenantWebhookEndpoint(
|
||||
mongo,
|
||||
tenant.id,
|
||||
endpointID,
|
||||
{ enabled: false }
|
||||
);
|
||||
if (!updatedTenant) {
|
||||
throw new Error("tenant not found");
|
||||
}
|
||||
|
||||
// Update the tenant cache.
|
||||
await cache.update(redis, updatedTenant);
|
||||
|
||||
// Find the updated endpoint.
|
||||
endpoint = getWebhookEndpoint(updatedTenant, endpointID);
|
||||
if (!endpoint) {
|
||||
throw new Error("referenced endpoint was not found on tenant");
|
||||
}
|
||||
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
export async function deleteWebhookEndpoint(
|
||||
mongo: Db,
|
||||
redis: Redis,
|
||||
cache: TenantCache,
|
||||
tenant: Tenant,
|
||||
endpointID: string
|
||||
) {
|
||||
// Find the endpoint.
|
||||
const endpoint = getWebhookEndpoint(tenant, endpointID);
|
||||
if (!endpoint) {
|
||||
throw new Error("referenced endpoint was not found on tenant");
|
||||
}
|
||||
|
||||
const updatedTenant = await deleteTenantWebhookEndpoint(
|
||||
mongo,
|
||||
tenant.id,
|
||||
endpointID
|
||||
);
|
||||
if (!updatedTenant) {
|
||||
throw new Error("tenant not found");
|
||||
}
|
||||
|
||||
// Update the tenant cache.
|
||||
await cache.update(redis, updatedTenant);
|
||||
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
export async function rotateWebhookEndpointSecret(
|
||||
mongo: Db,
|
||||
redis: Redis,
|
||||
cache: TenantCache,
|
||||
tenant: Tenant,
|
||||
endpointID: string,
|
||||
inactiveIn: number,
|
||||
now: Date
|
||||
) {
|
||||
// Compute the inactiveAt dates for the current active secrets.
|
||||
const inactiveAt = DateTime.fromJSDate(now)
|
||||
.plus({ seconds: inactiveIn })
|
||||
.toJSDate();
|
||||
|
||||
// Rotate the secrets.
|
||||
const updatedTenant = await rollTenantWebhookEndpointSecret(
|
||||
mongo,
|
||||
tenant.id,
|
||||
endpointID,
|
||||
inactiveAt,
|
||||
now
|
||||
);
|
||||
if (!updatedTenant) {
|
||||
throw new Error("tenant not found");
|
||||
}
|
||||
|
||||
// Update the tenant cache.
|
||||
await cache.update(redis, updatedTenant);
|
||||
|
||||
// Find the updated endpoint.
|
||||
const endpoint = getWebhookEndpoint(updatedTenant, endpointID);
|
||||
if (!endpoint) {
|
||||
throw new Error("referenced endpoint was not found on tenant");
|
||||
}
|
||||
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
export async function enableFeatureFlag(
|
||||
mongo: Db,
|
||||
redis: Redis,
|
||||
|
||||
@@ -4,7 +4,15 @@ import { DateTime } from "luxon";
|
||||
import { Db } from "mongodb";
|
||||
import uuid from "uuid";
|
||||
|
||||
import { constructTenantURL } from "coral-server/app/url";
|
||||
import { Config } from "coral-server/config";
|
||||
import {
|
||||
IntegrationDisabled,
|
||||
InviteIncludesExistingUser,
|
||||
InviteRequiresEmailAddresses,
|
||||
InviteTokenExpired,
|
||||
TokenInvalidError,
|
||||
} from "coral-server/errors";
|
||||
import {
|
||||
createInvite,
|
||||
Invite,
|
||||
@@ -28,15 +36,8 @@ import {
|
||||
verifyJWT,
|
||||
} from "coral-server/services/jwt";
|
||||
|
||||
import { constructTenantURL } from "coral-server/app/url";
|
||||
import {
|
||||
IntegrationDisabled,
|
||||
InviteIncludesExistingUser,
|
||||
InviteRequiresEmailAddresses,
|
||||
InviteTokenExpired,
|
||||
TokenInvalidError,
|
||||
} from "coral-server/errors";
|
||||
import { GQLUSER_ROLE } from "coral-server/graph/schema/__generated__/types";
|
||||
|
||||
import { validateEmail, validatePassword, validateUsername } from "../helpers";
|
||||
|
||||
export interface InviteToken extends Required<StandardClaims> {
|
||||
@@ -324,5 +325,7 @@ export async function redeem(
|
||||
now
|
||||
);
|
||||
|
||||
// TODO: (wyattjoh) emit that a user was created
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
@@ -6,9 +6,11 @@ import htmlToText from "html-to-text";
|
||||
import { kebabCase } from "lodash";
|
||||
import { Db } from "mongodb";
|
||||
|
||||
import { getLatestRevision } from "coral-server/models/comment";
|
||||
import { Comment } from "coral-server/models/comment";
|
||||
import { retrieveManyStories } from "coral-server/models/story";
|
||||
import { Comment, getLatestRevision } from "coral-server/models/comment";
|
||||
import {
|
||||
getURLWithCommentID,
|
||||
retrieveManyStories,
|
||||
} from "coral-server/models/story";
|
||||
import { Tenant } from "coral-server/models/tenant";
|
||||
import { User } from "coral-server/models/user";
|
||||
|
||||
@@ -102,15 +104,11 @@ export async function sendUserDownload(
|
||||
|
||||
const revision = getLatestRevision(comment);
|
||||
|
||||
const commentID = comment.id;
|
||||
const createdAt = formatter.format(new Date(comment.createdAt));
|
||||
const storyURL = story.url;
|
||||
const urlBuilder = new URL(storyURL);
|
||||
urlBuilder.searchParams.set("commentID", commentID);
|
||||
const commentURL = urlBuilder.href;
|
||||
const body = htmlToText.fromString(revision.body);
|
||||
const commentURL = getURLWithCommentID(story.url, comment.id);
|
||||
|
||||
csv.write([commentID, createdAt, storyURL, commentURL, body]);
|
||||
csv.write([comment.id, createdAt, story.url, commentURL, body]);
|
||||
}
|
||||
|
||||
commentBatch = [];
|
||||
|
||||
@@ -5,8 +5,8 @@ import {
|
||||
ALLOWED_USERNAME_CHANGE_TIMEFRAME_DURATION,
|
||||
COMMENT_REPEAT_POST_DURATION,
|
||||
DOWNLOAD_LIMIT_TIMEFRAME_DURATION,
|
||||
SCHEDULED_DELETION_WINDOW_DURATION,
|
||||
} from "coral-common/constants";
|
||||
import { SCHEDULED_DELETION_WINDOW_DURATION } from "coral-common/constants";
|
||||
import { Config } from "coral-server/config";
|
||||
import {
|
||||
DuplicateEmailError,
|
||||
@@ -25,10 +25,6 @@ import {
|
||||
UsernameUpdatedWithinWindowError,
|
||||
UserNotFoundError,
|
||||
} from "coral-server/errors";
|
||||
import {
|
||||
GQLAuthIntegrations,
|
||||
GQLUSER_ROLE,
|
||||
} from "coral-server/graph/schema/__generated__/types";
|
||||
import logger from "coral-server/logger";
|
||||
import { Comment, retrieveComment } from "coral-server/models/comment";
|
||||
import { Tenant } from "coral-server/models/tenant";
|
||||
@@ -72,12 +68,16 @@ import {
|
||||
import {
|
||||
getLocalProfile,
|
||||
hasLocalProfile,
|
||||
hasStaffRole,
|
||||
} from "coral-server/models/user/helpers";
|
||||
import { hasStaffRole } from "coral-server/models/user/helpers";
|
||||
import { MailerQueue } from "coral-server/queue/tasks/mailer";
|
||||
import { JWTSigningConfig, signPATString } from "coral-server/services/jwt";
|
||||
import { sendConfirmationEmail } from "coral-server/services/users/auth";
|
||||
|
||||
import { JWTSigningConfig, signPATString } from "coral-server/services/jwt";
|
||||
import {
|
||||
GQLAuthIntegrations,
|
||||
GQLUSER_ROLE,
|
||||
} from "coral-server/graph/schema/__generated__/types";
|
||||
|
||||
import { AugmentedRedis } from "../redis";
|
||||
import {
|
||||
@@ -125,7 +125,16 @@ export async function findOrCreate(
|
||||
// Validate the input.
|
||||
validateFindOrCreateUserInput(input, options);
|
||||
|
||||
const user = await findOrCreateUser(mongo, tenant.id, input, now);
|
||||
const { user, wasUpserted } = await findOrCreateUser(
|
||||
mongo,
|
||||
tenant.id,
|
||||
input,
|
||||
now
|
||||
);
|
||||
|
||||
if (wasUpserted) {
|
||||
// TODO: (wyattjoh) emit that a user was created
|
||||
}
|
||||
|
||||
// TODO: (wyattjoh) evaluate the tenant to determine if we should send the verification email.
|
||||
|
||||
@@ -169,6 +178,8 @@ export async function create(
|
||||
|
||||
const user = await createUser(mongo, tenant.id, input, now);
|
||||
|
||||
// TODO: (wyattjoh) emit that a user was created
|
||||
|
||||
// TODO: (wyattjoh) evaluate the tenant to determine if we should send the verification email.
|
||||
|
||||
return user;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Db } from "mongodb";
|
||||
|
||||
import { Config } from "coral-server/config";
|
||||
import { Publisher } from "coral-server/graph/subscriptions/publisher";
|
||||
import { CoralEventPublisherBroker } from "coral-server/events/publisher";
|
||||
import { Tenant } from "coral-server/models/tenant";
|
||||
import { moderate } from "coral-server/services/comments/moderation";
|
||||
import { notifyPerspectiveModerationDecision } from "coral-server/services/perspective";
|
||||
@@ -15,7 +15,7 @@ const approveComment = async (
|
||||
mongo: Db,
|
||||
redis: AugmentedRedis,
|
||||
config: Config,
|
||||
publisher: Publisher,
|
||||
broker: CoralEventPublisherBroker,
|
||||
tenant: Tenant,
|
||||
commentID: string,
|
||||
commentRevisionID: string,
|
||||
@@ -44,7 +44,7 @@ const approveComment = async (
|
||||
});
|
||||
|
||||
// Publish changes to the event publisher.
|
||||
await publishChanges(publisher, {
|
||||
await publishChanges(broker, {
|
||||
...result,
|
||||
...counts,
|
||||
moderatorID,
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
CoralError,
|
||||
StoryNotFoundError,
|
||||
} from "coral-server/errors";
|
||||
import { Publisher } from "coral-server/graph/subscriptions/publisher";
|
||||
import { CoralEventPublisherBroker } from "coral-server/events/publisher";
|
||||
import logger from "coral-server/logger";
|
||||
import {
|
||||
encodeActionCounts,
|
||||
@@ -59,7 +59,7 @@ export default async function create(
|
||||
mongo: Db,
|
||||
redis: AugmentedRedis,
|
||||
config: Config,
|
||||
publisher: Publisher,
|
||||
broker: CoralEventPublisherBroker,
|
||||
tenant: Tenant,
|
||||
author: User,
|
||||
input: CreateComment,
|
||||
@@ -233,19 +233,19 @@ export default async function create(
|
||||
});
|
||||
|
||||
// Publish changes to the event publisher.
|
||||
await publishChanges(publisher, {
|
||||
await publishChanges(broker, {
|
||||
...counts,
|
||||
after: comment,
|
||||
});
|
||||
|
||||
// If this is a reply, publish it.
|
||||
if (input.parentID) {
|
||||
publishCommentReplyCreated(publisher, comment);
|
||||
publishCommentReplyCreated(broker, comment);
|
||||
}
|
||||
|
||||
// If this comment is visible (and not a reply), publish it.
|
||||
if (!input.parentID && hasPublishedStatus(comment)) {
|
||||
publishCommentCreated(publisher, comment);
|
||||
publishCommentCreated(broker, comment);
|
||||
}
|
||||
|
||||
return comment;
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Db } from "mongodb";
|
||||
import { Omit } from "coral-common/types";
|
||||
import { Config } from "coral-server/config";
|
||||
import { CommentNotFoundError, StoryNotFoundError } from "coral-server/errors";
|
||||
import { Publisher } from "coral-server/graph/subscriptions/publisher";
|
||||
import { CoralEventPublisherBroker } from "coral-server/events/publisher";
|
||||
import logger from "coral-server/logger";
|
||||
import {
|
||||
encodeActionCounts,
|
||||
@@ -57,7 +57,7 @@ export default async function edit(
|
||||
mongo: Db,
|
||||
redis: AugmentedRedis,
|
||||
config: Config,
|
||||
publisher: Publisher,
|
||||
broker: CoralEventPublisherBroker,
|
||||
tenant: Tenant,
|
||||
author: User,
|
||||
input: EditComment,
|
||||
@@ -188,7 +188,7 @@ export default async function edit(
|
||||
});
|
||||
|
||||
// Publish changes to the event publisher.
|
||||
await publishChanges(publisher, {
|
||||
await publishChanges(broker, {
|
||||
...result,
|
||||
...counts,
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Publisher } from "coral-server/graph/subscriptions/publisher";
|
||||
import { CoralEventPublisherBroker } from "coral-server/events/publisher";
|
||||
import {
|
||||
Comment,
|
||||
CommentModerationQueueCounts,
|
||||
@@ -19,17 +19,17 @@ interface PublishChangesInput {
|
||||
}
|
||||
|
||||
export default async function publishChanges(
|
||||
publish: Publisher,
|
||||
broker: CoralEventPublisherBroker,
|
||||
input: PublishChangesInput
|
||||
) {
|
||||
// Publish changes.
|
||||
publishModerationQueueChanges(publish, input.moderationQueue, input.after);
|
||||
publishModerationQueueChanges(broker, input.moderationQueue, input.after);
|
||||
|
||||
// If this was a change, and it has a "before" state for the comment, process
|
||||
// those updates too.
|
||||
if (input.before) {
|
||||
publishCommentStatusChanges(
|
||||
publish,
|
||||
broker,
|
||||
input.before.status,
|
||||
input.after.status,
|
||||
input.after.id,
|
||||
@@ -37,7 +37,7 @@ export default async function publishChanges(
|
||||
);
|
||||
|
||||
if (hasModeratorStatus(input.before) && hasPublishedStatus(input.after)) {
|
||||
publishCommentReleased(publish, input.after);
|
||||
publishCommentReleased(broker, input.after);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Db } from "mongodb";
|
||||
|
||||
import { Config } from "coral-server/config";
|
||||
import { Publisher } from "coral-server/graph/subscriptions/publisher";
|
||||
import { CoralEventPublisherBroker } from "coral-server/events/publisher";
|
||||
import { hasTag } from "coral-server/models/comment";
|
||||
import { Tenant } from "coral-server/models/tenant";
|
||||
import { removeTag } from "coral-server/services/comments";
|
||||
@@ -20,7 +20,7 @@ const rejectComment = async (
|
||||
mongo: Db,
|
||||
redis: AugmentedRedis,
|
||||
config: Config,
|
||||
publisher: Publisher,
|
||||
broker: CoralEventPublisherBroker,
|
||||
tenant: Tenant,
|
||||
commentID: string,
|
||||
commentRevisionID: string,
|
||||
@@ -49,7 +49,7 @@ const rejectComment = async (
|
||||
});
|
||||
|
||||
// Publish changes to the event publisher.
|
||||
await publishChanges(publisher, {
|
||||
await publishChanges(broker, {
|
||||
...result,
|
||||
...counts,
|
||||
moderatorID,
|
||||
|
||||
Reference in New Issue
Block a user