@hed-hog/core 0.0.401 → 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 (106) 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/file/file-upload-ticket.service.d.ts +1 -1
  6. package/dist/file/file.controller.d.ts +3 -3
  7. package/dist/file/file.service.d.ts +121 -12
  8. package/dist/file/file.service.d.ts.map +1 -1
  9. package/dist/file/file.service.js +245 -3
  10. package/dist/file/file.service.js.map +1 -1
  11. package/dist/file/provider/abstract.provider.d.ts +33 -0
  12. package/dist/file/provider/abstract.provider.d.ts.map +1 -1
  13. package/dist/file/provider/abstract.provider.js +9 -0
  14. package/dist/file/provider/abstract.provider.js.map +1 -1
  15. package/dist/file/provider/s3-multipart.util.d.ts +17 -0
  16. package/dist/file/provider/s3-multipart.util.d.ts.map +1 -0
  17. package/dist/file/provider/s3-multipart.util.js +88 -0
  18. package/dist/file/provider/s3-multipart.util.js.map +1 -0
  19. package/dist/file/provider/s3.provider.d.ts +9 -1
  20. package/dist/file/provider/s3.provider.d.ts.map +1 -1
  21. package/dist/file/provider/s3.provider.js +16 -0
  22. package/dist/file/provider/s3.provider.js.map +1 -1
  23. package/dist/file/provider/spaces.provider.d.ts +10 -1
  24. package/dist/file/provider/spaces.provider.d.ts.map +1 -1
  25. package/dist/file/provider/spaces.provider.js +17 -0
  26. package/dist/file/provider/spaces.provider.js.map +1 -1
  27. package/dist/integration-profile/integration-profile.controller.d.ts +1 -0
  28. package/dist/integration-profile/integration-profile.controller.d.ts.map +1 -1
  29. package/dist/integration-profile/integration-profile.secrets.d.ts.map +1 -1
  30. package/dist/integration-profile/integration-profile.secrets.js +7 -2
  31. package/dist/integration-profile/integration-profile.secrets.js.map +1 -1
  32. package/dist/integration-profile/integration-profile.service.d.ts +1 -0
  33. package/dist/integration-profile/integration-profile.service.d.ts.map +1 -1
  34. package/dist/integration-profile/integration-profile.service.js +3 -3
  35. package/dist/integration-profile/integration-profile.service.js.map +1 -1
  36. package/dist/integration-profile/testers/http-probe.d.ts.map +1 -1
  37. package/dist/integration-profile/testers/http-probe.js +5 -3
  38. package/dist/integration-profile/testers/http-probe.js.map +1 -1
  39. package/dist/integration-profile/testers/integration-tester.types.d.ts +5 -0
  40. package/dist/integration-profile/testers/integration-tester.types.d.ts.map +1 -1
  41. package/dist/mail/mail.service.d.ts.map +1 -1
  42. package/dist/mail/mail.service.js +16 -1
  43. package/dist/mail/mail.service.js.map +1 -1
  44. package/dist/mcp/mcp-server.service.d.ts.map +1 -1
  45. package/dist/mcp/mcp-server.service.js +10 -1
  46. package/dist/mcp/mcp-server.service.js.map +1 -1
  47. package/dist/pdf/document/pdf-document.css.d.ts.map +1 -1
  48. package/dist/pdf/document/pdf-document.css.js +88 -0
  49. package/dist/pdf/document/pdf-document.css.js.map +1 -1
  50. package/dist/pdf/document/pdf-document.renderer.d.ts.map +1 -1
  51. package/dist/pdf/document/pdf-document.renderer.js +73 -0
  52. package/dist/pdf/document/pdf-document.renderer.js.map +1 -1
  53. package/dist/pdf/document/pdf-document.types.d.ts +56 -0
  54. package/dist/pdf/document/pdf-document.types.d.ts.map +1 -1
  55. package/dist/pdf/pdf.service.d.ts +6 -0
  56. package/dist/pdf/pdf.service.d.ts.map +1 -1
  57. package/dist/pdf/pdf.service.js +3 -0
  58. package/dist/pdf/pdf.service.js.map +1 -1
  59. package/dist/setting/setting.service.d.ts.map +1 -1
  60. package/dist/setting/setting.service.js +3 -0
  61. package/dist/setting/setting.service.js.map +1 -1
  62. package/dist/task/task.service.d.ts +6 -0
  63. package/dist/task/task.service.d.ts.map +1 -1
  64. package/dist/task/task.service.js +44 -13
  65. package/dist/task/task.service.js.map +1 -1
  66. package/dist/whatsapp-sender/whatsapp-sender.service.d.ts +35 -2
  67. package/dist/whatsapp-sender/whatsapp-sender.service.d.ts.map +1 -1
  68. package/dist/whatsapp-sender/whatsapp-sender.service.js +83 -21
  69. package/dist/whatsapp-sender/whatsapp-sender.service.js.map +1 -1
  70. package/hedhog/frontend/app/configurations/[slug]/components/setting-field.test.tsx.ejs +85 -2
  71. package/hedhog/frontend/app/configurations/[slug]/components/setting-field.tsx.ejs +576 -491
  72. package/hedhog/frontend/app/users/simulate-access-dialog.test.tsx.ejs +29 -50
  73. package/hedhog/frontend/app/users/simulate-access-dialog.tsx.ejs +146 -50
  74. package/hedhog/frontend/messages/en.json +46 -20
  75. package/hedhog/frontend/messages/pt.json +46 -20
  76. package/hedhog/table/file.yaml +3 -1
  77. package/package.json +4 -4
  78. package/src/access-log/access-log.interceptor.spec.ts +50 -0
  79. package/src/access-log/access-log.interceptor.ts +18 -0
  80. package/src/file/file-direct-upload.spec.ts +344 -0
  81. package/src/file/file.service.ts +363 -2
  82. package/src/file/provider/abstract.provider.ts +57 -0
  83. package/src/file/provider/s3-multipart.spec.ts +123 -0
  84. package/src/file/provider/s3-multipart.util.ts +126 -0
  85. package/src/file/provider/s3.provider.ts +65 -1
  86. package/src/file/provider/spaces.provider.ts +67 -1
  87. package/src/integration-profile/integration-profile.secrets.ts +7 -2
  88. package/src/integration-profile/integration-profile.service.ts +1 -0
  89. package/src/integration-profile/testers/http-probe.ts +5 -3
  90. package/src/integration-profile/testers/integration-tester.types.ts +5 -0
  91. package/src/integration-profile/testers/oauth.testers.spec.ts +13 -0
  92. package/src/integration-profile/testers/tester-coverage.spec.ts +2 -0
  93. package/src/mail/mail.service.spec.ts +25 -0
  94. package/src/mail/mail.service.ts +18 -1
  95. package/src/mcp/mcp-server.service.spec.ts +16 -0
  96. package/src/mcp/mcp-server.service.ts +10 -1
  97. package/src/pdf/document/pdf-document.css.ts +88 -0
  98. package/src/pdf/document/pdf-document.renderer.spec.ts +43 -0
  99. package/src/pdf/document/pdf-document.renderer.ts +102 -0
  100. package/src/pdf/document/pdf-document.types.ts +52 -0
  101. package/src/pdf/pdf.service.ts +11 -0
  102. package/src/setting/setting.service.ts +3 -0
  103. package/src/task/task.service.spec.ts +77 -1
  104. package/src/task/task.service.ts +58 -14
  105. package/src/whatsapp-sender/whatsapp-sender.service.spec.ts +191 -1
  106. package/src/whatsapp-sender/whatsapp-sender.service.ts +122 -14
