[next] Story Mutations (#2055)

*  feat: added mutations for removing + scraping stories

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

* fix: linting

* feat: implemented createStory

* feat: added support for createStory

* feat: improved Story deletion

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

* feat: implemented mergeStory

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

* fix: fixed story creation method

* fix: added action count merging

* fix: removed outdated file

* fix: removed queue that got duplicated via rebase

* fix: prevent error when action counts are empty
This commit is contained in:
Wyatt Johnson
2018-11-02 23:18:12 +00:00
committed by GitHub
parent 7aa9a6b4f7
commit ca12b73b55
32 changed files with 1123 additions and 232 deletions
+61
View File
@@ -0,0 +1,61 @@
import Queue, { Job, Queue as QueueType } from "bull";
import logger from "talk-server/logger";
export interface TaskOptions<T, U = any> {
jobName: string;
jobProcessor: (job: Job<T>) => Promise<U>;
queue: Queue.QueueOptions;
}
export default class Task<T, U = any> {
private options: TaskOptions<T, U>;
private queue: QueueType<T>;
constructor(options: TaskOptions<T, U>) {
this.queue = new Queue(options.jobName, options.queue);
this.options = options;
// Sets up and attaches the job processor to the queue.
this.setupAndAttachProcessor();
}
/**
* Add will add the job to the queue to get processed. It's not needed to
* handle the job after it has been created.
*
* @param data the data for the job to add.
*/
public async add(data: T) {
const job = await this.queue.add(data, {
// We always remove the job when it's complete, no need to fill up Redis
// with completed entries if we don't need to.
removeOnComplete: true,
});
logger.trace(
{ job_id: job.id, job_name: this.options.jobName },
"added job to queue"
);
return job;
}
private setupAndAttachProcessor() {
this.queue.process(async (job: Job<T>) => {
logger.trace(
{ job_id: job.id, job_name: this.options.jobName },
"processing job from queue"
);
// Send the job off to the job processor to be handled.
const promise: U = await this.options.jobProcessor(job);
logger.trace(
{ job_id: job.id, job_name: this.options.jobName },
"processing completed"
);
return promise;
});
logger.trace(
{ job_name: this.options.jobName },
"registered processor for job type"
);
}
}
+68
View File
@@ -0,0 +1,68 @@
import Queue from "bull";
import { Db } from "mongodb";
import { Config } from "talk-common/config";
import Task from "talk-server/queue/Task";
import { createMailerTask, Mailer } from "talk-server/queue/tasks/mailer";
import {
createScraperTask,
ScraperData,
} from "talk-server/queue/tasks/scraper";
import { createRedisClient } from "talk-server/services/redis";
import TenantCache from "talk-server/services/tenant/cache";
const createQueueOptions = (config: Config): Queue.QueueOptions => {
const client = createRedisClient(config);
const subscriber = createRedisClient(config);
const blockingClient = createRedisClient(config);
// Return the options that can be used by the Queue.
return {
// Here, we are reusing the clients based on the requested types. This way,
// any time we need a specific client, we get to use one of the ones that
// already have been created.
createClient: type => {
switch (type) {
case "subscriber":
return subscriber;
case "client":
return client;
case "bclient":
return blockingClient;
}
},
// Because bull uses atomic operations across separate keys, we need to add
// a prefix to the keys to help the Redis cluster place all those elements
// together to support the atomic operations. See:
// https://redis.io/topics/cluster-tutorial
prefix: "{queue}",
};
};
export interface QueueOptions {
mongo: Db;
config: Config;
tenantCache: TenantCache;
}
export interface TaskQueue {
mailer: Mailer;
scraper: Task<ScraperData>;
}
export function createQueue(options: QueueOptions): TaskQueue {
// Create the processor queue options. This holds references to the Redis
// clients that are shared per queue.
const queueOptions = createQueueOptions(options.config);
// Attach process functions to the various tasks in the queue.
const mailer = createMailerTask(queueOptions, options);
const scraper = createScraperTask(queueOptions, options);
// Return the tasks + client.
return {
mailer,
scraper,
};
}
+63
View File
@@ -0,0 +1,63 @@
import Queue, { Job, Queue as QueueType } from "bull";
import logger from "talk-server/logger";
export interface TaskOptions<T, U = any> {
jobName: string;
jobProcessor: (job: Job<T>) => Promise<U>;
queue: Queue.QueueOptions;
}
export default class Task<T, U = any> {
private options: TaskOptions<T, U>;
private queue: QueueType<T>;
constructor(options: TaskOptions<T, U>) {
this.queue = new Queue(options.jobName, options.queue);
this.options = options;
// Sets up and attaches the job processor to the queue.
this.setupAndAttachProcessor();
}
/**
* Add will add the job to the queue to get processed. It's not needed to
* handle the job after it has been created.
*
* @param data the data for the job to add.
*/
public async add(data: T) {
const job = await this.queue.add(data, {
// We always remove the job when it's complete, no need to fill up Redis
// with completed entries if we don't need to.
removeOnComplete: true,
});
logger.trace(
{ job_id: job.id, job_name: this.options.jobName },
"added job to queue"
);
return job;
}
private setupAndAttachProcessor() {
this.queue.process(async (job: Job<T>) => {
logger.trace(
{ job_id: job.id, job_name: this.options.jobName },
"processing job from queue"
);
// Send the job off to the job processor to be handled.
const promise: U = await this.options.jobProcessor(job);
logger.trace(
{ job_id: job.id, job_name: this.options.jobName },
"processing completed"
);
return promise;
});
logger.trace(
{ job_name: this.options.jobName },
"registered processor for job type"
);
}
}
@@ -0,0 +1,43 @@
import nunjucks from "nunjucks";
import path from "path";
import { Config } from "talk-common/config";
/**
* templateDirectory is the directory containing the email templates.
*/
const templateDirectory = path.join(__dirname, "templates");
export interface GenerateHTMLOptions {
/**
* name is the name of the template to render.
*/
name: string;
context: any;
}
export default class MailerContent {
private env: nunjucks.Environment;
constructor(config: Config) {
// Configure the nunjucks environment.
this.env = new nunjucks.Environment(
new nunjucks.FileSystemLoader(templateDirectory),
{
// When we aren't in production mode, reload the templates.
watch: config.get("env") !== "production",
}
);
}
/**
* generateHTML will generate the HTML for a template and optionally cache
* the compiled template based on the configured environment.
*
* @param options configuration for generating HTML based on the email
* template.
*/
public generateHTML(options: GenerateHTMLOptions): string {
return this.env.render(options.name + ".html", options.context);
}
}
+256
View File
@@ -0,0 +1,256 @@
import Queue, { Job } from "bull";
import htmlToText from "html-to-text";
import Joi from "joi";
import { Db } from "mongodb";
import { createTransport } from "nodemailer";
import { Config } from "talk-common/config";
import logger from "talk-server/logger";
import Task from "talk-server/queue/Task";
import MailerContent from "talk-server/queue/tasks/mailer/content";
import TenantCache from "talk-server/services/tenant/cache";
import { TenantCacheAdapter } from "talk-server/services/tenant/cache/adapter";
const JOB_NAME = "mailer";
export interface MailProcessorOptions {
config: Config;
mongo: Db;
tenantCache: TenantCache;
}
export interface MailerData {
message: {
to: string;
subject: string;
html: string;
};
tenantID: string;
}
const MailerDataSchema = Joi.object().keys({
message: Joi.object().keys({
to: Joi.string(),
subject: Joi.string(),
html: Joi.string(),
}),
tenantID: Joi.string(),
});
const createJobProcessor = (options: MailProcessorOptions) => {
const { tenantCache } = options;
// Create the cache adapter that will handle invalidating the email transport
// when the tenant experiences a change.
const cache = new TenantCacheAdapter<ReturnType<typeof createTransport>>(
tenantCache
);
return async (job: Job<MailerData>) => {
const { value, error: err } = Joi.validate(job.data, MailerDataSchema, {
stripUnknown: true,
presence: "required",
abortEarly: false,
});
if (err) {
logger.error(
{
job_id: job.id,
job_name: JOB_NAME,
err,
},
"job data did not match expected schema"
);
return;
}
// Pull the data out of the validated model.
const { message, tenantID } = value;
// Get the referenced tenant so we know who to send it from.
const tenant = await tenantCache.retrieveByID(tenantID);
if (!tenant) {
logger.error(
{
job_id: job.id,
job_name: JOB_NAME,
tenant_id: tenantID,
},
"referenced tenant was not found"
);
return;
}
if (!tenant.email.enabled) {
logger.error(
{
job_id: job.id,
job_name: JOB_NAME,
tenant_id: tenantID,
},
"not sending email, it was disabled"
);
return;
}
if (!tenant.email.smtpURI) {
logger.error(
{
job_id: job.id,
job_name: JOB_NAME,
tenant_id: tenantID,
},
"email was enabled but the smtpURI configuration was missing"
);
return;
}
if (!tenant.email.fromAddress) {
// TODO: possibly have fallback email address?
logger.error(
{
job_id: job.id,
job_name: JOB_NAME,
tenant_id: tenantID,
},
"email was enabled but the fromAddress configuration was missing"
);
return;
}
let transport = cache.get(tenantID);
if (!transport) {
// Create the transport based on the smtp uri.
transport = createTransport(tenant.email.smtpURI);
// Set the transport back into the cache.
cache.set(tenantID, transport);
logger.debug(
{
job_id: job.id,
job_name: JOB_NAME,
tenant_id: tenantID,
},
"transport was not cached"
);
} else {
logger.debug(
{
job_id: job.id,
job_name: JOB_NAME,
tenant_id: tenantID,
},
"transport was cached"
);
}
logger.debug(
{
job_id: job.id,
job_name: JOB_NAME,
tenant_id: tenantID,
},
"starting to send the email"
);
// Send the mail message.
await transport.sendMail({
...message,
// Generate the text content of the message from the HTML.
text: htmlToText.fromString(message.html),
from: tenant.email.fromAddress,
});
logger.debug(
{
job_id: job.id,
job_name: JOB_NAME,
tenant_id: tenantID,
},
"sent the email"
);
};
};
export interface MailerInput {
message: {
to: string;
subject: string;
};
template: {
name: string;
context: object;
};
tenantID: string;
}
export class Mailer {
private task: Task<MailerData>;
private content: MailerContent;
private tenantCache: TenantCache;
constructor(queue: Queue.QueueOptions, options: MailProcessorOptions) {
this.task = new Task<MailerData>({
jobName: JOB_NAME,
jobProcessor: createJobProcessor(options),
queue,
});
this.content = new MailerContent(options.config);
this.tenantCache = options.tenantCache;
}
public async add({ template, ...rest }: MailerInput) {
const { tenantID } = rest;
// All email templates require the tenant in order to insert the footer, so
// load it from the tenant cache here.
const tenant = await this.tenantCache.retrieveByID(tenantID);
if (!tenant) {
logger.error(
{
job_name: JOB_NAME,
tenant_id: tenantID,
},
"referenced tenant was not found"
);
// TODO: (wyattjoh) maybe throw an error here?
return;
}
if (!tenant.email.enabled) {
logger.error(
{
job_name: JOB_NAME,
tenant_id: tenantID,
},
"not adding email, it was disabled"
);
// TODO: (wyattjoh) maybe throw an error here?
return;
}
// Generate the HTML for the email template.
const html = this.content.generateHTML({
...template,
context: {
...template.context,
tenant,
},
});
// Return the job that'll add the email to the queue to be processed later.
return this.task.add({
...rest,
message: {
...rest.message,
html,
},
});
}
}
export const createMailerTask = (
queue: Queue.QueueOptions,
options: MailProcessorOptions
) => new Mailer(queue, options);
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head></head>
<body>
<h1>Forgot Password</h1>
<p>We received a request to reset your password on <a href="{{ tenant.organizationURL }}">{{ tenant.organizationName }}</a>.</p>
<p>Please follow the link below to reset your password:</p>
<p><a href="{{ resetURL }}">Click here to reset your password</a></p>
<p><i>If you did not request this change, you can ignore this email.</i></p>
<footer>
<p>Sent by <a href="{{ tenant.organizationURL }}">{{ tenant.organizationName }}</a></p>
</footer>
</body>
</html>
@@ -0,0 +1,55 @@
import Queue, { Job } from "bull";
import { Db } from "mongodb";
import logger from "talk-server/logger";
import Task from "talk-server/queue/Task";
import { scrape } from "talk-server/services/stories/scraper";
const JOB_NAME = "scraper";
export interface ScrapeProcessorOptions {
mongo: Db;
}
export interface ScraperData {
storyID: string;
storyURL: string;
tenantID: string;
}
const createJobProcessor = ({ mongo }: ScrapeProcessorOptions) => async (
job: Job<ScraperData>
) => {
// Pull out the job data.
const { storyID, storyURL, tenantID } = job.data;
const log = logger.child({
job_id: job.id,
job_name: JOB_NAME,
story_id: storyID,
story_url: storyURL,
tenant_id: tenantID,
});
log.debug("starting to scrape the story");
try {
await scrape(mongo, tenantID, storyID, storyURL);
log.debug("scraped the story");
} catch (err) {
log.error({ err }, "could not scrape the story");
throw err;
}
};
export function createScraperTask(
queue: Queue.QueueOptions,
options: ScrapeProcessorOptions
) {
return new Task({
jobName: JOB_NAME,
jobProcessor: createJobProcessor(options),
queue,
});
}