@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
@@ -2,6 +2,7 @@ import { forwardRef, Module } from '@nestjs/common';
2
2
  import { ConfigModule } from '@nestjs/config';
3
3
 
4
4
  import { SettingModule } from '../setting/setting.module';
5
+ import { PdfDocumentService } from './document/pdf-document.service';
5
6
  import { PdfController } from './pdf.controller';
6
7
  import { PdfService } from './pdf.service';
7
8
 
@@ -10,8 +11,8 @@ import { PdfService } from './pdf.service';
10
11
  ConfigModule,
11
12
  forwardRef(() => SettingModule),
12
13
  ],
13
- providers: [PdfService],
14
- exports: [PdfService],
14
+ providers: [PdfService, PdfDocumentService],
15
+ exports: [PdfService, PdfDocumentService],
15
16
  controllers: [PdfController],
16
17
  })
17
18
  export class PdfModule {}
@@ -0,0 +1,131 @@
1
+ import { ServiceUnavailableException } from '@nestjs/common';
2
+ import { afterEach, describe, expect, it, jest } from '@jest/globals';
3
+
4
+ import { PdfService, buildPdfPageOptions } from './pdf.service';
5
+
6
+ /** O serviço só toca em SettingService/BrandAssetService fora do caminho de render. */
7
+ const makeService = (env: Record<string, string> = {}): PdfService =>
8
+ new PdfService({} as any, {} as any, {
9
+ get: (key: string) => env[key],
10
+ } as any);
11
+
12
+ describe('buildPdfPageOptions', () => {
13
+ // O contrato que protege ceia, certificado e material de aula: quem não passa
14
+ // nenhuma das opções novas tem que receber o mesmo objeto de antes delas
15
+ // existirem — a simples presença de `displayHeaderFooter` muda o resultado.
16
+ it('não acrescenta chave nenhuma quando só as opções antigas vêm', () => {
17
+ expect(buildPdfPageOptions({})).toEqual({
18
+ format: 'A4',
19
+ printBackground: true,
20
+ landscape: false,
21
+ margin: { top: '0', right: '0', bottom: '0', left: '0' },
22
+ });
23
+
24
+ expect(Object.keys(buildPdfPageOptions({ format: 'A4' })).sort()).toEqual([
25
+ 'format',
26
+ 'landscape',
27
+ 'margin',
28
+ 'printBackground',
29
+ ]);
30
+ });
31
+
32
+ it('troca format/landscape por width/height quando os dois vêm', () => {
33
+ const options = buildPdfPageOptions({ width: '1600px', height: '1131px' });
34
+
35
+ expect(options).toEqual({
36
+ width: '1600px',
37
+ height: '1131px',
38
+ printBackground: true,
39
+ margin: { top: '0', right: '0', bottom: '0', left: '0' },
40
+ });
41
+ });
42
+
43
+ // Com um template só, o Chromium volta a injetar o dele (URL e data) no outro.
44
+ it('preenche os dois templates quando o cabeçalho/rodapé é ligado', () => {
45
+ const options = buildPdfPageOptions({
46
+ displayHeaderFooter: true,
47
+ headerTemplate: '<div class="bar"></div>',
48
+ });
49
+
50
+ expect(options.displayHeaderFooter).toBe(true);
51
+ expect(options.headerTemplate).toBe('<div class="bar"></div>');
52
+ expect(options.footerTemplate).toBe('<span></span>');
53
+ });
54
+
55
+ it('ignora os templates enquanto displayHeaderFooter não for ligado', () => {
56
+ const options = buildPdfPageOptions({ footerTemplate: '<span></span>' });
57
+
58
+ expect(options).not.toHaveProperty('displayHeaderFooter');
59
+ expect(options).not.toHaveProperty('footerTemplate');
60
+ });
61
+
62
+ it('só repassa scale e pageRanges quando vêm', () => {
63
+ expect(buildPdfPageOptions({ scale: 0.9, pageRanges: '1-2' })).toMatchObject({
64
+ scale: 0.9,
65
+ pageRanges: '1-2',
66
+ });
67
+ expect(buildPdfPageOptions({})).not.toHaveProperty('scale');
68
+ });
69
+ });
70
+
71
+ describe('fila de renderização', () => {
72
+ afterEach(() => {
73
+ jest.useRealTimers();
74
+ });
75
+
76
+ it('segura o render acima do teto até uma vaga ser devolvida', async () => {
77
+ const service = makeService({ PDF_MAX_CONCURRENCY: '1' });
78
+ const releaseFirst = await (service as any).acquireRenderSlot('primeiro');
79
+
80
+ let liberado = false;
81
+ const second = (service as any)
82
+ .acquireRenderSlot('segundo')
83
+ .then((release: () => void) => {
84
+ liberado = true;
85
+ return release;
86
+ });
87
+
88
+ await new Promise((resolve) => process.nextTick(resolve));
89
+ expect(liberado).toBe(false);
90
+
91
+ releaseFirst();
92
+ const releaseSecond = await second;
93
+ expect(liberado).toBe(true);
94
+ releaseSecond();
95
+ });
96
+
97
+ it('responde 503 quando a espera na fila estoura', async () => {
98
+ jest.useFakeTimers();
99
+ const service = makeService({ PDF_MAX_CONCURRENCY: '1', PDF_QUEUE_WAIT_MS: '50' });
100
+ const release = await (service as any).acquireRenderSlot('primeiro');
101
+
102
+ const queued = (service as any).acquireRenderSlot('segundo');
103
+ const rejeicao = expect(queued).rejects.toBeInstanceOf(ServiceUnavailableException);
104
+
105
+ jest.advanceTimersByTime(50);
106
+ await rejeicao;
107
+
108
+ release();
109
+ });
110
+
111
+ // Liberar duas vezes (ex.: `finally` somado a um caminho de erro) não pode
112
+ // inflar a capacidade e deixar dois Chromium subirem juntos.
113
+ it('ignora a segunda liberação do mesmo render', async () => {
114
+ const service = makeService({ PDF_MAX_CONCURRENCY: '1' });
115
+ const release = await (service as any).acquireRenderSlot('primeiro');
116
+ release();
117
+ release();
118
+
119
+ const releaseSecond = await (service as any).acquireRenderSlot('segundo');
120
+
121
+ let terceiroEntrou = false;
122
+ void (service as any).acquireRenderSlot('terceiro').then(() => {
123
+ terceiroEntrou = true;
124
+ });
125
+
126
+ await new Promise((resolve) => process.nextTick(resolve));
127
+ expect(terceiroEntrou).toBe(false);
128
+
129
+ releaseSecond();
130
+ });
131
+ });
@@ -1,9 +1,21 @@
1
- import { Inject, Injectable, InternalServerErrorException, Logger, forwardRef } from '@nestjs/common';
1
+ import {
2
+ Inject,
3
+ Injectable,
4
+ InternalServerErrorException,
5
+ Logger,
6
+ ServiceUnavailableException,
7
+ forwardRef,
8
+ } from '@nestjs/common';
2
9
  import { ConfigService } from '@nestjs/config';
