Compare commits

...

1 Commits

Author SHA1 Message Date
vchikalkin
72eab1dd0f feat: integrate Telegraf for Telegram notifications in order lifecycle
- 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.
2025-07-22 15:44:23 +03:00
11 changed files with 268 additions and 23 deletions

View File

@ -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

View File

@ -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'),
},

View File

@ -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",

84
pnpm-lock.yaml generated
View File

@ -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

View File

@ -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<State, string> = {
approved: 'Подтверждено',
cancelled: 'Отменено',
cancelling: 'Отменяется',
completed: 'Завершено',
created: 'Создано',
scheduled: 'Запланировано',
};
if (!dayjs.prototype.tz) {
dayjs.extend(utc);
dayjs.extend(timezone);
const EMOJI_MAP: Record<State, string> = {
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} <b>Запись изменена</b>`;
} else {
const isApproved = state === 'approved';
const creationText = isApproved
? `Запись создана и подтверждена!`
: `Запись создана!`;
heading = `${headingEmoji} <b>${creationText}</b>`;
}
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}
<b>Дата:</b> ${date}
<b>Время:</b> ${timeStartString} - ${timeEndString}
<b>Клиент:</b> ${clientName}
<b>Услуга:</b> ${serviceName}
<b>Статус:</b> ${emojiForState} ${stateLabel}`;
const messageForClient = `${heading}
<b>Дата:</b> ${date}
<b>Время:</b> ${timeStartString} - ${timeEndString}
<b>Мастер:</b> ${masterName}
<b>Услуга:</b> ${serviceName}
<b>Статус:</b> ${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);

View File

@ -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);
}

1
src/constants/index.ts Normal file
View File

@ -0,0 +1 @@
export const DEFAULT_TZ = 'Europe/Moscow';

13
src/utils/datetime.ts Normal file
View File

@ -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 };

3
src/utils/index.ts Normal file
View File

@ -0,0 +1,3 @@
export * from './telegram';
export * from './db';
export * from './datetime';

3
src/utils/telegram.ts Normal file
View File

@ -0,0 +1,3 @@
import { Telegraf } from 'telegraf';
export const bot = new Telegraf(process.env.bot_token);