@hed-hog/core 0.0.401 → 0.0.404

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 (114) 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/authorize/authorize.controller.d.ts +1 -0
  6. package/dist/authorize/authorize.controller.d.ts.map +1 -1
  7. package/dist/authorize/authorize.service.d.ts +1 -0
  8. package/dist/authorize/authorize.service.d.ts.map +1 -1
  9. package/dist/authorize/authorize.service.js +3 -0
  10. package/dist/authorize/authorize.service.js.map +1 -1
  11. package/dist/file/file-upload-ticket.service.d.ts +1 -1
  12. package/dist/file/file.controller.d.ts +3 -3
  13. package/dist/file/file.service.d.ts +121 -12
  14. package/dist/file/file.service.d.ts.map +1 -1
  15. package/dist/file/file.service.js +245 -3
  16. package/dist/file/file.service.js.map +1 -1
  17. package/dist/file/provider/abstract.provider.d.ts +33 -0
  18. package/dist/file/provider/abstract.provider.d.ts.map +1 -1
  19. package/dist/file/provider/abstract.provider.js +9 -0
  20. package/dist/file/provider/abstract.provider.js.map +1 -1
  21. package/dist/file/provider/s3-multipart.util.d.ts +17 -0
  22. package/dist/file/provider/s3-multipart.util.d.ts.map +1 -0
  23. package/dist/file/provider/s3-multipart.util.js +88 -0
  24. package/dist/file/provider/s3-multipart.util.js.map +1 -0
  25. package/dist/file/provider/s3.provider.d.ts +9 -1
  26. package/dist/file/provider/s3.provider.d.ts.map +1 -1
  27. package/dist/file/provider/s3.provider.js +16 -0
  28. package/dist/file/provider/s3.provider.js.map +1 -1
  29. package/dist/file/provider/spaces.provider.d.ts +10 -1
  30. package/dist/file/provider/spaces.provider.d.ts.map +1 -1
  31. package/dist/file/provider/spaces.provider.js +17 -0
  32. package/dist/file/provider/spaces.provider.js.map +1 -1
  33. package/dist/integration-profile/integration-profile.controller.d.ts +1 -0
  34. package/dist/integration-profile/integration-profile.controller.d.ts.map +1 -1
  35. package/dist/integration-profile/integration-profile.secrets.d.ts.map +1 -1
  36. package/dist/integration-profile/integration-profile.secrets.js +7 -2
  37. package/dist/integration-profile/integration-profile.secrets.js.map +1 -1
  38. package/dist/integration-profile/integration-profile.service.d.ts +1 -0
  39. package/dist/integration-profile/integration-profile.service.d.ts.map +1 -1
  40. package/dist/integration-profile/integration-profile.service.js +3 -3
  41. package/dist/integration-profile/integration-profile.service.js.map +1 -1
  42. package/dist/integration-profile/testers/http-probe.d.ts.map +1 -1
  43. package/dist/integration-profile/testers/http-probe.js +5 -3
  44. package/dist/integration-profile/testers/http-probe.js.map +1 -1
  45. package/dist/integration-profile/testers/integration-tester.types.d.ts +5 -0
  46. package/dist/integration-profile/testers/integration-tester.types.d.ts.map +1 -1
  47. package/dist/mail/mail.service.d.ts.map +1 -1
  48. package/dist/mail/mail.service.js +16 -1
  49. package/dist/mail/mail.service.js.map +1 -1
  50. package/dist/mcp/mcp-server.service.d.ts.map +1 -1
  51. package/dist/mcp/mcp-server.service.js +10 -1
  52. package/dist/mcp/mcp-server.service.js.map +1 -1
  53. package/dist/pdf/document/pdf-document.css.d.ts.map +1 -1
  54. package/dist/pdf/document/pdf-document.css.js +88 -0
  55. package/dist/pdf/document/pdf-document.css.js.map +1 -1
  56. package/dist/pdf/document/pdf-document.renderer.d.ts.map +1 -1
  57. package/dist/pdf/document/pdf-document.renderer.js +73 -0
  58. package/dist/pdf/document/pdf-document.renderer.js.map +1 -1
  59. package/dist/pdf/document/pdf-document.types.d.ts +56 -0
  60. package/dist/pdf/document/pdf-document.types.d.ts.map +1 -1
  61. package/dist/pdf/pdf.service.d.ts +6 -0
  62. package/dist/pdf/pdf.service.d.ts.map +1 -1
  63. package/dist/pdf/pdf.service.js +3 -0
  64. package/dist/pdf/pdf.service.js.map +1 -1
  65. package/dist/setting/setting.service.d.ts.map +1 -1
  66. package/dist/setting/setting.service.js +3 -0
  67. package/dist/setting/setting.service.js.map +1 -1
  68. package/dist/task/task.service.d.ts +6 -0
  69. package/dist/task/task.service.d.ts.map +1 -1
  70. package/dist/task/task.service.js +44 -13
  71. package/dist/task/task.service.js.map +1 -1
  72. package/dist/whatsapp-sender/whatsapp-sender.service.d.ts +35 -2
  73. package/dist/whatsapp-sender/whatsapp-sender.service.d.ts.map +1 -1
  74. package/dist/whatsapp-sender/whatsapp-sender.service.js +83 -21
  75. package/dist/whatsapp-sender/whatsapp-sender.service.js.map +1 -1
  76. package/hedhog/frontend/app/configurations/[slug]/components/setting-field.test.tsx.ejs +85 -2
  77. package/hedhog/frontend/app/configurations/[slug]/components/setting-field.tsx.ejs +576 -491
  78. package/hedhog/frontend/app/users/simulate-access-dialog.test.tsx.ejs +29 -50
  79. package/hedhog/frontend/app/users/simulate-access-dialog.tsx.ejs +146 -50
  80. package/hedhog/frontend/messages/en.json +46 -20
  81. package/hedhog/frontend/messages/pt.json +46 -20
  82. package/hedhog/table/file.yaml +3 -1
  83. package/package.json +3 -3
  84. package/src/access-log/access-log.interceptor.spec.ts +50 -0
  85. package/src/access-log/access-log.interceptor.ts +18 -0
  86. package/src/authorize/authorize.service.spec.ts +1 -0
  87. package/src/authorize/authorize.service.ts +2 -0
  88. package/src/file/file-direct-upload.spec.ts +344 -0
  89. package/src/file/file.service.ts +363 -2
  90. package/src/file/provider/abstract.provider.ts +57 -0
  91. package/src/file/provider/s3-multipart.spec.ts +123 -0
  92. package/src/file/provider/s3-multipart.util.ts +126 -0
  93. package/src/file/provider/s3.provider.ts +65 -1
  94. package/src/file/provider/spaces.provider.ts +67 -1
  95. package/src/integration-profile/integration-profile.secrets.ts +7 -2
  96. package/src/integration-profile/integration-profile.service.ts +1 -0
  97. package/src/integration-profile/testers/http-probe.ts +5 -3
  98. package/src/integration-profile/testers/integration-tester.types.ts +5 -0
  99. package/src/integration-profile/testers/oauth.testers.spec.ts +13 -0
  100. package/src/integration-profile/testers/tester-coverage.spec.ts +2 -0
  101. package/src/mail/mail.service.spec.ts +25 -0
  102. package/src/mail/mail.service.ts +18 -1
  103. package/src/mcp/mcp-server.service.spec.ts +16 -0
  104. package/src/mcp/mcp-server.service.ts +10 -1
  105. package/src/pdf/document/pdf-document.css.ts +88 -0
  106. package/src/pdf/document/pdf-document.renderer.spec.ts +43 -0
  107. package/src/pdf/document/pdf-document.renderer.ts +102 -0
  108. package/src/pdf/document/pdf-document.types.ts +52 -0
  109. package/src/pdf/pdf.service.ts +11 -0
  110. package/src/setting/setting.service.ts +3 -0
  111. package/src/task/task.service.spec.ts +77 -1
  112. package/src/task/task.service.ts +58 -14
  113. package/src/whatsapp-sender/whatsapp-sender.service.spec.ts +191 -1
  114. package/src/whatsapp-sender/whatsapp-sender.service.ts +122 -14