@@ -4,7 +4,10 @@ import {
4
4
  PdfDocumentBlock,
5
5
  PdfDocumentCard,
6
6
  PdfDocumentInput,
7
+ PdfDocumentProgressCard,
7
8
  PdfDocumentRow,
9
+ PdfDocumentSegment,
10
+ PdfDocumentSegmentTone,
8
11
  PdfDocumentTableCell,
9
12
  PdfDocumentTableColumn,
10
13
  } from './pdf-document.types';
@@ -119,6 +122,99 @@ function renderTable(block: Extract<PdfDocumentBlock, { kind: 'table' }>): strin
119
122
  return truncatedNote ? `${table}<div class="note">${escapeHtml(truncatedNote)}</div>` : table;
120
123
  }
121
124
 
125
+ /** Barra fina empilhada. Largura em % do total; total zero vira barra vazia. */
126
+ function renderSegments(segments: PdfDocumentSegment[]): string {
127
+ const total = segments.reduce((sum, segment) => sum + Math.max(0, segment.value), 0);
128
+ const parts =
129
+ total > 0
130
+ ? segments
131
+ .filter((segment) => segment.value > 0)
132
+ .map(
133
+ (segment) =>
134
+ `<span class="seg seg-${segment.tone}" style="width:${((segment.value / total) * 100).toFixed(2)}%"></span>`,
135
+ )
136
+ .join('')
137
+ : '';
138
+
139
+ return `<div class="seg-bar">${parts}</div>`;
140
+ }
141
+
142
+ const dot = (tone: PdfDocumentSegmentTone): string => `<span class="dot dot-${tone}"></span>`;
143
+
144
+ function renderSegmentBar(block: Extract<PdfDocumentBlock, { kind: 'segmentBar' }>): string {
145
+ const total = block.segments.reduce((sum, segment) => sum + Math.max(0, segment.value), 0);
146
+ const legend = block.segments
147
+ .map((segment) => {
148
+ const pct = total > 0 ? Math.round((segment.value / total) * 100) : 0;
149
+ return (
150
+ `<span class="seg-legend-item">${dot(segment.tone)}${escapeHtml(segment.label ?? '')} ` +
151
+ `<strong>${segment.value} (${pct}%)</strong></span>`
152
+ );
153
+ })
154
+ .join('');
155
+
156
+ return (
157
+ '<div class="seg-block">' +
158
+ (block.title ? `<div class="seg-title">${escapeHtml(block.title)}</div>` : '') +
159
+ renderSegments(block.segments) +
160
+ `<div class="seg-legend">${legend}</div>` +
161
+ (block.caption ? `<div class="seg-caption">${escapeHtml(block.caption)}</div>` : '') +
162
+ '</div>'
163
+ );
164
+ }
165
+
166
+ function renderProgressCard(card: PdfDocumentProgressCard): string {
167
+ const entries = card.entries
168
+ .map(
169
+ (entry) =>
170
+ `<div class="pc-entry${entry.highlight ? ` pc-entry-hl pc-entry-hl-${entry.tone}` : ''}">` +
171
+ `<div class="pc-entry-text">${escapeHtml(entry.text)}</div>` +
172
+ '<div class="pc-entry-meta">' +
173
+ `<span class="pc-entry-status">${dot(entry.tone)}${escapeHtml(entry.status)}</span>` +
174
+ (entry.aside ? `<span class="pc-entry-aside">${escapeHtml(entry.aside)}</span>` : '') +
175
+ '</div>' +
176
+ (entry.sub
177
+ ? `<div class="pc-entry-sub${entry.subTone === 'danger' ? ' pc-entry-sub-danger' : ''}">${escapeHtml(entry.sub)}</div>`
178
+ : '') +
179
+ '</div>',
180
+ )
181
+ .join('');
182
+
183
+ const body =
184
+ card.entries.length > 0
185
+ ? (card.listTitle ? `<div class="pc-list-title">${escapeHtml(card.listTitle)}</div>` : '') + entries
186
+ : card.emptyText
187
+ ? `<div class="pc-empty">${escapeHtml(card.emptyText)}</div>`
188
+ : '';
189
+
190
+ return (
191
+ '<div class="pc-card">' +
192
+ '<div class="pc-head">' +
193
+ `<div class="pc-head-text"><div class="pc-title">${escapeHtml(card.title)}</div>` +
194
+ (card.subtitle ? `<div class="pc-subtitle">${escapeHtml(card.subtitle)}</div>` : '') +
195
+ '</div>' +
196
+ `<div class="pc-value"><div class="pc-value-num">${escapeHtml(card.value)}</div>` +
197
+ (card.valueHint ? `<div class="pc-value-hint">${escapeHtml(card.valueHint)}</div>` : '') +
198
+ '</div></div>' +
199
+ renderSegments(card.segments) +
200
+ body +
201
+ '</div>'
202
+ );
203
+ }
204
+
205
+ function renderProgressCards(block: Extract<PdfDocumentBlock, { kind: 'progressCards' }>): string {
206
+ const legend = block.legend?.length
207
+ ? `<div class="pc-legend">${block.legend
208
+ .map((item) => `<span class="seg-legend-item">${dot(item.tone)}${escapeHtml(item.label)}</span>`)
209
+ .join('')}</div>`
210
+ : '';
211
+
212
+ return (
213
+ legend +
214
+ `<div class="pc-grid pc-grid-${block.columns ?? 2}">${block.items.map(renderProgressCard).join('')}</div>`
215
+ );
216
+ }
217
+
122
218
  export function renderBlock(block: PdfDocumentBlock): string {
123
219
  switch (block.kind) {
124
220
  case 'section':
@@ -159,6 +255,12 @@ export function renderBlock(block: PdfDocumentBlock): string {
159
255
  '</div>'
160
256
  );
161
257
 
258
+ case 'segmentBar':
259
+ return renderSegmentBar(block);
260
+
261
+ case 'progressCards':
262
+ return renderProgressCards(block);
263
+
162
264
  case 'note':
163
265
  return `<div class="note${block.tone === 'accent' ? ' note-accent' : ''}">${escapeHtml(block.text)}</div>`;
164
266
 
@@ -47,6 +47,47 @@ export interface PdfDocumentTableCell {
47
47
 
48
48
  export type PdfDocumentRow = Array<string | number | null | undefined | PdfDocumentTableCell>;
49
49
 
50
+ /**
51
+ * Paleta fechada das barras e marcadores — o chamador escolhe o papel, o tema
52
+ * decide a cor: 'ink' (feito), 'muted' e 'light' (dois cinzas), 'accent' (cor
53
+ * da marca) e 'danger' (atraso).
54
+ */
55
+ export type PdfDocumentSegmentTone = 'ink' | 'muted' | 'light' | 'accent' | 'danger';
56
+
57
+ export interface PdfDocumentSegment {
58
+ value: number;
59
+ tone: PdfDocumentSegmentTone;
60
+ /** Só a barra avulsa (`segmentBar`) mostra rótulo, na legenda abaixo dela. */
61
+ label?: string;
62
+ }
63
+
64
+ export interface PdfDocumentProgressEntry {
65
+ text: string;
66
+ /** Linha pequena com marcador da cor de `tone` (ex.: "Em revisão"). */
67
+ status: string;
68
+ tone: PdfDocumentSegmentTone;
69
+ /** Texto à direita da linha de status (ex.: "há 3d 4h"). */
70
+ aside?: string;
71
+ /** Terceira linha opcional (ex.: o prazo). */
72
+ sub?: string;
73
+ subTone?: 'muted' | 'danger';
74
+ /** Fundo tingido com a cor do `tone` — o item que pede atenção. */
75
+ highlight?: boolean;
76
+ }
77
+
78
+ export interface PdfDocumentProgressCard {
79
+ title: string;
80
+ subtitle?: string;
81
+ /** O número grande à direita (ex.: "33%"). */
82
+ value: string;
83
+ valueHint?: string;
84
+ segments: PdfDocumentSegment[];
85
+ listTitle?: string;
86
+ entries: PdfDocumentProgressEntry[];
87
+ /** Mostrado no lugar da lista quando `entries` vem vazio. */
88
+ emptyText?: string;
89
+ }
90
+
50
91
  export type PdfDocumentBlock =
51
92
  | { kind: 'section'; title: string; hint?: string }
52
93
  | {
@@ -73,6 +114,17 @@ export type PdfDocumentBlock =
73
114
  }
74
115
  | { kind: 'chart'; title?: string; svg: SafeSvg; caption?: string }
75
116
  | { kind: 'chartGrid'; items: Array<{ title?: string; svg: SafeSvg; caption?: string }> }
117
+ /** Barra empilhada com legenda "rótulo N (x%)" — a composição de um total. */
118
+ | { kind: 'segmentBar'; title?: string; segments: PdfDocumentSegment[]; caption?: string }
119
+ /** Grade de cartões com %, barra e lista de itens — um por pessoa, por exemplo. */
120
+ | {
121
+ kind: 'progressCards';
122
+ items: PdfDocumentProgressCard[];
123
+ /** Legenda dos marcadores, acima da grade. */
124
+ legend?: Array<{ label: string; tone: PdfDocumentSegmentTone }>;
125
+ /** Padrão: 2. */
126
+ columns?: 1 | 2;
127
+ }
76
128
  | { kind: 'note'; text: string; tone?: 'muted' | 'accent' }
77
129
  | { kind: 'legend'; lines: string[] }
78
130
  | { kind: 'pageBreak' };
@@ -53,6 +53,12 @@ export interface PdfGenerateOptions {
53
53
  waitUntil?: 'load' | 'domcontentloaded' | 'networkidle' | 'commit';
54
54
  /** Ceiling for `page.setContent`, in ms. Defaults to Playwright's own (30s). */
55
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;
56
62
  scale?: number;
57
63
  pageRanges?: string;
58
64
  /** Short identifier for the logs when a render fails or the queue saturates. */
@@ -385,6 +391,11 @@ export class PdfService {
385
391
  waitUntil: options.waitUntil ?? 'networkidle',
386
392
  ...(options.timeoutMs ? { timeout: options.timeoutMs } : {}),
387
393
  });
394
+ if (options.readyExpression) {
395
+ await page.waitForFunction(options.readyExpression, undefined, {
396
+ ...(options.timeoutMs ? { timeout: options.timeoutMs } : {}),
397
+ });
398
+ }
388
399
 
389
400
  return Buffer.from(await run(page));
390
401
  } catch (error) {
@@ -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', () => {
@@ -171,15 +173,203 @@ describe('WhatsappSenderService', () => {
171
173
  const error = await sendAndCatch();
172
174
 
173
175
  expect(error.retryable).toBe(false);
176
+ expect(error.isTransient).toBe(false);
174
177
  expect(error.message).toMatch(/HTTP 400/);
175
178
  });
176
179
 
177
180
  it('5xx e 429 podem ser tentados de novo', async () => {
178
181
  respond(502, {});
179
- expect((await sendAndCatch()).retryable).toBe(true);
182
+ const serverError = await sendAndCatch();
183
+ expect(serverError.retryable).toBe(true);
184
+ expect(serverError.isTransient).toBe(true);
180
185
 
181
186
  respond(429, {});
182
187
  expect((await sendAndCatch()).retryable).toBe(true);
183
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
+ });
184
374
  });
185
375
  });