3
10
 
4
11
  import { BrandAssetService } from '../setting/brand-asset.service';
5
12
  import { SettingService } from '../setting/setting.service';
6
13
  import { PdfAreaType, PdfThemeConfig } from './interfaces/pdf-theme-config.interface';
14
+ import {
15
+ CommercialDocumentTheme,
16
+ CommercialProposalData,
17
+ commercialProposalTemplate,
18
+ } from './templates/commercial-proposal.template';
7
19
  import { hcodeInstitutionalTemplate } from './templates/hcode-institutional.template';
8
20
  import { AreaDefaults, HCODE_DEFAULTS } from './themes/hcode.theme';
9
21
 
@@ -20,8 +32,46 @@ export interface PdfGenerateOptions {
20
32
  width?: string;
21
33
  /** Explicit CSS page size (e.g. '1131px'). Takes precedence over `format`/`landscape` when set. */
22
34
  height?: string;
35
+
36
+ /**
37
+ * Turns on Chromium's own per-page header and footer. This is the only way to
38
+ * get a repeating accent bar and a real page number: `counter(page)` resolves
39
+ * to nothing outside `@page` margin boxes, so a footer rendered as flow
40
+ * content (like `pdfFooter()` in ./components) prints an empty page slot.
41
+ *
42
+ * The templates are mini-documents, not part of the page: they start at
43
+ * `font-size: 0`, need their own `print-color-adjust: exact` to keep
44
+ * backgrounds, and only `<span class="pageNumber">` / `<span class="totalPages">`
45
+ * get substituted. Keep `margin.top`/`margin.bottom` larger than the templates,
46
+ * or the page content prints over them.
47
+ */
48
+ displayHeaderFooter?: boolean;
49
+ headerTemplate?: string;
50
+ footerTemplate?: string;
51
+
52
+ /** `page.setContent` wait condition. Defaults to 'networkidle'. */
53
+ waitUntil?: 'load' | 'domcontentloaded' | 'networkidle' | 'commit';
54
+ /** Ceiling for `page.setContent`, in ms. Defaults to Playwright's own (30s). */
55
+ timeoutMs?: number;
56
+ /**
57
+ * JS expression evaluated in the page after `setContent`; the snapshot waits
58
+ * until it is truthy. For HTML whose layout settles asynchronously (webfonts
59
+ * loaded via `document.fonts`, measuring scripts that run after them).
60
+ */
61
+ readyExpression?: string;
62
+ scale?: number;
63
+ pageRanges?: string;
64
+ /** Short identifier for the logs when a render fails or the queue saturates. */
65
+ label?: string;
23
66
  }
