@hed-hog/core 0.0.349 → 0.0.350

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/dist/auth/auth.controller.d.ts +3 -3
  2. package/dist/auth/auth.service.d.ts +8 -8
  3. package/dist/core.module.d.ts.map +1 -1
  4. package/dist/core.module.js +3 -0
  5. package/dist/core.module.js.map +1 -1
  6. package/dist/index.d.ts +3 -0
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +4 -0
  9. package/dist/index.js.map +1 -1
  10. package/dist/mcp/guards/mcp-auth.guard.d.ts +6 -1
  11. package/dist/mcp/guards/mcp-auth.guard.d.ts.map +1 -1
  12. package/dist/mcp/guards/mcp-auth.guard.js +29 -2
  13. package/dist/mcp/guards/mcp-auth.guard.js.map +1 -1
  14. package/dist/mcp/mcp-api-key.controller.d.ts +32 -0
  15. package/dist/mcp/mcp-api-key.controller.d.ts.map +1 -0
  16. package/dist/mcp/mcp-api-key.controller.js +64 -0
  17. package/dist/mcp/mcp-api-key.controller.js.map +1 -0
  18. package/dist/mcp/mcp-api-key.service.d.ts +27 -0
  19. package/dist/mcp/mcp-api-key.service.d.ts.map +1 -0
  20. package/dist/mcp/mcp-api-key.service.js +74 -0
  21. package/dist/mcp/mcp-api-key.service.js.map +1 -0
  22. package/dist/mcp/mcp-registry.service.d.ts +1 -2
  23. package/dist/mcp/mcp-registry.service.d.ts.map +1 -1
  24. package/dist/mcp/mcp-registry.service.js +6 -2
  25. package/dist/mcp/mcp-registry.service.js.map +1 -1
  26. package/dist/mcp/mcp.module.d.ts.map +1 -1
  27. package/dist/mcp/mcp.module.js +8 -1
  28. package/dist/mcp/mcp.module.js.map +1 -1
  29. package/dist/mcp-chat/mcp-chat.controller.d.ts +4 -0
  30. package/dist/mcp-chat/mcp-chat.controller.d.ts.map +1 -1
  31. package/dist/mcp-chat/mcp-chat.controller.js +46 -0
  32. package/dist/mcp-chat/mcp-chat.controller.js.map +1 -1
  33. package/dist/mcp-chat/mcp-chat.service.d.ts +6 -1
  34. package/dist/mcp-chat/mcp-chat.service.d.ts.map +1 -1
  35. package/dist/mcp-chat/mcp-chat.service.js +125 -17
  36. package/dist/mcp-chat/mcp-chat.service.js.map +1 -1
  37. package/dist/notification/dto/create-notification.dto.d.ts +26 -0
  38. package/dist/notification/dto/create-notification.dto.d.ts.map +1 -0
  39. package/dist/notification/dto/create-notification.dto.js +86 -0
  40. package/dist/notification/dto/create-notification.dto.js.map +1 -0
  41. package/dist/notification/dto/update-progress.dto.d.ts +6 -0
  42. package/dist/notification/dto/update-progress.dto.d.ts.map +1 -0
  43. package/dist/notification/dto/update-progress.dto.js +33 -0
  44. package/dist/notification/dto/update-progress.dto.js.map +1 -0
  45. package/dist/notification/notification.controller.d.ts +108 -0
  46. package/dist/notification/notification.controller.d.ts.map +1 -0
  47. package/dist/notification/notification.controller.js +118 -0
  48. package/dist/notification/notification.controller.js.map +1 -0
  49. package/dist/notification/notification.module.d.ts +3 -0
  50. package/dist/notification/notification.module.d.ts.map +1 -0
  51. package/dist/notification/notification.module.js +29 -0
  52. package/dist/notification/notification.module.js.map +1 -0
  53. package/dist/notification/notification.service.d.ts +98 -0
  54. package/dist/notification/notification.service.d.ts.map +1 -0
  55. package/dist/notification/notification.service.js +123 -0
  56. package/dist/notification/notification.service.js.map +1 -0
  57. package/dist/profile/profile.controller.d.ts +1 -1
  58. package/dist/profile/profile.service.d.ts +1 -1
  59. package/dist/session/session.controller.d.ts +1 -1
  60. package/dist/session/session.service.d.ts +3 -3
  61. package/dist/setting/dto/update-setting-list.dto.d.ts +9 -0
  62. package/dist/setting/dto/update-setting-list.dto.d.ts.map +1 -0
  63. package/dist/setting/dto/update-setting-list.dto.js +44 -0
  64. package/dist/setting/dto/update-setting-list.dto.js.map +1 -0
  65. package/dist/setting/setting.controller.d.ts +19 -0
  66. package/dist/setting/setting.controller.d.ts.map +1 -1
  67. package/dist/setting/setting.controller.js +12 -0
  68. package/dist/setting/setting.controller.js.map +1 -1
  69. package/dist/setting/setting.service.d.ts +19 -0
  70. package/dist/setting/setting.service.d.ts.map +1 -1
  71. package/dist/setting/setting.service.js +79 -0
  72. package/dist/setting/setting.service.js.map +1 -1
  73. package/dist/task/task.service.d.ts +12 -0
  74. package/dist/task/task.service.d.ts.map +1 -1
  75. package/dist/task/task.service.js +60 -0
  76. package/dist/task/task.service.js.map +1 -1
  77. package/dist/user/user.controller.d.ts +2 -2
  78. package/dist/user/user.service.d.ts +6 -6
  79. package/dist/webhook-integration/webhook-integration.service.d.ts +4 -0
  80. package/dist/webhook-integration/webhook-integration.service.d.ts.map +1 -1
  81. package/dist/webhook-integration/webhook-integration.service.js +184 -34
  82. package/dist/webhook-integration/webhook-integration.service.js.map +1 -1
  83. package/dist/webhook-integration/webhook-utils.d.ts +1 -0
  84. package/dist/webhook-integration/webhook-utils.d.ts.map +1 -1
  85. package/dist/webhook-integration/webhook-utils.js +38 -0
  86. package/dist/webhook-integration/webhook-utils.js.map +1 -1
  87. package/hedhog/data/menu.yaml +48 -4
  88. package/hedhog/data/route.yaml +133 -0
  89. package/hedhog/data/setting_group.yaml +45 -0
  90. package/hedhog/data/setting_subgroup.yaml +16 -0
  91. package/hedhog/frontend/app/account/components/mcp-tokens.tsx.ejs +285 -0
  92. package/hedhog/frontend/app/account/layout.tsx.ejs +32 -55
  93. package/hedhog/frontend/app/account/mcp-tokens/page.tsx.ejs +5 -0
  94. package/hedhog/frontend/app/integrations-webhooks/page.tsx.ejs +2098 -1835
  95. package/hedhog/frontend/app/mcp_chat/chat-components.tsx.ejs +595 -0
  96. package/hedhog/frontend/app/mcp_chat/chat-types.ts.ejs +16 -0
  97. package/hedhog/frontend/app/mcp_chat/page.tsx.ejs +554 -383
  98. package/hedhog/frontend/app/setting-list/page.tsx.ejs +649 -0
  99. package/hedhog/frontend/app/users/page.tsx.ejs +445 -203
  100. package/hedhog/frontend/app/users/user-sheet.tsx.ejs +16 -37
  101. package/hedhog/frontend/messages/en.json +241 -2
  102. package/hedhog/frontend/messages/pt.json +241 -2
  103. package/hedhog/table/mcp_api_key.yaml +30 -0
  104. package/hedhog/table/notification.yaml +72 -0
  105. package/hedhog/table/webhook_action_log.yaml +49 -0
  106. package/package.json +4 -4
  107. package/src/core.module.ts +3 -0
  108. package/src/index.ts +5 -0
  109. package/src/mcp/guards/mcp-auth.guard.ts +64 -32
  110. package/src/mcp/mcp-api-key.controller.ts +42 -0
  111. package/src/mcp/mcp-api-key.service.ts +66 -0
  112. package/src/mcp/mcp-registry.service.ts +75 -69
  113. package/src/mcp/mcp.module.ts +43 -36
  114. package/src/mcp-chat/mcp-chat.controller.ts +113 -60
  115. package/src/mcp-chat/mcp-chat.service.ts +494 -332
  116. package/src/notification/dto/create-notification.dto.ts +71 -0
  117. package/src/notification/dto/update-progress.dto.ts +16 -0
  118. package/src/notification/notification.controller.ts +71 -0
  119. package/src/notification/notification.module.ts +16 -0
  120. package/src/notification/notification.service.ts +124 -0
  121. package/src/setting/dto/update-setting-list.dto.ts +32 -0
  122. package/src/setting/setting.controller.ts +9 -0
  123. package/src/setting/setting.service.ts +91 -0
  124. package/src/task/task.service.ts +72 -0
  125. package/src/webhook-integration/webhook-integration.service.ts +934 -735
  126. package/src/webhook-integration/webhook-utils.ts +136 -79
