- number-поля теперь рендерятся как text + inputMode=numeric, чтобы браузер не округлял значения через input type=number - пробелы при вставке в number-поля удаляются - бэкенд нормализует значения number-полей в строку перед сохранением - добавлен хелпер normalizeFieldValueForStorage Closes: искажение расчётного счёта и других длинных числовых полей
187 lines
4.9 KiB
TypeScript
187 lines
4.9 KiB
TypeScript
import crypto from 'crypto';
|
|
import { storage } from '../storage';
|
|
import { generateBotServiceToken } from '../utils/jwt';
|
|
import { sendWebhook } from '../utils/webhook';
|
|
import type { BotSubscription } from '@shared/schema';
|
|
import { decrypt } from '../crypto';
|
|
|
|
export interface WebhookEvent {
|
|
event: string;
|
|
timestamp: string;
|
|
organizationId: number;
|
|
data: any;
|
|
}
|
|
|
|
export class WebhookService {
|
|
async dispatchEvent(event: WebhookEvent): Promise<void> {
|
|
const { organizationId, event: eventType, data } = event;
|
|
|
|
try {
|
|
let subscriptions: BotSubscription[] = [];
|
|
|
|
if (data.formId) {
|
|
const formSubs = await storage.getBotSubscriptionsByForm(data.formId, organizationId);
|
|
subscriptions = [...subscriptions, ...formSubs.filter(s => (s.events as string[]).includes(eventType))];
|
|
}
|
|
|
|
if (data.statusId) {
|
|
const statusSubs = await storage.getBotSubscriptionsByStatus(data.statusId, organizationId);
|
|
subscriptions = [...subscriptions, ...statusSubs.filter(s => (s.events as string[]).includes(eventType))];
|
|
}
|
|
|
|
if (data.transitionId) {
|
|
const transitionSubs = await storage.getBotSubscriptionsByTransition(data.transitionId, organizationId);
|
|
subscriptions = [...subscriptions, ...transitionSubs.filter(s => (s.events as string[]).includes(eventType))];
|
|
}
|
|
|
|
const uniqueBotIds = Array.from(new Set(subscriptions.map(s => s.botId)));
|
|
|
|
for (const botId of uniqueBotIds) {
|
|
const bot = await storage.getBot(botId, organizationId);
|
|
if (!bot || !bot.webhookUrl || !bot.webhookEnabled || !bot.isActive) {
|
|
continue;
|
|
}
|
|
|
|
// Generate short-lived access token for the bot
|
|
const botAccessToken = generateBotServiceToken(bot.id, organizationId);
|
|
|
|
const payload = {
|
|
...event,
|
|
bot: {
|
|
id: bot.id,
|
|
name: bot.name,
|
|
accessToken: botAccessToken // Short-lived token (10 min) for API callbacks
|
|
}
|
|
};
|
|
|
|
let signature = '';
|
|
if (bot.webhookSecret) {
|
|
const secret = decrypt(bot.webhookSecret);
|
|
signature = crypto
|
|
.createHmac('sha256', secret)
|
|
.update(JSON.stringify(payload))
|
|
.digest('hex');
|
|
}
|
|
|
|
await sendWebhook(
|
|
bot.webhookUrl,
|
|
payload,
|
|
{
|
|
'X-Webhook-Signature': signature,
|
|
'X-Bot-Id': bot.id.toString(),
|
|
'X-Event-Type': eventType,
|
|
},
|
|
{
|
|
taskId: data.taskId,
|
|
organizationId,
|
|
botId: bot.id,
|
|
eventType,
|
|
}
|
|
);
|
|
}
|
|
} catch (error) {
|
|
console.error('Webhook dispatch error:', error);
|
|
}
|
|
}
|
|
|
|
async dispatchStatusChange(
|
|
organizationId: number,
|
|
taskId: number,
|
|
formId: number,
|
|
fromStatusId: number,
|
|
toStatusId: number,
|
|
transitionId: number,
|
|
userId: number
|
|
): Promise<void> {
|
|
const task = await storage.getTask(taskId, organizationId);
|
|
const user = await storage.getUser(userId);
|
|
|
|
await this.dispatchEvent({
|
|
event: 'status_change',
|
|
timestamp: new Date().toISOString(),
|
|
organizationId,
|
|
data: {
|
|
taskId,
|
|
formId,
|
|
task,
|
|
fromStatusId,
|
|
toStatusId,
|
|
transitionId,
|
|
statusId: toStatusId,
|
|
user: user ? {
|
|
id: user.id,
|
|
firstName: user.firstName,
|
|
lastName: user.lastName,
|
|
email: user.email
|
|
} : null
|
|
}
|
|
});
|
|
}
|
|
|
|
async dispatchComment(
|
|
organizationId: number,
|
|
taskId: number,
|
|
formId: number,
|
|
message: any,
|
|
authorId: number
|
|
): Promise<void> {
|
|
const task = await storage.getTask(taskId, organizationId);
|
|
const author = await storage.getUser(authorId);
|
|
|
|
await this.dispatchEvent({
|
|
event: 'comment',
|
|
timestamp: new Date().toISOString(),
|
|
organizationId,
|
|
data: {
|
|
taskId,
|
|
formId,
|
|
task,
|
|
message,
|
|
author: author ? {
|
|
id: author.id,
|
|
firstName: author.firstName,
|
|
lastName: author.lastName,
|
|
email: author.email
|
|
} : null
|
|
}
|
|
});
|
|
}
|
|
|
|
async dispatchFieldChange(
|
|
organizationId: number,
|
|
taskId: number,
|
|
formId: number,
|
|
fieldId: number,
|
|
fieldCode: string,
|
|
oldValue: any,
|
|
newValue: any,
|
|
userId: number
|
|
): Promise<void> {
|
|
const task = await storage.getTask(taskId, organizationId);
|
|
const user = await storage.getUser(userId);
|
|
|
|
await this.dispatchEvent({
|
|
event: 'field_change',
|
|
timestamp: new Date().toISOString(),
|
|
organizationId,
|
|
data: {
|
|
taskId,
|
|
formId,
|
|
task,
|
|
fieldId,
|
|
fieldCode,
|
|
oldValue,
|
|
newValue,
|
|
user: user ? {
|
|
id: user.id,
|
|
firstName: user.firstName,
|
|
lastName: user.lastName,
|
|
email: user.email
|
|
} : null
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
export const webhookService = new WebhookService();
|