@hed-hog/core 0.0.325 → 0.0.327

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 (100) hide show
  1. package/dist/core.module.d.ts.map +1 -1
  2. package/dist/core.module.js +3 -0
  3. package/dist/core.module.js.map +1 -1
  4. package/dist/index.d.ts +4 -0
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +4 -0
  7. package/dist/index.js.map +1 -1
  8. package/dist/integration/services/outbox.processor.d.ts.map +1 -1
  9. package/dist/integration/services/outbox.processor.js +1 -0
  10. package/dist/integration/services/outbox.processor.js.map +1 -1
  11. package/dist/integration/types/event.types.d.ts +1 -0
  12. package/dist/integration/types/event.types.d.ts.map +1 -1
  13. package/dist/mail/mail.controller.d.ts +2 -0
  14. package/dist/mail/mail.controller.d.ts.map +1 -1
  15. package/dist/mail/mail.module.d.ts.map +1 -1
  16. package/dist/mail/mail.module.js +33 -53
  17. package/dist/mail/mail.module.js.map +1 -1
  18. package/dist/mail/mail.service.d.ts +5 -1
  19. package/dist/mail/mail.service.d.ts.map +1 -1
  20. package/dist/mail/mail.service.js +64 -84
  21. package/dist/mail/mail.service.js.map +1 -1
  22. package/dist/role/role.service.d.ts +3 -1
  23. package/dist/role/role.service.d.ts.map +1 -1
  24. package/dist/role/role.service.js +33 -5
  25. package/dist/role/role.service.js.map +1 -1
  26. package/dist/setting/setting.service.d.ts.map +1 -1
  27. package/dist/setting/setting.service.js +1 -0
  28. package/dist/setting/setting.service.js.map +1 -1
  29. package/dist/user/user.service.d.ts +3 -1
  30. package/dist/user/user.service.d.ts.map +1 -1
  31. package/dist/user/user.service.js +31 -4
  32. package/dist/user/user.service.js.map +1 -1
  33. package/dist/webhook-integration/event-webhook.controller.d.ts +29 -0
  34. package/dist/webhook-integration/event-webhook.controller.d.ts.map +1 -0
  35. package/dist/webhook-integration/event-webhook.controller.js +93 -0
  36. package/dist/webhook-integration/event-webhook.controller.js.map +1 -0
  37. package/dist/webhook-integration/event-webhook.service.d.ts +54 -0
  38. package/dist/webhook-integration/event-webhook.service.d.ts.map +1 -0
  39. package/dist/webhook-integration/event-webhook.service.js +334 -0
  40. package/dist/webhook-integration/event-webhook.service.js.map +1 -0
  41. package/dist/webhook-integration/integration-event-catalog.controller.d.ts +21 -0
  42. package/dist/webhook-integration/integration-event-catalog.controller.d.ts.map +1 -0
  43. package/dist/webhook-integration/integration-event-catalog.controller.js +91 -0
  44. package/dist/webhook-integration/integration-event-catalog.controller.js.map +1 -0
  45. package/dist/webhook-integration/integration-event-catalog.service.d.ts +26 -0
  46. package/dist/webhook-integration/integration-event-catalog.service.d.ts.map +1 -0
  47. package/dist/webhook-integration/integration-event-catalog.service.js +112 -0
  48. package/dist/webhook-integration/integration-event-catalog.service.js.map +1 -0
  49. package/dist/webhook-integration/webhook-integration.controller.d.ts +44 -0
  50. package/dist/webhook-integration/webhook-integration.controller.d.ts.map +1 -0
  51. package/dist/webhook-integration/webhook-integration.controller.js +181 -0
  52. package/dist/webhook-integration/webhook-integration.controller.js.map +1 -0
  53. package/dist/webhook-integration/webhook-integration.module.d.ts +3 -0
  54. package/dist/webhook-integration/webhook-integration.module.d.ts.map +1 -0
  55. package/dist/webhook-integration/webhook-integration.module.js +54 -0
  56. package/dist/webhook-integration/webhook-integration.module.js.map +1 -0
  57. package/dist/webhook-integration/webhook-integration.service.d.ts +73 -0
  58. package/dist/webhook-integration/webhook-integration.service.d.ts.map +1 -0
  59. package/dist/webhook-integration/webhook-integration.service.js +569 -0
  60. package/dist/webhook-integration/webhook-integration.service.js.map +1 -0
  61. package/dist/webhook-integration/webhook-utils.d.ts +7 -0
  62. package/dist/webhook-integration/webhook-utils.d.ts.map +1 -0
  63. package/dist/webhook-integration/webhook-utils.js +104 -0
  64. package/dist/webhook-integration/webhook-utils.js.map +1 -0
  65. package/hedhog/data/integration_event_catalog.yaml +118 -0
  66. package/hedhog/data/mail_profile.yaml +18 -0
  67. package/hedhog/data/menu.yaml +26 -12
  68. package/hedhog/data/route.yaml +152 -8
  69. package/hedhog/data/setting_group.yaml +7 -156
  70. package/hedhog/data/setting_subgroup.yaml +0 -32
  71. package/hedhog/frontend/app/configurations/[slug]/components/setting-field.tsx.ejs +205 -0
  72. package/hedhog/frontend/app/integrations-webhooks/page.tsx.ejs +1835 -0
  73. package/hedhog/frontend/messages/en.json +2 -1
  74. package/hedhog/frontend/messages/pt.json +2 -1
  75. package/hedhog/table/event_webhook.yaml +55 -0
  76. package/hedhog/table/event_webhook_delivery_log.yaml +66 -0
  77. package/hedhog/table/event_webhook_event.yaml +26 -0
  78. package/hedhog/table/integration_event_catalog.yaml +41 -0
  79. package/hedhog/table/setting.yaml +1 -0
  80. package/hedhog/table/webhook_action.yaml +85 -0
  81. package/hedhog/table/webhook_call_log.yaml +46 -0
  82. package/hedhog/table/webhook_integration.yaml +43 -0
  83. package/package.json +3 -3
  84. package/src/core.module.ts +10 -7
  85. package/src/index.ts +10 -5
  86. package/src/integration/services/outbox.processor.ts +5 -4
  87. package/src/integration/types/event.types.ts +4 -3
  88. package/src/mail/mail.module.ts +36 -59
  89. package/src/mail/mail.service.ts +84 -89
  90. package/src/role/role.service.ts +41 -8
  91. package/src/setting/setting.service.ts +7 -6
  92. package/src/user/user.service.ts +34 -3
  93. package/src/webhook-integration/event-webhook.controller.ts +50 -0
  94. package/src/webhook-integration/event-webhook.service.ts +384 -0
  95. package/src/webhook-integration/integration-event-catalog.controller.ts +50 -0
  96. package/src/webhook-integration/integration-event-catalog.service.ts +117 -0
  97. package/src/webhook-integration/webhook-integration.controller.ts +103 -0
  98. package/src/webhook-integration/webhook-integration.module.ts +45 -0
  99. package/src/webhook-integration/webhook-integration.service.ts +735 -0
  100. package/src/webhook-integration/webhook-utils.ts +79 -0
@@ -0,0 +1,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
+ 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
+ }