@@ -1,735 +1,934 @@
1
- import { DeleteDTO } from '@hed-hog/api';
2
- import { MailService as MailMainService } from '@hed-hog/api-mail';
3
- import { PaginationDTO, PaginationService } from '@hed-hog/api-pagination';
4
- import { PrismaService } from '@hed-hog/api-prisma';
5
- import { HttpService } from '@nestjs/axios';
6
- import {
7
- BadRequestException,
8
- Injectable,
9
- NotFoundException,
10
- } from '@nestjs/common';
11
- import { Request } from 'express';
12
- import { firstValueFrom } from 'rxjs';
13
- import { MailService } from '../mail/mail.service';
14
- import { SettingService } from '../setting/setting.service';
15
- import {
16
- createPlainToken,
17
- createPublicUuid,
18
- hashToken,
19
- normalizeJsonObject,
20
- renderTemplate,
21
- summarizePayload,
22
- } from './webhook-utils';
23
-
24
- @Injectable()
25
- export class WebhookIntegrationService {
26
- constructor(
27
- private readonly prisma: PrismaService,
28
- private readonly pagination: PaginationService,
29
- private readonly http: HttpService,
30
- private readonly mailMainService: MailMainService,
31
- private readonly coreMailService: MailService,
32
- private readonly settingService: SettingService,
33
- ) {}
34
-
35
- private get integrations() {
36
- return (this.prisma as any).webhook_integration;
37
- }
38
-
39
- private get actions() {
40
- return (this.prisma as any).webhook_action;
41
- }
42
-
43
- private get logs() {
44
- return (this.prisma as any).webhook_call_log;
45
- }
46
-
47
- async list(paginationParams: PaginationDTO) {
48
- const fields = ['slug', 'name', 'description', 'public_uuid', 'status'];
49
- const OR = this.prisma.createInsensitiveSearch(fields, paginationParams);
50
-
51
- const result = await this.pagination.paginate(
52
- this.integrations,
53
- paginationParams,
54
- {
55
- where: OR.length > 0 ? { OR } : {},
56
- include: {
57
- webhook_action: {
58
- orderBy: [{ order: 'asc' }, { id: 'asc' }],
59
- },
60
- },
61
- orderBy: [{ updated_at: 'desc' }, { id: 'desc' }],
62
- },
63
- );
64
-
65
- return {
66
- ...result,
67
- data: await this.withPublicUrls(result.data || []),
68
- };
69
- }
70
-
71
- async get(id: number) {
72
- const record = await this.integrations.findUnique({
73
- where: { id },
74
- include: {
75
- webhook_action: {
76
- orderBy: [{ order: 'asc' }, { id: 'asc' }],
77
- },
78
- },
79
- });
80
-
81
- if (!record) {
82
- throw new NotFoundException(
83
- `Webhook integration with id ${id} not found.`,
84
- );
85
- }
86
-
87
- return this.withPublicUrl(record);
88
- }
89
-
90
- async create(data: any) {
91
- await this.ensureSlugAvailable(data.slug);
92
-
93
- let plainToken: string | null = null;
94
- if (data.require_token) {
95
- plainToken = createPlainToken();
96
- }
97
-
98
- const record = await this.integrations.create({
99
- data: {
100
- ...this.normalizeIntegration(data),
101
- public_uuid: createPublicUuid(),
102
- token_hash: plainToken ? hashToken(plainToken) : null,
103
- },
104
- });
105
-
106
- return {
107
- ...(await this.withPublicUrl(record)),
108
- plainToken,
109
- };
110
- }
111
-
112
- async update(id: number, data: any) {
113
- const existing = await this.get(id);
114
- if (data.slug && data.slug !== existing.slug) {
115
- await this.ensureSlugAvailable(data.slug);
116
- }
117
-
118
- let plainToken: string | null = null;
119
- const updateData = this.normalizeIntegration(data, true);
120
- if (data.require_token === false) {
121
- updateData.token_hash = null;
122
- } else if (data.require_token === true && !existing.token_hash) {
123
- plainToken = createPlainToken();
124
- updateData.token_hash = hashToken(plainToken);
125
- }
126
-
127
- const record = await this.integrations.update({
128
- where: { id },
129
- data: updateData,
130
- });
131
-
132
- return {
133
- ...(await this.withPublicUrl(record)),
134
- plainToken,
135
- };
136
- }
137
-
138
- async delete(data: DeleteDTO) {
139
- if (!data.ids?.length) {
140
- throw new BadRequestException(
141
- 'You must select at least one item to delete.',
142
- );
143
- }
144
-
145
- return this.integrations.deleteMany({
146
- where: { id: { in: data.ids } },
147
- });
148
- }
149
-
150
- async regenerateUuid(id: number) {
151
- await this.get(id);
152
- const record = await this.integrations.update({
153
- where: { id },
154
- data: { public_uuid: createPublicUuid() },
155
- });
156
-
157
- return this.withPublicUrl(record);
158
- }
159
-
160
- async regenerateToken(id: number) {
161
- await this.get(id);
162
- const plainToken = createPlainToken();
163
- const record = await this.integrations.update({
164
- where: { id },
165
- data: {
166
- require_token: true,
167
- token_hash: hashToken(plainToken),
168
- },
169
- });
170
-
171
- return {
172
- ...(await this.withPublicUrl(record)),
173
- plainToken,
174
- };
175
- }
176
-
177
- private async getApplicationBaseUrl() {
178
- const settings = await this.settingService.getSettingValues('url');
179
- const value = settings?.url;
180
- return typeof value === 'string' ? value.replace(/\/+$/, '') : '';
181
- }
182
-
183
- private async withPublicUrl<T extends { public_uuid?: string | null }>(
184
- record: T,
185
- ) {
186
- const baseUrl = await this.getApplicationBaseUrl();
187
- return {
188
- ...record,
189
- public_url:
190
- baseUrl && record.public_uuid
191
- ? `${baseUrl}/webhook/${record.public_uuid}`
192
- : null,
193
- };
194
- }
195
-
196
- private async withPublicUrls<T extends { public_uuid?: string | null }>(
197
- records: T[],
198
- ) {
199
- const baseUrl = await this.getApplicationBaseUrl();
200
- return records.map((record) => ({
201
- ...record,
202
- public_url:
203
- baseUrl && record.public_uuid
204
- ? `${baseUrl}/webhook/${record.public_uuid}`
205
- : null,
206
- }));
207
- }
208
-
209
- async listActions(integrationId: number) {
210
- await this.get(integrationId);
211
- return this.actions.findMany({
212
- where: { webhook_integration_id: integrationId },
213
- orderBy: [{ order: 'asc' }, { id: 'asc' }],
214
- });
215
- }
216
-
217
- async createAction(integrationId: number, data: any) {
218
- await this.get(integrationId);
219
- return this.actions.create({
220
- data: {
221
- ...this.normalizeAction(data),
222
- webhook_integration_id: integrationId,
223
- },
224
- });
225
- }
226
-
227
- async updateAction(integrationId: number, actionId: number, data: any) {
228
- await this.ensureAction(integrationId, actionId);
229
- return this.actions.update({
230
- where: { id: actionId },
231
- data: this.normalizeAction(data, true),
232
- });
233
- }
234
-
235
- async deleteActions(integrationId: number, data: DeleteDTO) {
236
- await this.get(integrationId);
237
- if (!data.ids?.length) {
238
- throw new BadRequestException(
239
- 'You must select at least one item to delete.',
240
- );
241
- }
242
-
243
- return this.actions.deleteMany({
244
- where: {
245
- webhook_integration_id: integrationId,
246
- id: { in: data.ids },
247
- },
248
- });
249
- }
250
-
251
- async listLogs(integrationId: number, paginationParams: PaginationDTO) {
252
- await this.get(integrationId);
253
- return this.pagination.paginate(this.logs, paginationParams, {
254
- where: { webhook_integration_id: integrationId },
255
- orderBy: [{ created_at: 'desc' }, { id: 'desc' }],
256
- });
257
- }
258
-
259
- async executePublic(uuid: string, body: any, headers: any, req: Request) {
260
- const startedAt = Date.now();
261
- const remoteIp = this.getRemoteIp(req);
262
- let integration: any | null = null;
263
-
264
- try {
265
- integration = await this.integrations.findUnique({
266
- where: { public_uuid: uuid },
267
- include: {
268
- webhook_action: {
269
- where: { status: 'active' },
270
- orderBy: [{ order: 'asc' }, { id: 'asc' }],
271
- },
272
- },
273
- });
274
-
275
- if (!integration || integration.status !== 'active') {
276
- throw new NotFoundException('Webhook not found.');
277
- }
278
-
279
- this.validateToken(integration, headers);
280
- this.validateIp(integration, remoteIp);
281
- this.validateBodySchema(integration, body);
282
-
283
- const actionResults = [];
284
- for (const action of integration.webhook_action || []) {
285
- actionResults.push(await this.executeAction(action, body));
286
- }
287
-
288
- const response = {
289
- success: true,
290
- actions: actionResults,
291
- };
292
-
293
- await this.createCallLog({
294
- integration,
295
- uuid,
296
- status: 'success',
297
- remoteIp,
298
- durationMs: Date.now() - startedAt,
299
- payload: body,
300
- response,
301
- });
302
-
303
- return response;
304
- } catch (error) {
305
- await this.createCallLog({
306
- integration,
307
- uuid,
308
- status: 'failed',
309
- remoteIp,
310
- durationMs: Date.now() - startedAt,
311
- payload: body,
312
- error,
313
- });
314
-
315
- throw error;
316
- }
317
- }
318
-
319
- private async executeAction(action: any, body: any) {
320
- if (action.type === 'email') {
321
- await this.executeEmailAction(action, body);
322
- return { id: action.id, type: action.type, success: true };
323
- }
324
-
325
- if (action.type === 'whatsapp_evolution') {
326
- await this.executeWhatsappAction(action, body);
327
- return { id: action.id, type: action.type, success: true };
328
- }
329
-
330
- throw new BadRequestException(`Unsupported action type: ${action.type}`);
331
- }
332
-
333
- private async executeEmailAction(action: any, body: any) {
334
- if (!action.mail_profile_id || !action.mail_id || !action.email_to) {
335
- throw new BadRequestException(
336
- 'Email action is missing required configuration.',
337
- );
338
- }
339
-
340
- const profile = await (this.prisma as any).mail_profile.findUnique({
341
- where: { id: Number(action.mail_profile_id) },
342
- });
343
- const mail = await (this.prisma as any).mail.findUnique({
344
- where: { id: Number(action.mail_id) },
345
- include: {
346
- mail_locale: {
347
- orderBy: [{ locale_id: 'asc' }],
348
- },
349
- },
350
- });
351
-
352
- if (!profile || !mail?.mail_locale?.length) {
353
- throw new BadRequestException(
354
- 'Email action references an invalid profile or template.',
355
- );
356
- }
357
-
358
- const template = mail.mail_locale[0];
359
- const config = this.buildMailConfig(profile);
360
- const sender = profile.from_name
361
- ? `${profile.from_name} <${profile.from_email}>`
362
- : profile.from_email;
363
- const replyTo = profile.reply_to_email
364
- ? profile.reply_to_name
365
- ? `${profile.reply_to_name} <${profile.reply_to_email}>`
366
- : profile.reply_to_email
367
- : undefined;
368
-
369
- try {
370
- this.mailMainService.setConfig(config as any);
371
- await this.mailMainService.send({
372
- to: renderTemplate(action.email_to, body) as string,
373
- cc: action.email_cc
374
- ? (renderTemplate(action.email_cc, body) as string)
375
- : undefined,
376
- bcc: action.email_bcc
377
- ? (renderTemplate(action.email_bcc, body) as string)
378
- : undefined,
379
- from: sender,
380
- replyTo,
381
- subject: renderTemplate(template.subject, body) as string,
382
- body: renderTemplate(template.body, body) as string,
383
- mail_id: mail.id,
384
- } as any);
385
- } finally {
386
- await this.coreMailService.reloadConfig().catch(() => undefined);
387
- }
388
- }
389
-
390
- private async executeWhatsappAction(action: any, body: any) {
391
- const required = [
392
- action.whatsapp_target,
393
- action.whatsapp_template,
394
- action.whatsapp_instance,
395
- action.whatsapp_base_url,
396
- action.whatsapp_token,
397
- ];
398
-
399
- if (required.some((value) => !String(value || '').trim())) {
400
- throw new BadRequestException(
401
- 'WhatsApp action is missing required configuration.',
402
- );
403
- }
404
-
405
- const baseUrl = String(action.whatsapp_base_url).replace(/\/+$/, '');
406
- const instance = encodeURIComponent(action.whatsapp_instance);
407
- const url = `${baseUrl}/message/sendText/${instance}`;
408
- const message = renderTemplate(action.whatsapp_template, body);
409
- const target = renderTemplate(action.whatsapp_target, body);
410
-
411
- await firstValueFrom(
412
- this.http.post(
413
- url,
414
- {
415
- number: target,
416
- text: message,
417
- },
418
- {
419
- headers: {
420
- apikey: action.whatsapp_token,
421
- Authorization: `Bearer ${action.whatsapp_token}`,
422
- },
423
- timeout: 15000,
424
- },
425
- ),
426
- );
427
- }
428
-
429
- private validateToken(integration: any, headers: any) {
430
- if (!integration.require_token) {
431
- return;
432
- }
433
-
434
- const provided =
435
- headers['x-webhook-token'] ||
436
- headers['X-Webhook-Token'] ||
437
- headers.authorization?.replace(/^Bearer\s+/i, '');
438
-
439
- if (!provided || hashToken(String(provided)) !== integration.token_hash) {
440
- throw new BadRequestException('Invalid webhook token.');
441
- }
442
- }
443
-
444
- private validateIp(integration: any, remoteIp: string | null) {
445
- const allowedIps = Array.isArray(integration.allowed_ips)
446
- ? integration.allowed_ips
447
- : [];
448
-
449
- if (!allowedIps.length) {
450
- return;
451
- }
452
-
453
- if (!remoteIp || !allowedIps.includes(remoteIp)) {
454
- throw new BadRequestException('Remote IP is not allowed.');
455
- }
456
- }
457
-
458
- private validateBodySchema(integration: any, body: any) {
459
- if (!integration.body_schema) {
460
- return;
461
- }
462
-
463
- const errors = this.validateJsonSchema(integration.body_schema, body);
464
- if (errors.length) {
465
- throw new BadRequestException({
466
- message: 'Webhook body does not match the configured schema.',
467
- errors,
468
- });
469
- }
470
- }
471
-
472
- private validateJsonSchema(schema: any, value: any, path = '$'): string[] {
473
- if (!schema || typeof schema !== 'object') {
474
- return [];
475
- }
476
-
477
- const errors: string[] = [];
478
- const type = schema.type;
479
-
480
- if (type && !this.matchesJsonSchemaType(type, value)) {
481
- errors.push(`${path} must be ${type}`);
482
- return errors;
483
- }
484
-
485
- if (type === 'object' || schema.properties || schema.required) {
486
- if (!value || typeof value !== 'object' || Array.isArray(value)) {
487
- errors.push(`${path} must be object`);
488
- return errors;
489
- }
490
-
491
- for (const requiredKey of schema.required || []) {
492
- if (value[requiredKey] === undefined || value[requiredKey] === null) {
493
- errors.push(`${path}.${requiredKey} is required`);
494
- }
495
- }
496
-
497
- for (const [key, childSchema] of Object.entries(
498
- schema.properties || {},
499
- )) {
500
- if (value[key] !== undefined) {
501
- errors.push(
502
- ...this.validateJsonSchema(
503
- childSchema,
504
- value[key],
505
- `${path}.${key}`,
506
- ),
507
- );
508
- }
509
- }
510
- }
511
-
512
- if (type === 'array' && schema.items && Array.isArray(value)) {
513
- value.forEach((item, index) => {
514
- errors.push(
515
- ...this.validateJsonSchema(schema.items, item, `${path}[${index}]`),
516
- );
517
- });
518
- }
519
-
520
- return errors;
521
- }
522
-
523
- private matchesJsonSchemaType(type: string | string[], value: any) {
524
- const types = Array.isArray(type) ? type : [type];
525
- return types.some((item) => {
526
- switch (item) {
527
- case 'object':
528
- return (
529
- value !== null && typeof value === 'object' && !Array.isArray(value)
530
- );
531
- case 'array':
532
- return Array.isArray(value);
533
- case 'string':
534
- return typeof value === 'string';
535
- case 'number':
536
- return typeof value === 'number';
537
- case 'integer':
538
- return Number.isInteger(value);
539
- case 'boolean':
540
- return typeof value === 'boolean';
541
- case 'null':
542
- return value === null;
543
- default:
544
- return true;
545
- }
546
- });
547
- }
548
-
549
- private async createCallLog(input: {
550
- integration: any | null;
551
- uuid: string;
552
- status: 'success' | 'failed';
553
- remoteIp: string | null;
554
- durationMs: number;
555
- payload: any;
556
- response?: any;
557
- error?: any;
558
- }) {
559
- if (!this.logs) {
560
- return;
561
- }
562
-
563
- await this.logs.create({
564
- data: {
565
- webhook_integration_id: input.integration?.id ?? null,
566
- public_uuid: input.uuid,
567
- status: input.status,
568
- remote_ip: input.remoteIp,
569
- duration_ms: input.durationMs,
570
- error_message: input.error
571
- ? input.error instanceof Error
572
- ? input.error.message
573
- : String(input.error)
574
- : null,
575
- payload_summary: summarizePayload(input.payload),
576
- response_summary: summarizePayload(input.response),
577
- },
578
- });
579
- }
580
-
581
- private getRemoteIp(req: Request) {
582
- const forwardedFor = req.headers['x-forwarded-for'];
583
- const forwardedIp = Array.isArray(forwardedFor)
584
- ? forwardedFor[0]
585
- : forwardedFor?.split(',')[0];
586
-
587
- return forwardedIp?.trim() || req.ip || req.socket.remoteAddress || null;
588
- }
589
-
590
- private async ensureAction(integrationId: number, actionId: number) {
591
- const record = await this.actions.findFirst({
592
- where: {
593
- id: actionId,
594
- webhook_integration_id: integrationId,
595
- },
596
- });
597
-
598
- if (!record) {
599
- throw new NotFoundException(
600
- `Webhook action with id ${actionId} not found.`,
601
- );
602
- }
603
-
604
- return record;
605
- }
606
-
607
- private async ensureSlugAvailable(slug: string) {
608
- const existing = await this.integrations.findUnique({
609
- where: { slug: String(slug || '').trim() },
610
- select: { id: true },
611
- });
612
-
613
- if (existing) {
614
- throw new BadRequestException(
615
- `Webhook integration "${slug}" already exists.`,
616
- );
617
- }
618
- }
619
-
620
- private normalizeIntegration(data: any, partial = false) {
621
- const normalized: Record<string, any> = {};
622
- const assign = (key: string, value: any) => {
623
- if (!partial || value !== undefined) {
624
- normalized[key] = value;
625
- }
626
- };
627
-
628
- assign('slug', String(data.slug ?? '').trim());
629
- assign('name', String(data.name ?? '').trim());
630
- assign('description', this.nullableString(data.description, partial));
631
- assign('status', data.status === 'inactive' ? 'inactive' : 'active');
632
- assign('require_token', Boolean(data.require_token));
633
- assign('allowed_ips', normalizeJsonObject(data.allowed_ips));
634
- assign('body_schema', normalizeJsonObject(data.body_schema));
635
-
636
- return normalized;
637
- }
638
-
639
- private normalizeAction(data: any, partial = false) {
640
- const normalized: Record<string, any> = {};
641
- const assign = (key: string, value: any) => {
642
- if (!partial || value !== undefined) {
643
- normalized[key] = value;
644
- }
645
- };
646
-
647
- assign('type', data.type);
648
- assign('name', String(data.name ?? '').trim());
649
- assign('order', Number(data.order ?? 0));
650
- assign('status', data.status === 'inactive' ? 'inactive' : 'active');
651
- assign('mail_profile_id', this.nullableNumber(data.mail_profile_id));
652
- assign('mail_id', this.nullableNumber(data.mail_id));
653
- assign('email_to', this.nullableString(data.email_to, partial));
654
- assign('email_cc', this.nullableString(data.email_cc, partial));
655
- assign('email_bcc', this.nullableString(data.email_bcc, partial));
656
- assign('whatsapp_target_type', data.whatsapp_target_type || null);
657
- assign(
658
- 'whatsapp_target',
659
- this.nullableString(data.whatsapp_target, partial),
660
- );
661
- assign(
662
- 'whatsapp_template',
663
- this.nullableString(data.whatsapp_template, partial),
664
- );
665
- assign(
666
- 'whatsapp_instance',
667
- this.nullableString(data.whatsapp_instance, partial),
668
- );
669
- assign(
670
- 'whatsapp_base_url',
671
- this.nullableString(data.whatsapp_base_url, partial),
672
- );
673
- assign('whatsapp_token', this.nullableString(data.whatsapp_token, partial));
674
-
675
- return normalized;
676
- }
677
-
678
- private nullableString(value: any, partial = false) {
679
- if (value === undefined) {
680
- return partial ? undefined : null;
681
- }
682
-
683
- if (value === null) {
684
- return null;
685
- }
686
-
687
- const normalized = String(value).trim();
688
- return normalized || null;
689
- }
690
-
691
- private nullableNumber(value: any) {
692
- if (value === undefined || value === null || value === '') {
693
- return null;
694
- }
695
-
696
- return Number(value);
697
- }
698
-
699
- private buildMailConfig(profile: any) {
700
- switch (profile.provider) {
701
- case 'SMTP':
702
- return {
703
- global: true,
704
- type: 'SMTP',
705
- host: profile.smtp_host,
706
- port: Number(profile.smtp_port || 587),
707
- secure: Boolean(profile.smtp_secure),
708
- username: profile.smtp_username || '',
709
- password: profile.smtp_password || '',
710
- };
711
- case 'GMAIL':
712
- return {
713
- global: true,
714
- type: 'GMAIL',
715
- clientId: profile.gmail_client_id,
716
- clientSecret: profile.gmail_client_secret,
717
- refreshToken: profile.gmail_refresh_token,
718
- from: profile.from_email,
719
- };
720
- case 'SES':
721
- return {
722
- global: true,
723
- type: 'SES',
724
- region: profile.ses_region,
725
- accessKeyId: profile.ses_access_key_id,
726
- secretAccessKey: profile.ses_secret_access_key,
727
- from: profile.from_email,
728
- };
729
- default:
730
- throw new BadRequestException(
731
- `Invalid mail profile provider: ${profile.provider}`,
732
- );
733
- }
734
- }
735
- }
1
+ import { DeleteDTO } from '@hed-hog/api';
2
+ import { MailService as MailMainService } from '@hed-hog/api-mail';
3
+ import { PaginationDTO, PaginationService } from '@hed-hog/api-pagination';
4
+ import { PrismaService } from '@hed-hog/api-prisma';
5
+ import { HttpService } from '@nestjs/axios';
6
+ import {
7
+ BadRequestException,
8
+ HttpException,
9
+ Injectable,
10
+ NotFoundException,
11
+ } from '@nestjs/common';
12
+ import { Request } from 'express';
13
+ import { firstValueFrom } from 'rxjs';
14
+ import { MailService } from '../mail/mail.service';
15
+ import { SettingService } from '../setting/setting.service';
16
+ import {
17
+ createPlainToken,
18
+ createPublicUuid,
19
+ hashToken,
20
+ normalizeJsonObject,
21
+ renderTemplate,
22
+ sanitizeSensitiveData,
23
+ summarizePayload,
24
+ } from './webhook-utils';
25
+
26
+ type ActionExecutionLog = {
27
+ webhookActionId: number;
28
+ actionName: string;
29
+ actionType: 'email' | 'whatsapp_evolution';
30
+ status: 'success' | 'failed';
31
+ durationMs: number;
32
+ requestPayload?: unknown;
33
+ responsePayload?: unknown;
34
+ errorMessage?: string | null;
35
+ };
36
+
37
+ @Injectable()
38
+ export class WebhookIntegrationService {
39
+ constructor(
40
+ private readonly prisma: PrismaService,
41
+ private readonly pagination: PaginationService,
42
+ private readonly http: HttpService,
43
+ private readonly mailMainService: MailMainService,
44
+ private readonly coreMailService: MailService,
45
+ private readonly settingService: SettingService,
46
+ ) {}
47
+
48
+ private get integrations() {
49
+ return (this.prisma as any).webhook_integration;
50
+ }
51
+
52
+ private get actions() {
53
+ return (this.prisma as any).webhook_action;
54
+ }
55
+
56
+ private get logs() {
57
+ return (this.prisma as any).webhook_call_log;
58
+ }
59
+
60
+ private get actionLogs() {
61
+ return (this.prisma as any).webhook_action_log;
62
+ }
63
+
64
+ async list(paginationParams: PaginationDTO) {
65
+ const fields = ['slug', 'name', 'description', 'public_uuid', 'status'];
66
+ const OR = this.prisma.createInsensitiveSearch(fields, paginationParams);
67
+
68
+ const result = await this.pagination.paginate(
69
+ this.integrations,
70
+ paginationParams,
71
+ {
72
+ where: OR.length > 0 ? { OR } : {},
73
+ include: {
74
+ webhook_action: {
75
+ orderBy: [{ order: 'asc' }, { id: 'asc' }],
76
+ },
77
+ },
78
+ orderBy: [{ updated_at: 'desc' }, { id: 'desc' }],
79
+ },
80
+ );
81
+
82
+ return {
83
+ ...result,
84
+ data: await this.withPublicUrls(result.data || []),
85
+ };
86
+ }
87
+
88
+ async get(id: number) {
89
+ const record = await this.integrations.findUnique({
90
+ where: { id },
91
+ include: {
92
+ webhook_action: {
93
+ orderBy: [{ order: 'asc' }, { id: 'asc' }],
94
+ },
95
+ },
96
+ });
97
+
98
+ if (!record) {
99
+ throw new NotFoundException(
100
+ `Webhook integration with id ${id} not found.`,
101
+ );
102
+ }
103
+
104
+ return this.withPublicUrl(record);
105
+ }
106
+
107
+ async create(data: any) {
108
+ await this.ensureSlugAvailable(data.slug);
109
+
110
+ let plainToken: string | null = null;
111
+ if (data.require_token) {
112
+ plainToken = createPlainToken();
113
+ }
114
+
115
+ const record = await this.integrations.create({
116
+ data: {
117
+ ...this.normalizeIntegration(data),
118
+ public_uuid: createPublicUuid(),
119
+ token_hash: plainToken ? hashToken(plainToken) : null,
120
+ },
121
+ });
122
+
123
+ return {
124
+ ...(await this.withPublicUrl(record)),
125
+ plainToken,
126
+ };
127
+ }
128
+
129
+ async update(id: number, data: any) {
130
+ const existing = await this.get(id);
131
+ if (data.slug && data.slug !== existing.slug) {
132
+ await this.ensureSlugAvailable(data.slug);
133
+ }
134
+
135
+ let plainToken: string | null = null;
136
+ const updateData = this.normalizeIntegration(data, true);
137
+ if (data.require_token === false) {
138
+ updateData.token_hash = null;
139
+ } else if (data.require_token === true && !existing.token_hash) {
140
+ plainToken = createPlainToken();
141
+ updateData.token_hash = hashToken(plainToken);
142
+ }
143
+
144
+ const record = await this.integrations.update({
145
+ where: { id },
146
+ data: updateData,
147
+ });
148
+
149
+ return {
150
+ ...(await this.withPublicUrl(record)),
151
+ plainToken,
152
+ };
153
+ }
154
+
155
+ async delete(data: DeleteDTO) {
156
+ if (!data.ids?.length) {
157
+ throw new BadRequestException(
158
+ 'You must select at least one item to delete.',
159
+ );
160
+ }
161
+
162
+ return this.integrations.deleteMany({
163
+ where: { id: { in: data.ids } },
164
+ });
165
+ }
166
+
167
+ async regenerateUuid(id: number) {
168
+ await this.get(id);
169
+ const record = await this.integrations.update({
170
+ where: { id },
171
+ data: { public_uuid: createPublicUuid() },
172
+ });
173
+
174
+ return this.withPublicUrl(record);
175
+ }
176
+
177
+ async regenerateToken(id: number) {
178
+ await this.get(id);
179
+ const plainToken = createPlainToken();
180
+ const record = await this.integrations.update({
181
+ where: { id },
182
+ data: {
183
+ require_token: true,
184
+ token_hash: hashToken(plainToken),
185
+ },
186
+ });
187
+
188
+ return {
189
+ ...(await this.withPublicUrl(record)),
190
+ plainToken,
191
+ };
192
+ }
193
+
194
+ private async getApplicationBaseUrl() {
195
+ const settings = await this.settingService.getSettingValues('url');
196
+ const value = settings?.url;
197
+ return typeof value === 'string' ? value.replace(/\/+$/, '') : '';
198
+ }
199
+
200
+ private async withPublicUrl<T extends { public_uuid?: string | null }>(
201
+ record: T,
202
+ ) {
203
+ const baseUrl = await this.getApplicationBaseUrl();
204
+ return {
205
+ ...record,
206
+ public_url:
207
+ baseUrl && record.public_uuid
208
+ ? `${baseUrl}/webhook/${record.public_uuid}`
209
+ : null,
210
+ };
211
+ }
212
+
213
+ private async withPublicUrls<T extends { public_uuid?: string | null }>(
214
+ records: T[],
215
+ ) {
216
+ const baseUrl = await this.getApplicationBaseUrl();
217
+ return records.map((record) => ({
218
+ ...record,
219
+ public_url:
220
+ baseUrl && record.public_uuid
221
+ ? `${baseUrl}/webhook/${record.public_uuid}`
222
+ : null,
223
+ }));
224
+ }
225
+
226
+ async listActions(integrationId: number) {
227
+ await this.get(integrationId);
228
+ return this.actions.findMany({
229
+ where: { webhook_integration_id: integrationId },
230
+ orderBy: [{ order: 'asc' }, { id: 'asc' }],
231
+ });
232
+ }
233
+
234
+ async createAction(integrationId: number, data: any) {
235
+ await this.get(integrationId);
236
+ return this.actions.create({
237
+ data: {
238
+ ...this.normalizeAction(data),
239
+ webhook_integration_id: integrationId,
240
+ },
241
+ });
242
+ }
243
+
244
+ async updateAction(integrationId: number, actionId: number, data: any) {
245
+ await this.ensureAction(integrationId, actionId);
246
+ return this.actions.update({
247
+ where: { id: actionId },
248
+ data: this.normalizeAction(data, true),
249
+ });
250
+ }
251
+
252
+ async deleteActions(integrationId: number, data: DeleteDTO) {
253
+ await this.get(integrationId);
254
+ if (!data.ids?.length) {
255
+ throw new BadRequestException(
256
+ 'You must select at least one item to delete.',
257
+ );
258
+ }
259
+
260
+ return this.actions.deleteMany({
261
+ where: {
262
+ webhook_integration_id: integrationId,
263
+ id: { in: data.ids },
264
+ },
265
+ });
266
+ }
267
+
268
+ async listLogs(integrationId: number, paginationParams: PaginationDTO) {
269
+ await this.get(integrationId);
270
+ const result = await this.pagination.paginate(this.logs, paginationParams, {
271
+ where: { webhook_integration_id: integrationId },
272
+ orderBy: [{ created_at: 'desc' }, { id: 'desc' }],
273
+ });
274
+
275
+ const logIds = (result.data || [])
276
+ .map((item: any) => item.id)
277
+ .filter((id: unknown) => typeof id === 'number');
278
+
279
+ if (!logIds.length || !this.actionLogs) {
280
+ return result;
281
+ }
282
+
283
+ const actionLogs = await this.actionLogs.findMany({
284
+ where: {
285
+ webhook_call_log_id: { in: logIds },
286
+ },
287
+ orderBy: [{ created_at: 'asc' }, { id: 'asc' }],
288
+ });
289
+
290
+ const actionLogsByCallId = new Map<number, any[]>();
291
+ for (const actionLog of actionLogs || []) {
292
+ const list = actionLogsByCallId.get(actionLog.webhook_call_log_id) || [];
293
+ list.push(actionLog);
294
+ actionLogsByCallId.set(actionLog.webhook_call_log_id, list);
295
+ }
296
+
297
+ return {
298
+ ...result,
299
+ data: (result.data || []).map((item: any) => ({
300
+ ...item,
301
+ webhook_action_log: actionLogsByCallId.get(item.id) || [],
302
+ })),
303
+ };
304
+ }
305
+
306
+ async executePublic(uuid: string, body: any, headers: any, req: Request) {
307
+ const startedAt = Date.now();
308
+ const remoteIp = this.getRemoteIp(req);
309
+ const actionLogs: ActionExecutionLog[] = [];
310
+ let integration: any | null = null;
311
+
312
+ try {
313
+ integration = await this.integrations.findUnique({
314
+ where: { public_uuid: uuid },
315
+ include: {
316
+ webhook_action: {
317
+ where: { status: 'active' },
318
+ orderBy: [{ order: 'asc' }, { id: 'asc' }],
319
+ },
320
+ },
321
+ });
322
+
323
+ if (!integration || integration.status !== 'active') {
324
+ throw new NotFoundException('Webhook not found.');
325
+ }
326
+
327
+ this.validateToken(integration, headers);
328
+ this.validateIp(integration, remoteIp);
329
+ this.validateBodySchema(integration, body);
330
+
331
+ const actionResults = [];
332
+ for (const action of integration.webhook_action || []) {
333
+ const actionStartedAt = Date.now();
334
+
335
+ try {
336
+ const result = await this.executeAction(action, body);
337
+ const durationMs = Date.now() - actionStartedAt;
338
+
339
+ actionResults.push({
340
+ id: action.id,
341
+ type: action.type,
342
+ success: true,
343
+ duration_ms: durationMs,
344
+ response: summarizePayload(result.responsePayload),
345
+ });
346
+
347
+ actionLogs.push({
348
+ webhookActionId: action.id,
349
+ actionName: action.name,
350
+ actionType: action.type,
351
+ status: 'success',
352
+ durationMs,
353
+ requestPayload: result.requestPayload,
354
+ responsePayload: result.responsePayload,
355
+ });
356
+ } catch (error) {
357
+ const durationMs = Date.now() - actionStartedAt;
358
+ const errorMessage = this.extractErrorMessage(error);
359
+
360
+ actionResults.push({
361
+ id: action.id,
362
+ type: action.type,
363
+ success: false,
364
+ duration_ms: durationMs,
365
+ error: errorMessage,
366
+ response: summarizePayload(this.extractErrorResponse(error)),
367
+ });
368
+
369
+ actionLogs.push({
370
+ webhookActionId: action.id,
371
+ actionName: action.name,
372
+ actionType: action.type,
373
+ status: 'failed',
374
+ durationMs,
375
+ errorMessage,
376
+ responsePayload: this.extractErrorResponse(error),
377
+ });
378
+
379
+ throw error;
380
+ }
381
+ }
382
+
383
+ const response = {
384
+ success: true,
385
+ actions: actionResults,
386
+ };
387
+
388
+ const callLog = await this.createCallLog({
389
+ integration,
390
+ uuid,
391
+ status: 'success',
392
+ remoteIp,
393
+ durationMs: Date.now() - startedAt,
394
+ payload: body,
395
+ response,
396
+ });
397
+
398
+ await this.createActionLogs(callLog?.id, actionLogs);
399
+
400
+ return response;
401
+ } catch (error) {
402
+ const callLog = await this.createCallLog({
403
+ integration,
404
+ uuid,
405
+ status: 'failed',
406
+ remoteIp,
407
+ durationMs: Date.now() - startedAt,
408
+ payload: body,
409
+ response: {
410
+ success: false,
411
+ actions: actionLogs.map((item) => ({
412
+ id: item.webhookActionId,
413
+ type: item.actionType,
414
+ success: item.status === 'success',
415
+ duration_ms: item.durationMs,
416
+ error: item.errorMessage || null,
417
+ })),
418
+ },
419
+ error,
420
+ }).catch(() => null);
421
+
422
+ await this.createActionLogs(callLog?.id, actionLogs).catch(() => undefined);
423
+
424
+ if (error instanceof HttpException) {
425
+ throw error;
426
+ }
427
+
428
+ return {
429
+ success: false,
430
+ actions: actionLogs.map((item) => ({
431
+ id: item.webhookActionId,
432
+ type: item.actionType,
433
+ success: item.status === 'success',
434
+ duration_ms: item.durationMs,
435
+ error: item.errorMessage || null,
436
+ })),
437
+ };
438
+ }
439
+ }
440
+
441
+ private async executeAction(action: any, body: any) {
442
+ if (action.type === 'email') {
443
+ return this.executeEmailAction(action, body);
444
+ }
445
+
446
+ if (action.type === 'whatsapp_evolution') {
447
+ return this.executeWhatsappAction(action, body);
448
+ }
449
+
450
+ throw new BadRequestException(`Unsupported action type: ${action.type}`);
451
+ }
452
+
453
+ private async executeEmailAction(action: any, body: any) {
454
+ if (!action.mail_profile_id || !action.mail_id || !action.email_to) {
455
+ throw new BadRequestException(
456
+ 'Email action is missing required configuration.',
457
+ );
458
+ }
459
+
460
+ const profile = await (this.prisma as any).mail_profile.findUnique({
461
+ where: { id: Number(action.mail_profile_id) },
462
+ });
463
+ const mail = await (this.prisma as any).mail.findUnique({
464
+ where: { id: Number(action.mail_id) },
465
+ include: {
466
+ mail_locale: {
467
+ orderBy: [{ locale_id: 'asc' }],
468
+ },
469
+ },
470
+ });
471
+
472
+ if (!profile || !mail?.mail_locale?.length) {
473
+ throw new BadRequestException(
474
+ 'Email action references an invalid profile or template.',
475
+ );
476
+ }
477
+
478
+ const template = mail.mail_locale[0];
479
+ const config = this.buildMailConfig(profile);
480
+ const sender = profile.from_name
481
+ ? `${profile.from_name} <${profile.from_email}>`
482
+ : profile.from_email;
483
+ const replyTo = profile.reply_to_email
484
+ ? profile.reply_to_name
485
+ ? `${profile.reply_to_name} <${profile.reply_to_email}>`
486
+ : profile.reply_to_email
487
+ : undefined;
488
+
489
+ const requestPayload = {
490
+ to: renderTemplate(action.email_to, body) as string,
491
+ cc: action.email_cc
492
+ ? (renderTemplate(action.email_cc, body) as string)
493
+ : undefined,
494
+ bcc: action.email_bcc
495
+ ? (renderTemplate(action.email_bcc, body) as string)
496
+ : undefined,
497
+ from: sender,
498
+ replyTo,
499
+ subject: renderTemplate(template.subject, body) as string,
500
+ body: renderTemplate(template.body, body) as string,
501
+ mail_id: mail.id,
502
+ };
503
+
504
+ try {
505
+ this.mailMainService.setConfig(config as any);
506
+ const providerResponse = await this.mailMainService.send(
507
+ requestPayload as any,
508
+ );
509
+
510
+ return {
511
+ requestPayload,
512
+ responsePayload: {
513
+ provider: 'email',
514
+ response: providerResponse || null,
515
+ },
516
+ };
517
+ } finally {
518
+ await this.coreMailService.reloadConfig().catch(() => undefined);
519
+ }
520
+ }
521
+
522
+ private async executeWhatsappAction(action: any, body: any) {
523
+ const required = [
524
+ action.whatsapp_target,
525
+ action.whatsapp_template,
526
+ action.whatsapp_instance,
527
+ action.whatsapp_base_url,
528
+ action.whatsapp_token,
529
+ ];
530
+
531
+ if (required.some((value) => !String(value || '').trim())) {
532
+ throw new BadRequestException(
533
+ 'WhatsApp action is missing required configuration.',
534
+ );
535
+ }
536
+
537
+ const baseUrl = String(action.whatsapp_base_url).replace(/\/+$/, '');
538
+ const instance = encodeURIComponent(action.whatsapp_instance);
539
+ const url = `${baseUrl}/message/sendText/${instance}`;
540
+ const message = renderTemplate(action.whatsapp_template, body);
541
+ const target = renderTemplate(action.whatsapp_target, body);
542
+
543
+ const response = await firstValueFrom(
544
+ this.http.post(
545
+ url,
546
+ {
547
+ number: target,
548
+ text: message,
549
+ },
550
+ {
551
+ headers: {
552
+ apikey: action.whatsapp_token,
553
+ Authorization: `Bearer ${action.whatsapp_token}`,
554
+ },
555
+ timeout: 15000,
556
+ },
557
+ ),
558
+ );
559
+
560
+ return {
561
+ requestPayload: {
562
+ url,
563
+ number: target,
564
+ text: message,
565
+ },
566
+ responsePayload: {
567
+ status: response?.status || null,
568
+ statusText: response?.statusText || null,
569
+ data: response?.data || null,
570
+ },
571
+ };
572
+ }
573
+
574
+ private validateToken(integration: any, headers: any) {
575
+ if (!integration.require_token) {
576
+ return;
577
+ }
578
+
579
+ const provided =
580
+ headers['x-webhook-token'] ||
581
+ headers['X-Webhook-Token'] ||
582
+ headers.authorization?.replace(/^Bearer\s+/i, '');
583
+
584
+ if (!provided || hashToken(String(provided)) !== integration.token_hash) {
585
+ throw new BadRequestException('Invalid webhook token.');
586
+ }
587
+ }
588
+
589
+ private validateIp(integration: any, remoteIp: string | null) {
590
+ const allowedIps = Array.isArray(integration.allowed_ips)
591
+ ? integration.allowed_ips
592
+ : [];
593
+
594
+ if (!allowedIps.length) {
595
+ return;
596
+ }
597
+
598
+ if (!remoteIp || !allowedIps.includes(remoteIp)) {
599
+ throw new BadRequestException('Remote IP is not allowed.');
600
+ }
601
+ }
602
+
603
+ private validateBodySchema(integration: any, body: any) {
604
+ if (!integration.body_schema) {
605
+ return;
606
+ }
607
+
608
+ const errors = this.validateJsonSchema(integration.body_schema, body);
609
+ if (errors.length) {
610
+ throw new BadRequestException({
611
+ message: 'Webhook body does not match the configured schema.',
612
+ errors,
613
+ });
614
+ }
615
+ }
616
+
617
+ private validateJsonSchema(schema: any, value: any, path = '$'): string[] {
618
+ if (!schema || typeof schema !== 'object') {
619
+ return [];
620
+ }
621
+
622
+ const errors: string[] = [];
623
+ const type = schema.type;
624
+
625
+ if (type && !this.matchesJsonSchemaType(type, value)) {
626
+ errors.push(`${path} must be ${type}`);
627
+ return errors;
628
+ }
629
+
630
+ if (type === 'object' || schema.properties || schema.required) {
631
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
632
+ errors.push(`${path} must be object`);
633
+ return errors;
634
+ }
635
+
636
+ for (const requiredKey of schema.required || []) {
637
+ if (value[requiredKey] === undefined || value[requiredKey] === null) {
638
+ errors.push(`${path}.${requiredKey} is required`);
639
+ }
640
+ }
641
+
642
+ for (const [key, childSchema] of Object.entries(
643
+ schema.properties || {},
644
+ )) {
645
+ if (value[key] !== undefined) {
646
+ errors.push(
647
+ ...this.validateJsonSchema(
648
+ childSchema,
649
+ value[key],
650
+ `${path}.${key}`,
651
+ ),
652
+ );
653
+ }
654
+ }
655
+ }
656
+
657
+ if (type === 'array' && schema.items && Array.isArray(value)) {
658
+ value.forEach((item, index) => {
659
+ errors.push(
660
+ ...this.validateJsonSchema(schema.items, item, `${path}[${index}]`),
661
+ );
662
+ });
663
+ }
664
+
665
+ return errors;
666
+ }
667
+
668
+ private matchesJsonSchemaType(type: string | string[], value: any) {
669
+ const types = Array.isArray(type) ? type : [type];
670
+ return types.some((item) => {
671
+ switch (item) {
672
+ case 'object':
673
+ return (
674
+ value !== null && typeof value === 'object' && !Array.isArray(value)
675
+ );
676
+ case 'array':
677
+ return Array.isArray(value);
678
+ case 'string':
679
+ return typeof value === 'string';
680
+ case 'number':
681
+ return typeof value === 'number';
682
+ case 'integer':
683
+ return Number.isInteger(value);
684
+ case 'boolean':
685
+ return typeof value === 'boolean';
686
+ case 'null':
687
+ return value === null;
688
+ default:
689
+ return true;
690
+ }
691
+ });
692
+ }
693
+
694
+ private async createCallLog(input: {
695
+ integration: any | null;
696
+ uuid: string;
697
+ status: 'success' | 'failed';
698
+ remoteIp: string | null;
699
+ durationMs: number;
700
+ payload: any;
701
+ response?: any;
702
+ error?: any;
703
+ }) {
704
+ if (!this.logs) {
705
+ return null;
706
+ }
707
+
708
+ return this.logs.create({
709
+ data: {
710
+ webhook_integration_id: input.integration?.id ?? null,
711
+ public_uuid: input.uuid,
712
+ status: input.status,
713
+ remote_ip: input.remoteIp,
714
+ duration_ms: input.durationMs,
715
+ error_message: input.error ? this.extractErrorMessage(input.error) : null,
716
+ payload_summary: summarizePayload(sanitizeSensitiveData(input.payload)),
717
+ response_summary: summarizePayload(sanitizeSensitiveData(input.response)),
718
+ },
719
+ select: {
720
+ id: true,
721
+ },
722
+ });
723
+ }
724
+
725
+ private async createActionLogs(callLogId: number | undefined, logs: ActionExecutionLog[]) {
726
+ if (!callLogId || !logs.length || !this.actionLogs) {
727
+ return;
728
+ }
729
+
730
+ await this.actionLogs.createMany({
731
+ data: logs.map((item) => ({
732
+ webhook_call_log_id: callLogId,
733
+ webhook_action_id: item.webhookActionId,
734
+ action_name: item.actionName,
735
+ action_type: item.actionType,
736
+ status: item.status,
737
+ duration_ms: item.durationMs,
738
+ request_payload: summarizePayload(sanitizeSensitiveData(item.requestPayload)),
739
+ response_payload: summarizePayload(
740
+ sanitizeSensitiveData(item.responsePayload),
741
+ ),
742
+ error_message: item.errorMessage || null,
743
+ })),
744
+ });
745
+ }
746
+
747
+ private extractErrorMessage(error: unknown) {
748
+ if (!error) {
749
+ return 'Unknown error';
750
+ }
751
+
752
+ if (error instanceof Error && error.message) {
753
+ return error.message;
754
+ }
755
+
756
+ if (typeof error === 'object' && (error as any).message) {
757
+ return String((error as any).message);
758
+ }
759
+
760
+ return String(error);
761
+ }
762
+
763
+ private extractErrorResponse(error: unknown) {
764
+ if (!error || typeof error !== 'object') {
765
+ return null;
766
+ }
767
+
768
+ const maybeResponse = (error as any).response;
769
+ if (!maybeResponse) {
770
+ return null;
771
+ }
772
+
773
+ return {
774
+ status: maybeResponse.status || null,
775
+ statusText: maybeResponse.statusText || null,
776
+ data: maybeResponse.data || null,
777
+ };
778
+ }
779
+
780
+ private getRemoteIp(req: Request) {
781
+ const forwardedFor = req.headers['x-forwarded-for'];
782
+ const forwardedIp = Array.isArray(forwardedFor)
783
+ ? forwardedFor[0]
784
+ : forwardedFor?.split(',')[0];
785
+
786
+ return forwardedIp?.trim() || req.ip || req.socket.remoteAddress || null;
787
+ }
788
+
789
+ private async ensureAction(integrationId: number, actionId: number) {
790
+ const record = await this.actions.findFirst({
791
+ where: {
792
+ id: actionId,
793
+ webhook_integration_id: integrationId,
794
+ },
795
+ });
796
+
797
+ if (!record) {
798
+ throw new NotFoundException(
799
+ `Webhook action with id ${actionId} not found.`,
800
+ );
801
+ }
802
+
803
+ return record;
804
+ }
805
+
806
+ private async ensureSlugAvailable(slug: string) {
807
+ const existing = await this.integrations.findUnique({
808
+ where: { slug: String(slug || '').trim() },
809
+ select: { id: true },
810
+ });
811
+
812
+ if (existing) {
813
+ throw new BadRequestException(
814
+ `Webhook integration "${slug}" already exists.`,
815
+ );
816
+ }
817
+ }
818
+
819
+ private normalizeIntegration(data: any, partial = false) {
820
+ const normalized: Record<string, any> = {};
821
+ const assign = (key: string, value: any) => {
822
+ if (!partial || value !== undefined) {
823
+ normalized[key] = value;
824
+ }
825
+ };
826
+
827
+ assign('slug', String(data.slug ?? '').trim());
828
+ assign('name', String(data.name ?? '').trim());
829
+ assign('description', this.nullableString(data.description, partial));
830
+ assign('status', data.status === 'inactive' ? 'inactive' : 'active');
831
+ assign('require_token', Boolean(data.require_token));
832
+ assign('allowed_ips', normalizeJsonObject(data.allowed_ips));
833
+ assign('body_schema', normalizeJsonObject(data.body_schema));
834
+
835
+ return normalized;
836
+ }
837
+
838
+ private normalizeAction(data: any, partial = false) {
839
+ const normalized: Record<string, any> = {};
840
+ const assign = (key: string, value: any) => {
841
+ if (!partial || value !== undefined) {
842
+ normalized[key] = value;
843
+ }
844
+ };
845
+
846
+ assign('type', data.type);
847
+ assign('name', String(data.name ?? '').trim());
848
+ assign('order', Number(data.order ?? 0));
849
+ assign('status', data.status === 'inactive' ? 'inactive' : 'active');
850
+ assign('mail_profile_id', this.nullableNumber(data.mail_profile_id));
851
+ assign('mail_id', this.nullableNumber(data.mail_id));
852
+ assign('email_to', this.nullableString(data.email_to, partial));
853
+ assign('email_cc', this.nullableString(data.email_cc, partial));
854
+ assign('email_bcc', this.nullableString(data.email_bcc, partial));
855
+ assign('whatsapp_target_type', data.whatsapp_target_type || null);
856
+ assign(
857
+ 'whatsapp_target',
858
+ this.nullableString(data.whatsapp_target, partial),
859
+ );
860
+ assign(
861
+ 'whatsapp_template',
862
+ this.nullableString(data.whatsapp_template, partial),
863
+ );
864
+ assign(
865
+ 'whatsapp_instance',
866
+ this.nullableString(data.whatsapp_instance, partial),
867
+ );
868
+ assign(
869
+ 'whatsapp_base_url',
870
+ this.nullableString(data.whatsapp_base_url, partial),
871
+ );
872
+ assign('whatsapp_token', this.nullableString(data.whatsapp_token, partial));
873
+
874
+ return normalized;
875
+ }
876
+
877
+ private nullableString(value: any, partial = false) {
878
+ if (value === undefined) {
879
+ return partial ? undefined : null;
880
+ }
881
+
882
+ if (value === null) {
883
+ return null;
884
+ }
885
+
886
+ const normalized = String(value).trim();
887
+ return normalized || null;
888
+ }
889
+
890
+ private nullableNumber(value: any) {
891
+ if (value === undefined || value === null || value === '') {
892
+ return null;
893
+ }
894
+
895
+ return Number(value);
896
+ }
897
+
898
+ private buildMailConfig(profile: any) {
899
+ switch (profile.provider) {
900
+ case 'SMTP':
901
+ return {
902
+ global: true,
903
+ type: 'SMTP',
904
+ host: profile.smtp_host,
905
+ port: Number(profile.smtp_port || 587),
906
+ secure: Boolean(profile.smtp_secure),
907
+ username: profile.smtp_username || '',
908
+ password: profile.smtp_password || '',
909
+ };
910
+ case 'GMAIL':
911
+ return {
912
+ global: true,
913
+ type: 'GMAIL',
914
+ clientId: profile.gmail_client_id,
915
+ clientSecret: profile.gmail_client_secret,
916
+ refreshToken: profile.gmail_refresh_token,
917
+ from: profile.from_email,
918
+ };
919
+ case 'SES':
920
+ return {
921
+ global: true,
922
+ type: 'SES',
923
+ region: profile.ses_region,
924
+ accessKeyId: profile.ses_access_key_id,
925
+ secretAccessKey: profile.ses_secret_access_key,
926
+ from: profile.from_email,
927
+ };
928
+ default:
929
+ throw new BadRequestException(
930
+ `Invalid mail profile provider: ${profile.provider}`,
931
+ );
932
+ }
933
+ }
934
+ }