@@ -0,0 +1,126 @@
1
+ import {
2
+ AbortMultipartUploadCommand,
3
+ CompleteMultipartUploadCommand,
4
+ CreateMultipartUploadCommand,
5
+ GetObjectCommand,
6
+ ObjectCannedACL,
7
+ S3Client,
8
+ UploadPartCommand,
9
+ } from '@aws-sdk/client-s3';
10
+ import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
11
+ import { Readable } from 'stream';
12
+ import { MultipartUploadPart } from './abstract.provider';
13
+
14
+ /**
15
+ * Upload multipart assinado, comum ao S3 e ao Spaces (mesma API): o servidor abre,
16
+ * assina e fecha; o cliente so recebe URLs que valem para uma parte e expiram.
17
+ * Mesmo desenho de libraries/studio/src/common/studio-s3-compatible.client.ts.
18
+ */
19
+ export async function s3CreateMultipartUpload(
20
+ client: S3Client,
21
+ bucket: string,
22
+ key: string,
23
+ contentType: string,
24
+ acl?: ObjectCannedACL,
25
+ ): Promise<string> {
26
+ const result = await client.send(
27
+ new CreateMultipartUploadCommand({
28
+ Bucket: bucket,
29
+ Key: key,
30
+ ContentType: contentType,
31
+ ...(acl ? { ACL: acl } : {}),
32
+ }),
33
+ );
34
+
35
+ if (!result.UploadId) {
36
+ throw new Error('The storage did not return a multipart upload id.');
37
+ }
38
+
39
+ return result.UploadId;
40
+ }
41
+
42
+ export async function s3SignUploadParts(
43
+ client: S3Client,
44
+ bucket: string,
45
+ key: string,
46
+ uploadId: string,
47
+ partNumbers: number[],
48
+ ttlSeconds: number,
49
+ ): Promise<Array<{ partNumber: number; url: string }>> {
50
+ return Promise.all(
51
+ partNumbers.map(async (partNumber) => ({
52
+ partNumber,
53
+ url: await getSignedUrl(
54
+ client as any,
55
+ new UploadPartCommand({
56
+ Bucket: bucket,
57
+ Key: key,
58
+ UploadId: uploadId,
59
+ PartNumber: partNumber,
60
+ }),
61
+ { expiresIn: ttlSeconds },
62
+ ),
63
+ })),
64
+ );
65
+ }
66
+
67
+ export async function s3CompleteMultipartUpload(
68
+ client: S3Client,
69
+ bucket: string,
70
+ key: string,
71
+ uploadId: string,
72
+ parts: MultipartUploadPart[],
73
+ ): Promise<void> {
74
+ await client.send(
75
+ new CompleteMultipartUploadCommand({
76
+ Bucket: bucket,
77
+ Key: key,
78
+ UploadId: uploadId,
79
+ MultipartUpload: {
80
+ Parts: [...parts]
81
+ .sort((a, b) => a.partNumber - b.partNumber)
82
+ .map((part) => ({ PartNumber: part.partNumber, ETag: part.etag })),
83
+ },
84
+ }),
85
+ );
86
+ }
87
+
88
+ export async function s3AbortMultipartUpload(
89
+ client: S3Client,
90
+ bucket: string,
91
+ key: string,
92
+ uploadId: string,
93
+ ): Promise<void> {
94
+ await client.send(
95
+ new AbortMultipartUploadCommand({ Bucket: bucket, Key: key, UploadId: uploadId }),
96
+ );
97
+ }
98
+
99
+ /** Range GET dos primeiros `bytes`: basta para o sniff do tipo, sem baixar o objeto. */
100
+ export async function s3ReadHead(
101
+ client: S3Client,
102
+ bucket: string,
103
+ key: string,
104
+ bytes: number,
105
+ ): Promise<Buffer> {
106
+ const length = Math.max(1, Math.floor(bytes));
107
+ const result = await client.send(
108
+ new GetObjectCommand({ Bucket: bucket, Key: key, Range: `bytes=0-${length - 1}` }),
109
+ );
110
+
111
+ const stream = result.Body as Readable;
112
+ const chunks: Buffer[] = [];
113
+ let total = 0;
114
+ for await (const chunk of stream) {
115
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
116
+ chunks.push(buffer);
117
+ total += buffer.length;
118
+ // Storage que ignora o Range devolveria o objeto inteiro: para no que basta.
119
+ if (total >= length) {
120
+ stream.destroy?.();
121
+ break;
122
+ }
123
+ }
124
+
125
+ return Buffer.concat(chunks).subarray(0, length);
126
+ }
@@ -14,7 +14,14 @@ import { BadRequestException } from '@nestjs/common';
14
14
  import { createReadStream } from 'fs';