24
67
 
68
+ /** A render waiting for a Chromium slot. */
69
+ type QueuedRender = {
70
+ resolve: () => void;
71
+ reject: (error: Error) => void;
72
+ timer: ReturnType<typeof setTimeout>;
73
+ };
74
+
25
75
  // ─────────────────────────────────────────────────────────────────────────────
26
76
  // Helpers
27
77
  // ─────────────────────────────────────────────────────────────────────────────
@@ -33,6 +83,55 @@ function parsePxSize(value: string | undefined, fallback: number): number {
33
83
  return match ? Math.round(Number(match[1])) : fallback;
34
84
  }
35
85
 
86
+ const ZERO_MARGIN = { top: '0', right: '0', bottom: '0', left: '0' };
87
+
88
+ /**
89
+ * With `displayHeaderFooter` on, a template left undefined makes Chromium fall
90
+ * back to its own — the page URL and the print date. The unused slot gets
91
+ * something deliberately empty instead.
92
+ */
93
+ const EMPTY_TEMPLATE = '<span></span>';
94
+
95
+ const DEFAULT_MAX_CONCURRENCY = 2;
96
+ const DEFAULT_QUEUE_WAIT_MS = 30_000;
97
+
98
+ /**
99
+ * Builds the object handed to `page.pdf()`.
100
+ *
101
+ * Assembled key by key on purpose: a caller that passes none of the optional
102
+ * fields has to get exactly the object `page.pdf()` received before they
103
+ * existed — Chromium changes behaviour on the mere presence of some of them
104
+ * (`displayHeaderFooter` with a single template filled in brings back its own
105
+ * URL/date footer).
106
+ */
107
+ export function buildPdfPageOptions(options: PdfGenerateOptions): Record<string, unknown> {
108
+ const pdfOptions: Record<string, unknown> =
109
+ options.width && options.height
110
+ ? {
111
+ width: options.width,
112
+ height: options.height,
113
+ printBackground: options.printBackground ?? true,
114
+ margin: options.margin ?? ZERO_MARGIN,
115
+ }
116
+ : {
117
+ format: options.format ?? 'A4',
118
+ printBackground: options.printBackground ?? true,
119
+ landscape: options.landscape ?? false,
120
+ margin: options.margin ?? ZERO_MARGIN,
121
+ };
122
+
123
+ if (options.displayHeaderFooter) {
124
+ pdfOptions.displayHeaderFooter = true;
125
+ pdfOptions.headerTemplate = options.headerTemplate ?? EMPTY_TEMPLATE;
126
+ pdfOptions.footerTemplate = options.footerTemplate ?? EMPTY_TEMPLATE;
127
+ }
128
+
129
+ if (options.scale !== undefined) pdfOptions.scale = options.scale;
130
+ if (options.pageRanges) pdfOptions.pageRanges = options.pageRanges;
131
+
132
+ return pdfOptions;
133
+ }
134
+
36
135
  // ─────────────────────────────────────────────────────────────────────────────
37
136
  // Service
38
137
  // ─────────────────────────────────────────────────────────────────────────────
