@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.
Files changed (274) hide show
  1. package/dist/access-log/access-log.interceptor.d.ts +10 -0
  2. package/dist/access-log/access-log.interceptor.d.ts.map +1 -1
  3. package/dist/access-log/access-log.interceptor.js +15 -1
  4. package/dist/access-log/access-log.interceptor.js.map +1 -1
  5. package/dist/ai/ai.controller.d.ts +2 -0
  6. package/dist/ai/ai.controller.d.ts.map +1 -1
  7. package/dist/ai/ai.service.d.ts +86 -2
  8. package/dist/ai/ai.service.d.ts.map +1 -1
  9. package/dist/ai/ai.service.js +304 -23
  10. package/dist/ai/ai.service.js.map +1 -1
  11. package/dist/ai-instruction/ai-prompt-resolver.service.d.ts +12 -0
  12. package/dist/ai-instruction/ai-prompt-resolver.service.d.ts.map +1 -1
  13. package/dist/ai-instruction/ai-prompt-resolver.service.js +26 -0
  14. package/dist/ai-instruction/ai-prompt-resolver.service.js.map +1 -1
  15. package/dist/core.module.d.ts.map +1 -1
  16. package/dist/core.module.js +3 -0
  17. package/dist/core.module.js.map +1 -1
  18. package/dist/document-kit/document-kit.module.d.ts +3 -0
  19. package/dist/document-kit/document-kit.module.d.ts.map +1 -0
  20. package/dist/document-kit/document-kit.module.js +24 -0
  21. package/dist/document-kit/document-kit.module.js.map +1 -0
  22. package/dist/document-kit/document-kit.registry.d.ts +55 -0
  23. package/dist/document-kit/document-kit.registry.d.ts.map +1 -0
  24. package/dist/document-kit/document-kit.registry.js +91 -0
  25. package/dist/document-kit/document-kit.registry.js.map +1 -0
  26. package/dist/document-kit/document-kit.service.d.ts +49 -0
  27. package/dist/document-kit/document-kit.service.d.ts.map +1 -0
  28. package/dist/document-kit/document-kit.service.js +135 -0
  29. package/dist/document-kit/document-kit.service.js.map +1 -0
  30. package/dist/file/file-upload-ticket.service.d.ts +1 -1
  31. package/dist/file/file.controller.d.ts +3 -3
  32. package/dist/file/file.service.d.ts +136 -13
  33. package/dist/file/file.service.d.ts.map +1 -1
  34. package/dist/file/file.service.js +297 -27
  35. package/dist/file/file.service.js.map +1 -1
  36. package/dist/file/mcp-file-source.schema.d.ts.map +1 -1
  37. package/dist/file/mcp-file-source.schema.js +8 -5
  38. package/dist/file/mcp-file-source.schema.js.map +1 -1
  39. package/dist/file/provider/abstract.provider.d.ts +33 -0
  40. package/dist/file/provider/abstract.provider.d.ts.map +1 -1
  41. package/dist/file/provider/abstract.provider.js +9 -0
  42. package/dist/file/provider/abstract.provider.js.map +1 -1
  43. package/dist/file/provider/s3-multipart.util.d.ts +17 -0
  44. package/dist/file/provider/s3-multipart.util.d.ts.map +1 -0
  45. package/dist/file/provider/s3-multipart.util.js +88 -0
  46. package/dist/file/provider/s3-multipart.util.js.map +1 -0
  47. package/dist/file/provider/s3.provider.d.ts +9 -1
  48. package/dist/file/provider/s3.provider.d.ts.map +1 -1
  49. package/dist/file/provider/s3.provider.js +16 -0
  50. package/dist/file/provider/s3.provider.js.map +1 -1
  51. package/dist/file/provider/spaces.provider.d.ts +10 -1
  52. package/dist/file/provider/spaces.provider.d.ts.map +1 -1
  53. package/dist/file/provider/spaces.provider.js +17 -0
  54. package/dist/file/provider/spaces.provider.js.map +1 -1
  55. package/dist/index.d.ts +5 -0
  56. package/dist/index.d.ts.map +1 -1
  57. package/dist/index.js +5 -0
  58. package/dist/index.js.map +1 -1
  59. package/dist/integration-profile/integration-profile.controller.d.ts +1 -0
  60. package/dist/integration-profile/integration-profile.controller.d.ts.map +1 -1
  61. package/dist/integration-profile/integration-profile.secrets.d.ts.map +1 -1
  62. package/dist/integration-profile/integration-profile.secrets.js +26 -0
  63. package/dist/integration-profile/integration-profile.secrets.js.map +1 -1
  64. package/dist/integration-profile/integration-profile.service.d.ts +1 -0
  65. package/dist/integration-profile/integration-profile.service.d.ts.map +1 -1
  66. package/dist/integration-profile/integration-profile.service.js +3 -3
  67. package/dist/integration-profile/integration-profile.service.js.map +1 -1
  68. package/dist/integration-profile/testers/ai.testers.d.ts.map +1 -1
  69. package/dist/integration-profile/testers/ai.testers.js +46 -8
  70. package/dist/integration-profile/testers/ai.testers.js.map +1 -1
  71. package/dist/integration-profile/testers/http-probe.d.ts.map +1 -1
  72. package/dist/integration-profile/testers/http-probe.js +5 -3
  73. package/dist/integration-profile/testers/http-probe.js.map +1 -1
  74. package/dist/integration-profile/testers/integration-tester.types.d.ts +5 -0
  75. package/dist/integration-profile/testers/integration-tester.types.d.ts.map +1 -1
  76. package/dist/mail/mail.service.d.ts.map +1 -1
  77. package/dist/mail/mail.service.js +16 -1
  78. package/dist/mail/mail.service.js.map +1 -1
  79. package/dist/mcp/mcp-server.service.d.ts.map +1 -1
  80. package/dist/mcp/mcp-server.service.js +10 -1
  81. package/dist/mcp/mcp-server.service.js.map +1 -1
  82. package/dist/mcp/mcp.module.d.ts.map +1 -1
  83. package/dist/mcp/mcp.module.js +2 -0
  84. package/dist/mcp/mcp.module.js.map +1 -1
  85. package/dist/mcp/tools/core-access-log.mcp-tools.d.ts +17 -0
  86. package/dist/mcp/tools/core-access-log.mcp-tools.d.ts.map +1 -0
  87. package/dist/mcp/tools/core-access-log.mcp-tools.js +74 -0
  88. package/dist/mcp/tools/core-access-log.mcp-tools.js.map +1 -0
  89. package/dist/mcp/tools/core-files.mcp-tools.js +4 -4
  90. package/dist/mcp/tools/core-files.mcp-tools.js.map +1 -1
  91. package/dist/meeting/meeting-provider.registry.d.ts +72 -0
  92. package/dist/meeting/meeting-provider.registry.d.ts.map +1 -1
  93. package/dist/meeting/meeting-provider.registry.js.map +1 -1
  94. package/dist/oauth/oauth-token-broker.service.d.ts +50 -1
  95. package/dist/oauth/oauth-token-broker.service.d.ts.map +1 -1
  96. package/dist/oauth/oauth-token-broker.service.js +105 -3
  97. package/dist/oauth/oauth-token-broker.service.js.map +1 -1
  98. package/dist/oauth/providers/microsoft-entra-id.provider.d.ts +6 -0
  99. package/dist/oauth/providers/microsoft-entra-id.provider.d.ts.map +1 -1
  100. package/dist/oauth/providers/microsoft-entra-id.provider.js +30 -4
  101. package/dist/oauth/providers/microsoft-entra-id.provider.js.map +1 -1
  102. package/dist/pdf/document/charts/chart-bar.d.ts +24 -0
  103. package/dist/pdf/document/charts/chart-bar.d.ts.map +1 -0
  104. package/dist/pdf/document/charts/chart-bar.js +117 -0
  105. package/dist/pdf/document/charts/chart-bar.js.map +1 -0
  106. package/dist/pdf/document/charts/chart-donut.d.ts +16 -0
  107. package/dist/pdf/document/charts/chart-donut.d.ts.map +1 -0
  108. package/dist/pdf/document/charts/chart-donut.js +89 -0
  109. package/dist/pdf/document/charts/chart-donut.js.map +1 -0
  110. package/dist/pdf/document/charts/chart-gauge.d.ts +15 -0
  111. package/dist/pdf/document/charts/chart-gauge.d.ts.map +1 -0
  112. package/dist/pdf/document/charts/chart-gauge.js +60 -0
  113. package/dist/pdf/document/charts/chart-gauge.js.map +1 -0
  114. package/dist/pdf/document/charts/chart-line.d.ts +29 -0
  115. package/dist/pdf/document/charts/chart-line.d.ts.map +1 -0
  116. package/dist/pdf/document/charts/chart-line.js +89 -0
  117. package/dist/pdf/document/charts/chart-line.js.map +1 -0
  118. package/dist/pdf/document/charts/chart-primitives.d.ts +64 -0
  119. package/dist/pdf/document/charts/chart-primitives.d.ts.map +1 -0
  120. package/dist/pdf/document/charts/chart-primitives.js +110 -0
  121. package/dist/pdf/document/charts/chart-primitives.js.map +1 -0
  122. package/dist/pdf/document/charts/chart-radar.d.ts +13 -0
  123. package/dist/pdf/document/charts/chart-radar.d.ts.map +1 -0
  124. package/dist/pdf/document/charts/chart-radar.js +83 -0
  125. package/dist/pdf/document/charts/chart-radar.js.map +1 -0
  126. package/dist/pdf/document/charts/chart-scale.d.ts +39 -0
  127. package/dist/pdf/document/charts/chart-scale.d.ts.map +1 -0
  128. package/dist/pdf/document/charts/chart-scale.js +118 -0
  129. package/dist/pdf/document/charts/chart-scale.js.map +1 -0
  130. package/dist/pdf/document/charts/index.d.ts +8 -0
  131. package/dist/pdf/document/charts/index.d.ts.map +1 -0
  132. package/dist/pdf/document/charts/index.js +24 -0
  133. package/dist/pdf/document/charts/index.js.map +1 -0
  134. package/dist/pdf/document/index.d.ts +8 -0
  135. package/dist/pdf/document/index.d.ts.map +1 -0
  136. package/dist/pdf/document/index.js +24 -0
  137. package/dist/pdf/document/index.js.map +1 -0
  138. package/dist/pdf/document/pdf-document.chrome.d.ts +20 -0
  139. package/dist/pdf/document/pdf-document.chrome.d.ts.map +1 -0
  140. package/dist/pdf/document/pdf-document.chrome.js +36 -0
  141. package/dist/pdf/document/pdf-document.chrome.js.map +1 -0
  142. package/dist/pdf/document/pdf-document.css.d.ts +20 -0
  143. package/dist/pdf/document/pdf-document.css.d.ts.map +1 -0
  144. package/dist/pdf/document/pdf-document.css.js +327 -0
  145. package/dist/pdf/document/pdf-document.css.js.map +1 -0
  146. package/dist/pdf/document/pdf-document.renderer.d.ts +10 -0
  147. package/dist/pdf/document/pdf-document.renderer.d.ts.map +1 -0
  148. package/dist/pdf/document/pdf-document.renderer.js +226 -0
  149. package/dist/pdf/document/pdf-document.renderer.js.map +1 -0
  150. package/dist/pdf/document/pdf-document.service.d.ts +27 -0
  151. package/dist/pdf/document/pdf-document.service.d.ts.map +1 -0
  152. package/dist/pdf/document/pdf-document.service.js +81 -0
  153. package/dist/pdf/document/pdf-document.service.js.map +1 -0
  154. package/dist/pdf/document/pdf-document.theme.d.ts +41 -0
  155. package/dist/pdf/document/pdf-document.theme.d.ts.map +1 -0
  156. package/dist/pdf/document/pdf-document.theme.js +105 -0
  157. package/dist/pdf/document/pdf-document.theme.js.map +1 -0
  158. package/dist/pdf/document/pdf-document.types.d.ts +164 -0
  159. package/dist/pdf/document/pdf-document.types.d.ts.map +1 -0
  160. package/dist/pdf/document/pdf-document.types.js +18 -0
  161. package/dist/pdf/document/pdf-document.types.js.map +1 -0
  162. package/dist/pdf/pdf.module.d.ts.map +1 -1
  163. package/dist/pdf/pdf.module.js +3 -2
  164. package/dist/pdf/pdf.module.js.map +1 -1
  165. package/dist/pdf/pdf.service.d.ts +75 -1
  166. package/dist/pdf/pdf.service.d.ts.map +1 -1
  167. package/dist/pdf/pdf.service.js +167 -63
  168. package/dist/pdf/pdf.service.js.map +1 -1
  169. package/dist/pdf/templates/commercial-proposal.template.d.ts +96 -0
  170. package/dist/pdf/templates/commercial-proposal.template.d.ts.map +1 -0
  171. package/dist/pdf/templates/commercial-proposal.template.js +276 -0
  172. package/dist/pdf/templates/commercial-proposal.template.js.map +1 -0
  173. package/dist/setting/favicon.controller.d.ts +26 -0
  174. package/dist/setting/favicon.controller.d.ts.map +1 -0
  175. package/dist/setting/favicon.controller.js +130 -0
  176. package/dist/setting/favicon.controller.js.map +1 -0
  177. package/dist/setting/setting.module.d.ts.map +1 -1
  178. package/dist/setting/setting.module.js +2 -1
  179. package/dist/setting/setting.module.js.map +1 -1
  180. package/dist/setting/setting.service.d.ts.map +1 -1
  181. package/dist/setting/setting.service.js +3 -0
  182. package/dist/setting/setting.service.js.map +1 -1
  183. package/dist/task/task.service.d.ts +6 -0
  184. package/dist/task/task.service.d.ts.map +1 -1
  185. package/dist/task/task.service.js +44 -13
  186. package/dist/task/task.service.js.map +1 -1
  187. package/dist/whatsapp-sender/whatsapp-sender.service.d.ts +35 -2
  188. package/dist/whatsapp-sender/whatsapp-sender.service.d.ts.map +1 -1
  189. package/dist/whatsapp-sender/whatsapp-sender.service.js +107 -22
  190. package/dist/whatsapp-sender/whatsapp-sender.service.js.map +1 -1
  191. package/hedhog/data/integration_type.yaml +6 -0
  192. package/hedhog/data/route.yaml +8 -0
  193. package/hedhog/data/setting_group.yaml +52 -0
  194. package/hedhog/frontend/app/configurations/[slug]/components/setting-field.test.tsx.ejs +85 -2
  195. package/hedhog/frontend/app/configurations/[slug]/components/setting-field.tsx.ejs +576 -491
  196. package/hedhog/frontend/app/users/simulate-access-dialog.test.tsx.ejs +29 -50
  197. package/hedhog/frontend/app/users/simulate-access-dialog.tsx.ejs +146 -50
  198. package/hedhog/frontend/messages/en.json +81 -2
  199. package/hedhog/frontend/messages/pt.json +81 -2
  200. package/hedhog/table/file.yaml +3 -1
  201. package/package.json +5 -5
  202. package/src/access-log/access-log.interceptor.spec.ts +50 -0
  203. package/src/access-log/access-log.interceptor.ts +18 -0
  204. package/src/ai/ai-attachment.fragments.spec.ts +56 -3
  205. package/src/ai/ai.service.responses.spec.ts +429 -0
  206. package/src/ai/ai.service.ts +409 -27
  207. package/src/ai-instruction/ai-prompt-resolver.service.spec.ts +44 -0
  208. package/src/ai-instruction/ai-prompt-resolver.service.ts +29 -0
  209. package/src/core.module.ts +3 -0
  210. package/src/document-kit/document-kit.module.ts +12 -0
  211. package/src/document-kit/document-kit.registry.ts +125 -0
  212. package/src/document-kit/document-kit.service.spec.ts +156 -0
  213. package/src/document-kit/document-kit.service.ts +173 -0
  214. package/src/file/file-direct-upload.spec.ts +344 -0
  215. package/src/file/file.service.spec.ts +168 -1
  216. package/src/file/file.service.ts +435 -36
  217. package/src/file/mcp-file-source.schema.ts +8 -5
  218. package/src/file/provider/abstract.provider.ts +57 -0
  219. package/src/file/provider/s3-multipart.spec.ts +123 -0
  220. package/src/file/provider/s3-multipart.util.ts +126 -0
  221. package/src/file/provider/s3.provider.ts +65 -1
  222. package/src/file/provider/spaces.provider.ts +67 -1
  223. package/src/index.ts +5 -0
  224. package/src/integration-profile/integration-profile.secrets.ts +26 -0
  225. package/src/integration-profile/integration-profile.service.ts +1 -0
  226. package/src/integration-profile/testers/ai.testers.ts +70 -5
  227. package/src/integration-profile/testers/http-probe.ts +5 -3
  228. package/src/integration-profile/testers/integration-tester.types.ts +5 -0
  229. package/src/integration-profile/testers/oauth.testers.spec.ts +13 -0
  230. package/src/integration-profile/testers/remaining.testers.spec.ts +65 -1
  231. package/src/integration-profile/testers/tester-coverage.spec.ts +12 -0
  232. package/src/mail/mail.service.spec.ts +25 -0
  233. package/src/mail/mail.service.ts +18 -1
  234. package/src/mcp/mcp-server.service.spec.ts +16 -0
  235. package/src/mcp/mcp-server.service.ts +10 -1
  236. package/src/mcp/mcp.module.ts +2 -0
  237. package/src/mcp/tools/core-access-log.mcp-tools.ts +66 -0
  238. package/src/mcp/tools/core-files.mcp-tools.ts +4 -4
  239. package/src/meeting/meeting-provider.registry.ts +80 -0
  240. package/src/oauth/oauth-token-broker.service.spec.ts +108 -0
  241. package/src/oauth/oauth-token-broker.service.ts +129 -2
  242. package/src/oauth/providers/microsoft-entra-id.provider.spec.ts +133 -0
  243. package/src/oauth/providers/microsoft-entra-id.provider.ts +29 -2
  244. package/src/pdf/document/charts/chart-bar.ts +217 -0
  245. package/src/pdf/document/charts/chart-donut.ts +163 -0
  246. package/src/pdf/document/charts/chart-gauge.ts +94 -0
  247. package/src/pdf/document/charts/chart-line.ts +159 -0
  248. package/src/pdf/document/charts/chart-primitives.ts +188 -0
  249. package/src/pdf/document/charts/chart-radar.ts +136 -0
  250. package/src/pdf/document/charts/chart-scale.spec.ts +93 -0
  251. package/src/pdf/document/charts/chart-scale.ts +141 -0
  252. package/src/pdf/document/charts/charts.spec.ts +157 -0
  253. package/src/pdf/document/charts/index.ts +7 -0
  254. package/src/pdf/document/index.ts +7 -0
  255. package/src/pdf/document/pdf-document.chrome.ts +38 -0
  256. package/src/pdf/document/pdf-document.css.ts +326 -0
  257. package/src/pdf/document/pdf-document.renderer.spec.ts +172 -0
  258. package/src/pdf/document/pdf-document.renderer.ts +317 -0
  259. package/src/pdf/document/pdf-document.service.ts +77 -0
  260. package/src/pdf/document/pdf-document.theme.ts +146 -0
  261. package/src/pdf/document/pdf-document.types.ts +149 -0
  262. package/src/pdf/pdf.module.ts +3 -2
  263. package/src/pdf/pdf.service.spec.ts +131 -0
  264. package/src/pdf/pdf.service.ts +256 -82
  265. package/src/pdf/templates/commercial-proposal.template.spec.ts +177 -0
  266. package/src/pdf/templates/commercial-proposal.template.ts +400 -0
  267. package/src/setting/favicon.controller.spec.ts +92 -0
  268. package/src/setting/favicon.controller.ts +135 -0
  269. package/src/setting/setting.module.ts +2 -1
  270. package/src/setting/setting.service.ts +3 -0
  271. package/src/task/task.service.spec.ts +77 -1
  272. package/src/task/task.service.ts +58 -14
  273. package/src/whatsapp-sender/whatsapp-sender.service.spec.ts +263 -0
  274. 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
- import { TasksService } from './task.service';
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.
@@ -1,8 +1,15 @@
1
- import { Prisma, PrismaService } from '@hed-hog/api-prisma';
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(CronExpression.EVERY_DAY_AT_MIDNIGHT)
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
- const removed = await this.prismaService.$executeRaw`
311
- WITH del AS (
312
- SELECT id
313
- FROM access_log
314
- WHERE created_at < (now() - ${Prisma.sql`INTERVAL '${Prisma.raw(String(retentionDays))} days'`})
315
- ORDER BY id
316
- LIMIT ${batchLimit}
317
- )
318
- DELETE FROM access_log al
319
- USING del
320
- WHERE al.id = del.id
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
  });