15
15
  import { basename } from 'path';
16
16
  import { Readable } from 'stream';
17
- import { AbstractProvider } from './abstract.provider';
17
+ import { AbstractProvider, MultipartUploadPart } from './abstract.provider';
18
+ import {
19
+ s3AbortMultipartUpload,
20
+ s3CompleteMultipartUpload,
21
+ s3CreateMultipartUpload,
22
+ s3ReadHead,
23
+ s3SignUploadParts,
24
+ } from './s3-multipart.util';
18
25
 
19
26
  export class S3Provider extends AbstractProvider {
20
27
  private S3: S3 | undefined;
@@ -253,4 +260,61 @@ export class S3Provider extends AbstractProvider {
253
260
  // Use S3Client instance for getSignedUrl
254
261
  return await getSignedUrl(s3Client as any, command, { expiresIn: expires });
255
262
  }
263
+
264
+ async createMultipartUpload(key: string, contentType: string): Promise<string> {
265
+ return s3CreateMultipartUpload(
266
+ await this.getS3Client(),
267
+ this.setting['storage-s3-bucket'],
268
+ this.resolveS3Key(key),
269
+ contentType,
270
+ );
271
+ }
272
+
273
+ async signUploadParts(
274
+ key: string,
275
+ uploadId: string,
276
+ partNumbers: number[],
277
+ ttlSeconds: number,
278
+ ) {
279
+ return s3SignUploadParts(
280
+ await this.getS3Client(),
281
+ this.setting['storage-s3-bucket'],
282
+ this.resolveS3Key(key),
283
+ uploadId,
284
+ partNumbers,
285
+ ttlSeconds,
286
+ );
287
+ }
288
+
289
+ async completeMultipartUpload(
290
+ key: string,
291
+ uploadId: string,
292
+ parts: MultipartUploadPart[],
293
+ ): Promise<void> {
294
+ await s3CompleteMultipartUpload(
295
+ await this.getS3Client(),
296
+ this.setting['storage-s3-bucket'],
297
+ this.resolveS3Key(key),
298
+ uploadId,
299
+ parts,
300
+ );
301
+ }
302
+
303
+ async abortMultipartUpload(key: string, uploadId: string): Promise<void> {
304
+ await s3AbortMultipartUpload(
305
+ await this.getS3Client(),
306
+ this.setting['storage-s3-bucket'],
307
+ this.resolveS3Key(key),
308
+ uploadId,
309
+ );
310
+ }
311
+
312
+ async readHead(key: string, bytes: number): Promise<Buffer> {
313
+ return s3ReadHead(
314
+ await this.getS3Client(),
315
+ this.setting['storage-s3-bucket'],
316
+ this.resolveS3Key(key),
317
+ bytes,
318
+ );
319
+ }
256
320
  }