@@ -41,6 +140,10 @@ function parsePxSize(value: string | undefined, fallback: number): number {
41
140
  export class PdfService {
42
141
  private readonly logger = new Logger(PdfService.name);
43
142
 
143
+ /** Renders holding a Chromium right now, and the ones waiting for a slot. */
144
+ private activeRenders = 0;
145
+ private readonly renderQueue: QueuedRender[] = [];
146
+
44
147
  constructor(
45
148
  @Inject(forwardRef(() => SettingService))
46
149
  private readonly settingService: SettingService,
@@ -120,15 +223,136 @@ export class PdfService {
120
223
  }
121
224
  }
122
225
 
123
- // ── HTML → PDF ────────────────────────────────────────────────────────────
226
+ /**
227
+ * Theme for commercial documents (proposals and the like): the area theme
228
+ * plus the legal footer, partner line and font stacks of the `pdf` group.
229
+ * The logo is embedded as a data URI so the render does not depend on the
230
+ * API reaching its own public URL.
231
+ */
232
+ async loadCommercialDocumentTheme(
233
+ area: PdfAreaType = 'training',
234
+ ): Promise<CommercialDocumentTheme> {
235
+ const base = await this.loadThemeConfig(area);
236
+
237
+ let values: Record<string, any> = {};
238
+ try {
239
+ values = await this.settingService.getSettingValues([
240
+ 'pdf-legal-footer',
241
+ 'pdf-partner-line',
242
+ 'pdf-font-family',
243
+ 'pdf-font-mono',
244
+ ]);
245
+ } catch {
246
+ this.logger.warn('Could not load commercial PDF settings – using defaults.');
247
+ }
248
+
249
+ const logoOverride = base.logoOverride
250
+ ? ((await this.brandAsset.fetchAsDataUri(base.logoOverride)) ?? base.logoOverride)
251
+ : '';
252
+
253
+ return {
254
+ ...base,
255
+ logoOverride,
256
+ legalFooter: String(values['pdf-legal-footer'] ?? '').trim(),
257
+ partnerLine: String(values['pdf-partner-line'] ?? '').trim(),
258
+ fontFamily: String(values['pdf-font-family'] ?? '').trim() || "'Inter', sans-serif",
259
+ fontMono: String(values['pdf-font-mono'] ?? '').trim() || "'JetBrains Mono', monospace",
260
+ };
261
+ }
124
262
 
125
263
  /**
126
- * Renders an HTML string to a PDF buffer using Playwright/Chromium.
264
+ * Renders a commercial proposal with the installation's branding. Returns
265
+ * the HTML too, so the caller can keep the exact source of what was printed.
266
+ */
267
+ async renderCommercialProposal(
268
+ data: CommercialProposalData,
269
+ area: PdfAreaType = 'training',
270
+ ): Promise<{ html: string; buffer: Buffer }> {
271
+ const theme = await this.loadCommercialDocumentTheme(area);
272
+ const { html, pdfOptions } = commercialProposalTemplate(data, theme);
273
+ const buffer = await this.generatePdfFromHtml(html, pdfOptions);
274
+ return { html, buffer };
275
+ }
276
+
277
+ // ── Chromium runtime ──────────────────────────────────────────────────────
278
+
279
+ private get maxConcurrency(): number {
280
+ const raw = Number(this.configService.get<string>('PDF_MAX_CONCURRENCY'));
281
+ return Number.isFinite(raw) && raw >= 1 ? Math.floor(raw) : DEFAULT_MAX_CONCURRENCY;
282
+ }
283
+
284
+ private get queueWaitMs(): number {
285
+ const raw = Number(this.configService.get<string>('PDF_QUEUE_WAIT_MS'));
286
+ return Number.isFinite(raw) && raw >= 0 ? Math.floor(raw) : DEFAULT_QUEUE_WAIT_MS;
287
+ }
288
+
289
+ /**
290
+ * Every render launches a Chromium of its own, which costs a few hundred MB.
291
+ * The API pod is capped well below what a burst of simultaneous report
292
+ * downloads would ask for, and an OOM kill takes the whole API down — not
293
+ * just the PDF. So renders queue, and a queue that waits too long answers a
294
+ * retryable 503 instead of dragging the process down with it.
295
+ */
296
+ private async acquireRenderSlot(label?: string): Promise<() => void> {
297
+ if (this.activeRenders < this.maxConcurrency) {
298
+ this.activeRenders += 1;
299
+ } else {
300
+ await new Promise<void>((resolve, reject) => {
301
+ const entry: QueuedRender = {
302
+ resolve,
303
+ reject,
304
+ timer: setTimeout(() => {
305
+ const index = this.renderQueue.indexOf(entry);
306
+ if (index >= 0) this.renderQueue.splice(index, 1);
307
+ this.logger.warn(
308
+ `PDF render queue is saturated; gave up after ${this.queueWaitMs}ms${label ? ` (${label})` : ''}.`,
309
+ );
310
+ reject(
311
+ new ServiceUnavailableException(
312
+ 'The server is busy rendering other documents. Try again in a moment.',
313
+ ),
314
+ );
315
+ }, this.queueWaitMs),
316
+ };
317
+ this.renderQueue.push(entry);
318
+ });
319
+ // Resolved by releaseRenderSlot, which hands its slot over without
320
+ // decrementing — the counter already accounts for this render.
321
+ }
322
+
323
+ let released = false;
324
+ return () => {
325
+ if (released) return;
326
+ released = true;
327
+ this.releaseRenderSlot();
328
+ };
329
+ }
330
+
331
+ private releaseRenderSlot(): void {
332
+ const next = this.renderQueue.shift();
333
+ if (next) {
334
+ clearTimeout(next.timer);
335
+ next.resolve();
336
+ return;
337
+ }
338
+ this.activeRenders = Math.max(0, this.activeRenders - 1);
339
+ }
340
+
341
+ /**
342
+ * Opens a Chromium page with the HTML loaded and hands it to `run`, taking
343
+ * care of the slot, the viewport and the teardown.
127
344
  *
128
345
  * Uses dynamic `import()` via `new Function` to remain compatible with
129
346
  * CommonJS bundle output (same pattern as operations.service.ts).
130
347
  */
131
- async generatePdfFromHtml(html: string, options: PdfGenerateOptions = {}): Promise<Buffer> {
348
+ private async renderWithChromium(
349
+ kind: 'PDF' | 'Image',
350
+ html: string,
351
+ options: PdfGenerateOptions,
352
+ run: (page: any) => Promise<any>,
353
+ ): Promise<Buffer> {
354
+ const release = await this.acquireRenderSlot(options.label);
355
+
132
356
  const executablePath = this.configService.get<string>('CHROMIUM_EXECUTABLE_PATH');
133
357
  const noSandbox = this.configService.get<string>('CHROMIUM_NO_SANDBOX') === 'true';
134
358
 
@@ -163,25 +387,17 @@ export class PdfService {
163
387
  width: parsePxSize(options.width, 794),
164
388
  height: parsePxSize(options.height, 1123),
165
389
  });
166
- await page.setContent(html, { waitUntil: 'networkidle' });
167
-
168
- const pdfBuffer = await page.pdf(
169
- options.width && options.height
170
- ? {
171
- width: options.width,
172
- height: options.height,
173
- printBackground: options.printBackground ?? true,
174
- margin: options.margin ?? { top: '0', right: '0', bottom: '0', left: '0' },
175
- }
176
- : {
177
- format: options.format ?? 'A4',
178
- printBackground: options.printBackground ?? true,
179
- landscape: options.landscape ?? false,
180
- margin: options.margin ?? { top: '0', right: '0', bottom: '0', left: '0' },
181
- },
182
- );
390
+ await page.setContent(html, {
391
+ waitUntil: options.waitUntil ?? 'networkidle',
392
+ ...(options.timeoutMs ? { timeout: options.timeoutMs } : {}),
393
+ });
394
+ if (options.readyExpression) {
395
+ await page.waitForFunction(options.readyExpression, undefined, {
396
+ ...(options.timeoutMs ? { timeout: options.timeoutMs } : {}),
397
+ });
398
+ }
183
399
 
184
- return Buffer.from(pdfBuffer);
400
+ return Buffer.from(await run(page));
185
401
  } catch (error) {
186
402
  const errorMessage = error instanceof Error ? error.message : String(error);
187
403
  const errorStack = error instanceof Error ? (error.stack ?? errorMessage) : String(error);
@@ -191,7 +407,7 @@ export class PdfService {
191
407
  errorMessage,
192
408
  );
193
409
 
194
- this.logger.error(`PDF generation failed. ${errorMessage}`, errorStack);
410
+ this.logger.error(`${kind} generation failed. ${errorMessage}`, errorStack);
195
411
 
196
412
  if (missingRuntime) {
197
413
  throw new InternalServerErrorException(
@@ -199,7 +415,7 @@ export class PdfService {
199
415
  );
200
416
  }
201
417
 
202
- throw new InternalServerErrorException(`PDF generation failed: ${errorMessage}`);
418
+ throw new InternalServerErrorException(`${kind} generation failed: ${errorMessage}`);
203
419
  } finally {
204
420
  if (browser) {
205
421
  try {
@@ -208,9 +424,21 @@ export class PdfService {
208
424
  // Ignore close errors
209
425
  }
210
426
  }
427
+ release();
211
428
  }
212
429
  }
213
430
 
431
+ // ── HTML → PDF ────────────────────────────────────────────────────────────
432
+
433
+ /**
434
+ * Renders an HTML string to a PDF buffer using Playwright/Chromium.
435
+ */
436
+ async generatePdfFromHtml(html: string, options: PdfGenerateOptions = {}): Promise<Buffer> {
437
+ const pdfOptions = buildPdfPageOptions(options);
438
+
439
+ return this.renderWithChromium('PDF', html, options, (page) => page.pdf(pdfOptions));
440
+ }
441
+
214
442
  // ── HTML → PNG image ──────────────────────────────────────────────────────
215
443
 
216
444
  /**
@@ -221,69 +449,15 @@ export class PdfService {
221
449
  * certificate thumbnails on the web/app.
222
450
  */
223
451
  async generateImageFromHtml(html: string, options: PdfGenerateOptions = {}): Promise<Buffer> {
224
- const executablePath = this.configService.get<string>('CHROMIUM_EXECUTABLE_PATH');
225
- const noSandbox = this.configService.get<string>('CHROMIUM_NO_SANDBOX') === 'true';
226
-
227
- const launchArgs = noSandbox
228
- ? ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage']
229
- : [];
230
-
231
452
  const width = parsePxSize(options.width, 794);
232
453
  const height = parsePxSize(options.height, 1123);
233
454
 
234
- let browser: any = null;
235
-
236
- try {
237
- const importPlaywright = new Function(
238
- 'moduleName',
239
- 'return import(moduleName);',
240
- ) as (moduleName: string) => Promise<any>;
241
-
242
- const playwright = await importPlaywright('playwright');
243
-
244
- browser = await playwright.chromium.launch({
245
- headless: true,
246
- ...(executablePath ? { executablePath } : {}),
247
- args: launchArgs,
248
- });
249
-
250
- const page = await browser.newPage();
251
- await page.setViewportSize({ width, height });
252
- await page.setContent(html, { waitUntil: 'networkidle' });
253
-
254
- const imageBuffer = await page.screenshot({
455
+ return this.renderWithChromium('Image', html, options, (page) =>
456
+ page.screenshot({
255
457
  type: 'png',
256
458
  clip: { x: 0, y: 0, width, height },
257
- });
258
-
259
- return Buffer.from(imageBuffer);
260
- } catch (error) {
261
- const errorMessage = error instanceof Error ? error.message : String(error);
262
- const errorStack = error instanceof Error ? (error.stack ?? errorMessage) : String(error);
263
-
264
- const missingRuntime =
265
- /Cannot find package ['"]playwright['"]|Cannot find module ['"]playwright['"]|Executable doesn't exist|browserType\.launch|Failed to launch|Please run the following command/i.test(
266
- errorMessage,
267
- );
268
-
269
- this.logger.error(`Image generation failed. ${errorMessage}`, errorStack);
270
-
271
- if (missingRuntime) {
272
- throw new InternalServerErrorException(
273
- 'Playwright runtime is not available. Run `pnpm --filter api run playwright:install` in the API environment.',
274
- );
275
- }
276
-
277
- throw new InternalServerErrorException(`Image generation failed: ${errorMessage}`);
278
- } finally {
279
- if (browser) {
280
- try {
281
- await browser.close();
282
- } catch {
283
- // Ignore close errors
284
- }
285
- }
286
- }
459
+ }),
460
+ );
287
461
  }
288
462
 
289
463
  // ── Template engine ───────────────────────────────────────────────────────