From 2cb8966b4811d7c37605e35f72960e26c7b864f8 Mon Sep 17 00:00:00 2001 From: Igor Kamyshev Date: Thu, 28 Feb 2019 14:58:24 +0200 Subject: [PATCH] feat(telegram): add income and outcome commands in bot --- back/package.json | 1 + back/src/money/money.module.ts | 4 ++ .../telegram/actions/TransactionActions.ts | 67 +++++++++++++++++++ .../catcher/UnexpectedParameterCatcher.ts | 11 +++ .../telegram/helpers/parseAmount.ts | 2 + .../telegram/helpers/parseCurrency.ts | 17 +++++ back/src/user/domain/UserRepository.ts | 12 ++++ .../telegram/transformer/CurrentSender.ts | 18 +++++ back/src/user/user.module.ts | 4 +- .../Templating/HandlebarsTemplating.ts | 45 +++++++++++++ .../infrastructure/Templating/Templating.ts | 3 + .../Templating/handlebarsHelpers/money.ts | 7 ++ .../exception/UnexpectedParameterException.ts | 5 ++ back/src/utils/utils.module.ts | 8 ++- back/templates/telegram/income-created.twig | 3 + back/templates/telegram/outcome-created.twig | 3 + .../create/create-income/CreateIncome.tsx | 2 +- .../create/create-outcome/CreateOutcome.tsx | 2 +- .../features/history/components/Incomes.tsx | 2 +- .../features/history/components/Outcomes.tsx | 2 +- .../src/features/app/features/stats/Stats.tsx | 2 +- .../stats/components/currency-switch.tsx | 2 +- .../form/input-money/InputMoney.tsx | 2 +- ...InputMoneyProps.tsx => InputMoneyProps.ts} | 0 .../helpers/NON_BREAKING_SPACE.ts | 0 {front/src => shared}/helpers/displayMoney.ts | 0 .../src => shared}/helpers/getCurrencyName.ts | 0 .../src => shared}/helpers/getCurrencySign.ts | 0 28 files changed, 215 insertions(+), 9 deletions(-) create mode 100644 back/src/money/presentation/telegram/actions/TransactionActions.ts create mode 100644 back/src/money/presentation/telegram/catcher/UnexpectedParameterCatcher.ts create mode 100644 back/src/money/presentation/telegram/helpers/parseAmount.ts create mode 100644 back/src/money/presentation/telegram/helpers/parseCurrency.ts create mode 100644 back/src/user/presentation/telegram/transformer/CurrentSender.ts create mode 100644 back/src/utils/infrastructure/Templating/HandlebarsTemplating.ts create mode 100644 back/src/utils/infrastructure/Templating/Templating.ts create mode 100644 back/src/utils/infrastructure/Templating/handlebarsHelpers/money.ts create mode 100644 back/src/utils/infrastructure/exception/UnexpectedParameterException.ts create mode 100644 back/templates/telegram/income-created.twig create mode 100644 back/templates/telegram/outcome-created.twig rename front/src/ui/components/form/input-money/{InputMoneyProps.tsx => InputMoneyProps.ts} (100%) rename {front/src => shared}/helpers/NON_BREAKING_SPACE.ts (100%) rename {front/src => shared}/helpers/displayMoney.ts (100%) rename {front/src => shared}/helpers/getCurrencyName.ts (100%) rename {front/src => shared}/helpers/getCurrencySign.ts (100%) diff --git a/back/package.json b/back/package.json index 46572622..ee1f1a23 100644 --- a/back/package.json +++ b/back/package.json @@ -20,6 +20,7 @@ "@solid-soda/evolutions": "^0.0.6", "bcryptjs": "^2.4.3", "cors": "^2.8.5", + "handlebars": "^4.1.0", "morgan": "^1.9.1", "nanoid": "^2.0.1", "nest-telegram": "^0.1.1", diff --git a/back/src/money/money.module.ts b/back/src/money/money.module.ts index 548b11b3..ff00fd4f 100644 --- a/back/src/money/money.module.ts +++ b/back/src/money/money.module.ts @@ -18,6 +18,8 @@ import { ExchangeRateApi } from './insfrastructure/ExchangeRateApi' import { HistoryController } from './presentation/http/controller/HistoryController' import { StatisticsController } from './presentation/http/controller/StatisticsController' import { TransactionController } from './presentation/http/controller/TransactionController' +import { TransactionActions } from './presentation/telegram/actions/TransactionActions' +import { UnexpectedParameterCatcher } from './presentation/telegram/catcher/UnexpectedParameterCatcher' @Module({ imports: [ @@ -35,6 +37,8 @@ import { TransactionController } from './presentation/http/controller/Transactio CurrencyConverter, ExchangeRateApi, ExchangeRateRepository, + TransactionActions, + UnexpectedParameterCatcher, ], }) export class MoneyModule implements NestModule { diff --git a/back/src/money/presentation/telegram/actions/TransactionActions.ts b/back/src/money/presentation/telegram/actions/TransactionActions.ts new file mode 100644 index 00000000..b5c6087a --- /dev/null +++ b/back/src/money/presentation/telegram/actions/TransactionActions.ts @@ -0,0 +1,67 @@ +import { Injectable } from '@nestjs/common' +import { Context, PipeContext, TelegramActionHandler } from 'nest-telegram' + +import { CurrentSender } from '@back/user/presentation/telegram/transformer/CurrentSender' +import { TokenPayload } from '@back/user/application/dto/TokenPayload' +import { Accountant } from '@back/money/application/Accountant' +import { Templating } from '@back/utils/infrastructure/Templating/Templating' +import { parseCurrency } from '../helpers/parseCurrency' +import { parseAmount } from '../helpers/parseAmount' + +@Injectable() +export class TransactionActions { + public constructor( + private readonly accountant: Accountant, + private readonly templating: Templating, + ) {} + + @TelegramActionHandler({ command: '/income' }) + public async income( + ctx: Context, + @PipeContext(CurrentSender) { login }: TokenPayload, + ) { + const [_, rawAmount, rawCurrency, ...source] = ctx.message.text.split(' ') + + // TODO: Add message parser to nest-telegram lib as decorator + const incomeFields = { + amount: parseAmount(rawAmount), + currency: parseCurrency(rawCurrency), + date: new Date(), + source: source.join(' '), + } + + await this.accountant.income(login, incomeFields) + + const responseText = await this.templating.render( + 'telegram/income-created', + incomeFields, + ) + + await ctx.reply(responseText) + } + + @TelegramActionHandler({ command: '/outcome' }) + public async outcome( + ctx: Context, + @PipeContext(CurrentSender) { login }: TokenPayload, + ) { + const [_, rawAmount, rawCurrency, ...category] = ctx.message.text.split(' ') + + // TODO: Add message parser to nest-telegram lib as decorator + const outcomeFields = { + amount: parseAmount(rawAmount), + currency: parseCurrency(rawCurrency), + date: new Date(), + category: category.join(' '), + } + + await this.accountant.outcome(login, outcomeFields) + + const responseText = await this.templating.render( + 'telegram/outcome-created', + outcomeFields, + ) + + await ctx.reply(responseText) + } +} diff --git a/back/src/money/presentation/telegram/catcher/UnexpectedParameterCatcher.ts b/back/src/money/presentation/telegram/catcher/UnexpectedParameterCatcher.ts new file mode 100644 index 00000000..f1757711 --- /dev/null +++ b/back/src/money/presentation/telegram/catcher/UnexpectedParameterCatcher.ts @@ -0,0 +1,11 @@ +import { TelegramErrorHandler, TelegramCatch, Context } from 'nest-telegram' + +import { UnexpectedParameterException } from '@back/utils/infrastructure/exception/UnexpectedParameterException' + +@TelegramCatch(UnexpectedParameterException) +export class UnexpectedParameterCatcher + implements TelegramErrorHandler { + public async catch(ctx: Context, exception: UnexpectedParameterException) { + await ctx.reply(exception.message) + } +} diff --git a/back/src/money/presentation/telegram/helpers/parseAmount.ts b/back/src/money/presentation/telegram/helpers/parseAmount.ts new file mode 100644 index 00000000..82571aae --- /dev/null +++ b/back/src/money/presentation/telegram/helpers/parseAmount.ts @@ -0,0 +1,2 @@ +export const parseAmount = (rawAmount: string): number => + parseFloat(rawAmount.replace(/,/g, '.')) * 100 diff --git a/back/src/money/presentation/telegram/helpers/parseCurrency.ts b/back/src/money/presentation/telegram/helpers/parseCurrency.ts new file mode 100644 index 00000000..0bc2648d --- /dev/null +++ b/back/src/money/presentation/telegram/helpers/parseCurrency.ts @@ -0,0 +1,17 @@ +import { Currency } from '@shared/enum/Currency' +import { UnexpectedParameterException } from '@back/utils/infrastructure/exception/UnexpectedParameterException' + +export const parseCurrency = (rawCurrency: string): Currency => { + const transformedCurrency = rawCurrency.toUpperCase() + + if (!Object.values(Currency).includes(transformedCurrency)) { + throw new UnexpectedParameterException( + 'currency', + `"${rawCurrency}" is invalid currency, please use one of follow insted: ${Object.values( + Currency, + ).join(', ')}`, + ) + } + + return transformedCurrency as Currency +} diff --git a/back/src/user/domain/UserRepository.ts b/back/src/user/domain/UserRepository.ts index 79d11870..c31992e3 100644 --- a/back/src/user/domain/UserRepository.ts +++ b/back/src/user/domain/UserRepository.ts @@ -32,6 +32,18 @@ class UserRepo { return Option.of(user) } + public async getOneByTeleram(telegramId: number): Promise { + const user = await this.findOneByTelegram(telegramId) + + if (user.nonEmpty()) { + return user.get() + } + + throw new EntityNotFoundException(User.name, { + telegramId, + }) + } + public async findOneByTelegram(telegramId: number): Promise> { const user = await this.userRepo .createQueryBuilder() diff --git a/back/src/user/presentation/telegram/transformer/CurrentSender.ts b/back/src/user/presentation/telegram/transformer/CurrentSender.ts new file mode 100644 index 00000000..36ff40e7 --- /dev/null +++ b/back/src/user/presentation/telegram/transformer/CurrentSender.ts @@ -0,0 +1,18 @@ +import { Injectable } from '@nestjs/common' +import { ContextTransformer, Context } from 'nest-telegram' + +import { UserRepository } from '@back/user/domain/UserRepository' +import { TokenPayload } from '@back/user/application/dto/TokenPayload' + +@Injectable() +export class CurrentSender implements ContextTransformer { + public constructor(private readonly userRepo: UserRepository) {} + + public async transform(ctx: Context) { + const user = await this.userRepo.getOneByTeleram(ctx.from.id) + + return { + login: user.login, + } + } +} diff --git a/back/src/user/user.module.ts b/back/src/user/user.module.ts index 643be9d2..c7d55945 100644 --- a/back/src/user/user.module.ts +++ b/back/src/user/user.module.ts @@ -13,6 +13,7 @@ import { JwtGuard } from './presentation/http/security/JwtGuard' import { AuthActions } from './presentation/telegram/actions/AuthActions' import { InvalidCredentialsCatcher } from './presentation/telegram/catcher/InvalidCredentialsCatcher' import { IsKnownUser } from './presentation/telegram/transformer/IsKnownUser' +import { CurrentSender } from './presentation/telegram/transformer/CurrentSender' import { User } from './domain/User.entity' import { UserRepository } from './domain/UserRepository' @@ -50,8 +51,9 @@ import { PasswordEncoder } from './infrastructure/PasswordEncoder/PasswordEncode AuthActions, InvalidCredentialsCatcher, IsKnownUser, + CurrentSender, ], - exports: [UserRepository, JwtGuard, Authenticator], + exports: [UserRepository, JwtGuard, Authenticator, CurrentSender], }) export class UserModule implements NestModule { public configure(consumer: MiddlewareConsumer) { diff --git a/back/src/utils/infrastructure/Templating/HandlebarsTemplating.ts b/back/src/utils/infrastructure/Templating/HandlebarsTemplating.ts new file mode 100644 index 00000000..c038c75a --- /dev/null +++ b/back/src/utils/infrastructure/Templating/HandlebarsTemplating.ts @@ -0,0 +1,45 @@ +import { Injectable } from '@nestjs/common' +import * as Handlebars from 'handlebars' +import { readFile } from 'fs' +import { promisify } from 'util' +import { resolve } from 'path' + +import { Templating } from './Templating' +import { money } from './handlebarsHelpers/money' + +type PrecompiledTempltes = { + [name: string]: Handlebars.TemplateDelegate +} + +@Injectable() +export class HandlebarsTemplating implements Templating { + private readonly precompiledTemplates = {} + + public constructor() { + Handlebars.registerHelper('money', money) + } + + public async render(templateName: string, context: object) { + const compiled = await this.compile(templateName) + + return compiled(context) + } + + private async compile( + templateName: string, + ): Promise { + if (this.precompiledTemplates[templateName]) { + return this.precompiledTemplates[templateName] + } + + const templateContent = (await promisify(readFile)( + resolve(__dirname, '../../../../templates', `${templateName}.twig`), + )).toString() + + const compiled = Handlebars.compile(templateContent) + + this.precompiledTemplates[templateName] = compiled + + return compiled + } +} diff --git a/back/src/utils/infrastructure/Templating/Templating.ts b/back/src/utils/infrastructure/Templating/Templating.ts new file mode 100644 index 00000000..7058ea1e --- /dev/null +++ b/back/src/utils/infrastructure/Templating/Templating.ts @@ -0,0 +1,3 @@ +export abstract class Templating { + public abstract render(templateName: string, context: object): Promise +} diff --git a/back/src/utils/infrastructure/Templating/handlebarsHelpers/money.ts b/back/src/utils/infrastructure/Templating/handlebarsHelpers/money.ts new file mode 100644 index 00000000..4178b83d --- /dev/null +++ b/back/src/utils/infrastructure/Templating/handlebarsHelpers/money.ts @@ -0,0 +1,7 @@ +import * as Handlebars from 'handlebars' + +import { Currency } from '@shared/enum/Currency' +import { displayMoney } from '@shared/helpers/displayMoney' + +export const money = (amount: number, currency: Currency) => + new Handlebars.SafeString(displayMoney(currency)(amount)) diff --git a/back/src/utils/infrastructure/exception/UnexpectedParameterException.ts b/back/src/utils/infrastructure/exception/UnexpectedParameterException.ts new file mode 100644 index 00000000..2d821a6b --- /dev/null +++ b/back/src/utils/infrastructure/exception/UnexpectedParameterException.ts @@ -0,0 +1,5 @@ +export class UnexpectedParameterException extends Error { + public constructor(public readonly parameter: string, message: string) { + super(message) + } +} diff --git a/back/src/utils/utils.module.ts b/back/src/utils/utils.module.ts index 43f9cbb6..c65c7056 100644 --- a/back/src/utils/utils.module.ts +++ b/back/src/utils/utils.module.ts @@ -3,6 +3,8 @@ import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common' import { IdGenerator } from './infrastructure/IdGenerator/IdGenerator' import { NanoIdGenerator } from './infrastructure/IdGenerator/NanoIdGenerator' import { ParseDateRangePipe } from './presentation/http/pipes/dateRange/ParseDateRangePipe' +import { Templating } from './infrastructure/Templating/Templating' +import { HandlebarsTemplating } from './infrastructure/Templating/HandlebarsTemplating' @Module({ providers: [ @@ -11,8 +13,12 @@ import { ParseDateRangePipe } from './presentation/http/pipes/dateRange/ParseDat provide: IdGenerator, useClass: NanoIdGenerator, }, + { + provide: Templating, + useClass: HandlebarsTemplating, + }, ], - exports: [ParseDateRangePipe, IdGenerator], + exports: [ParseDateRangePipe, IdGenerator, Templating], }) export class UtilsModule implements NestModule { public configure(consumer: MiddlewareConsumer) { diff --git a/back/templates/telegram/income-created.twig b/back/templates/telegram/income-created.twig new file mode 100644 index 00000000..699afb86 --- /dev/null +++ b/back/templates/telegram/income-created.twig @@ -0,0 +1,3 @@ +Income transaction created. + +{{money amount currency}} ({{source}}) \ No newline at end of file diff --git a/back/templates/telegram/outcome-created.twig b/back/templates/telegram/outcome-created.twig new file mode 100644 index 00000000..d5e88f6f --- /dev/null +++ b/back/templates/telegram/outcome-created.twig @@ -0,0 +1,3 @@ +Outcome transaction created. + +{{money amount currency}} ({{category}}) \ No newline at end of file diff --git a/front/src/features/app/features/create/create-income/CreateIncome.tsx b/front/src/features/app/features/create/create-income/CreateIncome.tsx index 545c188e..eb587adc 100644 --- a/front/src/features/app/features/create/create-income/CreateIncome.tsx +++ b/front/src/features/app/features/create/create-income/CreateIncome.tsx @@ -11,7 +11,7 @@ import { Input, InputMoney, } from '@front/features/final-form' -import { getCurrencyName } from '@front/helpers/getCurrencyName' +import { getCurrencyName } from '@shared/helpers/getCurrencyName' import { Label } from '@front/ui/components/form/label' import { LoadingButton } from '@front/ui/components/form/loading-button' import { Card } from '@front/ui/components/layout/card' diff --git a/front/src/features/app/features/create/create-outcome/CreateOutcome.tsx b/front/src/features/app/features/create/create-outcome/CreateOutcome.tsx index 38e6abb4..ab575c73 100644 --- a/front/src/features/app/features/create/create-outcome/CreateOutcome.tsx +++ b/front/src/features/app/features/create/create-outcome/CreateOutcome.tsx @@ -11,7 +11,7 @@ import { Input, InputMoney, } from '@front/features/final-form' -import { getCurrencyName } from '@front/helpers/getCurrencyName' +import { getCurrencyName } from '@shared/helpers/getCurrencyName' import { Label } from '@front/ui/components/form/label' import { LoadingButton } from '@front/ui/components/form/loading-button' import { Card } from '@front/ui/components/layout/card' diff --git a/front/src/features/app/features/history/components/Incomes.tsx b/front/src/features/app/features/history/components/Incomes.tsx index eda30331..4998d464 100644 --- a/front/src/features/app/features/history/components/Incomes.tsx +++ b/front/src/features/app/features/history/components/Incomes.tsx @@ -1,4 +1,4 @@ -import { displayMoney } from '@front/helpers/displayMoney' +import { displayMoney } from '@shared/helpers/displayMoney' import { displayNullableDate } from '@front/helpers/displayNullableDtae' import { Table } from '@front/ui/components/layout/table' import { IncomeModel } from '@shared/models/money/IncomeModel' diff --git a/front/src/features/app/features/history/components/Outcomes.tsx b/front/src/features/app/features/history/components/Outcomes.tsx index 158943da..62db6a3b 100644 --- a/front/src/features/app/features/history/components/Outcomes.tsx +++ b/front/src/features/app/features/history/components/Outcomes.tsx @@ -1,4 +1,4 @@ -import { displayMoney } from '@front/helpers/displayMoney' +import { displayMoney } from '@shared/helpers/displayMoney' import { displayNullableDate } from '@front/helpers/displayNullableDtae' import { Table } from '@front/ui/components/layout/table' import { OutcomeModel } from '@shared/models/money/OutcomeModel' diff --git a/front/src/features/app/features/stats/Stats.tsx b/front/src/features/app/features/stats/Stats.tsx index 26d6516d..299ef081 100644 --- a/front/src/features/app/features/stats/Stats.tsx +++ b/front/src/features/app/features/stats/Stats.tsx @@ -7,7 +7,7 @@ import { getFirstTransactionDate } from '@front/domain/money/selectors/getFirstT import { getStats } from '@front/domain/money/selectors/getStats' import { getStatsFetchingStatus } from '@front/domain/money/selectors/getStatsFetchingStatus' import { useThunk } from '@front/domain/store' -import { displayMoney } from '@front/helpers/displayMoney' +import { displayMoney } from '@shared/helpers/displayMoney' import { BarChart } from '@front/ui/components/chart/bar-chart' import { Period } from '@front/ui/components/form/period' import { Loader } from '@front/ui/components/layout/loader' diff --git a/front/src/features/app/features/stats/components/currency-switch.tsx b/front/src/features/app/features/stats/components/currency-switch.tsx index c3e5a650..ac07468a 100644 --- a/front/src/features/app/features/stats/components/currency-switch.tsx +++ b/front/src/features/app/features/stats/components/currency-switch.tsx @@ -1,6 +1,6 @@ import { useCallback } from 'react' -import { getCurrencyName } from '@front/helpers/getCurrencyName' +import { getCurrencyName } from '@shared/helpers/getCurrencyName' import { EnumSelect } from '@front/ui/components/form/select' import { Currency } from '@shared/enum/Currency' diff --git a/front/src/ui/components/form/input-money/InputMoney.tsx b/front/src/ui/components/form/input-money/InputMoney.tsx index 7d2f0493..24b8b014 100644 --- a/front/src/ui/components/form/input-money/InputMoney.tsx +++ b/front/src/ui/components/form/input-money/InputMoney.tsx @@ -1,4 +1,4 @@ -import { getCurrencySign } from '@front/helpers/getCurrencySign' +import { getCurrencySign } from '@shared/helpers/getCurrencySign' import { useCustomInput } from '@front/ui/hooks/useCustomInput' import { Input } from '../input' diff --git a/front/src/ui/components/form/input-money/InputMoneyProps.tsx b/front/src/ui/components/form/input-money/InputMoneyProps.ts similarity index 100% rename from front/src/ui/components/form/input-money/InputMoneyProps.tsx rename to front/src/ui/components/form/input-money/InputMoneyProps.ts diff --git a/front/src/helpers/NON_BREAKING_SPACE.ts b/shared/helpers/NON_BREAKING_SPACE.ts similarity index 100% rename from front/src/helpers/NON_BREAKING_SPACE.ts rename to shared/helpers/NON_BREAKING_SPACE.ts diff --git a/front/src/helpers/displayMoney.ts b/shared/helpers/displayMoney.ts similarity index 100% rename from front/src/helpers/displayMoney.ts rename to shared/helpers/displayMoney.ts diff --git a/front/src/helpers/getCurrencyName.ts b/shared/helpers/getCurrencyName.ts similarity index 100% rename from front/src/helpers/getCurrencyName.ts rename to shared/helpers/getCurrencyName.ts diff --git a/front/src/helpers/getCurrencySign.ts b/shared/helpers/getCurrencySign.ts similarity index 100% rename from front/src/helpers/getCurrencySign.ts rename to shared/helpers/getCurrencySign.ts