@@ -14,7 +14,14 @@ import { BadRequestException } from '@nestjs/common';
14
14
  import { createReadStream } from 'fs';
15
15
  import { basename } from 'path';
16
16
  import { Readable } from 'stream';
17
- import { AbstractProvider } from './abstract.provider';
17
+ import { AbstractProvider, MultipartUploadPart } from './abstract.provider';
18
+ import {
19
+ s3AbortMultipartUpload,
20
+ s3CompleteMultipartUpload,
21
+ s3CreateMultipartUpload,
22
+ s3ReadHead,
23
+ s3SignUploadParts,
24
+ } from './s3-multipart.util';
18
25
 
19
26
  export class SpacesProvider extends AbstractProvider {
20
27
  private S3: S3 | undefined;
@@ -280,4 +287,63 @@ export class SpacesProvider extends AbstractProvider {
280
287
  });
281
288
  return await getSignedUrl(s3Client as any, command, { expiresIn: expires });
282
289
  }
290
+
291
+ /** Mesmo ACL dos demais uploads deste provider (o objeto nasce com ele no create). */
292
+ async createMultipartUpload(key: string, contentType: string): Promise<string> {
293
+ return s3CreateMultipartUpload(
294
+ await this.getS3Client(),
295
+ this.setting['storage-spaces-bucket'],
296
+ this.resolveKey(key),
297
+ contentType,
298
+ 'public-read',
299
+ );
300
+ }
301
+
302
+ async signUploadParts(
303
+ key: string,
304
+ uploadId: string,
305
+ partNumbers: number[],
306
+ ttlSeconds: number,
307
+ ) {
308
+ return s3SignUploadParts(
309
+ await this.getS3Client(),
310
+ this.setting['storage-spaces-bucket'],
311
+ this.resolveKey(key),
312
+ uploadId,
313
+ partNumbers,
314
+ ttlSeconds,
315
+ );
316
+ }
317
+
318
+ async completeMultipartUpload(
319
+ key: string,
320
+ uploadId: string,
321
+ parts: MultipartUploadPart[],
322
+ ): Promise<void> {
323
+ await s3CompleteMultipartUpload(
324
+ await this.getS3Client(),
325
+ this.setting['storage-spaces-bucket'],
326
+ this.resolveKey(key),
327
+ uploadId,
328
+ parts,
329
+ );
330
+ }
331
+
332
+ async abortMultipartUpload(key: string, uploadId: string): Promise<void> {
333
+ await s3AbortMultipartUpload(
334
+ await this.getS3Client(),
335
+ this.setting['storage-spaces-bucket'],
336
+ this.resolveKey(key),
337
+ uploadId,
338
+ );
339
+ }
340
+
341
+ async readHead(key: string, bytes: number): Promise<Buffer> {
342
+ return s3ReadHead(
343
+ await this.getS3Client(),
344
+ this.setting['storage-spaces-bucket'],
345
+ this.resolveKey(key),
346
+ bytes,
347
+ );
348
+ }
283
349
  }
