diff --git a/.gitignore b/.gitignore index cb3f9358e..b4b457984 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,15 @@ coverage .DS_Store globalConfig.json *.log +*.js +!jest.config.js +!jest.global-teardown.js +!jest.setup.js +!jest.setup.mongo-repl-set.js +!jest.setup.redis-mock.js +!jest-mongodb-config.js +!migrate-mongo-config.js +!/env.js +!convertors/**/*.js +!tools/**/*.js +!bin/**/*.js diff --git a/lib/utils/decl.ts b/lib/utils/decl.ts new file mode 100644 index 000000000..7a4b93cc9 --- /dev/null +++ b/lib/utils/decl.ts @@ -0,0 +1,29 @@ +/** + * Decl of number + * + * @param value - value to decl + * @param titles - titles to decl: ['новое событие', 'новых события', 'новых событий'] + * @example declOfNum(1, ['новое событие', 'новых события', 'новых событий']) -> 'новое событие' + * @example declOfNum(2, ['новое событие', 'новых события', 'новых событий']) -> 'новых события' + * @example declOfNum(10, ['новое событие', 'новых события', 'новых событий']) -> 'новых событий' + * @example declOfNum(21, ['новое событие', 'новых события', 'новых событий']) -> 'новое событие' + * @returns decl of number + */ +export function declOfNum(value: number, titles: string[]): string { + const decimalBase = 10; + const hundredBase = 100; + const minExclusiveTeens = 4; + const maxExclusiveTeens = 20; + const manyFormIndex = 2; + const maxCaseIndex = 5; + const declCases = [manyFormIndex, 0, 1, 1, 1, manyFormIndex]; + + const valueModHundred = value % hundredBase; + const valueModTen = value % decimalBase; + const isTeens = valueModHundred > minExclusiveTeens && valueModHundred < maxExclusiveTeens; + const caseIndex = isTeens + ? manyFormIndex + : declCases[valueModTen < maxCaseIndex ? valueModTen : maxCaseIndex]; + + return titles[caseIndex]; +} \ No newline at end of file diff --git a/lib/utils/payday.ts b/lib/utils/payday.ts new file mode 100644 index 000000000..0826c8169 --- /dev/null +++ b/lib/utils/payday.ts @@ -0,0 +1,52 @@ +import { HOURS_IN_DAY, MINUTES_IN_HOUR, SECONDS_IN_MINUTE, MS_IN_SEC } from './consts'; + +/** + * Milliseconds in day. Needs for calculating difference between dates in days. + */ +const MILLISECONDS_IN_DAY = HOURS_IN_DAY * MINUTES_IN_HOUR * SECONDS_IN_MINUTE * MS_IN_SEC; + +/** + * Returns difference between now and payday in days + * + * Pay day is calculated by formula: paidUntil date or last charge date + 1 month + * + * @param date - last charge date + * @param paidUntil - paid until date + * @param isDebug - flag for debug purposes + */ +export function daysBeforePayday(date: Date, paidUntil: Date = null, isDebug = false): number { + const expectedPayDay = paidUntil ? new Date(paidUntil) : new Date(date); + + if (isDebug) { + expectedPayDay.setDate(date.getDate() + 1); + } else if (!paidUntil) { + expectedPayDay.setMonth(date.getMonth() + 1); + } + + const now = new Date().getTime(); + + return Math.floor((expectedPayDay.getTime() - now) / MILLISECONDS_IN_DAY); +} + +/** + * Returns difference between payday and now in days + * + * Pay day is calculated by formula: paidUntil date or last charge date + 1 month + * + * @param date - last charge date + * @param paidUntil - paid until date + * @param isDebug - flag for debug purposes + */ +export function daysAfterPayday(date: Date, paidUntil: Date = null, isDebug = false): number { + const expectedPayDay = paidUntil ? new Date(paidUntil) : new Date(date); + + if (isDebug) { + expectedPayDay.setDate(date.getDate() + 1); + } else if (!paidUntil) { + expectedPayDay.setMonth(date.getMonth() + 1); + } + + const now = new Date().getTime(); + + return Math.floor((now - expectedPayDay.getTime()) / MILLISECONDS_IN_DAY); +} \ No newline at end of file diff --git a/package.json b/package.json index 9f094dc2c..69cfe1e81 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "test:javascript": "jest workers/javascript", "test:release": "jest workers/release", "test:slack": "jest workers/slack", + "test:loop": "jest workers/loop", "test:limiter": "jest workers/limiter --runInBand", "test:grouper": "jest workers/grouper", "test:diff": "jest ./workers/grouper/tests/diff.test.ts", @@ -37,6 +38,7 @@ "run-sentry": "yarn worker hawk-worker-sentry", "run-js": "yarn worker hawk-worker-javascript", "run-slack": "yarn worker hawk-worker-slack", + "run-loop": "yarn worker hawk-worker-loop", "run-grouper": "yarn worker hawk-worker-grouper", "run-archiver": "yarn worker hawk-worker-archiver", "run-accountant": "yarn worker hawk-worker-accountant", diff --git a/workers/email/scripts/emailOverview.ts b/workers/email/scripts/emailOverview.ts index 981093dbd..aa31a0998 100644 --- a/workers/email/scripts/emailOverview.ts +++ b/workers/email/scripts/emailOverview.ts @@ -19,6 +19,7 @@ import { ObjectId } from 'mongodb'; import * as path from 'path'; import * as dotenv from 'dotenv'; import { HttpStatusCode } from '../../../lib/utils/consts'; +import { daysAfterPayday } from '../../../lib/utils/payday'; /** * Merge email worker .env and root workers .env @@ -147,6 +148,7 @@ class EmailTestServer { user, period: 10, reason: 'error on the payment server side', + daysAfterPayday: await this.calculateDaysAfterPayday(workspace), }; try { @@ -210,7 +212,7 @@ class EmailTestServer { */ private sendHTML(html: string, response: http.ServerResponse): void { response.writeHead(HttpStatusCode.Ok, { - 'Content-Type': 'text/html', + 'Content-Type': 'text/html; charset=utf-8', }); response.write(html); response.end(); @@ -323,6 +325,25 @@ class EmailTestServer { return connection.collection('workspaces').findOne({ _id: new ObjectId(workspaceId) }); } + /** + * Calculate days after payday + * Return number of days after payday. If payday is in the future, return 0 + * + * @param workspace - workspace data + * @returns {Promise} number of days after payday + */ + private async calculateDaysAfterPayday( + workspace: WorkspaceDBScheme + ): Promise { + if (!workspace.lastChargeDate) { + return 0; + } + + const days = daysAfterPayday(workspace.lastChargeDate, workspace.paidUntil); + + return days > 0 ? days : 0; + } + /** * Get user info * diff --git a/workers/email/src/provider.ts b/workers/email/src/provider.ts index b8d0ce478..9ffaba12c 100644 --- a/workers/email/src/provider.ts +++ b/workers/email/src/provider.ts @@ -44,6 +44,7 @@ export default class EmailProvider extends NotificationsProvider { switch (notification.type) { case 'assignee': templateName = Templates.Assignee; break; case 'block-workspace': templateName = Templates.BlockWorkspace; break; + case 'blocked-workspace-reminder': templateName = Templates.BlockedWorkspaceReminder; break; case 'days-limit-almost-reached': templateName = Templates.DaysLimitAlmostReached; break; case 'event': templateName = Templates.Event; break; case 'events-limit-almost-reached': templateName = Templates.EventsLimitAlmostReached; break; diff --git a/workers/email/src/templates/components/layout.twig b/workers/email/src/templates/components/layout.twig index fb07c0e38..539967d8d 100644 --- a/workers/email/src/templates/components/layout.twig +++ b/workers/email/src/templates/components/layout.twig @@ -123,7 +123,7 @@ style="text-decoration: none; border-bottom: 1px solid #494F5E;"> - Unsubscribe + Отписаться @@ -143,7 +143,7 @@ - Hawk + Хоук @@ -152,7 +152,7 @@ - Errors tracking system + Российский трекер ошибок diff --git a/workers/email/src/templates/emails/block-workspace/html.twig b/workers/email/src/templates/emails/block-workspace/html.twig index c3074ce9b..3bdc4830a 100644 --- a/workers/email/src/templates/emails/block-workspace/html.twig +++ b/workers/email/src/templates/emails/block-workspace/html.twig @@ -14,7 +14,7 @@ - Workspace was blocked + «{{ workspace.name | escape }}» не принимает события @@ -22,13 +22,18 @@ - Your workspace "{{ workspace.name | escape }}" was blocked because the plan was not renewed or events limit has been reached. Please, check payment settings and renew the plan. +

+ Вы больше не отслеживаете новые ошибки, потому что закончился лимит или срок действия тарифного плана. +

+

+ Чтобы продолжить получать события, выберите подходящий тарифный план и продлите подписку в настройках оплаты. +

- {% include '../../components/button.twig' with {href: host ~ '/workspace/' ~ workspace._id ~ '/settings/billing', label: 'Go to payment settings'} %} + {% include '../../components/button.twig' with {href: host ~ '/workspace/' ~ workspace._id ~ '/settings/billing', label: workspace.tariffPlanId is same as('5f47f031ff71510040f433c1') ? 'Увеличить лимит от 99 ₽' : 'Открыть настройки'} %} {% endblock %} diff --git a/workers/email/src/templates/emails/block-workspace/subject.twig b/workers/email/src/templates/emails/block-workspace/subject.twig index 2396f770a..e8e7a87bf 100644 --- a/workers/email/src/templates/emails/block-workspace/subject.twig +++ b/workers/email/src/templates/emails/block-workspace/subject.twig @@ -1 +1 @@ -Workspace {{ workspace.name | escape }} was blocked! +Мониторинг ошибок остановлен diff --git a/workers/email/src/templates/emails/block-workspace/text.twig b/workers/email/src/templates/emails/block-workspace/text.twig index 0f2c0582e..e0994e1fd 100644 --- a/workers/email/src/templates/emails/block-workspace/text.twig +++ b/workers/email/src/templates/emails/block-workspace/text.twig @@ -1,10 +1,12 @@ -Your workspace "{{ workspace.name | escape }}" was blocked because the plan was not renewed or events limit has been reached. +Мониторинг ошибок остановлен -Please, check payment settings and renew the plan: {{ host }}/workspace/{{ workspace._id }}/settings/billing +Вы больше не отслеживаете новые ошибки «{{ workspace.name | escape }}», потому что закончился лимит или срок действия тарифного плана + +Чтобы продолжить получать события, выберите подходящий тарифный план и продлите подписку в настройках оплаты: {{ host }}/workspace/{{ workspace._id }}/settings/billing *** -Hawk -Errors tracking system +Хоук +Российский трекер ошибок -Made by CodeX +Made by CodeX \ No newline at end of file diff --git a/workers/email/src/templates/emails/blocked-workspace-reminder/html.twig b/workers/email/src/templates/emails/blocked-workspace-reminder/html.twig new file mode 100644 index 000000000..80828db6f --- /dev/null +++ b/workers/email/src/templates/emails/blocked-workspace-reminder/html.twig @@ -0,0 +1,39 @@ +{% extends '../../components/layout.twig' %} + +{% block header %} + {% include '../../components/workspace.twig' with {workspace: workspace} %} +{% endblock %} + +{% block content %} + + + + + + + + + + {{ daysAfterPayday }} {{ pluralize_ru(daysAfterPayday, ['день', 'дня', 'дней']) }} без мониторинга + + + + + + + +

+ Напоминаем, что мониторинг ошибок в «{{ workspace.name | escape }}» всё ещё остановлен, потому что закончился лимит или срок действия тарифного плана. +

+

+ Чтобы снова видеть актуальные события, выберите подходящий тарифный план и продлите подписку в настройках оплаты. +

+
+ + + + + {% include '../../components/button.twig' with {href: host ~ '/workspace/' ~ workspace._id ~ '/settings/billing', label: workspace.tariffPlanId is same as('5f47f031ff71510040f433c1') ? 'Выбрать тариф от 99 ₽' : 'Открыть настройки'} %} + + +{% endblock %} diff --git a/workers/email/src/templates/emails/blocked-workspace-reminder/subject.twig b/workers/email/src/templates/emails/blocked-workspace-reminder/subject.twig new file mode 100644 index 000000000..e2c4b7a0a --- /dev/null +++ b/workers/email/src/templates/emails/blocked-workspace-reminder/subject.twig @@ -0,0 +1 @@ +Требуется действие: мониторинг ошибок в {{ workspace.name }} не работает уже {{ daysAfterPayday }} {{ pluralize_ru(daysAfterPayday, ['день', 'дня', 'дней']) }} \ No newline at end of file diff --git a/workers/email/src/templates/emails/blocked-workspace-reminder/text.twig b/workers/email/src/templates/emails/blocked-workspace-reminder/text.twig new file mode 100644 index 000000000..235015668 --- /dev/null +++ b/workers/email/src/templates/emails/blocked-workspace-reminder/text.twig @@ -0,0 +1,10 @@ +Требуется действие: мониторинг ошибок в {{ workspace.name }} не работает уже {{ daysAfterPayday }} {{ pluralize_ru(daysAfterPayday, ['день', 'дня', 'дней']) }} + +Чтобы снова видеть актуальные события, выберите подходящий тарифный план и продлите подписку в настройках оплаты: {{ host }}/workspace/{{ workspace._id }}/settings/billing + +*** + +Хоук +Российский трекер ошибок + +Made by CodeX \ No newline at end of file diff --git a/workers/email/src/templates/extensions.ts b/workers/email/src/templates/extensions.ts index a61ac35ff..2df6a526e 100644 --- a/workers/email/src/templates/extensions.ts +++ b/workers/email/src/templates/extensions.ts @@ -154,3 +154,29 @@ Twig.extendFilter('abbrNumber', (value: number): string => { Twig.extendFilter('sortEvents', (events: TemplateEventData[]): TemplateEventData[] => { return events.sort((a, b) => a.newCount - b.newCount); }); + +/** + * Pluralize Russian words based on a number + * + * @param {number} n - the number to determine the form + * @param {string[]} forms - array of word forms [singular, few, many] + * @returns {string} + */ +Twig.extendFunction('pluralize_ru', (n: number, forms: string[]): string => { + // eslint-disable-next-line @typescript-eslint/no-magic-numbers + if (n % 100 >= 11 && n % 100 <= 19) { + return forms[2]; + } + + // eslint-disable-next-line @typescript-eslint/no-magic-numbers + const last = n % 10; + + if (last === 1) { + return forms[0]; + // eslint-disable-next-line @typescript-eslint/no-magic-numbers + } else if (last >= 2 && last <= 4) { + return forms[1]; + } else { + return forms[2]; + } +}); \ No newline at end of file diff --git a/workers/email/src/templates/names.ts b/workers/email/src/templates/names.ts index e01db2921..82983533f 100644 --- a/workers/email/src/templates/names.ts +++ b/workers/email/src/templates/names.ts @@ -4,6 +4,7 @@ enum Templates { Assignee = 'assignee', BlockWorkspace = 'block-workspace', + BlockedWorkspaceReminder = 'blocked-workspace-reminder', DaysLimitAlmostReached = 'days-limit-almost-reached', Event = 'event', EventsLimitAlmostReached = 'events-limit-almost-reached', diff --git a/workers/javascript/src/index.ts b/workers/javascript/src/index.ts index 807024540..ba0ec3166 100644 --- a/workers/javascript/src/index.ts +++ b/workers/javascript/src/index.ts @@ -14,6 +14,7 @@ import { beautifyUserAgent } from './utils'; import { Collection } from 'mongodb'; import { parse } from '@babel/parser'; import traverse from '@babel/traverse'; +import { extname } from 'path'; /* eslint-disable-next-line no-unused-vars */ import { memoize } from '../../../lib/memoize'; @@ -231,7 +232,11 @@ export default class JavascriptEventWorker extends EventWorker { const originalContent = consumer.sourceContentFor(originalLocation.source); - functionContext = await this.getFunctionContext(originalContent, originalLocation.line) ?? originalLocation.name; + functionContext = await this.getFunctionContext( + originalContent, + originalLocation.line, + originalLocation.source + ) ?? originalLocation.name; } catch (e) { HawkCatcher.send(e); this.logger.error('Can\'t get function context'); @@ -253,28 +258,20 @@ export default class JavascriptEventWorker extends EventWorker { * * @param sourceCode - content of the source file * @param line - number of the line from the stack trace + * @param sourcePath - original source path from the source map (used to pick parser plugins) * @returns {string | null} - string of the function context or null if it could not be parsed */ - private getFunctionContext(sourceCode: string, line: number): string | null { + private getFunctionContext(sourceCode: string, line: number, sourcePath?: string): string | null { let functionName: string | null = null; let className: string | null = null; let isAsync = false; try { - // @todo choose plugins based on source code file extention (related to possible jsx parser usage in future) + const parserPlugins = this.getBabelParserPluginsForFile(sourcePath); + const ast = parse(sourceCode, { sourceType: 'module', - plugins: [ - 'jsx', - 'typescript', - 'classProperties', - 'decorators', - 'optionalChaining', - 'nullishCoalescingOperator', - 'dynamicImport', - 'bigInt', - 'topLevelAwait', - ], + plugins: parserPlugins, }); traverse(ast as any, { @@ -454,4 +451,55 @@ export default class JavascriptEventWorker extends EventWorker { this.logger.error(`Error on source-map consumer initialization: ${e}`); } } + + /** + * Choose babel parser plugins based on source file extension + * + * @param sourcePath - original file path from source map (e.g. "src/App.tsx") + */ + private getBabelParserPluginsForFile(sourcePath?: string): any[] { + const basePlugins: string[] = [ + 'classProperties', + 'decorators', + 'optionalChaining', + 'nullishCoalescingOperator', + 'dynamicImport', + 'bigInt', + 'topLevelAwait', + ]; + + /** + * Default - use only typescript plugin because it's more stable and less likely will produce errors + */ + let enableTypeScript = true; + let enableJSX = false; + + if (sourcePath) { + // remove query/hash if there is any + const cleanPath = sourcePath.split('?')[0].split('#')[0]; + const ext = extname(cleanPath).toLowerCase(); + + const isTs = ext === '.ts' || ext === '.d.ts'; + const isTsx = ext === '.tsx'; + const isJs = ext === '.js' || ext === '.mjs' || ext === '.cjs'; + const isJsx = ext === '.jsx'; + + enableTypeScript = isTs || isTsx; + // JSX: + // - for .ts/.d.ts — DISABLE + // - for .tsx/.jsx — ENABLE + // - for .js — keep enabled, to not break App.js with JSX + enableJSX = isTsx || isJsx || isJs; + } + + if (enableTypeScript) { + basePlugins.push('typescript'); + } + + if (enableJSX) { + basePlugins.push('jsx'); + } + + return basePlugins; + } } diff --git a/workers/javascript/tests/index.test.ts b/workers/javascript/tests/index.test.ts index 531826e89..da6f89157 100644 --- a/workers/javascript/tests/index.test.ts +++ b/workers/javascript/tests/index.test.ts @@ -442,4 +442,118 @@ describe('JavaScript event worker', () => { await worker.finish(); }); + + it('should resolve function context for TypeScript function with angle-bracket type assertion', () => { + const worker = new JavascriptEventWorker(); + + const tsSource = ` + const foo: string | null = 'bar'; + + function throwError() { + const value = foo; + throw new Error(value); + } + + export { throwError }; + `; + + /** + * String with throw new Error(...) is the 6th line (if counting from 1) + * 1: '' + * 2: const foo... + * 3: '' + * 4: function throwError() { + * 5: const value = foo; + * 6: throw new Error(value); + * ... + */ + const context = (worker as any).getFunctionContext(tsSource, 6, 'example.ts'); + + /** + * We expect that the build with fixes will return the function name, + * but with the current configuration the jsx+typescript parser fails + * and getFunctionContext returns null. + */ + expect(context).toBe('throwError'); + }); + + it('should resolve function context for TypeScript generic arrow function', () => { + const worker = new JavascriptEventWorker(); + + const tsSource = ` + type User = { + id: string; + name: string; + }; + + const wrap = (value: T): T => { + return value; + }; + + export const useUser = () => { + const user: User = wrap({ id: '1', name: 'John' }); + + return user; + }; + `; + + /** + * String inside useUser - where we want to get context: + * 1: '' + * 2: type User = { ... + * ... + * 7: const wrap = (value: T): T => { + * ... + * 12: export const useUser = () => { + * 13: const user: User = wrap({ id: '1', name: 'John' }); + * 14: + * 15: return user; + * 16: }; + */ + const context = (worker as any).getFunctionContext(tsSource, 13, 'example.ts'); + + expect(context).toBe('useUser'); + }); + + it('should resolve class method context for TypeScript class with type assertion', () => { + const worker = new JavascriptEventWorker(); + + const tsSource = ` + class ApiClient { + private baseUrl: string = 'https://example.com'; + + public request() { + const raw = '{"ok":true}'; + const parsed = >JSON.parse(raw); + + if (!parsed.ok) { + throw new Error('Request failed'); + } + + return parsed; + } + } + + export default ApiClient; + `; + + /** + * String where we want to get context - inside the request method: + * 1: '' + * 2: class ApiClient { + * 3: private baseUrl... + * 4: + * 5: public request() { + * 6: const raw = '{"ok":true}'; + * 7: const parsed = >JSON.parse(raw); + * 8: + * 9: if (!parsed.ok) { + * 10: throw new Error('Request failed'); + * ... + */ + const context = (worker as any).getFunctionContext(tsSource, 7, 'example.ts'); + + // We expect "ApiClient.request" + expect(context).toBe('ApiClient.request'); + }); }); diff --git a/workers/loop/package.json b/workers/loop/package.json new file mode 100644 index 000000000..eccaaec7f --- /dev/null +++ b/workers/loop/package.json @@ -0,0 +1,15 @@ +{ + "name": "hawk-worker-loop", + "version": "1.0.0", + "description": "", + "main": "src/index.ts", + "license": "MIT", + "workerType": "sender/loop", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "dependencies": { + "@slack/webhook": "^5.0.3", + "json-templater": "^1.2.0" + } +} diff --git a/workers/loop/src/deliverer.ts b/workers/loop/src/deliverer.ts new file mode 100644 index 000000000..6645dec82 --- /dev/null +++ b/workers/loop/src/deliverer.ts @@ -0,0 +1,47 @@ +import { IncomingWebhook } from '@slack/webhook'; +import { createLogger, format, Logger, transports } from 'winston'; + +/** + * Deliverer is the man who will send messages to external service + * Separated from the provider to allow testing 'send' method + * Loop is Slack-like platform, so we use Slack API to send messages. + * + */ +export default class LoopDeliverer { + /** + * Logger module + * (default level='info') + */ + private logger: Logger = createLogger({ + level: process.env.LOG_LEVEL || 'info', + transports: [ + new transports.Console({ + format: format.combine( + format.timestamp(), + format.colorize(), + format.simple(), + format.printf((msg) => `${msg.timestamp} - ${msg.level}: ${msg.message}`) + ), + }), + ], + }); + + /** + * Sends message to the Loop through the Incoming Webhook app + * https://developers.loop.ru/integrate/webhooks/incoming/ + * + * @param endpoint - where to send + * @param message - what to send + */ + public async deliver(endpoint: string, message: string): Promise { + try { + const webhook = new IncomingWebhook(endpoint, { + username: 'Hawk', + }); + + await webhook.send(message); + } catch (e) { + this.logger.log('error', 'Can\'t deliver Incoming Webhook. Loop returns an error: ', e); + } + } +} diff --git a/workers/loop/src/index.ts b/workers/loop/src/index.ts new file mode 100644 index 000000000..174a5ac37 --- /dev/null +++ b/workers/loop/src/index.ts @@ -0,0 +1,24 @@ +import * as pkg from './../package.json'; +import LoopProvider from './provider'; +import SenderWorker from 'hawk-worker-sender/src'; +import { ChannelType } from 'hawk-worker-notifier/types/channel'; + +/** + * Worker to send email notifications + */ +export default class LoopSenderWorker extends SenderWorker { + /** + * Worker type + */ + public readonly type: string = pkg.workerType; + + /** + * Email channel type + */ + protected channelType = ChannelType.Loop; + + /** + * Email provider + */ + protected provider = new LoopProvider(); +} diff --git a/workers/loop/src/provider.ts b/workers/loop/src/provider.ts new file mode 100644 index 000000000..d1143c6de --- /dev/null +++ b/workers/loop/src/provider.ts @@ -0,0 +1,44 @@ +import NotificationsProvider from 'hawk-worker-sender/src/provider'; +import { Notification, EventsTemplateVariables } from 'hawk-worker-sender/types/template-variables'; +import templates from './templates'; +import { LoopTemplate } from '../types/template'; +import LoopDeliverer from './deliverer'; + +/** + * This class provides a 'send' method that will renders and sends a notification + */ +export default class LoopProvider extends NotificationsProvider { + /** + * Class with the 'deliver' method for sending messages to the Loop + */ + private readonly deliverer: LoopDeliverer; + + /** + * Constructor allows to separate dependencies that can't be tested, + * so in tests they will be mocked. + */ + constructor() { + super(); + + this.deliverer = new LoopDeliverer(); + } + + /** + * Send loop message to recipient + * + * @param to - recipient endpoint + * @param notification - notification with payload and type + */ + public async send(to: string, notification: Notification): Promise { + let template: LoopTemplate; + + switch (notification.type) { + case 'event': template = templates.EventTpl; break; + case 'several-events':template = templates.SeveralEventsTpl; break; + } + + const message = template(notification.payload as EventsTemplateVariables); + + await this.deliverer.deliver(to, message); + } +} diff --git a/workers/loop/src/templates/event.ts b/workers/loop/src/templates/event.ts new file mode 100644 index 000000000..bd1f9ced0 --- /dev/null +++ b/workers/loop/src/templates/event.ts @@ -0,0 +1,57 @@ +import { GroupedEventDBScheme } from '@hawk.so/types'; +import type { EventsTemplateVariables, TemplateEventData } from 'hawk-worker-sender/types/template-variables'; +import { toMaxLen } from '../../../slack/src/templates/utils'; + +/** + * Renders backtrace overview + * + * @param event - event to render + */ +function renderBacktrace(event: GroupedEventDBScheme): string { + let code = ''; + + const firstNotEmptyFrame = event.payload.backtrace.find(frame => !!frame.sourceCode); + + if (!firstNotEmptyFrame) { + return code; + } + + code = firstNotEmptyFrame.sourceCode.map(({ line, content }) => { + let colDelimiter = ': '; + + if (line === firstNotEmptyFrame.line) { + colDelimiter = ' ->'; + } + + const MAX_SOURCE_CODE_LINE_LENGTH = 65; + + return `${line}${colDelimiter} ${toMaxLen(content, MAX_SOURCE_CODE_LINE_LENGTH)}`; + }).join('\n'); + + return code; +} + +/** + * Return tpl with data substitutions + * + * @param tplData - event template data + */ +export default function render(tplData: EventsTemplateVariables): string { + const eventInfo = tplData.events[0] as TemplateEventData; + const event = eventInfo.event; + const eventURL = tplData.host + '/project/' + tplData.project._id + '/event/' + event._id + '/'; + let location = 'Неизвестное место'; + + if (event.payload.backtrace && event.payload.backtrace.length > 0) { + location = event.payload.backtrace[0].file; + } + + return ''.concat( + `**${event.payload.title}**`, + '\n', + `*${location}*\n`, + '```\n' + renderBacktrace(event) + '\n```', + '\n', + `[Посмотреть подробности](${eventURL}) `, `| *${tplData.project.name}*`, ` | ${eventInfo.newCount} новых (${eventInfo.event.totalCount} всего)` + ); +} diff --git a/workers/loop/src/templates/index.ts b/workers/loop/src/templates/index.ts new file mode 100644 index 000000000..6e23324c1 --- /dev/null +++ b/workers/loop/src/templates/index.ts @@ -0,0 +1,7 @@ +import EventTpl from './event'; +import SeveralEventsTpl from './several-events'; + +export default { + EventTpl, + SeveralEventsTpl, +}; diff --git a/workers/loop/src/templates/several-events.ts b/workers/loop/src/templates/several-events.ts new file mode 100644 index 000000000..bc7923060 --- /dev/null +++ b/workers/loop/src/templates/several-events.ts @@ -0,0 +1,23 @@ +import type { EventsTemplateVariables } from 'hawk-worker-sender/types/template-variables'; +import { declOfNum } from '../../../../lib/utils/decl'; + +/** + * Return tpl with data substitutions + * + * @param tplData - event template data + */ +export default function render(tplData: EventsTemplateVariables): string { + const projectUrl = tplData.host + '/project/' + tplData.project._id; + let message = tplData.events.length + ' ' + declOfNum( + tplData.events.length, + ['новое событие', 'новых события', 'новых событий'] + ) + '\n\n'; + + tplData.events.forEach(({ event, newCount }) => { + message += `(${newCount}) ${event.payload.title} \n`; + }); + + message += `\n[Посмотреть все события](${projectUrl}) | *${tplData.project.name}*`; + + return message; +} diff --git a/workers/loop/tests/__mocks__/event-notify.ts b/workers/loop/tests/__mocks__/event-notify.ts new file mode 100644 index 000000000..77a53b54a --- /dev/null +++ b/workers/loop/tests/__mocks__/event-notify.ts @@ -0,0 +1,43 @@ +import { EventNotification } from 'hawk-worker-sender/types/template-variables'; +import { ObjectId } from 'mongodb'; + +/** + * Example of new-events notify template variables + */ +export default { + type: 'event', + payload: { + events: [ + { + event: { + totalCount: 10, + timestamp: Date.now(), + payload: { + title: 'New event', + backtrace: [ { + file: 'file', + line: 1, + sourceCode: [ { + line: 1, + content: 'code', + } ], + } ], + }, + }, + daysRepeated: 1, + newCount: 1, + }, + ], + period: 60, + host: process.env.GARAGE_URL, + hostOfStatic: process.env.API_STATIC_URL, + project: { + _id: new ObjectId('5d206f7f9aaf7c0071d64596'), + token: 'project-token', + name: 'Project', + workspaceId: new ObjectId('5d206f7f9aaf7c0071d64596'), + uidAdded: new ObjectId('5d206f7f9aaf7c0071d64596'), + notifications: [], + }, + }, +} as EventNotification; diff --git a/workers/loop/tests/__mocks__/several-events-notify.ts b/workers/loop/tests/__mocks__/several-events-notify.ts new file mode 100644 index 000000000..24ed30e87 --- /dev/null +++ b/workers/loop/tests/__mocks__/several-events-notify.ts @@ -0,0 +1,63 @@ +import { SeveralEventsNotification } from 'hawk-worker-sender/types/template-variables'; +import { GroupedEventDBScheme } from '@hawk.so/types'; +import { ObjectId } from 'mongodb'; + +/** + * Example of several-events notify template variables + */ +export default { + type: 'several-events', + payload: { + events: [ + { + event: { + totalCount: 10, + timestamp: Date.now(), + payload: { + title: 'New event', + backtrace: [ { + file: 'file', + line: 1, + sourceCode: [ { + line: 1, + content: 'code', + } ], + } ], + }, + } as GroupedEventDBScheme, + daysRepeated: 1, + newCount: 1, + }, + { + event: { + totalCount: 5, + payload: { + title: 'New event 2', + timestamp: Date.now(), + backtrace: [ { + file: 'file', + line: 1, + sourceCode: [ { + line: 1, + content: 'code', + } ], + } ], + }, + }, + daysRepeated: 100, + newCount: 1, + }, + ], + period: 60, + host: process.env.GARAGE_URL, + hostOfStatic: process.env.API_STATIC_URL, + project: { + _id: new ObjectId('5d206f7f9aaf7c0071d64596'), + token: 'project-token', + name: 'Project', + workspaceId: new ObjectId('5d206f7f9aaf7c0071d64596'), + uidAdded: new ObjectId('5d206f7f9aaf7c0071d64596'), + notifications: [], + }, + }, +} as SeveralEventsNotification; diff --git a/workers/loop/tests/provider.test.ts b/workers/loop/tests/provider.test.ts new file mode 100644 index 000000000..b8185928e --- /dev/null +++ b/workers/loop/tests/provider.test.ts @@ -0,0 +1,246 @@ +import { EventNotification, SeveralEventsNotification, EventsTemplateVariables } from 'hawk-worker-sender/types/template-variables'; +import { DecodedGroupedEvent, ProjectDBScheme } from '@hawk.so/types'; +import LoopProvider from '../src/provider'; +import templates from '../src/templates'; +import SeveralEventsTpl from '../src/templates/several-events'; +import EventNotifyMock from './__mocks__/event-notify'; +import SeveralEventsNotifyMock from './__mocks__/several-events-notify'; +import { ObjectId } from 'mongodb'; + +/** + * The sample of the Loop Incoming Webhook endpoint + */ +const loopEndpointSample = 'https://hooks.loop.com/services/XXXXXXXXX/XXXXXXXXXX/XXXXXXXXXXX'; + +/** + * Mock the 'deliver' method of LoopDeliverer + */ +const deliver = jest.fn(); + +/** + * Loop Deliverer mock + */ +jest.mock('./../src/deliverer.ts', () => { + return jest.fn().mockImplementation(() => { + /** + * Now we can track calls to 'deliver' + */ + return { + deliver: deliver, + }; + }); +}); + +/** + * Clear all records of mock calls between tests + */ +afterEach(() => { + jest.clearAllMocks(); +}); + +describe('LoopProvider', () => { + /** + * Check that the 'send' method works without errors + */ + it('The "send" method should render and deliver message', async () => { + const provider = new LoopProvider(); + + await provider.send(loopEndpointSample, EventNotifyMock); + + expect(deliver).toHaveBeenCalledTimes(1); + expect(deliver).toHaveBeenCalledWith(loopEndpointSample, expect.anything()); + }); + + /** + * Logic for select the template depended on events count + */ + describe('Select correct template', () => { + /** + * If there is a single event in payload, use the 'new-event' template + */ + it('Select the new-event template if there is a single event in notify payload', async () => { + const provider = new LoopProvider(); + const EventTpl = jest.spyOn(templates, 'EventTpl'); + const SeveralEventsTplSpy = jest.spyOn(templates, 'SeveralEventsTpl'); + + await provider.send(loopEndpointSample, EventNotifyMock); + + expect(EventTpl).toHaveBeenCalledTimes(1); + expect(SeveralEventsTplSpy).toHaveBeenCalledTimes(0); + }); + + /** + * If there are several events in payload, use the 'several-events' template + */ + it('Select the several-events template if there are several events in notify payload', async () => { + const provider = new LoopProvider(); + const EventTpl = jest.spyOn(templates, 'EventTpl'); + const SeveralEventsTplSpy = jest.spyOn(templates, 'SeveralEventsTpl'); + + await provider.send(loopEndpointSample, SeveralEventsNotifyMock); + + expect(EventTpl).toHaveBeenCalledTimes(0); + expect(SeveralEventsTplSpy).toHaveBeenCalledTimes(1); + }); + }); + + /** + * Check templates rendering + */ + describe('templates', () => { + /** + * Check that rendering of a single event message works without errors + */ + it('should successfully render a new-event template', async () => { + const vars: EventNotification = { + type: 'event', + payload: { + events: [ { + event: { + totalCount: 10, + timestamp: Date.now(), + payload: { + title: 'New event', + backtrace: [ { + file: 'file', + line: 1, + sourceCode: [ { + line: 1, + content: 'code', + } ], + } ], + }, + } as DecodedGroupedEvent, + daysRepeated: 1, + newCount: 1, + } ], + period: 60, + host: process.env.GARAGE_URL, + hostOfStatic: process.env.API_STATIC_URL, + project: { + _id: new ObjectId('5d206f7f9aaf7c0071d64596'), + token: 'project-token', + name: 'Project', + workspaceId: new ObjectId('5d206f7f9aaf7c0071d64596'), + uidAdded: new ObjectId('5d206f7f9aaf7c0071d64596'), + notifications: [], + } as ProjectDBScheme, + }, + }; + + const render = (): string => templates.EventTpl(vars.payload); + + expect(render).not.toThrowError(); + + const message = render(); + + expect(message).toBeDefined(); + }); + + /** + * Check that rendering of a several events message works without errors + */ + it('should successfully render a several-events template', () => { + const vars: SeveralEventsNotification = SeveralEventsNotifyMock; + + const render = (): string => templates.SeveralEventsTpl(vars.payload); + + expect(render).not.toThrowError(); + + const message = render(); + + expect(message).toBeDefined(); + + // Header contains number of events and declension + expect(message).toContain(`${vars.payload.events.length} `); + + // Each event should be listed with "(newCount) title" + vars.payload.events.forEach(({ event, newCount }) => { + expect(message).toContain(`(${newCount}) ${event.payload.title}`); + }); + + // Footer should contain link to the project and project name + const projectUrl = vars.payload.host + '/project/' + vars.payload.project._id; + + expect(message).toContain(`[Посмотреть все события](${projectUrl})`); + expect(message).toContain(`*${vars.payload.project.name}*`); + }); + + /** + * Check declensions in several-events template header + */ + describe('several-events declensions', () => { + const baseEvent = { + event: { + totalCount: 10, + timestamp: Date.now(), + payload: { + title: 'New event', + backtrace: [ { + file: 'file', + line: 1, + sourceCode: [ { + line: 1, + content: 'code', + } ], + } ], + }, + } as DecodedGroupedEvent, + daysRepeated: 1, + newCount: 1, + }; + + const baseProject: ProjectDBScheme = { + _id: new ObjectId('5d206f7f9aaf7c0071d64596'), + token: 'project-token', + name: 'Project', + workspaceId: new ObjectId('5d206f7f9aaf7c0071d64596'), + uidAdded: new ObjectId('5d206f7f9aaf7c0071d64596'), + notifications: [], + } as ProjectDBScheme; + + const makePayload = (count: number): EventsTemplateVariables => ({ + events: Array.from({ length: count }, () => baseEvent), + host: process.env.GARAGE_URL, + hostOfStatic: process.env.API_STATIC_URL, + project: baseProject, + period: 60, + }); + + it('uses correct declension for 1 event', () => { + const payload = makePayload(1); + const message = SeveralEventsTpl(payload); + + expect(message).toContain('1 новое событие'); + }); + + it('uses correct declension for 2 events', () => { + const payload = makePayload(2); + const message = SeveralEventsTpl(payload); + + expect(message).toContain('2 новых события'); + }); + + it('uses correct declension for 5 events', () => { + const payload = makePayload(5); + const message = SeveralEventsTpl(payload); + + expect(message).toContain('5 новых событий'); + }); + + it('uses correct declension for 10 events', () => { + const payload = makePayload(10); + const message = SeveralEventsTpl(payload); + + expect(message).toContain('10 новых событий'); + }); + + it('uses correct declension for 21 events', () => { + const payload = makePayload(21); + const message = SeveralEventsTpl(payload); + + expect(message).toContain('21 новое событие'); + }); + }); + }); +}); diff --git a/workers/loop/types/template.d.ts b/workers/loop/types/template.d.ts new file mode 100644 index 000000000..8a5623786 --- /dev/null +++ b/workers/loop/types/template.d.ts @@ -0,0 +1,13 @@ +import type { EventsTemplateVariables } from 'hawk-worker-sender/types/template-variables'; + +/** + * Loop templates should implement this interface + */ +export interface LoopTemplate { + /** + * Rendering method that accepts tpl args and return rendered string + * + * @param tplData - template variables + */ + (tplData: EventsTemplateVariables): string; +} diff --git a/workers/loop/yarn.lock b/workers/loop/yarn.lock new file mode 100644 index 000000000..1f72f556a --- /dev/null +++ b/workers/loop/yarn.lock @@ -0,0 +1,48 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@slack/types@^1.2.1": + version "1.5.0" + resolved "https://registry.yarnpkg.com/@slack/types/-/types-1.5.0.tgz#5c2cb0f718689266ff295aad33301d489272c842" + integrity sha512-oCYgatJYxHf9wE3tKXzOLeeTsF0ghX1TIcguNfVmO2V6NDe+cHAzZRglEOmJLdRINDS5gscAgSkeZpDhpKBeUA== + +"@slack/webhook@^5.0.3": + version "5.0.3" + resolved "https://registry.yarnpkg.com/@slack/webhook/-/webhook-5.0.3.tgz#2205cba9a8d49d2ae84ca93f11ab4a1dba2f963b" + integrity sha512-51vnejJ2zABNumPVukOLyerpHQT39/Lt0TYFtOEz/N2X77bPofOgfPj2atB3etaM07mxWHLT9IRJ4Zuqx38DkQ== + dependencies: + "@slack/types" "^1.2.1" + "@types/node" ">=8.9.0" + axios "^0.19.0" + +"@types/node@>=8.9.0": + version "13.11.0" + resolved "https://registry.yarnpkg.com/@types/node/-/node-13.11.0.tgz#390ea202539c61c8fa6ba4428b57e05bc36dc47b" + integrity sha512-uM4mnmsIIPK/yeO+42F2RQhGUIs39K2RFmugcJANppXe6J1nvH87PvzPZYpza7Xhhs8Yn9yIAVdLZ84z61+0xQ== + +axios@^0.19.0: + version "0.19.2" + resolved "https://registry.yarnpkg.com/axios/-/axios-0.19.2.tgz#3ea36c5d8818d0d5f8a8a97a6d36b86cdc00cb27" + integrity sha512-fjgm5MvRHLhx+osE2xoekY70AhARk3a6hkN+3Io1jc00jtquGvxYlKlsFUhmUET0V5te6CcZI7lcv2Ym61mjHA== + dependencies: + follow-redirects "1.5.10" + +debug@=3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/debug/-/debug-3.1.0.tgz#5bb5a0672628b64149566ba16819e61518c67261" + integrity sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g== + dependencies: + ms "2.0.0" + +follow-redirects@1.5.10: + version "1.5.10" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.5.10.tgz#7b7a9f9aea2fdff36786a94ff643ed07f4ff5e2a" + integrity sha512-0V5l4Cizzvqt5D44aTXbFZz+FtyXV1vrDN6qrelxtfYQKW0KO0W2T/hkE8xvGa/540LkZlkaUjO4ailYTFtHVQ== + dependencies: + debug "=3.1.0" + +ms@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" + integrity sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g= diff --git a/workers/notifier/tests/worker.test.ts b/workers/notifier/tests/worker.test.ts index b757d31e8..1f128ac16 100644 --- a/workers/notifier/tests/worker.test.ts +++ b/workers/notifier/tests/worker.test.ts @@ -42,6 +42,11 @@ const rule = { endpoint: 'emailEndpoint', minPeriod: 0.5, }, + loop: { + isEnabled: true, + endpoint: 'loopEndpoint', + minPeriod: 0.5, + }, }, } as any; @@ -70,6 +75,11 @@ const alternativeRule = { endpoint: 'emailEndpoint', minPeriod: 0.5, }, + loop: { + isEnabled: true, + endpoint: 'loopEndpoint', + minPeriod: 0.5, + }, }, }; @@ -361,11 +371,16 @@ describe('NotifierWorker', () => { endpoint: 'emailEndpoint', minPeriod: 0.5, }, + loop: { + isEnabled: true, + endpoint: 'loopEndpoint', + minPeriod: 0.5, + }, }; await worker.handle(message); - expect(worker.sendToSenderWorker).toBeCalledTimes(2); + expect(worker.sendToSenderWorker).toBeCalledTimes(3); }); it('should compute event count for period for each fitted rule', async () => { diff --git a/workers/notifier/types/channel.ts b/workers/notifier/types/channel.ts index c6b1bc70c..3a195c3d7 100644 --- a/workers/notifier/types/channel.ts +++ b/workers/notifier/types/channel.ts @@ -5,6 +5,7 @@ export enum ChannelType { Email = 'email', Telegram = 'telegram', Slack = 'slack', + Loop = 'loop', } /** diff --git a/workers/paymaster/src/index.ts b/workers/paymaster/src/index.ts index 0ce4d681e..a57b54c16 100644 --- a/workers/paymaster/src/index.ts +++ b/workers/paymaster/src/index.ts @@ -7,19 +7,14 @@ import { Collection } from 'mongodb'; import { PlanDBScheme, WorkspaceDBScheme } from '@hawk.so/types'; import { EventType, PaymasterEvent } from '../types/paymaster-worker-events'; import axios from 'axios'; -import { HOURS_IN_DAY, MINUTES_IN_HOUR, MS_IN_SEC, SECONDS_IN_MINUTE } from '../../../lib/utils/consts'; import * as WorkerNames from '../../../lib/workerNames'; import HawkCatcher from '@hawk.so/nodejs'; +import { daysBeforePayday, daysAfterPayday } from '../../../lib/utils/payday'; dotenv.config({ path: path.resolve(__dirname, '../.env'), }); -/** - * Milliseconds in day. Needs for calculating difference between dates in days. - */ -const MILLISECONDS_IN_DAY = HOURS_IN_DAY * MINUTES_IN_HOUR * SECONDS_IN_MINUTE * MS_IN_SEC; - /** * Days after payday to try paying in actual subscription * When days after payday is more than this const and we still @@ -33,6 +28,12 @@ const DAYS_AFTER_PAYDAY_TO_TRY_PAYING = 3; // eslint-disable-next-line @typescript-eslint/no-magic-numbers const DAYS_LEFT_ALERT = [3, 2, 1, 0]; +/** + * Days after payday to remind admins about blocked workspace + */ +// eslint-disable-next-line @typescript-eslint/no-magic-numbers +const DAYS_AFTER_PAYDAY_TO_REMIND = [1, 2, 3, 5, 7, 30]; + /** * Worker to check workspaces subscription status and ban workspaces without actual subscription */ @@ -103,52 +104,6 @@ export default class PaymasterWorker extends Worker { return endDate; } - /** - * Returns difference between now and payday in days - * - * Pay day is calculated by formula: paidUntil date or last charge date + 1 month - * - * @param date - last charge date - * @param paidUntil - paid until date - * @param isDebug - flag for debug purposes - */ - private static daysBeforePayday(date: Date, paidUntil: Date = null, isDebug = false): number { - const expectedPayDay = paidUntil ? new Date(paidUntil) : new Date(date); - - if (isDebug) { - expectedPayDay.setDate(date.getDate() + 1); - } else if (!paidUntil) { - expectedPayDay.setMonth(date.getMonth() + 1); - } - - const now = new Date().getTime(); - - return Math.floor((expectedPayDay.getTime() - now) / MILLISECONDS_IN_DAY); - } - - /** - * Returns difference between payday and now in days - * - * Pay day is calculated by formula: paidUntil date or last charge date + 1 month - * - * @param date - last charge date - * @param paidUntil - paid until date - * @param isDebug - flag for debug purposes - */ - private static daysAfterPayday(date: Date, paidUntil: Date = null, isDebug = false): number { - const expectedPayDay = paidUntil ? new Date(paidUntil) : new Date(date); - - if (isDebug) { - expectedPayDay.setDate(date.getDate() + 1); - } else if (!paidUntil) { - expectedPayDay.setMonth(date.getMonth() + 1); - } - - const now = new Date().getTime(); - - return Math.floor((now - expectedPayDay.getTime()) / MILLISECONDS_IN_DAY); - } - /** * Start consuming messages */ @@ -247,13 +202,13 @@ export default class PaymasterWorker extends Worker { * How many days have passed since payments the expected day of payments */ // @ts-expect-error debug - const daysAfterPayday = PaymasterWorker.daysAfterPayday(workspace.lastChargeDate, workspace.paidUntil, workspace.isDebug); + const daysAfterPaydayValue = daysAfterPayday(workspace.lastChargeDate, workspace.paidUntil, workspace.isDebug); /** * How many days left for the expected day of payments */ // @ts-expect-error debug - const daysLeft = PaymasterWorker.daysBeforePayday(workspace.lastChargeDate, workspace.paidUntil, workspace.isDebug); + const daysLeft = daysBeforePayday(workspace.lastChargeDate, workspace.paidUntil, workspace.isDebug); /** * Do we need to ask for money @@ -319,9 +274,15 @@ export default class PaymasterWorker extends Worker { /** * Time to pay but workspace has paid plan - * If it is blocked then do nothing + * If it is blocked then remind admins about it */ if (workspace.isBlocked) { + // Send reminders on certain days after payday + // eslint-disable-next-line @typescript-eslint/no-magic-numbers + if (DAYS_AFTER_PAYDAY_TO_REMIND.includes(daysAfterPaydayValue)) { + await this.sendBlockedWorkspaceReminders(workspace, daysAfterPaydayValue); + } + return [workspace, true]; } @@ -338,7 +299,7 @@ export default class PaymasterWorker extends Worker { * Block workspace if it has paid subscription, * but a few days have passed after payday */ - if (daysAfterPayday > DAYS_AFTER_PAYDAY_TO_TRY_PAYING) { + if (daysAfterPaydayValue > DAYS_AFTER_PAYDAY_TO_TRY_PAYING) { await this.blockWorkspace(workspace); return [workspace, true]; @@ -403,6 +364,26 @@ export default class PaymasterWorker extends Worker { }); } + + /** + * Sends reminder emails to blocked workspace admins + * + * @param workspace - workspace to send reminders for + * @param days - number of days the workspace spent after payday + */ + private async sendBlockedWorkspaceReminders( + workspace: WorkspaceDBScheme, + days: number + ): Promise { + await this.addTask(WorkerNames.EMAIL, { + type: 'blocked-workspace-reminder', + payload: { + workspaceId: workspace._id.toString(), + daysAfterPayday: days, + }, + }); + } + /** * Sets BillingPeriodEventsCount to 0 in workspace * diff --git a/workers/paymaster/tests/index.test.ts b/workers/paymaster/tests/index.test.ts index 74f52e1f3..964f32e06 100644 --- a/workers/paymaster/tests/index.test.ts +++ b/workers/paymaster/tests/index.test.ts @@ -210,7 +210,7 @@ describe('PaymasterWorker', () => { /** * Arrange */ - const currentDate = new Date('2005-12-26'); + const currentDate = new Date('2005-12-27'); const plan = createPlanMock({ monthlyCharge: 100, isDefault: true, @@ -257,6 +257,101 @@ describe('PaymasterWorker', () => { MockDate.reset(); }); + /** + * Helper function to run blocked workspace reminder test + * + * @param lastChargeDate - date of last charge + * @param currentDate - current date to test + * @param shouldBeCalled - whether the reminder should be called + * @param expectedDaysAfterPayday - expected days after payday in the call + */ + const testBlockedWorkspaceReminder = async ( + lastChargeDate: Date, + currentDate: Date, + shouldBeCalled: boolean, + expectedDaysAfterPayday?: number + ): Promise => { + const plan = createPlanMock({ + monthlyCharge: 100, + isDefault: true, + }); + const workspace = createWorkspaceMock({ + plan, + subscriptionId: 'some-subscription-id', + lastChargeDate, + isBlocked: true, + billingPeriodEventsCount: 10, + }); + + await fillDatabaseWithMockedData({ + workspace, + plan, + }); + + MockDate.set(currentDate); + + const worker = new PaymasterWorker(); + const addTaskSpy = jest.spyOn(worker, 'addTask'); + + await worker.start(); + await worker.handle(WORKSPACE_SUBSCRIPTION_CHECK); + await worker.finish(); + + if (shouldBeCalled) { + expect(addTaskSpy).toHaveBeenCalledWith('sender/email', { + type: 'blocked-workspace-reminder', + payload: { + workspaceId: workspace._id.toString(), + daysAfterPayday: expectedDaysAfterPayday, + }, + }); + } else { + expect(addTaskSpy).not.toHaveBeenCalledWith('sender/email', expect.objectContaining({ + type: 'blocked-workspace-reminder', + })); + } + + MockDate.reset(); + return addTaskSpy; + }; + + describe('Blocked workspace reminder tests', () => { + test('Should remind admins for blocked workspace if it has subscription and after payday passed 1 day', async () => { + await testBlockedWorkspaceReminder( + new Date('2005-11-22'), + new Date('2005-12-23'), + true, + 1 + ); + }); + + test('Should remind admins for blocked workspace if it has subscription and after payday passed 5 days', async () => { + await testBlockedWorkspaceReminder( + new Date('2005-11-22'), + new Date('2005-12-27'), + true, + 5 + ); + }); + + test('Should remind admins for blocked workspace if it has subscription and after payday passed 30 days', async () => { + await testBlockedWorkspaceReminder( + new Date('2005-11-22'), + new Date('2006-01-21'), + true, + 30 + ); + }); + + test('Should not remind admins for blocked workspace on days not in reminder schedule (day 4)', async () => { + await testBlockedWorkspaceReminder( + new Date('2005-11-22'), + new Date('2005-12-26'), + false + ); + }); + }); + test('Should update lastChargeDate and billingPeriodEventsCount if workspace has free tariff plan and it\'s time to pay', async () => { /** * Arrange diff --git a/workers/sender/src/index.ts b/workers/sender/src/index.ts index a92c9d190..022f63f60 100644 --- a/workers/sender/src/index.ts +++ b/workers/sender/src/index.ts @@ -29,7 +29,8 @@ import { SenderWorkerEventsLimitAlmostReachedTask, SenderWorkerSignUpTask, SenderWorkerPasswordResetTask, - SenderWorkerWorkspaceInviteTask + SenderWorkerWorkspaceInviteTask, + SenderWorkerBlockedWorkspaceReminderTask } from '../types/sender-task'; import { decodeUnsafeFields } from '../../../lib/utils/unsafeFields'; import { Notification, EventNotification, SeveralEventsNotification, PaymentFailedNotification, AssigneeNotification, SignUpNotification } from '../types/template-variables'; @@ -115,6 +116,8 @@ export default abstract class SenderWorker extends Worker { return this.handleAssigneeTask(task as SenderWorkerAssigneeTask); case 'block-workspace': return this.handleBlockWorkspaceTask(task as SenderWorkerBlockWorkspaceTask); + case 'blocked-workspace-reminder': + return this.handleBlockedWorkspaceReminderTask(task as SenderWorkerBlockedWorkspaceReminderTask); case 'days-limit-almost-reached': return this.handleDaysLimitAlmostReachedTask(task as SenderWorkerDaysLimitAlmostReachedTask); case 'event': @@ -196,7 +199,7 @@ export default abstract class SenderWorker extends Worker { project, events: eventsData, period: channel.minPeriod, - notificationRuleId: rule._id, + notificationRuleId: rule._id.toString(), }, } as EventNotification | SeveralEventsNotification); } @@ -297,6 +300,63 @@ export default abstract class SenderWorker extends Worker { })); } + private async handleBlockedWorkspaceReminderTask(task: SenderWorkerBlockedWorkspaceReminderTask): Promise { + const eventType = 'blocked-workspace-reminder'; + + /** + * Send message not often than once per day + */ + const throttleInterval = TimeMs.DAY; + + const { workspaceId, daysAfterPayday } = task.payload; + + const workspace = await this.getWorkspace(workspaceId); + + if (!workspace) { + this.logger.error(`Cannot send blocked workspace reminder notification: workspace not found. Payload: ${JSON.stringify(task)}`); + + return; + } + + const allowToSendNotification = this.needToSendNextNotification(workspace, eventType, throttleInterval); + + /** + * Do not send any notifications if we have already done in target throttle time + */ + if (!allowToSendNotification) { + return; + } + + const admins = await this.getWorkspaceAdmins(workspaceId); + + if (!admins) { + this.logger.error(`Cannot send blocked workspace reminder notification: workspace team not found. Payload: ${JSON.stringify(task)}`); + + return; + } + + const adminIds = admins.map(admin => admin.userId.toString()); + const users = await this.getUsers(adminIds); + + await Promise.all(users.map(async user => { + const channel = user.notifications.channels[this.channelType]; + + if (channel.isEnabled) { + await this.provider.send(channel.endpoint, { + type: 'blocked-workspace-reminder', + payload: { + host: process.env.GARAGE_URL, + hostOfStatic: process.env.API_STATIC_URL, + workspace, + daysAfterPayday, + }, + }); + } + })); + + await this.updateLastNotificationDate(workspace, eventType); + } + /** * Handle task when days limit is almost reached * @@ -356,13 +416,10 @@ export default abstract class SenderWorker extends Worker { daysLeft, }, }); - - /** - * Update last notification data in DB - */ - await this.updateLastNoticationDate(workspace, eventType); } })); + + await this.updateLastNotificationDate(workspace, eventType); } /** @@ -422,10 +479,10 @@ export default abstract class SenderWorker extends Worker { eventsLimit, }, }); - - await this.updateLastNoticationDate(workspace, eventType); } })); + + await this.updateLastNotificationDate(workspace, eventType); } /** @@ -715,7 +772,7 @@ export default abstract class SenderWorker extends Worker { * @param {string} type - event type * @param {number} date - date to be set */ - private async updateLastNoticationDate(workspace: WorkspaceDBScheme, type: string, date = new Date()): Promise { + private async updateLastNotificationDate(workspace: WorkspaceDBScheme, type: string, date = new Date()): Promise { /** * Throw an error if workspace is missing */ diff --git a/workers/sender/types/sender-task/blockWorkspace.ts b/workers/sender/types/sender-task/block-workspace.ts similarity index 100% rename from workers/sender/types/sender-task/blockWorkspace.ts rename to workers/sender/types/sender-task/block-workspace.ts diff --git a/workers/sender/types/sender-task/blocked-workspace-reminder.ts b/workers/sender/types/sender-task/blocked-workspace-reminder.ts new file mode 100644 index 000000000..df502a67d --- /dev/null +++ b/workers/sender/types/sender-task/blocked-workspace-reminder.ts @@ -0,0 +1,29 @@ +/** + * Payload for task for blocked workspace reminder + */ +export interface SenderWorkerBlockedWorkspaceReminderPayload { + /** + * Blocked workspace id + */ + workspaceId: string; + + /** + * Days after payday + */ + daysAfterPayday: number; +} + +/** + * Payload of an event for blocked workspace reminder + */ +export interface SenderWorkerBlockedWorkspaceReminderTask { + /** + * Task for blocked workspace reminder + */ + type: 'blocked-workspace-reminder'; + + /** + * Payload for task for blocked workspace reminder + */ + payload: SenderWorkerBlockedWorkspaceReminderPayload; +} diff --git a/workers/sender/types/sender-task/index.ts b/workers/sender/types/sender-task/index.ts index fb9e89c87..5733e6681 100644 --- a/workers/sender/types/sender-task/index.ts +++ b/workers/sender/types/sender-task/index.ts @@ -1,6 +1,7 @@ import { SenderWorkerAssigneeTask } from './assignee'; import { SenderWorkerEventTask } from './event'; -import { SenderWorkerBlockWorkspaceTask } from './blockWorkspace'; +import { SenderWorkerBlockWorkspaceTask } from './block-workspace'; +import { SenderWorkerBlockedWorkspaceReminderTask } from './blocked-workspace-reminder'; import { SenderWorkerPaymentFailedTask } from './payment-failed'; import { SenderWorkerPaymentSuccessTask } from './payment-success'; import { SenderWorkerDaysLimitAlmostReachedTask } from './days-limit-almost-reached'; @@ -11,7 +12,8 @@ import { SenderWorkerWorkspaceInviteTask } from './workspace-invite'; export { SenderWorkerEventTask, SenderWorkerEventPayload } from './event'; export { SenderWorkerAssigneeTask, SenderWorkerAssigneePayload } from './assignee'; -export { SenderWorkerBlockWorkspaceTask, SenderWorkerBlockWorkspacePayload } from './blockWorkspace'; +export { SenderWorkerBlockWorkspaceTask, SenderWorkerBlockWorkspacePayload } from './block-workspace'; +export { SenderWorkerBlockedWorkspaceReminderTask, SenderWorkerBlockedWorkspaceReminderPayload } from './blocked-workspace-reminder'; export { SenderWorkerPaymentFailedTask, SenderWorkerPaymentFailedPayload } from './payment-failed'; export { SenderWorkerPaymentSuccessTask, SenderWorkerPaymentSuccessPayload } from './payment-success'; export { SenderWorkerDaysLimitAlmostReachedTask, SenderWorkerDaysLimitAlmostReachedPayload } from './days-limit-almost-reached'; @@ -23,6 +25,7 @@ export { SenderWorkerWorkspaceInviteTask, SenderWorkerWorkspaceInvitePayload } f export type SenderWorkerTask = SenderWorkerEventTask | SenderWorkerAssigneeTask | SenderWorkerBlockWorkspaceTask + | SenderWorkerBlockedWorkspaceReminderTask | SenderWorkerPaymentFailedTask | SenderWorkerPaymentSuccessTask | SenderWorkerDaysLimitAlmostReachedTask diff --git a/workers/sender/types/template-variables/blockWorkspace.ts b/workers/sender/types/template-variables/block-workspace.ts similarity index 100% rename from workers/sender/types/template-variables/blockWorkspace.ts rename to workers/sender/types/template-variables/block-workspace.ts diff --git a/workers/sender/types/template-variables/blocked-workspace-reminder.ts b/workers/sender/types/template-variables/blocked-workspace-reminder.ts new file mode 100644 index 000000000..9b3aaa96b --- /dev/null +++ b/workers/sender/types/template-variables/blocked-workspace-reminder.ts @@ -0,0 +1,33 @@ +import { CommonTemplateVariables } from './common-template'; +import { WorkspaceDBScheme } from '@hawk.so/types'; +import { Notification } from './notification'; + +/** + * Variables for block workspace template + */ +export interface BlockedWorkspaceReminderTemplateVariables extends CommonTemplateVariables { + /** + * Blocked workspace data + */ + workspace: WorkspaceDBScheme; + + /** + * Number of days after payday when workspace was blocked + */ + daysAfterPayday: number; +} + +/** + * Object with notification type and variables for the block workspace event template + */ +export interface BlockedWorkspaceReminderNotification extends Notification { + /** + * Notification when workspace blocked + */ + type: 'blocked-workspace-reminder'; + + /** + * Notification payload + */ + payload: BlockedWorkspaceReminderTemplateVariables; +} diff --git a/workers/sender/types/template-variables/index.ts b/workers/sender/types/template-variables/index.ts index 058ad34f3..864060b52 100644 --- a/workers/sender/types/template-variables/index.ts +++ b/workers/sender/types/template-variables/index.ts @@ -1,7 +1,8 @@ import { EventsTemplateVariables, EventNotification } from './event'; import { SeveralEventsNotification } from './several-events'; import { AssigneeTemplateVariables, AssigneeNotification } from './assignee'; -import { BlockWorkspaceTemplateVariables, BlockWorkspaceNotification } from './blockWorkspace'; +import { BlockWorkspaceTemplateVariables, BlockWorkspaceNotification } from './block-workspace'; +import { BlockedWorkspaceReminderTemplateVariables, BlockedWorkspaceReminderNotification } from './blocked-workspace-reminder'; import { PaymentFailedTemplateVariables, PaymentFailedNotification } from './payment-failed'; import { PaymentSuccessNotification, PaymentSuccessTemplateVariables } from './payment-success'; import { DaysLimitAlmostReachedTemplateVariables, DaysLimitAlmostReachedNotification } from './days-limit-almost-reached'; @@ -14,7 +15,8 @@ export { CommonTemplateVariables } from './common-template'; export { TemplateEventData, EventsTemplateVariables, EventNotification } from './event'; export { SeveralEventsNotification } from './several-events'; export { AssigneeTemplateVariables, AssigneeNotification } from './assignee'; -export { BlockWorkspaceTemplateVariables, BlockWorkspaceNotification } from './blockWorkspace'; +export { BlockWorkspaceTemplateVariables, BlockWorkspaceNotification } from './block-workspace'; +export { BlockedWorkspaceReminderTemplateVariables, BlockedWorkspaceReminderNotification } from './blocked-workspace-reminder'; export { PaymentFailedTemplateVariables, PaymentFailedNotification } from './payment-failed'; export { PaymentSuccessNotification, PaymentSuccessTemplateVariables } from './payment-success'; export { SignUpNotification, SignUpVariables } from './sign-up'; @@ -28,6 +30,7 @@ export type Notification = EventNotification | SeveralEventsNotification | AssigneeNotification | BlockWorkspaceNotification + | BlockedWorkspaceReminderNotification | PaymentFailedNotification | PaymentSuccessNotification | DaysLimitAlmostReachedNotification @@ -42,6 +45,7 @@ export type Notification = EventNotification export type TemplateVariables = EventsTemplateVariables | AssigneeTemplateVariables | BlockWorkspaceTemplateVariables + | BlockedWorkspaceReminderTemplateVariables | PaymentFailedTemplateVariables | PaymentSuccessTemplateVariables | DaysLimitAlmostReachedTemplateVariables