@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.
- package/dist/access-log/access-log.interceptor.d.ts +10 -0
- package/dist/access-log/access-log.interceptor.d.ts.map +1 -1
- package/dist/access-log/access-log.interceptor.js +15 -1
- package/dist/access-log/access-log.interceptor.js.map +1 -1
- package/dist/authorize/authorize.controller.d.ts +1 -0
- package/dist/authorize/authorize.controller.d.ts.map +1 -1
- package/dist/authorize/authorize.service.d.ts +1 -0
- package/dist/authorize/authorize.service.d.ts.map +1 -1
- package/dist/authorize/authorize.service.js +3 -0
- package/dist/authorize/authorize.service.js.map +1 -1
- package/dist/file/file-upload-ticket.service.d.ts +1 -1
- package/dist/file/file.controller.d.ts +3 -3
- package/dist/file/file.service.d.ts +121 -12
- package/dist/file/file.service.d.ts.map +1 -1
- package/dist/file/file.service.js +245 -3
- package/dist/file/file.service.js.map +1 -1
- package/dist/file/provider/abstract.provider.d.ts +33 -0
- package/dist/file/provider/abstract.provider.d.ts.map +1 -1
- package/dist/file/provider/abstract.provider.js +9 -0
- package/dist/file/provider/abstract.provider.js.map +1 -1
- package/dist/file/provider/s3-multipart.util.d.ts +17 -0
- package/dist/file/provider/s3-multipart.util.d.ts.map +1 -0
- package/dist/file/provider/s3-multipart.util.js +88 -0
- package/dist/file/provider/s3-multipart.util.js.map +1 -0
- package/dist/file/provider/s3.provider.d.ts +9 -1
- package/dist/file/provider/s3.provider.d.ts.map +1 -1
- package/dist/file/provider/s3.provider.js +16 -0
- package/dist/file/provider/s3.provider.js.map +1 -1
- package/dist/file/provider/spaces.provider.d.ts +10 -1
- package/dist/file/provider/spaces.provider.d.ts.map +1 -1
- package/dist/file/provider/spaces.provider.js +17 -0
- package/dist/file/provider/spaces.provider.js.map +1 -1
- package/dist/integration-profile/integration-profile.controller.d.ts +1 -0
- package/dist/integration-profile/integration-profile.controller.d.ts.map +1 -1
- package/dist/integration-profile/integration-profile.secrets.d.ts.map +1 -1
- package/dist/integration-profile/integration-profile.secrets.js +7 -2
- package/dist/integration-profile/integration-profile.secrets.js.map +1 -1
- package/dist/integration-profile/integration-profile.service.d.ts +1 -0
- package/dist/integration-profile/integration-profile.service.d.ts.map +1 -1
- package/dist/integration-profile/integration-profile.service.js +3 -3
- package/dist/integration-profile/integration-profile.service.js.map +1 -1
- package/dist/integration-profile/testers/http-probe.d.ts.map +1 -1
- package/dist/integration-profile/testers/http-probe.js +5 -3
- package/dist/integration-profile/testers/http-probe.js.map +1 -1
- package/dist/integration-profile/testers/integration-tester.types.d.ts +5 -0
- package/dist/integration-profile/testers/integration-tester.types.d.ts.map +1 -1
- package/dist/mail/mail.service.d.ts.map +1 -1
- package/dist/mail/mail.service.js +16 -1
- package/dist/mail/mail.service.js.map +1 -1
- package/dist/mcp/mcp-server.service.d.ts.map +1 -1
- package/dist/mcp/mcp-server.service.js +10 -1
- package/dist/mcp/mcp-server.service.js.map +1 -1
- package/dist/pdf/document/pdf-document.css.d.ts.map +1 -1
- package/dist/pdf/document/pdf-document.css.js +88 -0
- package/dist/pdf/document/pdf-document.css.js.map +1 -1
- package/dist/pdf/document/pdf-document.renderer.d.ts.map +1 -1
- package/dist/pdf/document/pdf-document.renderer.js +73 -0
- package/dist/pdf/document/pdf-document.renderer.js.map +1 -1
- package/dist/pdf/document/pdf-document.types.d.ts +56 -0
- package/dist/pdf/document/pdf-document.types.d.ts.map +1 -1
- package/dist/pdf/pdf.service.d.ts +6 -0
- package/dist/pdf/pdf.service.d.ts.map +1 -1
- package/dist/pdf/pdf.service.js +3 -0
- package/dist/pdf/pdf.service.js.map +1 -1
- package/dist/setting/setting.service.d.ts.map +1 -1
- package/dist/setting/setting.service.js +3 -0
- package/dist/setting/setting.service.js.map +1 -1
- package/dist/task/task.service.d.ts +6 -0
- package/dist/task/task.service.d.ts.map +1 -1
- package/dist/task/task.service.js +44 -13
- package/dist/task/task.service.js.map +1 -1
- package/dist/whatsapp-sender/whatsapp-sender.service.d.ts +35 -2
- package/dist/whatsapp-sender/whatsapp-sender.service.d.ts.map +1 -1
- package/dist/whatsapp-sender/whatsapp-sender.service.js +83 -21
- package/dist/whatsapp-sender/whatsapp-sender.service.js.map +1 -1
- package/hedhog/frontend/app/configurations/[slug]/components/setting-field.test.tsx.ejs +85 -2
- package/hedhog/frontend/app/configurations/[slug]/components/setting-field.tsx.ejs +576 -491
- package/hedhog/frontend/app/users/simulate-access-dialog.test.tsx.ejs +29 -50
- package/hedhog/frontend/app/users/simulate-access-dialog.tsx.ejs +146 -50
- package/hedhog/frontend/messages/en.json +46 -20
- package/hedhog/frontend/messages/pt.json +46 -20
- package/hedhog/table/file.yaml +3 -1
- package/package.json +3 -3
- package/src/access-log/access-log.interceptor.spec.ts +50 -0
- package/src/access-log/access-log.interceptor.ts +18 -0
- package/src/authorize/authorize.service.spec.ts +1 -0
- package/src/authorize/authorize.service.ts +2 -0
- package/src/file/file-direct-upload.spec.ts +344 -0
- package/src/file/file.service.ts +363 -2
- package/src/file/provider/abstract.provider.ts +57 -0
- package/src/file/provider/s3-multipart.spec.ts +123 -0
- package/src/file/provider/s3-multipart.util.ts +126 -0
- package/src/file/provider/s3.provider.ts +65 -1
- package/src/file/provider/spaces.provider.ts +67 -1
- package/src/integration-profile/integration-profile.secrets.ts +7 -2
- package/src/integration-profile/integration-profile.service.ts +1 -0
- package/src/integration-profile/testers/http-probe.ts +5 -3
- package/src/integration-profile/testers/integration-tester.types.ts +5 -0
- package/src/integration-profile/testers/oauth.testers.spec.ts +13 -0
- package/src/integration-profile/testers/tester-coverage.spec.ts +2 -0
- package/src/mail/mail.service.spec.ts +25 -0
- package/src/mail/mail.service.ts +18 -1
- package/src/mcp/mcp-server.service.spec.ts +16 -0
- package/src/mcp/mcp-server.service.ts +10 -1
- package/src/pdf/document/pdf-document.css.ts +88 -0
- package/src/pdf/document/pdf-document.renderer.spec.ts +43 -0
- package/src/pdf/document/pdf-document.renderer.ts +102 -0
- package/src/pdf/document/pdf-document.types.ts +52 -0
- package/src/pdf/pdf.service.ts +11 -0
- package/src/setting/setting.service.ts +3 -0
- package/src/task/task.service.spec.ts +77 -1
- package/src/task/task.service.ts +58 -14
- package/src/whatsapp-sender/whatsapp-sender.service.spec.ts +191 -1
- 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' };
|
package/src/pdf/pdf.service.ts
CHANGED
|
@@ -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
|
-
|
|
2
|
+
|
|
3
|
+
const runMonitoredCron = jest.fn((_prisma: any, _options: any, work: () => any) => work());
|
|
4
|
+
|
|
5
|
+
jest.mock('../observability/monitored-cron', () => ({ runMonitoredCron }));
|
|
6
|
+
|
|
7
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
8
|
+
const { TasksService } = require('./task.service');
|
|
3
9
|
|
|
4
10
|
const makeDeps = () => {
|
|
5
11
|
const prismaService = { $executeRaw: jest.fn().mockResolvedValue(0) };
|
|
@@ -23,6 +29,25 @@ describe('TasksService', () => {
|
|
|
23
29
|
expect(deps.prismaService.$executeRaw).toHaveBeenCalledTimes(8);
|
|
24
30
|
expect(deps.settingService.getSettingValues).toHaveBeenCalled();
|
|
25
31
|
});
|
|
32
|
+
|
|
33
|
+
it('elege um unico pod por tick via runMonitoredCron', async () => {
|
|
34
|
+
// Sem eleicao, todos os pods drenavam o access_log ao mesmo tempo e
|
|
35
|
+
// esgotavam o pool (Sentry 7692792694).
|
|
36
|
+
const { service, deps } = makeService();
|
|
37
|
+
await service.clear();
|
|
38
|
+
expect(runMonitoredCron).toHaveBeenCalledWith(
|
|
39
|
+
deps.prismaService,
|
|
40
|
+
expect.objectContaining({ slug: 'core-daily-cleanup', maxRuntimeMinutes: 15 }),
|
|
41
|
+
expect.any(Function),
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('nao limpa nada quando outro pod ganhou o tick', async () => {
|
|
46
|
+
runMonitoredCron.mockImplementationOnce(async () => undefined);
|
|
47
|
+
const { service, deps } = makeService();
|
|
48
|
+
await service.clear();
|
|
49
|
+
expect(deps.prismaService.$executeRaw).not.toHaveBeenCalled();
|
|
50
|
+
});
|
|
26
51
|
});
|
|
27
52
|
|
|
28
53
|
describe('clearExpiredMfaChallenges (default de batch limit)', () => {
|
|
@@ -46,6 +71,57 @@ describe('TasksService', () => {
|
|
|
46
71
|
});
|
|
47
72
|
|
|
48
73
|
describe('clearOldAccessLogs', () => {
|
|
74
|
+
let setTimeoutSpy: ReturnType<typeof jest.spyOn>;
|
|
75
|
+
|
|
76
|
+
beforeEach(() => {
|
|
77
|
+
// A pausa entre lotes nao interessa aqui: resolve na hora.
|
|
78
|
+
setTimeoutSpy = jest.spyOn(global, 'setTimeout').mockImplementation(((fn: () => void) => {
|
|
79
|
+
fn();
|
|
80
|
+
return 0;
|
|
81
|
+
}) as any);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
afterEach(() => setTimeoutSpy.mockRestore());
|
|
85
|
+
|
|
86
|
+
it('pausa entre lotes cheios, mas nao depois do ultimo', async () => {
|
|
87
|
+
const { service, deps } = makeService();
|
|
88
|
+
deps.settingService.getSettingValues.mockResolvedValue({
|
|
89
|
+
'cleanup-batch-limit': 100,
|
|
90
|
+
});
|
|
91
|
+
deps.prismaService.$executeRaw
|
|
92
|
+
.mockResolvedValueOnce(100)
|
|
93
|
+
.mockResolvedValueOnce(100)
|
|
94
|
+
.mockResolvedValueOnce(42);
|
|
95
|
+
|
|
96
|
+
await (service as any).clearOldAccessLogs();
|
|
97
|
+
|
|
98
|
+
expect(setTimeoutSpy).toHaveBeenCalledTimes(2);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('para sem falhar quando o pool esgota no meio da drenagem', async () => {
|
|
102
|
+
const { service, deps } = makeService();
|
|
103
|
+
deps.settingService.getSettingValues.mockResolvedValue({
|
|
104
|
+
'cleanup-batch-limit': 100,
|
|
105
|
+
});
|
|
106
|
+
deps.prismaService.$executeRaw
|
|
107
|
+
.mockResolvedValueOnce(100)
|
|
108
|
+
.mockRejectedValueOnce(
|
|
109
|
+
Object.assign(new Error('Timed out fetching a new connection from the connection pool'), {
|
|
110
|
+
code: 'P2024',
|
|
111
|
+
}),
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
await expect((service as any).clearOldAccessLogs()).resolves.toBeUndefined();
|
|
115
|
+
expect(deps.prismaService.$executeRaw).toHaveBeenCalledTimes(2);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('propaga erro que nao e de pool', async () => {
|
|
119
|
+
const { service, deps } = makeService();
|
|
120
|
+
deps.prismaService.$executeRaw.mockRejectedValueOnce(new Error('syntax error'));
|
|
121
|
+
|
|
122
|
+
await expect((service as any).clearOldAccessLogs()).rejects.toThrow('syntax error');
|
|
123
|
+
});
|
|
124
|
+
|
|
49
125
|
it('repete lotes ate drenar o acumulo', async () => {
|
|
50
126
|
// O bug: um unico lote por execucao diaria contra uma ingestao uma ordem
|
|
51
127
|
// de grandeza maior deixava a retencao inalcancavel.
|
package/src/task/task.service.ts
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
classifyDatabaseInfraError,
|
|
3
|
+
Prisma,
|
|
4
|
+
PrismaService,
|
|
5
|
+
} from '@hed-hog/api-prisma';
|
|
2
6
|
import { Injectable, Logger } from '@nestjs/common';
|
|
3
7
|
import { Cron, CronExpression } from '@nestjs/schedule';
|
|
8
|
+
import { runMonitoredCron } from '../observability/monitored-cron';
|
|
4
9
|
import { SettingService } from '../setting/setting.service';
|
|
5
10
|
|
|
11
|
+
const CLEANUP_CRON_SCHEDULE = CronExpression.EVERY_DAY_AT_MIDNIGHT;
|
|
12
|
+
|
|
6
13
|
/**
|
|
7
14
|
* Teto de tempo da limpeza do `access_log`. Existe para o caso da entrada
|
|
8
15
|
* superar a saída de novo: o laço para e devolve o resto para a execução
|
|
@@ -10,6 +17,14 @@ import { SettingService } from '../setting/setting.service';
|
|
|
10
17
|
*/
|
|
11
18
|
const ACCESS_LOG_CLEANUP_BUDGET_MS = 10 * 60 * 1000;
|
|
12
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Pausa entre dois lotes cheios do `access_log`. Sem ela o laço emenda um DELETE
|
|
22
|
+
* de 20 mil linhas no outro e o Postgres (1 CPU) não sobra para o tráfego: as
|
|
23
|
+
* requests do pod ficam lentas, seguram as 5 conexões do pool e o próprio lote
|
|
24
|
+
* seguinte morre em "Timed out fetching a new connection" (Sentry 7692792694).
|
|
25
|
+
*/
|
|
26
|
+
const ACCESS_LOG_BATCH_PAUSE_MS = 500;
|
|
27
|
+
|
|
13
28
|
@Injectable()
|
|
14
29
|
export class TasksService {
|
|
15
30
|
private readonly logger = new Logger(TasksService.name);
|
|
@@ -24,9 +39,21 @@ export class TasksService {
|
|
|
24
39
|
*
|
|
25
40
|
* As tarefas são executadas sequencialmente para evitar contenção excessiva no banco.
|
|
26
41
|
* Cada tarefa possui logging detalhado para monitoramento e troubleshooting.
|
|
42
|
+
*
|
|
43
|
+
* O @Cron dispara no mesmo segundo em todos os pods (API e workers); sem a
|
|
44
|
+
* eleição do `runMonitoredCron` cada um drenava o `access_log` em paralelo,
|
|
45
|
+
* disputando as mesmas linhas e saturando o banco.
|
|
27
46
|
*/
|
|
28
|
-
@Cron(
|
|
47
|
+
@Cron(CLEANUP_CRON_SCHEDULE)
|
|
29
48
|
async clear() {
|
|
49
|
+
await runMonitoredCron(
|
|
50
|
+
this.prismaService,
|
|
51
|
+
{ slug: 'core-daily-cleanup', schedule: CLEANUP_CRON_SCHEDULE, maxRuntimeMinutes: 15 },
|
|
52
|
+
() => this.runCleanup(),
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
private async runCleanup(): Promise<void> {
|
|
30
57
|
this.logger.verbose('++++++++++++++++++++++++++++++++++');
|
|
31
58
|
this.logger.verbose('Running scheduled cleanup tasks');
|
|
32
59
|
this.logger.verbose('++++++++++++++++++++++++++++++++++');
|
|
@@ -307,18 +334,18 @@ export class TasksService {
|
|
|
307
334
|
let deleted = 0;
|
|
308
335
|
let batches = 0;
|
|
309
336
|
for (;;) {
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
337
|
+
let removed: number;
|
|
338
|
+
try {
|
|
339
|
+
removed = await this.deleteAccessLogBatch(retentionDays, batchLimit);
|
|
340
|
+
} catch (error) {
|
|
341
|
+
// Limpeza é best-effort: com o pool saturado o resto fica para a próxima
|
|
342
|
+
// execução, como no estouro do orçamento de tempo.
|
|
343
|
+
if (classifyDatabaseInfraError(error) !== 'pool') throw error;
|
|
344
|
+
this.logger.warn(
|
|
345
|
+
`Access log cleanup stopped on a saturated connection pool after ${deleted} rows; the remainder goes on the next run.`,
|
|
346
|
+
);
|
|
347
|
+
break;
|
|
348
|
+
}
|
|
322
349
|
|
|
323
350
|
deleted += removed;
|
|
324
351
|
batches += 1;
|
|
@@ -331,6 +358,8 @@ export class TasksService {
|
|
|
331
358
|
);
|
|
332
359
|
break;
|
|
333
360
|
}
|
|
361
|
+
|
|
362
|
+
await new Promise((resolve) => setTimeout(resolve, ACCESS_LOG_BATCH_PAUSE_MS));
|
|
334
363
|
}
|
|
335
364
|
|
|
336
365
|
this.logger.verbose(
|
|
@@ -338,6 +367,21 @@ export class TasksService {
|
|
|
338
367
|
);
|
|
339
368
|
}
|
|
340
369
|
|
|
370
|
+
private deleteAccessLogBatch(retentionDays: number, batchLimit: number): Promise<number> {
|
|
371
|
+
return this.prismaService.$executeRaw`
|
|
372
|
+
WITH del AS (
|
|
373
|
+
SELECT id
|
|
374
|
+
FROM access_log
|
|
375
|
+
WHERE created_at < (now() - ${Prisma.sql`INTERVAL '${Prisma.raw(String(retentionDays))} days'`})
|
|
376
|
+
ORDER BY id
|
|
377
|
+
LIMIT ${batchLimit}
|
|
378
|
+
)
|
|
379
|
+
DELETE FROM access_log al
|
|
380
|
+
USING del
|
|
381
|
+
WHERE al.id = del.id
|
|
382
|
+
`;
|
|
383
|
+
}
|
|
384
|
+
|
|
341
385
|
private async clearOldNotifications(): Promise<void> {
|
|
342
386
|
const settings = await this.settingService.getSettingValues([
|
|
343
387
|
'cleanup-batch-limit',
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import {
|
|
2
2
|
WhatsappSendError,
|
|
3
3
|
WhatsappSenderService,
|
|
4
|
+
WHATSAPP_OFFICIAL_ONLY,
|
|
5
|
+
toTemplateParam,
|
|
4
6
|
} from './whatsapp-sender.service';
|
|
5
7
|
|
|
6
8
|
describe('WhatsappSenderService', () => {
|
|
@@ -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
|
-
|
|
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
|
});
|