@@ -85,8 +85,9 @@ export const INTEGRATION_SECRET_KEYS: Record<string, string[]> = {
85
85
  // App do Instagram (Instagram API with Instagram Login) — o segredo do app do
86
86
  // INSTAGRAM, nao o do app da Meta. Troca o code do OAuth pelo token de
87
87
  // qualquer conta que autorize o app; os tokens de 60 dias das contas ficam
88
- // cifrados em social_channel.
89
- 'instagram-social': ['app_secret'],
88
+ // cifrados em social_channel. O webhook_verify_token e comparado no
89
+ // handshake do webhook do Instagram (inbox de comentarios e DMs).
90
+ 'instagram-social': ['app_secret', 'webhook_verify_token'],
90
91
  // Apps do Google para o modulo social (um projeto por rede). O client_secret
91
92
  // troca o code do OAuth pelo refresh token de qualquer canal/local que
92
93
  // autorize o app; os refresh tokens ficam cifrados em social_channel.
@@ -97,6 +98,10 @@ export const INTEGRATION_SECRET_KEYS: Record<string, string[]> = {
97
98
  // pagina que autorize o app; as credenciais das paginas (envelope lir1:/lia1:)
98
99
  // ficam cifradas em social_channel.
99
100
  'linkedin-social': ['client_secret'],
101
+ // App do X (OAuth 2.0, cliente confidencial). O client_secret vai no header
102
+ // Basic da troca do code, da renovacao e da revogacao; os tokens de cada
103
+ // conta (que rotacionam) ficam cifrados em social_channel_oauth_token.
104
+ 'x-social': ['client_secret'],
100
105
  };
101
106
 
102
107
  /** Chaves sensíveis de um provider (vazio se desconhecido). */
@@ -544,6 +544,7 @@ export class IntegrationProfileService {
544
544
  provider: provider.slug,
545
545
  ...(outcome.info ? { info: outcome.info } : {}),
546
546
  ...(outcome.warning ? { warning: outcome.warning } : {}),
547
+ ...(outcome.warningCodes?.length ? { warning_codes: outcome.warningCodes } : {}),
547
548
  tested_at: new Date().toISOString(),
548
549
  persisted,
549
550
  };
@@ -81,16 +81,18 @@ export async function probeJson(
81
81
  signal: AbortSignal,
82
82
  ): Promise<ProbeResult> {
83
83
  const response = await fetch(url, { ...init, signal });
84
- const text = (await response.text().catch(() => '')).slice(0, 2_000);
84
+ const raw = await response.text().catch(() => '');
85
85
 
86
+ // O parse usa o corpo inteiro: um access token JWT (ex.: o do Graph com muitas
87
+ // permissões de aplicação) passa fácil de 2 KB, e o JSON truncado viraria `{}`.
86
88
  let body: any = {};
87
89
  try {
88
- body = text ? JSON.parse(text) : {};
90
+ body = raw ? JSON.parse(raw) : {};
89
91
  } catch {
90
92
  body = {};
91
93
  }
92
94
 
93
- return { ok: response.ok, status: response.status, body, text };
95
+ return { ok: response.ok, status: response.status, body, text: raw.slice(0, 2_000) };
94
96
  }
95
97
 
96
98
  /** Remove qualquer ocorrência dos segredos do texto. */
@@ -26,6 +26,11 @@ export type IntegrationTestOutcome = {
26
26
  * verificado, service account sem acesso ao app, pool inexistente). Não reprova.
27
27
  */
28
28
  warning?: string;
29
+ /**
30
+ * Códigos estáveis dos avisos, na ordem do `warning`, para a UI traduzir. O
31
+ * `warning` continua sendo o texto de fallback de quem não conhece o código.
32
+ */
33
+ warningCodes?: string[];
29
34
  };
30
35
 
