@hed-hog/core 0.0.400 → 0.0.402
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.
- package/dist/access-log/access-log.interceptor.d.ts +10 -0
- package/dist/access-log/access-log.interceptor.d.ts.map +1 -1
- package/dist/access-log/access-log.interceptor.js +15 -1
- package/dist/access-log/access-log.interceptor.js.map +1 -1
- package/dist/ai/ai.controller.d.ts +2 -0
- package/dist/ai/ai.controller.d.ts.map +1 -1
- package/dist/ai/ai.service.d.ts +86 -2
- package/dist/ai/ai.service.d.ts.map +1 -1
- package/dist/ai/ai.service.js +304 -23
- package/dist/ai/ai.service.js.map +1 -1
- package/dist/ai-instruction/ai-prompt-resolver.service.d.ts +12 -0
- package/dist/ai-instruction/ai-prompt-resolver.service.d.ts.map +1 -1
- package/dist/ai-instruction/ai-prompt-resolver.service.js +26 -0
- package/dist/ai-instruction/ai-prompt-resolver.service.js.map +1 -1
- package/dist/core.module.d.ts.map +1 -1
- package/dist/core.module.js +3 -0
- package/dist/core.module.js.map +1 -1
- package/dist/document-kit/document-kit.module.d.ts +3 -0
- package/dist/document-kit/document-kit.module.d.ts.map +1 -0
- package/dist/document-kit/document-kit.module.js +24 -0
- package/dist/document-kit/document-kit.module.js.map +1 -0
- package/dist/document-kit/document-kit.registry.d.ts +55 -0
- package/dist/document-kit/document-kit.registry.d.ts.map +1 -0
- package/dist/document-kit/document-kit.registry.js +91 -0
- package/dist/document-kit/document-kit.registry.js.map +1 -0
- package/dist/document-kit/document-kit.service.d.ts +49 -0
- package/dist/document-kit/document-kit.service.d.ts.map +1 -0
- package/dist/document-kit/document-kit.service.js +135 -0
- package/dist/document-kit/document-kit.service.js.map +1 -0
- package/dist/file/file-upload-ticket.service.d.ts +1 -1
- package/dist/file/file.controller.d.ts +3 -3
- package/dist/file/file.service.d.ts +136 -13
- package/dist/file/file.service.d.ts.map +1 -1
- package/dist/file/file.service.js +297 -27
- package/dist/file/file.service.js.map +1 -1
- package/dist/file/mcp-file-source.schema.d.ts.map +1 -1
- package/dist/file/mcp-file-source.schema.js +8 -5
- package/dist/file/mcp-file-source.schema.js.map +1 -1
- package/dist/file/provider/abstract.provider.d.ts +33 -0
- package/dist/file/provider/abstract.provider.d.ts.map +1 -1
- package/dist/file/provider/abstract.provider.js +9 -0
- package/dist/file/provider/abstract.provider.js.map +1 -1
- package/dist/file/provider/s3-multipart.util.d.ts +17 -0
- package/dist/file/provider/s3-multipart.util.d.ts.map +1 -0
- package/dist/file/provider/s3-multipart.util.js +88 -0
- package/dist/file/provider/s3-multipart.util.js.map +1 -0
- package/dist/file/provider/s3.provider.d.ts +9 -1
- package/dist/file/provider/s3.provider.d.ts.map +1 -1
- package/dist/file/provider/s3.provider.js +16 -0
- package/dist/file/provider/s3.provider.js.map +1 -1
- package/dist/file/provider/spaces.provider.d.ts +10 -1
- package/dist/file/provider/spaces.provider.d.ts.map +1 -1
- package/dist/file/provider/spaces.provider.js +17 -0
- package/dist/file/provider/spaces.provider.js.map +1 -1
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/integration-profile/integration-profile.controller.d.ts +1 -0
- package/dist/integration-profile/integration-profile.controller.d.ts.map +1 -1
- package/dist/integration-profile/integration-profile.secrets.d.ts.map +1 -1
- package/dist/integration-profile/integration-profile.secrets.js +26 -0
- package/dist/integration-profile/integration-profile.secrets.js.map +1 -1
- package/dist/integration-profile/integration-profile.service.d.ts +1 -0
- package/dist/integration-profile/integration-profile.service.d.ts.map +1 -1
- package/dist/integration-profile/integration-profile.service.js +3 -3
- package/dist/integration-profile/integration-profile.service.js.map +1 -1
- package/dist/integration-profile/testers/ai.testers.d.ts.map +1 -1
- package/dist/integration-profile/testers/ai.testers.js +46 -8
- package/dist/integration-profile/testers/ai.testers.js.map +1 -1
- package/dist/integration-profile/testers/http-probe.d.ts.map +1 -1
- package/dist/integration-profile/testers/http-probe.js +5 -3
- package/dist/integration-profile/testers/http-probe.js.map +1 -1
- package/dist/integration-profile/testers/integration-tester.types.d.ts +5 -0
- package/dist/integration-profile/testers/integration-tester.types.d.ts.map +1 -1
- package/dist/mail/mail.service.d.ts.map +1 -1
- package/dist/mail/mail.service.js +16 -1
- package/dist/mail/mail.service.js.map +1 -1
- package/dist/mcp/mcp-server.service.d.ts.map +1 -1
- package/dist/mcp/mcp-server.service.js +10 -1
- package/dist/mcp/mcp-server.service.js.map +1 -1
- package/dist/mcp/mcp.module.d.ts.map +1 -1
- package/dist/mcp/mcp.module.js +2 -0
- package/dist/mcp/mcp.module.js.map +1 -1
- package/dist/mcp/tools/core-access-log.mcp-tools.d.ts +17 -0
- package/dist/mcp/tools/core-access-log.mcp-tools.d.ts.map +1 -0
- package/dist/mcp/tools/core-access-log.mcp-tools.js +74 -0
- package/dist/mcp/tools/core-access-log.mcp-tools.js.map +1 -0
- package/dist/mcp/tools/core-files.mcp-tools.js +4 -4
- package/dist/mcp/tools/core-files.mcp-tools.js.map +1 -1
- package/dist/meeting/meeting-provider.registry.d.ts +72 -0
- package/dist/meeting/meeting-provider.registry.d.ts.map +1 -1
- package/dist/meeting/meeting-provider.registry.js.map +1 -1
- package/dist/oauth/oauth-token-broker.service.d.ts +50 -1
- package/dist/oauth/oauth-token-broker.service.d.ts.map +1 -1
- package/dist/oauth/oauth-token-broker.service.js +105 -3
- package/dist/oauth/oauth-token-broker.service.js.map +1 -1
- package/dist/oauth/providers/microsoft-entra-id.provider.d.ts +6 -0
- package/dist/oauth/providers/microsoft-entra-id.provider.d.ts.map +1 -1
- package/dist/oauth/providers/microsoft-entra-id.provider.js +30 -4
- package/dist/oauth/providers/microsoft-entra-id.provider.js.map +1 -1
- package/dist/pdf/document/charts/chart-bar.d.ts +24 -0
- package/dist/pdf/document/charts/chart-bar.d.ts.map +1 -0
- package/dist/pdf/document/charts/chart-bar.js +117 -0
- package/dist/pdf/document/charts/chart-bar.js.map +1 -0
- package/dist/pdf/document/charts/chart-donut.d.ts +16 -0
- package/dist/pdf/document/charts/chart-donut.d.ts.map +1 -0
- package/dist/pdf/document/charts/chart-donut.js +89 -0
- package/dist/pdf/document/charts/chart-donut.js.map +1 -0
- package/dist/pdf/document/charts/chart-gauge.d.ts +15 -0
- package/dist/pdf/document/charts/chart-gauge.d.ts.map +1 -0
- package/dist/pdf/document/charts/chart-gauge.js +60 -0
- package/dist/pdf/document/charts/chart-gauge.js.map +1 -0
- package/dist/pdf/document/charts/chart-line.d.ts +29 -0
- package/dist/pdf/document/charts/chart-line.d.ts.map +1 -0
- package/dist/pdf/document/charts/chart-line.js +89 -0
- package/dist/pdf/document/charts/chart-line.js.map +1 -0
- package/dist/pdf/document/charts/chart-primitives.d.ts +64 -0
- package/dist/pdf/document/charts/chart-primitives.d.ts.map +1 -0
- package/dist/pdf/document/charts/chart-primitives.js +110 -0
- package/dist/pdf/document/charts/chart-primitives.js.map +1 -0
- package/dist/pdf/document/charts/chart-radar.d.ts +13 -0
- package/dist/pdf/document/charts/chart-radar.d.ts.map +1 -0
- package/dist/pdf/document/charts/chart-radar.js +83 -0
- package/dist/pdf/document/charts/chart-radar.js.map +1 -0
- package/dist/pdf/document/charts/chart-scale.d.ts +39 -0
- package/dist/pdf/document/charts/chart-scale.d.ts.map +1 -0
- package/dist/pdf/document/charts/chart-scale.js +118 -0
- package/dist/pdf/document/charts/chart-scale.js.map +1 -0
- package/dist/pdf/document/charts/index.d.ts +8 -0
- package/dist/pdf/document/charts/index.d.ts.map +1 -0
- package/dist/pdf/document/charts/index.js +24 -0
- package/dist/pdf/document/charts/index.js.map +1 -0
- package/dist/pdf/document/index.d.ts +8 -0
- package/dist/pdf/document/index.d.ts.map +1 -0
- package/dist/pdf/document/index.js +24 -0
- package/dist/pdf/document/index.js.map +1 -0
- package/dist/pdf/document/pdf-document.chrome.d.ts +20 -0
- package/dist/pdf/document/pdf-document.chrome.d.ts.map +1 -0
- package/dist/pdf/document/pdf-document.chrome.js +36 -0
- package/dist/pdf/document/pdf-document.chrome.js.map +1 -0
- package/dist/pdf/document/pdf-document.css.d.ts +20 -0
- package/dist/pdf/document/pdf-document.css.d.ts.map +1 -0
- package/dist/pdf/document/pdf-document.css.js +327 -0
- package/dist/pdf/document/pdf-document.css.js.map +1 -0
- package/dist/pdf/document/pdf-document.renderer.d.ts +10 -0
- package/dist/pdf/document/pdf-document.renderer.d.ts.map +1 -0
- package/dist/pdf/document/pdf-document.renderer.js +226 -0
- package/dist/pdf/document/pdf-document.renderer.js.map +1 -0
- package/dist/pdf/document/pdf-document.service.d.ts +27 -0
- package/dist/pdf/document/pdf-document.service.d.ts.map +1 -0
- package/dist/pdf/document/pdf-document.service.js +81 -0
- package/dist/pdf/document/pdf-document.service.js.map +1 -0
- package/dist/pdf/document/pdf-document.theme.d.ts +41 -0
- package/dist/pdf/document/pdf-document.theme.d.ts.map +1 -0
- package/dist/pdf/document/pdf-document.theme.js +105 -0
- package/dist/pdf/document/pdf-document.theme.js.map +1 -0
- package/dist/pdf/document/pdf-document.types.d.ts +164 -0
- package/dist/pdf/document/pdf-document.types.d.ts.map +1 -0
- package/dist/pdf/document/pdf-document.types.js +18 -0
- package/dist/pdf/document/pdf-document.types.js.map +1 -0
- package/dist/pdf/pdf.module.d.ts.map +1 -1
- package/dist/pdf/pdf.module.js +3 -2
- package/dist/pdf/pdf.module.js.map +1 -1
- package/dist/pdf/pdf.service.d.ts +75 -1
- package/dist/pdf/pdf.service.d.ts.map +1 -1
- package/dist/pdf/pdf.service.js +167 -63
- package/dist/pdf/pdf.service.js.map +1 -1
- package/dist/pdf/templates/commercial-proposal.template.d.ts +96 -0
- package/dist/pdf/templates/commercial-proposal.template.d.ts.map +1 -0
- package/dist/pdf/templates/commercial-proposal.template.js +276 -0
- package/dist/pdf/templates/commercial-proposal.template.js.map +1 -0
- package/dist/setting/favicon.controller.d.ts +26 -0
- package/dist/setting/favicon.controller.d.ts.map +1 -0
- package/dist/setting/favicon.controller.js +130 -0
- package/dist/setting/favicon.controller.js.map +1 -0
- package/dist/setting/setting.module.d.ts.map +1 -1
- package/dist/setting/setting.module.js +2 -1
- package/dist/setting/setting.module.js.map +1 -1
- package/dist/setting/setting.service.d.ts.map +1 -1
- package/dist/setting/setting.service.js +3 -0
- package/dist/setting/setting.service.js.map +1 -1
- package/dist/task/task.service.d.ts +6 -0
- package/dist/task/task.service.d.ts.map +1 -1
- package/dist/task/task.service.js +44 -13
- package/dist/task/task.service.js.map +1 -1
- package/dist/whatsapp-sender/whatsapp-sender.service.d.ts +35 -2
- package/dist/whatsapp-sender/whatsapp-sender.service.d.ts.map +1 -1
- package/dist/whatsapp-sender/whatsapp-sender.service.js +107 -22
- package/dist/whatsapp-sender/whatsapp-sender.service.js.map +1 -1
- package/hedhog/data/integration_type.yaml +6 -0
- package/hedhog/data/route.yaml +8 -0
- package/hedhog/data/setting_group.yaml +52 -0
- package/hedhog/frontend/app/configurations/[slug]/components/setting-field.test.tsx.ejs +85 -2
- package/hedhog/frontend/app/configurations/[slug]/components/setting-field.tsx.ejs +576 -491
- package/hedhog/frontend/app/users/simulate-access-dialog.test.tsx.ejs +29 -50
- package/hedhog/frontend/app/users/simulate-access-dialog.tsx.ejs +146 -50
- package/hedhog/frontend/messages/en.json +81 -2
- package/hedhog/frontend/messages/pt.json +81 -2
- package/hedhog/table/file.yaml +3 -1
- package/package.json +5 -5
- package/src/access-log/access-log.interceptor.spec.ts +50 -0
- package/src/access-log/access-log.interceptor.ts +18 -0
- package/src/ai/ai-attachment.fragments.spec.ts +56 -3
- package/src/ai/ai.service.responses.spec.ts +429 -0
- package/src/ai/ai.service.ts +409 -27
- package/src/ai-instruction/ai-prompt-resolver.service.spec.ts +44 -0
- package/src/ai-instruction/ai-prompt-resolver.service.ts +29 -0
- package/src/core.module.ts +3 -0
- package/src/document-kit/document-kit.module.ts +12 -0
- package/src/document-kit/document-kit.registry.ts +125 -0
- package/src/document-kit/document-kit.service.spec.ts +156 -0
- package/src/document-kit/document-kit.service.ts +173 -0
- package/src/file/file-direct-upload.spec.ts +344 -0
- package/src/file/file.service.spec.ts +168 -1
- package/src/file/file.service.ts +435 -36
- package/src/file/mcp-file-source.schema.ts +8 -5
- package/src/file/provider/abstract.provider.ts +57 -0
- package/src/file/provider/s3-multipart.spec.ts +123 -0
- package/src/file/provider/s3-multipart.util.ts +126 -0
- package/src/file/provider/s3.provider.ts +65 -1
- package/src/file/provider/spaces.provider.ts +67 -1
- package/src/index.ts +5 -0
- package/src/integration-profile/integration-profile.secrets.ts +26 -0
- package/src/integration-profile/integration-profile.service.ts +1 -0
- package/src/integration-profile/testers/ai.testers.ts +70 -5
- package/src/integration-profile/testers/http-probe.ts +5 -3
- package/src/integration-profile/testers/integration-tester.types.ts +5 -0
- package/src/integration-profile/testers/oauth.testers.spec.ts +13 -0
- package/src/integration-profile/testers/remaining.testers.spec.ts +65 -1
- package/src/integration-profile/testers/tester-coverage.spec.ts +12 -0
- package/src/mail/mail.service.spec.ts +25 -0
- package/src/mail/mail.service.ts +18 -1
- package/src/mcp/mcp-server.service.spec.ts +16 -0
- package/src/mcp/mcp-server.service.ts +10 -1
- package/src/mcp/mcp.module.ts +2 -0
- package/src/mcp/tools/core-access-log.mcp-tools.ts +66 -0
- package/src/mcp/tools/core-files.mcp-tools.ts +4 -4
- package/src/meeting/meeting-provider.registry.ts +80 -0
- package/src/oauth/oauth-token-broker.service.spec.ts +108 -0
- package/src/oauth/oauth-token-broker.service.ts +129 -2
- package/src/oauth/providers/microsoft-entra-id.provider.spec.ts +133 -0
- package/src/oauth/providers/microsoft-entra-id.provider.ts +29 -2
- package/src/pdf/document/charts/chart-bar.ts +217 -0
- package/src/pdf/document/charts/chart-donut.ts +163 -0
- package/src/pdf/document/charts/chart-gauge.ts +94 -0
- package/src/pdf/document/charts/chart-line.ts +159 -0
- package/src/pdf/document/charts/chart-primitives.ts +188 -0
- package/src/pdf/document/charts/chart-radar.ts +136 -0
- package/src/pdf/document/charts/chart-scale.spec.ts +93 -0
- package/src/pdf/document/charts/chart-scale.ts +141 -0
- package/src/pdf/document/charts/charts.spec.ts +157 -0
- package/src/pdf/document/charts/index.ts +7 -0
- package/src/pdf/document/index.ts +7 -0
- package/src/pdf/document/pdf-document.chrome.ts +38 -0
- package/src/pdf/document/pdf-document.css.ts +326 -0
- package/src/pdf/document/pdf-document.renderer.spec.ts +172 -0
- package/src/pdf/document/pdf-document.renderer.ts +317 -0
- package/src/pdf/document/pdf-document.service.ts +77 -0
- package/src/pdf/document/pdf-document.theme.ts +146 -0
- package/src/pdf/document/pdf-document.types.ts +149 -0
- package/src/pdf/pdf.module.ts +3 -2
- package/src/pdf/pdf.service.spec.ts +131 -0
- package/src/pdf/pdf.service.ts +256 -82
- package/src/pdf/templates/commercial-proposal.template.spec.ts +177 -0
- package/src/pdf/templates/commercial-proposal.template.ts +400 -0
- package/src/setting/favicon.controller.spec.ts +92 -0
- package/src/setting/favicon.controller.ts +135 -0
- package/src/setting/setting.module.ts +2 -1
- package/src/setting/setting.service.ts +3 -0
- package/src/task/task.service.spec.ts +77 -1
- package/src/task/task.service.ts +58 -14
- package/src/whatsapp-sender/whatsapp-sender.service.spec.ts +263 -0
- package/src/whatsapp-sender/whatsapp-sender.service.ts +154 -16
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { Public } from '@hed-hog/api';
|
|
2
|
+
import { Controller, Get, Logger, NotFoundException, Res } from '@nestjs/common';
|
|
3
|
+
import { Response } from 'express';
|
|
4
|
+
import { BrandAssetService } from './brand-asset.service';
|
|
5
|
+
import { SettingService } from './setting.service';
|
|
6
|
+
|
|
7
|
+
/** Timeout do fetch da imagem configurada. */
|
|
8
|
+
const FETCH_TIMEOUT_MS = 5_000;
|
|
9
|
+
|
|
10
|
+
/** Quanto tempo o binario baixado fica em memoria antes de buscar de novo. */
|
|
11
|
+
const CACHE_TTL_MS = 10 * 60_000;
|
|
12
|
+
|
|
13
|
+
type CachedIcon = {
|
|
14
|
+
url: string;
|
|
15
|
+
body: Buffer;
|
|
16
|
+
contentType: string;
|
|
17
|
+
expiresAt: number;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* `GET /favicon.ico` no host da API, com o icone do Branding (`icon-url`).
|
|
22
|
+
*
|
|
23
|
+
* Quem procura o favicon no dominio da API sao os clientes que so conhecem esse
|
|
24
|
+
* endereco - o conector MCP no claude.ai, por exemplo, mostra o icone do dominio
|
|
25
|
+
* do servidor ao lado do nome. Sem esta rota a resposta era o 404 JSON do Nest e
|
|
26
|
+
* o conector caia na letra inicial.
|
|
27
|
+
*
|
|
28
|
+
* O arquivo e servido daqui (e nao por redirect) porque nem todo coletor de
|
|
29
|
+
* favicon segue redirecionamento. O Content-Type e o da origem, entao um SVG
|
|
30
|
+
* configurado no Branding chega como SVG.
|
|
31
|
+
*/
|
|
32
|
+
@Controller()
|
|
33
|
+
export class FaviconController {
|
|
34
|
+
private readonly logger = new Logger(FaviconController.name);
|
|
35
|
+
private cache: CachedIcon | null = null;
|
|
36
|
+
|
|
37
|
+
constructor(
|
|
38
|
+
private readonly setting: SettingService,
|
|
39
|
+
private readonly brandAsset: BrandAssetService,
|
|
40
|
+
) {}
|
|
41
|
+
|
|
42
|
+
@Public()
|
|
43
|
+
@Get('favicon.ico')
|
|
44
|
+
async favicon(@Res() res: Response) {
|
|
45
|
+
const icon = await this.loadIcon();
|
|
46
|
+
|
|
47
|
+
if (!icon) {
|
|
48
|
+
throw new NotFoundException();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
res.setHeader('Content-Type', icon.contentType);
|
|
52
|
+
res.setHeader('Cache-Control', 'public, max-age=3600');
|
|
53
|
+
res.send(icon.body);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
private async loadIcon(): Promise<CachedIcon | null> {
|
|
57
|
+
const settings = await this.setting.getSettingValues([
|
|
58
|
+
'icon-url',
|
|
59
|
+
'image-url',
|
|
60
|
+
]);
|
|
61
|
+
const value =
|
|
62
|
+
String(settings['icon-url'] ?? '').trim() ||
|
|
63
|
+
String(settings['image-url'] ?? '').trim();
|
|
64
|
+
const url = await this.brandAsset.resolveAssetUrl(value);
|
|
65
|
+
|
|
66
|
+
if (!url) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (
|
|
71
|
+
this.cache &&
|
|
72
|
+
this.cache.url === url &&
|
|
73
|
+
this.cache.expiresAt > Date.now()
|
|
74
|
+
) {
|
|
75
|
+
return this.cache;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const icon = await this.download(url);
|
|
79
|
+
|
|
80
|
+
if (icon) {
|
|
81
|
+
this.cache = icon;
|
|
82
|
+
return icon;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Origem fora do ar: melhor o icone anterior do que nenhum.
|
|
86
|
+
return this.cache?.url === url ? this.cache : null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private async download(url: string): Promise<CachedIcon | null> {
|
|
90
|
+
const dataUri = /^data:([^;,]+)(;base64)?,([\s\S]*)$/i.exec(url);
|
|
91
|
+
|
|
92
|
+
if (dataUri) {
|
|
93
|
+
const [, contentType, base64, payload] = dataUri;
|
|
94
|
+
return {
|
|
95
|
+
url,
|
|
96
|
+
contentType,
|
|
97
|
+
body: base64
|
|
98
|
+
? Buffer.from(payload, 'base64')
|
|
99
|
+
: Buffer.from(decodeURIComponent(payload)),
|
|
100
|
+
expiresAt: Date.now() + CACHE_TTL_MS,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
const controller = new AbortController();
|
|
106
|
+
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
107
|
+
const response = await globalThis
|
|
108
|
+
.fetch(url, { signal: controller.signal })
|
|
109
|
+
.finally(() => clearTimeout(timeout));
|
|
110
|
+
|
|
111
|
+
const contentType = response.headers.get('content-type') ?? '';
|
|
112
|
+
|
|
113
|
+
if (!response.ok || !contentType.startsWith('image/')) {
|
|
114
|
+
this.logger.warn(
|
|
115
|
+
`Favicon source ${url} answered ${response.status} ${contentType || '(no content-type)'}`,
|
|
116
|
+
);
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
url,
|
|
122
|
+
contentType,
|
|
123
|
+
body: Buffer.from(await response.arrayBuffer()),
|
|
124
|
+
expiresAt: Date.now() + CACHE_TTL_MS,
|
|
125
|
+
};
|
|
126
|
+
} catch (error) {
|
|
127
|
+
this.logger.warn(
|
|
128
|
+
`Could not fetch favicon source ${url}: ${
|
|
129
|
+
error instanceof Error ? error.message : String(error)
|
|
130
|
+
}`,
|
|
131
|
+
);
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -5,6 +5,7 @@ import { Global, Module, forwardRef } from '@nestjs/common';
|
|
|
5
5
|
import { AuthModule } from '../auth/auth.module';
|
|
6
6
|
import { MailModule } from '../mail/mail.module';
|
|
7
7
|
import { BrandAssetService } from './brand-asset.service';
|
|
8
|
+
import { FaviconController } from './favicon.controller';
|
|
8
9
|
import { SettingsController } from './setting.controller';
|
|
9
10
|
import { SettingService } from './setting.service';
|
|
10
11
|
|
|
@@ -17,7 +18,7 @@ import { SettingService } from './setting.service';
|
|
|
17
18
|
forwardRef(() => LocaleModule),
|
|
18
19
|
forwardRef(() => MailModule),
|
|
19
20
|
],
|
|
20
|
-
controllers: [SettingsController],
|
|
21
|
+
controllers: [SettingsController, FaviconController],
|
|
21
22
|
providers: [SettingService, BrandAssetService],
|
|
22
23
|
exports: [SettingService, BrandAssetService],
|
|
23
24
|
})
|
|
@@ -555,6 +555,9 @@ export class SettingService implements OnModuleInit, OnModuleDestroy {
|
|
|
555
555
|
'lms-video-frame-capture-interval-seconds',
|
|
556
556
|
'lms-error-modal-enabled',
|
|
557
557
|
'url',
|
|
558
|
+
// Base dos callbacks OAuth do backend (canais do social): o perfil de
|
|
559
|
+
// integração mostra a URL exata que o backend envia ao provider.
|
|
560
|
+
'api-url',
|
|
558
561
|
'app-urls',
|
|
559
562
|
// Hcode Store: liga/desliga a venda no frontend (o backend também
|
|
560
563
|
// recusa o checkout — isto é só para a UI mostrar "Em breve" cedo).
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals';
|
|
2
|
-
|
|
2
|
+
|
|
3
|
+
const runMonitoredCron = jest.fn((_prisma: any, _options: any, work: () => any) => work());
|
|
4
|
+
|
|
5
|
+
jest.mock('../observability/monitored-cron', () => ({ runMonitoredCron }));
|
|
6
|
+
|
|
7
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
8
|
+
const { TasksService } = require('./task.service');
|
|
3
9
|
|
|
4
10
|
const makeDeps = () => {
|
|
5
11
|
const prismaService = { $executeRaw: jest.fn().mockResolvedValue(0) };
|
|
@@ -23,6 +29,25 @@ describe('TasksService', () => {
|
|
|
23
29
|
expect(deps.prismaService.$executeRaw).toHaveBeenCalledTimes(8);
|
|
24
30
|
expect(deps.settingService.getSettingValues).toHaveBeenCalled();
|
|
25
31
|
});
|
|
32
|
+
|
|
33
|
+
it('elege um unico pod por tick via runMonitoredCron', async () => {
|
|
34
|
+
// Sem eleicao, todos os pods drenavam o access_log ao mesmo tempo e
|
|
35
|
+
// esgotavam o pool (Sentry 7692792694).
|
|
36
|
+
const { service, deps } = makeService();
|
|
37
|
+
await service.clear();
|
|
38
|
+
expect(runMonitoredCron).toHaveBeenCalledWith(
|
|
39
|
+
deps.prismaService,
|
|
40
|
+
expect.objectContaining({ slug: 'core-daily-cleanup', maxRuntimeMinutes: 15 }),
|
|
41
|
+
expect.any(Function),
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('nao limpa nada quando outro pod ganhou o tick', async () => {
|
|
46
|
+
runMonitoredCron.mockImplementationOnce(async () => undefined);
|
|
47
|
+
const { service, deps } = makeService();
|
|
48
|
+
await service.clear();
|
|
49
|
+
expect(deps.prismaService.$executeRaw).not.toHaveBeenCalled();
|
|
50
|
+
});
|
|
26
51
|
});
|
|
27
52
|
|
|
28
53
|
describe('clearExpiredMfaChallenges (default de batch limit)', () => {
|
|
@@ -46,6 +71,57 @@ describe('TasksService', () => {
|
|
|
46
71
|
});
|
|
47
72
|
|
|
48
73
|
describe('clearOldAccessLogs', () => {
|
|
74
|
+
let setTimeoutSpy: ReturnType<typeof jest.spyOn>;
|
|
75
|
+
|
|
76
|
+
beforeEach(() => {
|
|
77
|
+
// A pausa entre lotes nao interessa aqui: resolve na hora.
|
|
78
|
+
setTimeoutSpy = jest.spyOn(global, 'setTimeout').mockImplementation(((fn: () => void) => {
|
|
79
|
+
fn();
|
|
80
|
+
return 0;
|
|
81
|
+
}) as any);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
afterEach(() => setTimeoutSpy.mockRestore());
|
|
85
|
+
|
|
86
|
+
it('pausa entre lotes cheios, mas nao depois do ultimo', async () => {
|
|
87
|
+
const { service, deps } = makeService();
|
|
88
|
+
deps.settingService.getSettingValues.mockResolvedValue({
|
|
89
|
+
'cleanup-batch-limit': 100,
|
|
90
|
+
});
|
|
91
|
+
deps.prismaService.$executeRaw
|
|
92
|
+
.mockResolvedValueOnce(100)
|
|
93
|
+
.mockResolvedValueOnce(100)
|
|
94
|
+
.mockResolvedValueOnce(42);
|
|
95
|
+
|
|
96
|
+
await (service as any).clearOldAccessLogs();
|
|
97
|
+
|
|
98
|
+
expect(setTimeoutSpy).toHaveBeenCalledTimes(2);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('para sem falhar quando o pool esgota no meio da drenagem', async () => {
|
|
102
|
+
const { service, deps } = makeService();
|
|
103
|
+
deps.settingService.getSettingValues.mockResolvedValue({
|
|
104
|
+
'cleanup-batch-limit': 100,
|
|
105
|
+
});
|
|
106
|
+
deps.prismaService.$executeRaw
|
|
107
|
+
.mockResolvedValueOnce(100)
|
|
108
|
+
.mockRejectedValueOnce(
|
|
109
|
+
Object.assign(new Error('Timed out fetching a new connection from the connection pool'), {
|
|
110
|
+
code: 'P2024',
|
|
111
|
+
}),
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
await expect((service as any).clearOldAccessLogs()).resolves.toBeUndefined();
|
|
115
|
+
expect(deps.prismaService.$executeRaw).toHaveBeenCalledTimes(2);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('propaga erro que nao e de pool', async () => {
|
|
119
|
+
const { service, deps } = makeService();
|
|
120
|
+
deps.prismaService.$executeRaw.mockRejectedValueOnce(new Error('syntax error'));
|
|
121
|
+
|
|
122
|
+
await expect((service as any).clearOldAccessLogs()).rejects.toThrow('syntax error');
|
|
123
|
+
});
|
|
124
|
+
|
|
49
125
|
it('repete lotes ate drenar o acumulo', async () => {
|
|
50
126
|
// O bug: um unico lote por execucao diaria contra uma ingestao uma ordem
|
|
51
127
|
// de grandeza maior deixava a retencao inalcancavel.
|
package/src/task/task.service.ts
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
classifyDatabaseInfraError,
|
|
3
|
+
Prisma,
|
|
4
|
+
PrismaService,
|
|
5
|
+
} from '@hed-hog/api-prisma';
|
|
2
6
|
import { Injectable, Logger } from '@nestjs/common';
|
|
3
7
|
import { Cron, CronExpression } from '@nestjs/schedule';
|
|
8
|
+
import { runMonitoredCron } from '../observability/monitored-cron';
|
|
4
9
|
import { SettingService } from '../setting/setting.service';
|
|
5
10
|
|
|
11
|
+
const CLEANUP_CRON_SCHEDULE = CronExpression.EVERY_DAY_AT_MIDNIGHT;
|
|
12
|
+
|
|
6
13
|
/**
|
|
7
14
|
* Teto de tempo da limpeza do `access_log`. Existe para o caso da entrada
|
|
8
15
|
* superar a saída de novo: o laço para e devolve o resto para a execução
|
|
@@ -10,6 +17,14 @@ import { SettingService } from '../setting/setting.service';
|
|
|
10
17
|
*/
|
|
11
18
|
const ACCESS_LOG_CLEANUP_BUDGET_MS = 10 * 60 * 1000;
|
|
12
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Pausa entre dois lotes cheios do `access_log`. Sem ela o laço emenda um DELETE
|
|
22
|
+
* de 20 mil linhas no outro e o Postgres (1 CPU) não sobra para o tráfego: as
|
|
23
|
+
* requests do pod ficam lentas, seguram as 5 conexões do pool e o próprio lote
|
|
24
|
+
* seguinte morre em "Timed out fetching a new connection" (Sentry 7692792694).
|
|
25
|
+
*/
|
|
26
|
+
const ACCESS_LOG_BATCH_PAUSE_MS = 500;
|
|
27
|
+
|
|
13
28
|
@Injectable()
|
|
14
29
|
export class TasksService {
|
|
15
30
|
private readonly logger = new Logger(TasksService.name);
|
|
@@ -24,9 +39,21 @@ export class TasksService {
|
|
|
24
39
|
*
|
|
25
40
|
* As tarefas são executadas sequencialmente para evitar contenção excessiva no banco.
|
|
26
41
|
* Cada tarefa possui logging detalhado para monitoramento e troubleshooting.
|
|
42
|
+
*
|
|
43
|
+
* O @Cron dispara no mesmo segundo em todos os pods (API e workers); sem a
|
|
44
|
+
* eleição do `runMonitoredCron` cada um drenava o `access_log` em paralelo,
|
|
45
|
+
* disputando as mesmas linhas e saturando o banco.
|
|
27
46
|
*/
|
|
28
|
-
@Cron(
|
|
47
|
+
@Cron(CLEANUP_CRON_SCHEDULE)
|
|
29
48
|
async clear() {
|
|
49
|
+
await runMonitoredCron(
|
|
50
|
+
this.prismaService,
|
|
51
|
+
{ slug: 'core-daily-cleanup', schedule: CLEANUP_CRON_SCHEDULE, maxRuntimeMinutes: 15 },
|
|
52
|
+
() => this.runCleanup(),
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
private async runCleanup(): Promise<void> {
|
|
30
57
|
this.logger.verbose('++++++++++++++++++++++++++++++++++');
|
|
31
58
|
this.logger.verbose('Running scheduled cleanup tasks');
|
|
32
59
|
this.logger.verbose('++++++++++++++++++++++++++++++++++');
|
|
@@ -307,18 +334,18 @@ export class TasksService {
|
|
|
307
334
|
let deleted = 0;
|
|
308
335
|
let batches = 0;
|
|
309
336
|
for (;;) {
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
337
|
+
let removed: number;
|
|
338
|
+
try {
|
|
339
|
+
removed = await this.deleteAccessLogBatch(retentionDays, batchLimit);
|
|
340
|
+
} catch (error) {
|
|
341
|
+
// Limpeza é best-effort: com o pool saturado o resto fica para a próxima
|
|
342
|
+
// execução, como no estouro do orçamento de tempo.
|
|
343
|
+
if (classifyDatabaseInfraError(error) !== 'pool') throw error;
|
|
344
|
+
this.logger.warn(
|
|
345
|
+
`Access log cleanup stopped on a saturated connection pool after ${deleted} rows; the remainder goes on the next run.`,
|
|
346
|
+
);
|
|
347
|
+
break;
|
|
348
|
+
}
|
|
322
349
|
|
|
323
350
|
deleted += removed;
|
|
324
351
|
batches += 1;
|
|
@@ -331,6 +358,8 @@ export class TasksService {
|
|
|
331
358
|
);
|
|
332
359
|
break;
|
|
333
360
|
}
|
|
361
|
+
|
|
362
|
+
await new Promise((resolve) => setTimeout(resolve, ACCESS_LOG_BATCH_PAUSE_MS));
|
|
334
363
|
}
|
|
335
364
|
|
|
336
365
|
this.logger.verbose(
|
|
@@ -338,6 +367,21 @@ export class TasksService {
|
|
|
338
367
|
);
|
|
339
368
|
}
|
|
340
369
|
|
|
370
|
+
private deleteAccessLogBatch(retentionDays: number, batchLimit: number): Promise<number> {
|
|
371
|
+
return this.prismaService.$executeRaw`
|
|
372
|
+
WITH del AS (
|
|
373
|
+
SELECT id
|
|
374
|
+
FROM access_log
|
|
375
|
+
WHERE created_at < (now() - ${Prisma.sql`INTERVAL '${Prisma.raw(String(retentionDays))} days'`})
|
|
376
|
+
ORDER BY id
|
|
377
|
+
LIMIT ${batchLimit}
|
|
378
|
+
)
|
|
379
|
+
DELETE FROM access_log al
|
|
380
|
+
USING del
|
|
381
|
+
WHERE al.id = del.id
|
|
382
|
+
`;
|
|
383
|
+
}
|
|
384
|
+
|
|
341
385
|
private async clearOldNotifications(): Promise<void> {
|
|
342
386
|
const settings = await this.settingService.getSettingValues([
|
|
343
387
|
'cleanup-batch-limit',
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import {
|
|
2
2
|
WhatsappSendError,
|
|
3
3
|
WhatsappSenderService,
|
|
4
|
+
WHATSAPP_OFFICIAL_ONLY,
|
|
5
|
+
toTemplateParam,
|
|
4
6
|
} from './whatsapp-sender.service';
|
|
5
7
|
|
|
6
8
|
describe('WhatsappSenderService', () => {
|
|
@@ -109,4 +111,265 @@ describe('WhatsappSenderService', () => {
|
|
|
109
111
|
expect(JSON.parse(init.body).number).toBe('120363430651137530@g.us');
|
|
110
112
|
});
|
|
111
113
|
});
|
|
114
|
+
|
|
115
|
+
// O worker so insiste quando a recusa pode mudar; numero sem WhatsApp e dado
|
|
116
|
+
// de cadastro e reprocessaria o job tres vezes a toa.
|
|
117
|
+
describe('recusa da Evolution', () => {
|
|
118
|
+
let fetchSpy: jest.SpyInstance;
|
|
119
|
+
|
|
120
|
+
const buildEvolution = () => {
|
|
121
|
+
const built = buildService('evolution-api');
|
|
122
|
+
built.prisma.integration_profile.findUnique.mockResolvedValue({
|
|
123
|
+
id: 2,
|
|
124
|
+
slug: 'hcode',
|
|
125
|
+
config: {
|
|
126
|
+
host: 'https://evolution.hcode.com.br',
|
|
127
|
+
token: 'apikey',
|
|
128
|
+
instance_name: 'hcode',
|
|
129
|
+
},
|
|
130
|
+
integration_provider: { slug: 'evolution-api' },
|
|
131
|
+
integration_type: { slug: 'whatsapp' },
|
|
132
|
+
});
|
|
133
|
+
return built.service;
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const respond = (status: number, body: unknown) =>
|
|
137
|
+
fetchSpy.mockResolvedValue({
|
|
138
|
+
ok: false,
|
|
139
|
+
status,
|
|
140
|
+
text: async () => JSON.stringify(body),
|
|
141
|
+
} as any);
|
|
142
|
+
|
|
143
|
+
const sendAndCatch = () =>
|
|
144
|
+
buildEvolution()
|
|
145
|
+
.send({ profileSlug: 'hcode', to: '5515991103304', text: 'Oi' })
|
|
146
|
+
.catch((thrown) => thrown);
|
|
147
|
+
|
|
148
|
+
beforeEach(() => {
|
|
149
|
+
fetchSpy = jest.spyOn(global, 'fetch' as any);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
afterEach(() => fetchSpy.mockRestore());
|
|
153
|
+
|
|
154
|
+
it('numero sem WhatsApp e recusa permanente com mensagem legivel', async () => {
|
|
155
|
+
respond(400, {
|
|
156
|
+
status: 400,
|
|
157
|
+
error: 'Bad Request',
|
|
158
|
+
response: {
|
|
159
|
+
message: [{ jid: '5515991103304@s.whatsapp.net', exists: false, number: '5515991103304' }],
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
const error = await sendAndCatch();
|
|
164
|
+
|
|
165
|
+
expect(error).toBeInstanceOf(WhatsappSendError);
|
|
166
|
+
expect(error.retryable).toBe(false);
|
|
167
|
+
expect(error.message).toMatch(/5515991103304 não tem WhatsApp/);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('outro 4xx tambem e permanente', async () => {
|
|
171
|
+
respond(400, { response: { message: ['text is required'] } });
|
|
172
|
+
|
|
173
|
+
const error = await sendAndCatch();
|
|
174
|
+
|
|
175
|
+
expect(error.retryable).toBe(false);
|
|
176
|
+
expect(error.isTransient).toBe(false);
|
|
177
|
+
expect(error.message).toMatch(/HTTP 400/);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('5xx e 429 podem ser tentados de novo', async () => {
|
|
181
|
+
respond(502, {});
|
|
182
|
+
const serverError = await sendAndCatch();
|
|
183
|
+
expect(serverError.retryable).toBe(true);
|
|
184
|
+
expect(serverError.isTransient).toBe(true);
|
|
185
|
+
|
|
186
|
+
respond(429, {});
|
|
187
|
+
expect((await sendAndCatch()).retryable).toBe(true);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('sessao fechada na Evolution pede para reconectar a instancia', async () => {
|
|
191
|
+
respond(500, {
|
|
192
|
+
status: 500,
|
|
193
|
+
error: 'Internal Server Error',
|
|
194
|
+
response: { message: 'Connection Closed' },
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
const error = await sendAndCatch();
|
|
198
|
+
|
|
199
|
+
expect(error).toBeInstanceOf(WhatsappSendError);
|
|
200
|
+
expect(error.retryable).toBe(true);
|
|
201
|
+
expect(error.isTransient).toBe(true);
|
|
202
|
+
expect(error.message).toMatch(/desconectada/);
|
|
203
|
+
expect(error.message).toMatch(/Reconecte a instância/);
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
// Sem phone_number_id no envio nem no perfil, vale o número padrão que a
|
|
208
|
+
// library whatsapp registra — senão cada comunicação pede o id à mão.
|
|
209
|
+
describe('número padrão do perfil no provider oficial', () => {
|
|
210
|
+
let fetchSpy: jest.SpyInstance;
|
|
211
|
+
|
|
212
|
+
const build = (phoneNumber: any, withDelegate = true) => {
|
|
213
|
+
const prisma: any = {
|
|
214
|
+
integration_profile: {
|
|
215
|
+
findUnique: jest.fn().mockResolvedValue({
|
|
216
|
+
id: 15,
|
|
217
|
+
slug: 'whatsapp-api-hcode',
|
|
218
|
+
config: { access_token: 'token' },
|
|
219
|
+
integration_provider: { slug: 'whatsapp-official' },
|
|
220
|
+
integration_type: { slug: 'whatsapp' },
|
|
221
|
+
}),
|
|
222
|
+
findFirst: jest.fn(),
|
|
223
|
+
},
|
|
224
|
+
};
|
|
225
|
+
if (withDelegate) {
|
|
226
|
+
prisma.whatsapp_phone_number = {
|
|
227
|
+
findFirst: jest.fn().mockResolvedValue(phoneNumber),
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
return { prisma, service: new WhatsappSenderService(prisma) };
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
beforeEach(() => {
|
|
234
|
+
fetchSpy = jest
|
|
235
|
+
.spyOn(global, 'fetch' as any)
|
|
236
|
+
.mockResolvedValue({ ok: true, json: async () => ({}) } as any);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
afterEach(() => fetchSpy.mockRestore());
|
|
240
|
+
|
|
241
|
+
it('envia pelo número padrão quando o chamador não informa', async () => {
|
|
242
|
+
const { prisma, service } = build({ phone_number_id: '1188434797689179' });
|
|
243
|
+
|
|
244
|
+
await service.send({ profileSlug: 'whatsapp-api-hcode', to: '5511999999999', text: 'oi' });
|
|
245
|
+
|
|
246
|
+
expect(prisma.whatsapp_phone_number.findFirst).toHaveBeenCalledWith(
|
|
247
|
+
expect.objectContaining({
|
|
248
|
+
where: { integration_profile_id: 15, is_default: true, status: 'active' },
|
|
249
|
+
}),
|
|
250
|
+
);
|
|
251
|
+
expect(String(fetchSpy.mock.calls[0][0])).toContain('/1188434797689179/messages');
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('o id informado pelo chamador vence o padrão', async () => {
|
|
255
|
+
const { prisma, service } = build({ phone_number_id: '1188434797689179' });
|
|
256
|
+
|
|
257
|
+
await service.send({
|
|
258
|
+
profileSlug: 'whatsapp-api-hcode',
|
|
259
|
+
to: '5511999999999',
|
|
260
|
+
text: 'oi',
|
|
261
|
+
phoneNumberId: '555',
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
expect(prisma.whatsapp_phone_number.findFirst).not.toHaveBeenCalled();
|
|
265
|
+
expect(String(fetchSpy.mock.calls[0][0])).toContain('/555/messages');
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('sem padrão nem tabela, recusa sem chamar a Graph API', async () => {
|
|
269
|
+
const { service } = build(null, false);
|
|
270
|
+
|
|
271
|
+
await expect(
|
|
272
|
+
service.send({ profileSlug: 'whatsapp-api-hcode', to: '5511999999999', text: 'oi' }),
|
|
273
|
+
).rejects.toThrow('não tem número padrão');
|
|
274
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('achata as quebras de linha dos parâmetros do template', async () => {
|
|
278
|
+
const { service } = build({ phone_number_id: '1188434797689179' });
|
|
279
|
+
|
|
280
|
+
await service.send({
|
|
281
|
+
profileSlug: 'whatsapp-api-hcode',
|
|
282
|
+
to: '5511999999999',
|
|
283
|
+
text: 'oi',
|
|
284
|
+
template: { name: 'lms_resumo', params: ['Maria', 'linha 1\n\nlinha 2'] },
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
const body = JSON.parse(fetchSpy.mock.calls[0][1].body);
|
|
288
|
+
expect(body.template.components[0].parameters).toEqual([
|
|
289
|
+
{ type: 'text', text: 'Maria' },
|
|
290
|
+
{ type: 'text', text: 'linha 1 · linha 2' },
|
|
291
|
+
]);
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
describe('toTemplateParam', () => {
|
|
296
|
+
it('troca quebra de linha e tab por separador e colapsa espaços', () => {
|
|
297
|
+
expect(toTemplateParam('a\n\nb\tc d')).toBe('a · b · c d');
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it('não deixa parâmetro vazio, que a Meta recusa', () => {
|
|
301
|
+
expect(toTemplateParam(' ')).toBe('-');
|
|
302
|
+
expect(toTemplateParam(undefined)).toBe('-');
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
// Envio em lote para aluno não pode sair pela Evolution (WhatsApp Web): um
|
|
307
|
+
// disparo desses desconectou e bloqueou o número por 24h.
|
|
308
|
+
describe('restrição de provider', () => {
|
|
309
|
+
let fetchSpy: jest.SpyInstance;
|
|
310
|
+
|
|
311
|
+
beforeEach(() => {
|
|
312
|
+
fetchSpy = jest
|
|
313
|
+
.spyOn(global, 'fetch' as any)
|
|
314
|
+
.mockResolvedValue({ ok: true, json: async () => ({}) } as any);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
afterEach(() => fetchSpy.mockRestore());
|
|
318
|
+
|
|
319
|
+
it('recusa um perfil Evolution escolhido à mão, sem nova tentativa', async () => {
|
|
320
|
+
const { service } = buildService('evolution-api');
|
|
321
|
+
|
|
322
|
+
const error = await service
|
|
323
|
+
.send({
|
|
324
|
+
profileSlug: 'hcode',
|
|
325
|
+
to: '5511999999999',
|
|
326
|
+
text: 'oi',
|
|
327
|
+
providers: WHATSAPP_OFFICIAL_ONLY,
|
|
328
|
+
})
|
|
329
|
+
.catch((e) => e);
|
|
330
|
+
|
|
331
|
+
expect(error).toBeInstanceOf(WhatsappSendError);
|
|
332
|
+
expect(error.retryable).toBe(false);
|
|
333
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it('sem perfil escolhido, o fallback só procura perfis do provider exigido', async () => {
|
|
337
|
+
const { prisma, service } = buildService('whatsapp-official');
|
|
338
|
+
prisma.integration_profile.findFirst.mockResolvedValue({
|
|
339
|
+
id: 15,
|
|
340
|
+
slug: 'whatsapp-api-hcode',
|
|
341
|
+
config: { access_token: 'token', phone_number_id: '1188434797689179' },
|
|
342
|
+
integration_provider: { slug: 'whatsapp-official' },
|
|
343
|
+
integration_type: { slug: 'whatsapp' },
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
await service.send({ to: '5511999999999', text: 'oi', providers: WHATSAPP_OFFICIAL_ONLY });
|
|
347
|
+
|
|
348
|
+
expect(prisma.integration_profile.findFirst).toHaveBeenCalledWith(
|
|
349
|
+
expect.objectContaining({
|
|
350
|
+
where: expect.objectContaining({
|
|
351
|
+
integration_provider: { slug: { in: ['whatsapp-official'] } },
|
|
352
|
+
}),
|
|
353
|
+
}),
|
|
354
|
+
);
|
|
355
|
+
expect(String(fetchSpy.mock.calls[0][0])).toContain('/1188434797689179/messages');
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it('isConfigured é falso para perfil de outro provider', async () => {
|
|
359
|
+
const { service } = buildService('evolution-api');
|
|
360
|
+
|
|
361
|
+
await expect(service.isConfigured('hcode', WHATSAPP_OFFICIAL_ONLY)).resolves.toBe(false);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it('sem restrição, a Evolution continua aceita', async () => {
|
|
365
|
+
const { service } = buildService('evolution-api');
|
|
366
|
+
|
|
367
|
+
await expect(service.isConfigured('hcode')).resolves.toBe(false);
|
|
368
|
+
// `false` aqui é só por falta de baseUrl/token no config de teste — o
|
|
369
|
+
// perfil foi resolvido, não recusado.
|
|
370
|
+
await expect(
|
|
371
|
+
service.describeProfile('hcode'),
|
|
372
|
+
).resolves.toMatchObject({ providerSlug: 'evolution-api' });
|
|
373
|
+
});
|
|
374
|
+
});
|
|
112
375
|
});
|