From c22357b71e8f3ccbeedb80b99ae3c4ce525bb41f Mon Sep 17 00:00:00 2001 From: Vlad Chikalkin Date: Wed, 23 Jul 2025 13:14:02 +0300 Subject: [PATCH] feat: integrate Telegraf for Telegram notifications in order lifecycle (#4) - Added Telegraf dependency to handle Telegram bot interactions. - Updated order lifecycle methods to send notifications upon order creation and updates. - Refactored datetime handling to use a consistent timezone. - Removed unused utility functions to streamline codebase. --- .github/workflows/deploy.yml | 1 + config/server.ts | 1 + package.json | 3 +- pnpm-lock.yaml | 84 +++++++++ .../order/content-types/order/lifecycles.ts | 167 ++++++++++++++++-- src/api/slot/content-types/slot/lifecycles.ts | 15 +- src/constants/index.ts | 1 + src/utils/datetime.ts | 13 ++ src/{utils.ts => utils/db.ts} | 0 src/utils/index.ts | 3 + src/utils/telegram.ts | 3 + 11 files changed, 268 insertions(+), 23 deletions(-) create mode 100644 src/constants/index.ts create mode 100644 src/utils/datetime.ts rename src/{utils.ts => utils/db.ts} (100%) create mode 100644 src/utils/index.ts create mode 100644 src/utils/telegram.ts diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index de8a0d7..ebaf30f 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -57,6 +57,7 @@ jobs: echo "DATABASE_USERNAME=${{ secrets.DATABASE_USERNAME }}" >> .env echo "DATABASE_PASSWORD=${{ secrets.DATABASE_PASSWORD }}" >> .env echo "DATABASE_SSL=false" >> .env + echo "BOT_TOKEN=${{ secrets.BOT_TOKEN }}" >> .env - name: Copy .env to VPS via SCP uses: appleboy/scp-action@master diff --git a/config/server.ts b/config/server.ts index 31c8997..74ae9c0 100644 --- a/config/server.ts +++ b/config/server.ts @@ -1,6 +1,7 @@ export default ({ env }) => ({ host: env('HOST', '0.0.0.0'), port: env.int('PORT', 1337), + bot_token: env('BOT_TOKEN'), app: { keys: env.array('APP_KEYS'), }, diff --git a/package.json b/package.json index 0c384c2..9517692 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,8 @@ "react": "^18.0.0", "react-dom": "^18.0.0", "react-router-dom": "^6.0.0", - "styled-components": "^6.0.0" + "styled-components": "^6.0.0", + "telegraf": "^4.16.3" }, "devDependencies": { "@types/node": "^20", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 32829f1..48a989b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,6 +38,9 @@ importers: styled-components: specifier: ^6.0.0 version: 6.1.13(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + telegraf: + specifier: ^4.16.3 + version: 4.16.3 devDependencies: '@types/node': specifier: ^20 @@ -1900,6 +1903,9 @@ packages: resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==} engines: {node: '>=10'} + '@telegraf/types@7.1.0': + resolution: {integrity: sha512-kGevOIbpMcIlCDeorKGpwZmdH7kHbqlk/Yj6dEpJMKEQw5lk0KVQY0OLXaCswy8GqlIVLd5625OB+rAntP9xVw==} + '@testing-library/dom@10.1.0': resolution: {integrity: sha512-wdsYKy5zupPyLCW2Je5DLHSxSfbIp6h80WoHOQc+RPtmPGA52O9x5MJEkv92Sjonpq+poOAtUKhh1kBGAXBrNA==} engines: {node: '>=18'} @@ -2185,6 +2191,10 @@ packages: '@xtuc/long@4.2.2': resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==} + abort-controller@3.0.0: + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} + engines: {node: '>=6.5'} + accepts@1.3.8: resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} engines: {node: '>= 0.6'} @@ -2422,9 +2432,18 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + buffer-alloc-unsafe@1.1.0: + resolution: {integrity: sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg==} + + buffer-alloc@1.2.0: + resolution: {integrity: sha512-CFsHQgjtW1UChdXgbyJGtnm+O/uLQeZdtbDo8mfUgYXCHSM1wgrVxXm6bSyrUuErEb+4sYVGCzASBRot7zyrow==} + buffer-equal-constant-time@1.0.1: resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + buffer-fill@1.0.0: + resolution: {integrity: sha512-T7zexNBwiiaCOGDg9xNX9PBmjrubblRkENuptryuI64URkXDFum9il/JGL8Lm8wYfAXpredVXXZz7eMHilimiQ==} + buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -3165,6 +3184,10 @@ packages: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} + event-target-shim@5.0.1: + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} + engines: {node: '>=6'} + events@3.3.0: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} @@ -4415,6 +4438,10 @@ packages: engines: {node: '>=10'} hasBin: true + mri@1.2.0: + resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} + engines: {node: '>=4'} + mrmime@2.0.0: resolution: {integrity: sha512-eu38+hdgojoyq63s+yTpN4XMBdt5l8HhMhc4VKLO9KM5caLIBvUm4thi7fFaxyTmCKeNnXZ5pAlBwCUnhA09uw==} engines: {node: '>=10'} @@ -4660,6 +4687,10 @@ packages: resolution: {integrity: sha512-VFqfGDHlx87K66yZrNdI4YGtD70IRyd+zSvgks6mzHPRNkoKy+9EKP4SFC77/vTTQYmRmti7dvqC+m5jBrBAcg==} engines: {node: '>=12'} + p-timeout@4.1.0: + resolution: {integrity: sha512-+/wmHtzJuWii1sXn3HCuH/FTwGhrp4tmJTxSKJbfS+vkipci6osxXM5mY0jUiRzWKMTgUT8l7HFbeSwZAynqHw==} + engines: {node: '>=10'} + p-try@2.2.0: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} @@ -5345,6 +5376,9 @@ packages: safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + safe-compare@1.1.4: + resolution: {integrity: sha512-b9wZ986HHCo/HbKrRpBJb2kqXMK9CEWIE1egeEvZsYn69ay3kdfl9nG3RyOcR+jInTDf7a86WQ1d4VJX7goSSQ==} + safe-stable-stringify@2.5.0: resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} engines: {node: '>=10'} @@ -5352,6 +5386,10 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + sandwich-stream@2.0.2: + resolution: {integrity: sha512-jLYV0DORrzY3xaz/S9ydJL6Iz7essZeAfnAavsJ+zsJGZ1MOnsS52yRjU3uF3pJa/lla7+wisp//fxOwOH8SKQ==} + engines: {node: '>= 0.10'} + sanitize-html@2.13.0: resolution: {integrity: sha512-Xff91Z+4Mz5QiNSLdLWwjgBDm5b1RU6xBT0+12rapjiaR7SwfRdjw8f+6Rir2MXKLrDicRFHdb51hGOAxmsUIA==} @@ -5690,6 +5728,11 @@ packages: resolution: {integrity: sha512-51LAVKUSZSVfI05vjPESNc5vwqqZpbXCsU+/+wxlOrUjk2SnFTt97v9ZgQrD4YmxYW1Px6w2KjaDitCfkvgxMQ==} engines: {node: '>=8.0.0'} + telegraf@4.16.3: + resolution: {integrity: sha512-yjEu2NwkHlXu0OARWoNhJlIjX09dRktiMQFsM678BAH/PEPVwctzL67+tvXqLCRQQvm3SDtki2saGO9hLlz68w==} + engines: {node: ^12.20.0 || >=14.13.1} + hasBin: true + terser-webpack-plugin@5.3.10: resolution: {integrity: sha512-BKFPWlPDndPs+NGGCr1U59t0XScL5317Y0UReNrHaw9/FwhPENlq6bfgs+4yPfyP51vqC1bQ4rp1EfXW5ZSH9w==} engines: {node: '>= 10.13.0'} @@ -8823,6 +8866,8 @@ snapshots: dependencies: defer-to-connect: 2.0.1 + '@telegraf/types@7.1.0': {} + '@testing-library/dom@10.1.0': dependencies: '@babel/code-frame': 7.26.2 @@ -9201,6 +9246,10 @@ snapshots: '@xtuc/long@4.2.2': {} + abort-controller@3.0.0: + dependencies: + event-target-shim: 5.0.1 + accepts@1.3.8: dependencies: mime-types: 2.1.35 @@ -9450,8 +9499,17 @@ snapshots: node-releases: 2.0.18 update-browserslist-db: 1.1.1(browserslist@4.24.2) + buffer-alloc-unsafe@1.1.0: {} + + buffer-alloc@1.2.0: + dependencies: + buffer-alloc-unsafe: 1.1.0 + buffer-fill: 1.0.0 + buffer-equal-constant-time@1.0.1: {} + buffer-fill@1.0.0: {} + buffer-from@1.1.2: {} buffer-writer@2.0.0: {} @@ -10242,6 +10300,8 @@ snapshots: etag@1.8.1: {} + event-target-shim@5.0.1: {} + events@3.3.0: {} eventsource@2.0.2: {} @@ -11603,6 +11663,8 @@ snapshots: mkdirp@3.0.1: {} + mri@1.2.0: {} + mrmime@2.0.0: {} ms@2.0.0: {} @@ -11879,6 +11941,8 @@ snapshots: dependencies: aggregate-error: 4.0.1 + p-timeout@4.1.0: {} + p-try@2.2.0: {} package-json-from-dist@1.0.1: {} @@ -12612,10 +12676,16 @@ snapshots: safe-buffer@5.2.1: {} + safe-compare@1.1.4: + dependencies: + buffer-alloc: 1.2.0 + safe-stable-stringify@2.5.0: {} safer-buffer@2.1.2: {} + sandwich-stream@2.0.2: {} + sanitize-html@2.13.0: dependencies: deepmerge: 4.3.1 @@ -13007,6 +13077,20 @@ snapshots: tarn@3.0.2: {} + telegraf@4.16.3: + dependencies: + '@telegraf/types': 7.1.0 + abort-controller: 3.0.0 + debug: 4.3.7(supports-color@5.5.0) + mri: 1.2.0 + node-fetch: 2.7.0 + p-timeout: 4.1.0 + safe-compare: 1.1.4 + sandwich-stream: 2.0.2 + transitivePeerDependencies: + - encoding + - supports-color + terser-webpack-plugin@5.3.10(esbuild@0.25.5)(webpack@5.96.1(esbuild@0.25.5)): dependencies: '@jridgewell/trace-mapping': 0.3.25 diff --git a/src/api/order/content-types/order/lifecycles.ts b/src/api/order/content-types/order/lifecycles.ts index b571024..0b2c3e3 100644 --- a/src/api/order/content-types/order/lifecycles.ts +++ b/src/api/order/content-types/order/lifecycles.ts @@ -1,4 +1,5 @@ -import { extractId } from '../../../../utils'; +import { bot, extractId, dayjs } from '../../../../utils'; +import { DEFAULT_TZ } from '../../../../constants'; const ERR_MISSING_TIME = 'Не указано время'; const ERR_INVALID_TIME = 'Некорректное время'; @@ -12,19 +13,163 @@ const ERR_MISSING_CLIENT = 'Не указан клиент'; const ERR_MISSING_SLOT = 'Не указан слот'; const ERR_MISSING_SERVICE = 'Не указан сервис'; const ERR_ORDER_OUT_OF_SLOT = 'Время заказа выходит за пределы слота'; -const ERR_EXISTING_ORDER_OR_SLOT_NOT_FOUND = 'Существующий заказ или слот не найден'; -const ERR_CANNOT_COMPLETE_BEFORE_START = 'Нельзя завершить запись до её наступления'; +const ERR_EXISTING_ORDER_OR_SLOT_NOT_FOUND = + 'Существующий заказ или слот не найден'; +const ERR_CANNOT_COMPLETE_BEFORE_START = + 'Нельзя завершить запись до её наступления'; -const dayjs = require('dayjs'); -const utc = require('dayjs/plugin/utc'); -const timezone = require('dayjs/plugin/timezone'); +const STATE_MAP: Record = { + approved: 'Подтверждено', + cancelled: 'Отменено', + cancelling: 'Отменяется', + completed: 'Завершено', + created: 'Создано', + scheduled: 'Запланировано', +}; -if (!dayjs.prototype.tz) { - dayjs.extend(utc); - dayjs.extend(timezone); +const EMOJI_MAP: Record = { + approved: '✅', + cancelled: '❌', + cancelling: '🟠', + completed: '🏁', + created: '📝', + scheduled: '📅', +}; + +type State = + | 'approved' + | 'cancelled' + | 'cancelling' + | 'completed' + | 'created' + | 'scheduled'; + +type Order = { + id: number; + documentId: string; + state: State; + order_number: number; + createdAt: string; + updatedAt: string; + publishedAt: any; + locale: any; + datetime_start: string; + datetime_end: string; +}; + +async function sendTelegramNotification(orderEntity: Order, isUpdate = false) { + try { + const order = await strapi.db.query('api::order.order').findOne({ + where: { id: orderEntity.id }, + populate: ['client', 'slot', 'services'], + }); + + if (!order) throw new Error('Order not found'); + + const slotId = extractId(order.slot); + const slot = await strapi.db.query('api::slot.slot').findOne({ + where: { id: slotId }, + populate: ['master'], + }); + + if (!slot) throw new Error('Slot not found'); + + const clientTelegramId = order.client?.telegramId; + const masterTelegramId = slot.master?.telegramId; + + if (!masterTelegramId) { + console.warn('Master telegram ID not found'); + return; + } + + const state = orderEntity.state; + const emojiForState = EMOJI_MAP[state] || ''; + const stateLabel = STATE_MAP[state] || state; + + // Эмодзи в заголовке: карандаш при обновлении, иначе эмодзи статуса + const headingEmoji = isUpdate ? '✏️' : emojiForState; + + let heading = ''; + + if (isUpdate) { + heading = `${headingEmoji} Запись изменена`; + } else { + const isApproved = state === 'approved'; + const creationText = isApproved + ? `Запись создана и подтверждена!` + : `Запись создана!`; + heading = `${headingEmoji} ${creationText}`; + } + + const date = dayjs + .utc(orderEntity.datetime_start) + .tz(DEFAULT_TZ) + .format('D MMMM YYYY'); + const timeStartString = dayjs + .utc(orderEntity.datetime_start) + .tz(DEFAULT_TZ) + .format('HH:mm'); + const timeEndString = dayjs + .utc(orderEntity.datetime_end) + .tz(DEFAULT_TZ) + .format('HH:mm'); + + const clientName = order.client?.name || '-'; + const masterName = slot.master?.name || '-'; + const serviceName = order.services?.[0]?.name || '-'; + + const messageForMaster = `${heading} +Дата: ${date} +Время: ${timeStartString} - ${timeEndString} +Клиент: ${clientName} +Услуга: ${serviceName} +Статус: ${emojiForState} ${stateLabel}`; + + const messageForClient = `${heading} +Дата: ${date} +Время: ${timeStartString} - ${timeEndString} +Мастер: ${masterName} +Услуга: ${serviceName} +Статус: ${emojiForState} ${stateLabel}`; + + if (masterTelegramId) { + await bot.telegram.sendMessage(masterTelegramId, messageForMaster, { + parse_mode: 'HTML', + }); + } + + if (clientTelegramId) { + await bot.telegram.sendMessage(clientTelegramId, messageForClient, { + parse_mode: 'HTML', + }); + } + } catch (error) { + console.error('❌ Error sending Telegram notification:', error); + } } export default { + async afterCreate({ result }) { + const createdEntity = result as Order; + + if (!createdEntity.publishedAt) return; + + const isUpdate = createdEntity.createdAt !== createdEntity.updatedAt; + + await sendTelegramNotification(createdEntity, isUpdate); + }, + + async afterUpdate({ result, params }) { + const updatedEntity = result as Order; + + if (!updatedEntity.publishedAt) return; + + const previousState = params?.data?.state; + if (!previousState || previousState === updatedEntity.state) return; + + await sendTelegramNotification(updatedEntity, true); + }, + async beforeCreate(event) { const { data } = event.params; const { datetime_start, datetime_end, client, services } = data; @@ -160,8 +305,8 @@ export default { data.order_number = lastOrderNumber + 1; } - const now = dayjs().tz('Europe/Moscow'); - const orderStart = dayjs(existingOrder.datetime_start).tz('Europe/Moscow'); + const now = dayjs().tz(DEFAULT_TZ); + const orderStart = dayjs(existingOrder.datetime_start).tz(DEFAULT_TZ); if (state === 'completed' && now.isBefore(orderStart, 'minute')) { throw new Error(ERR_CANNOT_COMPLETE_BEFORE_START); diff --git a/src/api/slot/content-types/slot/lifecycles.ts b/src/api/slot/content-types/slot/lifecycles.ts index 4d445cc..5d12975 100644 --- a/src/api/slot/content-types/slot/lifecycles.ts +++ b/src/api/slot/content-types/slot/lifecycles.ts @@ -1,12 +1,5 @@ -import { extractId } from '../../../../utils'; -const dayjs = require('dayjs'); -const utc = require('dayjs/plugin/utc'); -const timezone = require('dayjs/plugin/timezone'); - -if (!dayjs.prototype.tz) { - dayjs.extend(utc); - dayjs.extend(timezone); -} +import { DEFAULT_TZ } from '../../../../constants'; +import { extractId, dayjs } from '../../../../utils'; const ERR_INVALID_TIME = 'Некорректное время'; const ERR_OVERLAPPING_TIME = 'Время пересекается с другими слотами'; @@ -45,8 +38,8 @@ export default { // Проверка, что слот не создаётся в прошлом if (datetime_start) { - const now = dayjs().tz('Europe/Moscow'); - const slotStart = dayjs(datetime_start).tz('Europe/Moscow'); + const now = dayjs().tz(DEFAULT_TZ); + const slotStart = dayjs(datetime_start).tz(DEFAULT_TZ); if (slotStart.isBefore(now, 'minute')) { throw new Error(ERR_PAST_SLOT); } diff --git a/src/constants/index.ts b/src/constants/index.ts new file mode 100644 index 0000000..324f61d --- /dev/null +++ b/src/constants/index.ts @@ -0,0 +1 @@ +export const DEFAULT_TZ = 'Europe/Moscow'; diff --git a/src/utils/datetime.ts b/src/utils/datetime.ts new file mode 100644 index 0000000..609b079 --- /dev/null +++ b/src/utils/datetime.ts @@ -0,0 +1,13 @@ +import dayjs, { type ConfigType } from 'dayjs'; +import timezone from 'dayjs/plugin/timezone'; +import utc from 'dayjs/plugin/utc'; +import 'dayjs/locale/ru'; + +if (!dayjs.prototype.tz) { + dayjs.extend(utc); + dayjs.extend(timezone); +} + +dayjs.locale('ru'); + +export { dayjs }; diff --git a/src/utils.ts b/src/utils/db.ts similarity index 100% rename from src/utils.ts rename to src/utils/db.ts diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 0000000..e86817f --- /dev/null +++ b/src/utils/index.ts @@ -0,0 +1,3 @@ +export * from './telegram'; +export * from './db'; +export * from './datetime'; diff --git a/src/utils/telegram.ts b/src/utils/telegram.ts new file mode 100644 index 0000000..80179ed --- /dev/null +++ b/src/utils/telegram.ts @@ -0,0 +1,3 @@ +import { Telegraf } from 'telegraf'; + +export const bot = new Telegraf(process.env.bot_token);