31
36
  export type IntegrationTestContext = {
@@ -126,6 +126,19 @@ describe('microsoft-oauth tester', () => {
126
126
  expect(outcome.info).toContain('contoso');
127
127
  });
128
128
 
129
+ it('aprova quando o token é maior que o limite de texto guardado', async () => {
130
+ fetchMock.mockResolvedValue(
131
+ jsonResponse({ token_type: 'Bearer', access_token: 'x'.repeat(5_000) }),
132
+ );
133
+
134
+ const outcome = await tester.test(
135
+ { client_id: 'a', client_secret: 'b', tenant_id: 'contoso' },
136
+ ctx(),
137
+ );
138
+
139
+ expect(outcome.ok).toBe(true);
140
+ });
141
+
129
142
  it.each([
130
143
  ['AADSTS7000215', 'Invalid client secret.'],
131
144
  ['AADSTS700016', 'Application not found in tenant "contoso".'],
@@ -25,6 +25,8 @@ const EXTERNAL_PROVIDERS = new Set([
25
25
  'google-business-social',
26
26
  // App do LinkedIn (paginas de empresa): o tester vive em libraries/social.
27
27
  'linkedin-social',
28
+ // App do X (OAuth 2.0 com PKCE): o tester vive em libraries/social.
29
+ 'x-social',
28
30
  ]);
29
31
 
30
32
  /** Providers legítimos sem nenhum campo secreto. */
@@ -82,6 +82,31 @@ describe('MailService', () => {
82
82
  expect(call('A\r\nB', {})).toBe('A<br/>B');
83
83
  });
84
84
 
85
+ // Bug real: o aviso interno do SAC, escrito em HTML indentado, chegava com
86
+ // um vao entre cada campo — cada quebra entre dois <p> virava <br/>.
87
+ it('não gera <br/> na quebra que só separa uma tag da seguinte', () => {
88
+ expect(call('<div>\n <p>A</p>\n <p>B</p>\n</div>', {})).toBe(
89
+ '<div><p>A</p><p>B</p></div>',
90
+ );
91
+ expect(call('<p>A</p>\r\n\r\n<p>B</p>', {})).toBe('<p>A</p><p>B</p>');
92
+ });
93
+
94
+ it('não gera <br/> entre tag e bloco {{#if}}/{{/if}}', () => {
95
+ const src =
96
+ '<p>A</p>\n{{#if x}}<p>X</p>{{/if}}\n<p>B</p>\n{{#if y}}\n <p>Y</p>\n{{else}}\n <p>N</p>\n{{/if}}';
97
+ expect(call(src, { x: true })).toBe('<p>A</p><p>X</p><p>B</p><p>N</p>');
98
+ });
99
+
100
+ it('mantém o <br/> da quebra que vem depois de texto ou de variável', () => {
101
+ expect(call('Olá,\n<div>x</div>', {})).toBe('Olá,<br/><div>x</div>');
102
+ expect(call('Oi {{name}}\n<p>x</p>', { name: 'Bob' })).toBe(
103
+ 'Oi Bob<br/><p>x</p>',
104
+ );
105
+ expect(call('<strong>A</strong>\ntexto', {})).toBe(
106
+ '<strong>A</strong><br/>texto',
107
+ );
108
+ });
109
+
85
110
  it('preserva as quebras de linha de um documento HTML completo (não injeta <br/> dentro de <style>/<head>)', () => {
86
111
  // Bug real: um HTML completo colado em Templates de e-mail (que
87
112
  // funciona no preview cru de Campanhas) aparecia quebrado aqui porque
@@ -44,6 +44,14 @@ import { IncidentService } from '../incident/incident.service';
44
44
 
45
45
  export type { SendTemplatedMailResult } from './dto/send.dto';
46
46
 
47
+ /**
48
+ * Quebra de linha (com a indentacao em volta) entre o fim de uma tag ou de um
49
+ * bloco do Handlebars e o comeco da proxima tag ou bloco. `{{variavel}}` nao
50
+ * conta como bloco: depois dela vem texto, e a quebra e do autor.
51
+ */
52
+ const TAG_TO_TAG_LINE_BREAK =
53
+ /(>|\{\{[#/][^}]*\}\}|\{\{else\}\})[ \t]*(?:\r\n|\n|\r)\s*(?=<|\{\{[#/]|\{\{else\}\})/g;
54
+
47
55
  @Injectable()
48
56
  export class MailService implements OnModuleInit {
49
57
  private readonly logger = new Logger(MailService.name);
@@ -692,9 +700,18 @@ export class MailService implements OnModuleInit {
692
700
  // caso; aqui só espelhamos a mesma detecção pra não mutilar antes
693
701
  // disso). Emails simples (sem <html>) continuam ganhando <br/> pra
694
702
  // preservar quebras de linha digitadas pelo usuário.
703
+ //
704
+ // A quebra que só separa uma tag (ou um bloco `{{#if}}`/`{{/if}}`) da
705
+ // seguinte é indentação do template, não texto: virar <br/> ali punha
706
+ // uma linha em branco entre cada <p> de todo template escrito em HTML
707
+ // de várias linhas. Essa sai antes; a que vem depois de texto continua.
708
+ // A quebra final (todo bloco `|` do YAML termina em uma) também sai.
695
709
  const normalized = isFullHtmlDocument(source)
696
710
  ? source
697
- : source.replace(/(\r\n|\n|\r)/g, '<br/>');
711
+ : source
712
+ .trimEnd()
713
+ .replace(TAG_TO_TAG_LINE_BREAK, '$1')
714
+ .replace(/(\r\n|\n|\r)/g, '<br/>');
698
715
  const template = Handlebars.compile(normalized, { noEscape: !escapeHtml });
699
716
  return template(data);
700
717
  } catch (error: any) {
@@ -195,6 +195,22 @@ describe('McpServerService', () => {
195
195
  );
196
196
  });
197
197
 
198
+ it('handler void responde { success: true } em vez de um text ausente', async () => {
199
+ const { service, deps } = makeService();
200
+ const handler = jest.fn<any>().mockResolvedValue(undefined);
201
+ deps.registry.getAll.mockReturnValue([]);
202
+ deps.registry.get.mockReturnValue({ definition: { name: 'tool-a' }, handler });
203
+
204
+ (service as any).buildServer(context);
205
+ const callHandler = getRegisteredHandler(CallToolRequestSchema);
206
+
207
+ const result = await callHandler({ params: { name: 'tool-a', arguments: {} } });
208
+
209
+ expect(result).toEqual({
210
+ content: [{ type: 'text', text: JSON.stringify({ success: true }) }],
211
+ });
212
+ });
213
+
198
214
  it('trata argumentos ausentes como objeto vazio', async () => {
199
215
  const { service, deps } = makeService();
200
216
  const handler = jest.fn<any>().mockResolvedValue({});
@@ -88,7 +88,16 @@ export class McpServerService {
88
88
  success: true,
89
89
  durationMs: Date.now() - start,
90
90
  });
91
- return { content: [{ type: 'text', text: JSON.stringify(result) }] };
91
+ // Handler `void` (ex.: um delete) dá `JSON.stringify(undefined) === undefined`, e o
92
+ // cliente MCP recusa o resultado inteiro por faltar `text` — mesmo com a ação feita.
93
+ return {
94
+ content: [
95
+ {
96
+ type: 'text',
97
+ text: result === undefined ? JSON.stringify({ success: true }) : JSON.stringify(result),
98
+ },
99
+ ],
100
+ };
92
101
  } catch (e: any) {
93
102
  await this.audit.record({
94
103
  userId: context.userId,
@@ -234,5 +234,93 @@ export function buildDocumentCss(theme: PdfDocumentTheme, landscape = false): st
234
234
  }
235
235
  .legend p { margin: 0 0 3px; }
236
236
  .page-break { break-before: page; }
237
+
238
+ /* ── Barra empilhada e cartões de progresso ── */
239
+ .seg-bar {
240
+ display: flex;
241
+ height: 6px;
242
+ border-radius: 999px;
243
+ overflow: hidden;
244
+ background: ${line};
245
+ margin: 8px 0;
246
+ }
247
+ .seg { display: block; height: 100%; }
248
+ .seg-ink, .dot-ink { background: ${ink}; }
249
+ .seg-muted, .dot-muted { background: #8f8f94; }
250
+ .seg-light, .dot-light { background: #cfcfd4; }
251
+ .seg-accent, .dot-accent { background: ${primary}; }
252
+ .seg-danger, .dot-danger { background: #dc2626; }
253
+ .dot {
254
+ display: inline-block;
255
+ width: 7px;
256
+ height: 7px;
257
+ border-radius: 50%;
258
+ margin-right: 5px;
259
+ vertical-align: middle;
260
+ }
261
+ .seg-block {
262
+ border: 1px solid ${line};
263
+ border-radius: 12px;
264
+ padding: 12px 16px;
265
+ margin-bottom: 18px;
266
+ break-inside: avoid;
267
+ }
268
+ .seg-title { font-size: 13px; font-weight: 700; color: ${ink}; }
269
+ .seg-legend { display: flex; flex-wrap: wrap; gap: 6px 18px; font-size: 10px; color: ${ink}; }
270
+ .seg-legend-item { white-space: nowrap; }
271
+ .seg-caption { font-size: 10.5px; color: ${ink}; margin-top: 8px; }
272
+ .pc-legend {
273
+ display: flex;
274
+ flex-wrap: wrap;
275
+ gap: 6px 18px;
276
+ font-size: 10px;
277
+ color: ${mutedFg};
278
+ margin-bottom: 12px;
279
+ }
280
+ .pc-grid { display: grid; gap: 12px; align-items: start; }
281
+ .pc-grid-1 { grid-template-columns: 1fr; }
282
+ .pc-grid-2 { grid-template-columns: repeat(2, 1fr); }
283
+ .pc-card {
284
+ border: 1px solid ${line};
285
+ border-radius: 12px;
286
+ padding: 12px 14px;
287
+ break-inside: avoid;
288
+ }
289
+ .pc-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 10px; }
290
+ .pc-title { font-size: 13px; font-weight: 700; color: ${ink}; line-height: 1.25; }
291
+ .pc-subtitle { font-size: 9.5px; color: ${mutedFg}; margin-top: 2px; }
292
+ .pc-value { text-align: right; white-space: nowrap; }
293
+ .pc-value-num { font-size: 22px; font-weight: 700; color: ${ink}; line-height: 1; }
294
+ .pc-value-hint { font-size: 9px; color: ${mutedFg}; margin-top: 2px; }
295
+ .pc-list-title { font-size: 10px; font-weight: 700; color: ${ink}; margin: 4px 0 6px; }
296
+ .pc-entry {
297
+ border: 1px solid ${line};
298
+ border-radius: 8px;
299
+ padding: 6px 10px;
300
+ margin-bottom: 6px;
301
+ break-inside: avoid;
302
+ }
303
+ .pc-entry-hl { border-color: transparent; }
304
+ .pc-entry-hl-accent { background: color-mix(in srgb, ${primary} 16%, white); }
305
+ .pc-entry-hl-danger { background: #fee2e2; }
306
+ .pc-entry-text { font-size: 10.5px; color: ${ink}; }
307
+ .pc-entry-meta {
308
+ display: flex;
309
+ justify-content: space-between;
310
+ gap: 8px;
311
+ font-size: 9.5px;
312
+ margin-top: 1px;
313
+ }
314
+ .pc-entry-status { font-weight: 700; color: ${ink}; }
315
+ .pc-entry-aside { color: ${mutedFg}; white-space: nowrap; }
316
+ .pc-entry-sub { font-size: 9px; color: ${mutedFg}; margin-top: 2px; }
317
+ .pc-entry-sub-danger { color: #b91c1c; font-weight: 700; }
318
+ .pc-empty {
319
+ background: ${mutedBg};
320
+ border-radius: 8px;
321
+ padding: 8px 10px;
322
+ font-size: 10px;
323
+ color: ${ink};
324
+ }
237
325
  `;
238
326
  }
@@ -127,3 +127,46 @@ describe('documento', () => {
127
127
  expect(html).toContain('size: A4 landscape');
128
128
  });
129
129
  });
130
+
131
+ describe('barra e cartões de progresso', () => {
132
+ it('reparte a barra pelo total e mostra a legenda com a porcentagem', () => {
133
+ const html = renderBlock({
134
+ kind: 'segmentBar',
135
+ title: 'Com quem estão',
136
+ segments: [
137
+ { label: 'Com o aluno', value: 1, tone: 'muted' },
138
+ { label: 'Aguardando', value: 3, tone: 'accent' },
139
+ { label: 'Em revisão', value: 0, tone: 'light' },
140
+ ],
141
+ });
142
+
143
+ expect(html).toContain('seg-muted" style="width:25.00%"');
144
+ expect(html).toContain('seg-accent" style="width:75.00%"');
145
+ expect(html).not.toContain('seg-light" style');
146
+ expect(html).toContain('<strong>3 (75%)</strong>');
147
+ });
148
+
149
+ it('escapa o texto de cada cartão e cai no texto de vazio sem itens', () => {
150
+ const html = renderBlock({
151
+ kind: 'progressCards',
152
+ items: [
153
+ {
154
+ title: '<b>Ana</b>',
155
+ value: '25%',
156
+ segments: [{ value: 1, tone: 'ink' }],
157
+ listTitle: 'Tarefas em aberto (1)',
158
+ entries: [
159
+ { text: 'Acesso & API', status: 'Aguardando', tone: 'accent', highlight: true, sub: 'Atrasada', subTone: 'danger' },
160
+ ],
161
+ },
162
+ { title: 'Bruno', value: '100%', segments: [], entries: [], emptyText: 'Tudo concluído.' },
163
+ ],
164
+ });
165
+
166
+ expect(html).toContain('&lt;b&gt;Ana&lt;/b&gt;');
167
+ expect(html).toContain('Acesso &amp; API');
168
+ expect(html).toContain('pc-entry-hl-accent');
169
+ expect(html).toContain('pc-entry-sub-danger');
170
+ expect(html).toContain('<div class="pc-empty">Tudo concluído.</div>');
171
+ });
172
+ });