@ecdt/server-common 1.7.0 → 2.0.0
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/README.md +0 -83
- package/package.json +1 -1
- package/src/index.d.ts +82 -25
- package/src/index.js +2 -3
- package/src/services/motorAvaliadorService.js +443 -0
- package/test/motorAvaliadorService.test.js +222 -0
- package/src/services/cookieService.js +0 -43
- package/src/services/motorEventosService.js +0 -103
- package/test/cookieService.test.js +0 -59
- package/test/motorEventosService.test.js +0 -310
package/README.md
CHANGED
|
@@ -158,89 +158,6 @@ export default defineEventHandler(async (event) => {
|
|
|
158
158
|
|
|
159
159
|
---
|
|
160
160
|
|
|
161
|
-
### `publicarEvento(options)`
|
|
162
|
-
|
|
163
|
-
Envia um evento para o coletor de eventos configurado, sem bloquear o fluxo que o gerou.
|
|
164
|
-
|
|
165
|
-
```js
|
|
166
|
-
const { publicarEvento } = require('@ecdt/server-common');
|
|
167
|
-
|
|
168
|
-
publicarEvento({
|
|
169
|
-
tipo: 'meu_evento',
|
|
170
|
-
autorizacao: req.headers.authorization,
|
|
171
|
-
payload: { campo: 'valor' },
|
|
172
|
-
});
|
|
173
|
-
```
|
|
174
|
-
|
|
175
|
-
| Opção | Default | Descrição |
|
|
176
|
-
|---|---|---|
|
|
177
|
-
| `tipo` | — | Nome do evento, em `snake_case` |
|
|
178
|
-
| `autorizacao` | — | Header `Authorization` da requisição que originou o evento |
|
|
179
|
-
| `idVisitante` | — | Identificador anônimo, para quando não há token |
|
|
180
|
-
| `payload` | `{}` | Campos do evento |
|
|
181
|
-
| `url` | `process.env.MOTOR_EVENTOS_URL` | URL completa do coletor; a função não monta caminho nenhum |
|
|
182
|
-
| `timeoutMs` | `2000` | Aborta a requisição |
|
|
183
|
-
| `aoFalhar` | `console.error` | Recebe a mensagem quando a publicação não acontece |
|
|
184
|
-
|
|
185
|
-
A identidade do usuário sai do token, nunca do corpo: o coletor a resolve a partir do `Authorization`
|
|
186
|
-
repassado. Quando não há token — rota pública, visitante anônimo — vale o `idVisitante`, que vai no
|
|
187
|
-
corpo como `id_visitante`. Ele não é identidade autenticada, é um identificador de correlação: forjá-lo
|
|
188
|
-
não atribui nada à conta de ninguém, e por isso pode vir do corpo da requisição. Basta um dos dois.
|
|
189
|
-
|
|
190
|
-
Sem `tipo`, ou sem nenhum dos dois identificadores, a função não faz nada; sem `url` ela ainda registra
|
|
191
|
-
um `console.warn`, porque url ausente é configuração faltando e não um evento que não se aplica.
|
|
192
|
-
|
|
193
|
-
**A função nunca lança e nunca devolve promise.** O evento é secundário ao fluxo que o gerou —
|
|
194
|
-
falha de publicação não pode virar erro de quem chamou, e o retorno `undefined` impede que alguém
|
|
195
|
-
consiga dar `await` e acoplar a latência do request ao motor.
|
|
196
|
-
|
|
197
|
-
---
|
|
198
|
-
|
|
199
|
-
### `publicarEventoDaRequisicao(req, options)`
|
|
200
|
-
|
|
201
|
-
O mesmo publish, montado a partir da requisição que gerou o evento. Resolve o token pelo header
|
|
202
|
-
`Authorization` e o identificador anônimo pelo corpo (`id_visitante`) ou pelo cookie, nessa ordem.
|
|
203
|
-
|
|
204
|
-
```js
|
|
205
|
-
const { publicarEventoDaRequisicao } = require('@ecdt/server-common');
|
|
206
|
-
|
|
207
|
-
publicarEventoDaRequisicao(req, {
|
|
208
|
-
tipo: 'meu_evento',
|
|
209
|
-
payload: { campo: 'valor' },
|
|
210
|
-
});
|
|
211
|
-
```
|
|
212
|
-
|
|
213
|
-
| Opção | Default | Descrição |
|
|
214
|
-
|---|---|---|
|
|
215
|
-
| `tipo`, `payload`, `url`, `timeoutMs`, `aoFalhar` | — | Idênticos ao `publicarEvento` |
|
|
216
|
-
| `cookieVisitante` | `process.env.MOTOR_EVENTOS_COOKIE_VISITANTE` | Nome do cookie que guarda o identificador anônimo |
|
|
217
|
-
|
|
218
|
-
Sem `cookieVisitante` configurado, só o corpo alimenta o `id_visitante`. Também não lança em
|
|
219
|
-
nenhuma hipótese, inclusive com `req` sem `headers` nem `body`.
|
|
220
|
-
|
|
221
|
-
---
|
|
222
|
-
|
|
223
|
-
### `lerCookie`, `idDoCookie` e `sanitizarId`
|
|
224
|
-
|
|
225
|
-
Leitura do header `Cookie` no servidor, sem dependência de framework.
|
|
226
|
-
|
|
227
|
-
```js
|
|
228
|
-
const { idDoCookie } = require('@ecdt/server-common');
|
|
229
|
-
|
|
230
|
-
const idVisitante = idDoCookie(req.headers.cookie, 'nome_do_cookie');
|
|
231
|
-
```
|
|
232
|
-
|
|
233
|
-
| Função | Devolve |
|
|
234
|
-
|---|---|
|
|
235
|
-
| `lerCookie(cabecalho, nome)` | Valor cru do cookie, ou `null` |
|
|
236
|
-
| `idDoCookie(cabecalho, nome, { maxLen })` | O identificador: aceita o cookie como string simples ou como JSON com campo `id`, decodifica o valor e corta em `maxLen` (100 por padrão) |
|
|
237
|
-
| `sanitizarId(valor, maxLen)` | O mesmo corte e validação, para identificador que veio de outro lugar |
|
|
238
|
-
|
|
239
|
-
O match do nome é exato, então `nome_antigo` não casa com `nome`. Nenhuma das três lança: entrada
|
|
240
|
-
que não é string vira `null`.
|
|
241
|
-
|
|
242
|
-
---
|
|
243
|
-
|
|
244
161
|
## Instalação
|
|
245
162
|
|
|
246
163
|
```bash
|
package/package.json
CHANGED
package/src/index.d.ts
CHANGED
|
@@ -136,38 +136,95 @@ export declare function renderMetrics(
|
|
|
136
136
|
registry?: MetricsRegistry
|
|
137
137
|
): Promise<{ contentType: string; body: string }>;
|
|
138
138
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
139
|
+
/**
|
|
140
|
+
* Avaliação de gatilhos do motor — pura. As cinco leituras chegam em `fonte`, então a mesma função
|
|
141
|
+
* serve a Function (consultas reais) e o simulador do backoffice (eventos de mentira).
|
|
142
|
+
*/
|
|
143
|
+
export interface FonteDeLeitura {
|
|
144
|
+
levantouMaoRecentemente(email: string, desde: string): Promise<unknown>;
|
|
145
|
+
contagemAgrupada(
|
|
146
|
+
idUsuario: number | null,
|
|
147
|
+
tipos: string[],
|
|
148
|
+
campo: string,
|
|
149
|
+
valor: string,
|
|
150
|
+
desde: string,
|
|
151
|
+
ate: string
|
|
152
|
+
): Promise<{ total: string | number } | undefined>;
|
|
153
|
+
contagemDeTipos(
|
|
154
|
+
idUsuario: number | null,
|
|
155
|
+
tipos: string[],
|
|
156
|
+
desde: string,
|
|
157
|
+
ate: string
|
|
158
|
+
): Promise<{ total: string | number } | undefined>;
|
|
159
|
+
ultimoDisparo(
|
|
160
|
+
idUsuario: number | null,
|
|
161
|
+
gatilho: string
|
|
162
|
+
): Promise<{ dt_gatilho: Date | string } | undefined>;
|
|
163
|
+
contagensNaJanela(
|
|
164
|
+
idUsuario: number | null,
|
|
165
|
+
desde: string,
|
|
166
|
+
ate: string
|
|
167
|
+
): Promise<{ tipo: string; total: string | number }[]>;
|
|
147
168
|
}
|
|
148
169
|
|
|
149
|
-
export
|
|
170
|
+
export interface EventoAvaliado {
|
|
171
|
+
eventoId?: string;
|
|
172
|
+
tipo: string;
|
|
173
|
+
idUsuario: number | null;
|
|
174
|
+
dtEvento: Date;
|
|
175
|
+
payload: Record<string, unknown>;
|
|
176
|
+
}
|
|
150
177
|
|
|
151
|
-
export interface
|
|
152
|
-
|
|
153
|
-
|
|
178
|
+
export interface ParamsDoTipo {
|
|
179
|
+
peso?: number;
|
|
180
|
+
bucket?: number;
|
|
154
181
|
}
|
|
155
182
|
|
|
156
|
-
export
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
183
|
+
export interface GatilhoNoCatalogo {
|
|
184
|
+
nome: string;
|
|
185
|
+
disparo: 'imediato' | 'contagem' | 'pontuacao';
|
|
186
|
+
condicao: Record<string, unknown>;
|
|
187
|
+
acoes: { acao: string; ref?: string; usa?: Record<string, string>; params?: unknown }[];
|
|
188
|
+
cooldownH?: number | null;
|
|
189
|
+
}
|
|
160
190
|
|
|
161
|
-
export interface
|
|
162
|
-
|
|
191
|
+
export interface CatalogoDoMotor<G extends GatilhoNoCatalogo = GatilhoNoCatalogo> {
|
|
192
|
+
tipos: Record<string, ParamsDoTipo>;
|
|
193
|
+
acoes: Record<string, { canal: string; ativo: boolean; params: Record<string, unknown> }>;
|
|
194
|
+
gatilhos: G[];
|
|
195
|
+
janelaEmDias: number;
|
|
163
196
|
}
|
|
164
197
|
|
|
165
|
-
export
|
|
198
|
+
export interface PerfilDaConta {
|
|
199
|
+
temConta: boolean;
|
|
200
|
+
cargo: string | null;
|
|
201
|
+
porte: string | null;
|
|
202
|
+
}
|
|
166
203
|
|
|
167
|
-
export
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
204
|
+
export interface ResultadoDaAvaliacao<G extends GatilhoNoCatalogo = GatilhoNoCatalogo> {
|
|
205
|
+
/** Sem `gatilhoId`: a identidade determinística é do chamador, não da decisão. */
|
|
206
|
+
disparos: { gatilho: G; contexto: Record<string, unknown> }[];
|
|
207
|
+
recusas: { gatilho: string; motivo: string }[];
|
|
208
|
+
}
|
|
172
209
|
|
|
173
|
-
|
|
210
|
+
/**
|
|
211
|
+
* Genérica no tipo do gatilho para devolver ao chamador o MESMO tipo que ele passou. O catálogo de
|
|
212
|
+
* cada consumidor é mais rico que o mínimo exigido aqui, e um retorno fixo no tipo mínimo obrigaria
|
|
213
|
+
* a recontruir ou a fazer cast do que já se sabia.
|
|
214
|
+
*/
|
|
215
|
+
export declare function avaliarGatilhos<G extends GatilhoNoCatalogo>(
|
|
216
|
+
evento: EventoAvaliado,
|
|
217
|
+
catalogo: CatalogoDoMotor<G>,
|
|
218
|
+
perfil: PerfilDaConta,
|
|
219
|
+
emailDaConta: string | null,
|
|
220
|
+
fonte: FonteDeLeitura
|
|
221
|
+
): Promise<ResultadoDaAvaliacao<G>>;
|
|
222
|
+
|
|
223
|
+
/** Normaliza o porte às faixas canônicas do cadastro; valor irreconhecível volta vazio. */
|
|
224
|
+
export declare function normalizarPorte(bruto: unknown): string;
|
|
225
|
+
|
|
226
|
+
export declare function pontuar(
|
|
227
|
+
contagens: Record<string, number>,
|
|
228
|
+
tipos: Record<string, ParamsDoTipo>,
|
|
229
|
+
pesosDoGatilho?: Record<string, number>
|
|
230
|
+
): { total: number; porTipo: Record<string, number> };
|
package/src/index.js
CHANGED
|
@@ -3,8 +3,7 @@ const { devMktTokenSanitaze } = require("./services/tokenService.js");
|
|
|
3
3
|
const { expressCors } = require("./services/corsService.js");
|
|
4
4
|
const { setupGracefulShutdown } = require("./services/shutdownService.js");
|
|
5
5
|
const { setupRedMetrics, initRedMetrics, redMetricsMiddleware, metricsHandler, renderMetrics } = require("./services/redMetricsService.js");
|
|
6
|
-
const {
|
|
7
|
-
const { lerCookie, idDoCookie, sanitizarId } = require("./services/cookieService.js");
|
|
6
|
+
const { avaliarGatilhos, pontuar, normalizarPorte } = require("./services/motorAvaliadorService.js");
|
|
8
7
|
|
|
9
8
|
function expressCommonMiddlewares({ cookieName } = {}){
|
|
10
9
|
return [bodyParser.text(), bodyParser.json(), bodyParser.urlencoded({extended: false}), devMktTokenSanitaze({ cookieName })];
|
|
@@ -13,4 +12,4 @@ function expressCommonMiddlewares({ cookieName } = {}){
|
|
|
13
12
|
|
|
14
13
|
|
|
15
14
|
|
|
16
|
-
module.exports = { expressCommonMiddlewares, expressCors, setupGracefulShutdown, setupRedMetrics, initRedMetrics, redMetricsMiddleware, metricsHandler, renderMetrics,
|
|
15
|
+
module.exports = { expressCommonMiddlewares, expressCors, setupGracefulShutdown, setupRedMetrics, initRedMetrics, redMetricsMiddleware, metricsHandler, renderMetrics, avaliarGatilhos, pontuar, normalizarPorte }
|
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Avaliação de gatilhos do motor de eventos — PURA.
|
|
3
|
+
*
|
|
4
|
+
* Nada aqui toca banco, fila ou variável de ambiente: as cinco leituras que a avaliação precisa
|
|
5
|
+
* chegam no parâmetro `fonte`. É isso que permite a mesma função decidir em dois lugares — a
|
|
6
|
+
* `events-engine`, que passa as consultas reais, e o simulador do backoffice, que passa uma
|
|
7
|
+
* implementação em memória sobre eventos de mentira.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// ── porte ─────────────────────────────────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
// Faixas canônicas dos formulários de cadastro do site (site-vue3/utils/normalizeQtdFuncionarios.ts).
|
|
13
|
+
const FAIXAS = ["1-4", "5-10", "11-20", "21-50", "51-200", "201-500", "Mais que 500"];
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* O porte chega do banco em mais de uma grafia: `1-4` convive com o legado `01-04`, e são 60.861
|
|
17
|
+
* usuários na segunda forma. Comparar a string crua faz o gate de perfil deixar passar exatamente a
|
|
18
|
+
* coorte que ele existe para barrar.
|
|
19
|
+
*
|
|
20
|
+
* Valor irreconhecível volta vazio, e não a primeira faixa: público é filtro, e chutar a faixa
|
|
21
|
+
* incluiria quem não deveria entrar.
|
|
22
|
+
*/
|
|
23
|
+
const normalizarPorte = (bruto) => {
|
|
24
|
+
if (!bruto) return "";
|
|
25
|
+
const texto = String(bruto).trim();
|
|
26
|
+
if (FAIXAS.includes(texto)) return texto;
|
|
27
|
+
|
|
28
|
+
const faixa = /^0*(\d+)\s*-\s*0*(\d+)$/.exec(texto);
|
|
29
|
+
if (faixa) {
|
|
30
|
+
const candidato = `${Number(faixa[1])}-${Number(faixa[2])}`;
|
|
31
|
+
if (FAIXAS.includes(candidato)) return candidato;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (/(mais|acima).*500|500\s*\+|>\s*500/i.test(texto)) return "Mais que 500";
|
|
35
|
+
return "";
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// ── público ───────────────────────────────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Casa o público do gatilho com o perfil da conta e devolve a ESPECIFICIDADE — quantos critérios o
|
|
42
|
+
* público declara. É ela que decide o A24: quem casa dois gatilhos por pontuação dispara só o mais
|
|
43
|
+
* específico, e o sem critério é fallback.
|
|
44
|
+
*
|
|
45
|
+
* `plano` na v1: todo usuário do site é gratuito (A28/A39). Um público só de `premium` não casa
|
|
46
|
+
* ninguém, e dizer isso no motivo evita que alguém procure o defeito na pontuação.
|
|
47
|
+
*/
|
|
48
|
+
const casaPublico = (publico, perfil) => {
|
|
49
|
+
if (!publico || Object.keys(publico).length === 0) {
|
|
50
|
+
return { casa: true, especificidade: 0, motivo: "sem critério (fallback)" };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let especificidade = 0;
|
|
54
|
+
|
|
55
|
+
if (publico.com_conta !== undefined) {
|
|
56
|
+
if (publico.com_conta !== perfil.temConta) {
|
|
57
|
+
return { casa: false, motivo: `com_conta=${publico.com_conta} não casa` };
|
|
58
|
+
}
|
|
59
|
+
especificidade += 1;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (publico.cargo) {
|
|
63
|
+
if (!perfil.cargo || !publico.cargo.includes(perfil.cargo)) {
|
|
64
|
+
return { casa: false, motivo: `cargo "${perfil.cargo ?? "vazio"}" fora da lista` };
|
|
65
|
+
}
|
|
66
|
+
especificidade += 1;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (publico.porte) {
|
|
70
|
+
const porte = normalizarPorte(perfil.porte);
|
|
71
|
+
if (!porte || !publico.porte.map(normalizarPorte).includes(porte)) {
|
|
72
|
+
return { casa: false, motivo: `porte "${perfil.porte ?? "vazio"}" fora da lista` };
|
|
73
|
+
}
|
|
74
|
+
especificidade += 1;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (publico.plano) {
|
|
78
|
+
if (!publico.plano.includes("gratuito")) {
|
|
79
|
+
return {
|
|
80
|
+
casa: false,
|
|
81
|
+
motivo: "plano premium não casa ninguém na v1: o usuário do site é gratuito",
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
especificidade += 1;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (publico.excluir) {
|
|
88
|
+
if (perfil.cargo && publico.excluir.cargo?.includes(perfil.cargo)) {
|
|
89
|
+
return { casa: false, motivo: `excluído pelo cargo "${perfil.cargo}"` };
|
|
90
|
+
}
|
|
91
|
+
const porte = normalizarPorte(perfil.porte);
|
|
92
|
+
if (porte && publico.excluir.porte?.map(normalizarPorte).includes(porte)) {
|
|
93
|
+
return { casa: false, motivo: `excluído pelo porte "${perfil.porte}"` };
|
|
94
|
+
}
|
|
95
|
+
especificidade += 1;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return { casa: true, especificidade, motivo: `casa ${especificidade} critério(s)` };
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
// ── horário comercial ─────────────────────────────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* 9h–17h, segunda a sexta, sem feriado nacional, em `America/Sao_Paulo`.
|
|
105
|
+
*
|
|
106
|
+
* É a QUARTA cópia da regra no ecossistema (`site-vue3`, `lps-site` e `ecdt-lead-segmentation`) —
|
|
107
|
+
* veio do `dev-mkt-functions`, não é uma quinta. Mudar a janela exige mudar as quatro, e não há gate
|
|
108
|
+
* que cruze os repositórios.
|
|
109
|
+
*
|
|
110
|
+
* A janela é a do widget (fim exclusivo às 17h), não a do outbound de WhatsApp do
|
|
111
|
+
* `ecdt-lead-segmentation`, que é inclusiva até 17h00 de propósito.
|
|
112
|
+
*
|
|
113
|
+
* O dia e a hora saem do MESMO `Intl`: ler o dia do relógio do processo e a hora do fuso faz o
|
|
114
|
+
* feriado cair no dia errado num contêiner em UTC depois das 21h BRT.
|
|
115
|
+
*/
|
|
116
|
+
const FUSO = "America/Sao_Paulo";
|
|
117
|
+
const HORA_INICIO = 9;
|
|
118
|
+
const HORA_FIM = 17;
|
|
119
|
+
const FERIADOS_FIXOS = ["1-1", "4-21", "5-1", "9-7", "10-12", "11-2", "11-15", "11-20", "12-25"];
|
|
120
|
+
|
|
121
|
+
const partesEmSaoPaulo = (quando) => {
|
|
122
|
+
const formatador = new Intl.DateTimeFormat("en-US", {
|
|
123
|
+
timeZone: FUSO,
|
|
124
|
+
year: "numeric",
|
|
125
|
+
month: "numeric",
|
|
126
|
+
day: "numeric",
|
|
127
|
+
hour: "numeric",
|
|
128
|
+
weekday: "short",
|
|
129
|
+
hour12: false,
|
|
130
|
+
});
|
|
131
|
+
const partes = {};
|
|
132
|
+
for (const parte of formatador.formatToParts(quando)) partes[parte.type] = parte.value;
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
ano: Number(partes.year),
|
|
136
|
+
mes: Number(partes.month),
|
|
137
|
+
dia: Number(partes.day),
|
|
138
|
+
// `hour12: false` devolve 24 para a meia-noite em algumas versões do ICU.
|
|
139
|
+
hora: Number(partes.hour) % 24,
|
|
140
|
+
semana: String(partes.weekday),
|
|
141
|
+
};
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
// Meeus/Jones/Butcher.
|
|
145
|
+
const pascoa = (ano) => {
|
|
146
|
+
const a = ano % 19;
|
|
147
|
+
const b = Math.floor(ano / 100);
|
|
148
|
+
const c = ano % 100;
|
|
149
|
+
const d = Math.floor(b / 4);
|
|
150
|
+
const e = b % 4;
|
|
151
|
+
const f = Math.floor((b + 8) / 25);
|
|
152
|
+
const g = Math.floor((b - f + 1) / 3);
|
|
153
|
+
const h = (19 * a + b - d - g + 15) % 30;
|
|
154
|
+
const i = Math.floor(c / 4);
|
|
155
|
+
const k = c % 4;
|
|
156
|
+
const l = (32 + 2 * e + 2 * i - h - k) % 7;
|
|
157
|
+
const m = Math.floor((a + 11 * h + 22 * l) / 451);
|
|
158
|
+
const mes = Math.floor((h + l - 7 * m + 114) / 31);
|
|
159
|
+
const dia = ((h + l - 7 * m + 114) % 31) + 1;
|
|
160
|
+
return new Date(Date.UTC(ano, mes - 1, dia));
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const ehFeriadoNacional = (ano, mes, dia) => {
|
|
164
|
+
if (FERIADOS_FIXOS.includes(`${mes}-${dia}`)) return true;
|
|
165
|
+
|
|
166
|
+
const base = pascoa(ano);
|
|
167
|
+
const deslocado = (dias) => {
|
|
168
|
+
const data = new Date(base);
|
|
169
|
+
data.setUTCDate(data.getUTCDate() + dias);
|
|
170
|
+
return `${data.getUTCMonth() + 1}-${data.getUTCDate()}`;
|
|
171
|
+
};
|
|
172
|
+
// Terça de Carnaval, Sexta-feira Santa, Corpus Christi.
|
|
173
|
+
return [deslocado(-47), deslocado(-2), deslocado(60)].includes(`${mes}-${dia}`);
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const horarioComercial = (quando) => {
|
|
177
|
+
const { ano, mes, dia, hora, semana } = partesEmSaoPaulo(quando);
|
|
178
|
+
if (semana === "Sat" || semana === "Sun") return { dentro: false, motivo: "fim de semana" };
|
|
179
|
+
if (ehFeriadoNacional(ano, mes, dia)) return { dentro: false, motivo: "feriado nacional" };
|
|
180
|
+
if (hora < HORA_INICIO || hora >= HORA_FIM)
|
|
181
|
+
return { dentro: false, motivo: `${hora}h BRT, fora de 9h-17h` };
|
|
182
|
+
return { dentro: true };
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
// ── pontuação ─────────────────────────────────────────────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Pontuação da janela: para cada tipo, `floor(ocorrências / bucket) × peso`.
|
|
189
|
+
*
|
|
190
|
+
* O bucket é o que reproduz o widget — 25 pontos a cada 3 listas, não 25 por lista. Peso vem do
|
|
191
|
+
* catálogo e pode ser sobrescrito pelo gatilho (A25); tipo sem peso conta zero, e é assim que o
|
|
192
|
+
* `fishing_score` fica gravado sem mexer em pontuação nenhuma.
|
|
193
|
+
*/
|
|
194
|
+
const pontuar = (contagens, tipos, pesosDoGatilho = {}) => {
|
|
195
|
+
const porTipo = {};
|
|
196
|
+
let total = 0;
|
|
197
|
+
|
|
198
|
+
for (const [tipo, ocorrencias] of Object.entries(contagens)) {
|
|
199
|
+
const params = tipos[tipo];
|
|
200
|
+
if (!params) continue;
|
|
201
|
+
|
|
202
|
+
const peso = pesosDoGatilho[tipo] ?? params.peso ?? 0;
|
|
203
|
+
const bucket = params.bucket && params.bucket >= 1 ? params.bucket : 1;
|
|
204
|
+
const soma = Math.floor(ocorrencias / bucket) * peso;
|
|
205
|
+
if (soma > 0) {
|
|
206
|
+
porTipo[tipo] = soma;
|
|
207
|
+
total += soma;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return { total, porTipo };
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Começo da janela de pontuação: a global de N dias, ou o último disparo por pontuação deste gatilho
|
|
216
|
+
* para esta conta, o que for mais recente. É o que substitui o "score zera ao exibir" do widget por um
|
|
217
|
+
* marco derivado — sem coluna de estado, e correto mesmo se o evento for reprocessado (P9).
|
|
218
|
+
*/
|
|
219
|
+
const inicioDaJanela = (agora, dias, ultimoDisparo) => {
|
|
220
|
+
const janela = new Date(agora.getTime() - dias * 86400000);
|
|
221
|
+
if (ultimoDisparo && ultimoDisparo > janela) return ultimoDisparo;
|
|
222
|
+
return janela;
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
// ── avaliador ─────────────────────────────────────────────────────────────────────────────────
|
|
226
|
+
|
|
227
|
+
const DIA_EM_MS = 86400000;
|
|
228
|
+
|
|
229
|
+
const duracaoEmMs = (texto) => {
|
|
230
|
+
const casado = /^([1-9][0-9]*)(h|d)$/.exec(texto ?? "");
|
|
231
|
+
if (!casado) return null;
|
|
232
|
+
return Number(casado[1]) * (casado[2] === "h" ? 3600000 : DIA_EM_MS);
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
const temAcaoInPage = (gatilho, catalogo) =>
|
|
236
|
+
gatilho.acoes.some((item) => catalogo.acoes[item.acao]?.canal === "in_page");
|
|
237
|
+
|
|
238
|
+
const tipoConta = (tipo, catalogo) => catalogo.tipos[tipo] !== undefined;
|
|
239
|
+
|
|
240
|
+
const METODOS_DA_FONTE = [
|
|
241
|
+
"levantouMaoRecentemente",
|
|
242
|
+
"contagemAgrupada",
|
|
243
|
+
"contagemDeTipos",
|
|
244
|
+
"ultimoDisparo",
|
|
245
|
+
"contagensNaJanela",
|
|
246
|
+
];
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Avalia os gatilhos publicados para a conta do evento.
|
|
250
|
+
*
|
|
251
|
+
* A ordem das verificações é por custo: público e horário são memória, e só quem passa por eles chega
|
|
252
|
+
* a consultar a janela. O gate pós-levantada vem antes da contagem pelo mesmo motivo.
|
|
253
|
+
*
|
|
254
|
+
* `fonte` traz as cinco leituras. Num simulador elas respondem sobre eventos de mentira; note que
|
|
255
|
+
* `ultimoDisparo` e `levantouMaoRecentemente` não têm equivalente numa simulação sem passado, e
|
|
256
|
+
* devolver vazio ali faz o intervalo entre disparos e o gate pós-levantada NUNCA barrarem — quem
|
|
257
|
+
* apresenta o resultado precisa dizer isso, senão "não barrou" é lido como "o gate está quebrado".
|
|
258
|
+
*/
|
|
259
|
+
const avaliarGatilhos = async (evento, catalogo, perfil, emailDaConta, fonte) => {
|
|
260
|
+
const faltando = METODOS_DA_FONTE.filter((m) => typeof fonte?.[m] !== "function");
|
|
261
|
+
if (faltando.length) {
|
|
262
|
+
throw new TypeError(`avaliarGatilhos: fonte sem os métodos ${faltando.join(", ")}`);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const disparos = [];
|
|
266
|
+
const recusas = [];
|
|
267
|
+
const janela = horarioComercial(evento.dtEvento);
|
|
268
|
+
let levantouMao = null;
|
|
269
|
+
|
|
270
|
+
// Candidato: para `imediato` e `contagem`, o tipo do evento tem de estar na condição. Para
|
|
271
|
+
// `pontuacao`, qualquer evento que pontue pode fechar o limiar.
|
|
272
|
+
const candidatos = catalogo.gatilhos.filter((g) =>
|
|
273
|
+
g.disparo === "pontuacao" ? true : (g.condicao.tipos ?? []).includes(evento.tipo),
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
const porPontuacao = [];
|
|
277
|
+
|
|
278
|
+
for (const gatilho of candidatos) {
|
|
279
|
+
const publico = casaPublico(gatilho.condicao.publico, perfil);
|
|
280
|
+
if (!publico.casa) {
|
|
281
|
+
recusas.push({ gatilho: gatilho.nome, motivo: publico.motivo });
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Gate pós-levantada (D26): quem levantou a mão nos últimos 30 dias não recebe convite de página.
|
|
286
|
+
if (temAcaoInPage(gatilho, catalogo) && emailDaConta) {
|
|
287
|
+
if (levantouMao === null) {
|
|
288
|
+
const desde = new Date(evento.dtEvento.getTime() - 30 * DIA_EM_MS).toISOString();
|
|
289
|
+
levantouMao = Boolean(await fonte.levantouMaoRecentemente(emailDaConta, desde));
|
|
290
|
+
}
|
|
291
|
+
if (levantouMao) {
|
|
292
|
+
recusas.push({ gatilho: gatilho.nome, motivo: "levantou a mão nos últimos 30 dias" });
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Os três ramos abaixo sempre atribuem os dois; inicializar aqui seria valor morto.
|
|
298
|
+
let fecha;
|
|
299
|
+
let detalhe;
|
|
300
|
+
|
|
301
|
+
if (gatilho.disparo === "imediato") {
|
|
302
|
+
fecha = tipoConta(evento.tipo, catalogo);
|
|
303
|
+
detalhe = { evento: evento.tipo };
|
|
304
|
+
} else if (gatilho.disparo === "contagem") {
|
|
305
|
+
const duracao = duracaoEmMs(gatilho.condicao.janela);
|
|
306
|
+
const quantidade = gatilho.condicao.quantidade ?? Number.POSITIVE_INFINITY;
|
|
307
|
+
if (duracao === null) {
|
|
308
|
+
recusas.push({ gatilho: gatilho.nome, motivo: "janela inválida no cadastro" });
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
const desde = new Date(evento.dtEvento.getTime() - duracao).toISOString();
|
|
312
|
+
const ate = evento.dtEvento.toISOString();
|
|
313
|
+
const tipos = gatilho.condicao.tipos ?? [];
|
|
314
|
+
const campo = gatilho.condicao.agrupar_por?.replace("payload.", "");
|
|
315
|
+
const valor = campo ? evento.payload[campo] : undefined;
|
|
316
|
+
|
|
317
|
+
if (campo && (valor === undefined || valor === null)) {
|
|
318
|
+
recusas.push({
|
|
319
|
+
gatilho: gatilho.nome,
|
|
320
|
+
motivo: `evento sem o campo ${campo} do agrupamento`,
|
|
321
|
+
});
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const linha = campo
|
|
326
|
+
? await fonte.contagemAgrupada(evento.idUsuario, tipos, campo, String(valor), desde, ate)
|
|
327
|
+
: await fonte.contagemDeTipos(evento.idUsuario, tipos, desde, ate);
|
|
328
|
+
const total = Number(linha?.total ?? 0);
|
|
329
|
+
fecha = total >= quantidade;
|
|
330
|
+
detalhe = { contagem: total, quantidade, janela: gatilho.condicao.janela };
|
|
331
|
+
if (campo) detalhe.agrupamento = { [campo]: valor };
|
|
332
|
+
} else {
|
|
333
|
+
const ultimo = await fonte.ultimoDisparo(evento.idUsuario, gatilho.nome);
|
|
334
|
+
const desde = inicioDaJanela(
|
|
335
|
+
evento.dtEvento,
|
|
336
|
+
catalogo.janelaEmDias,
|
|
337
|
+
ultimo?.dt_gatilho ? new Date(ultimo.dt_gatilho) : null,
|
|
338
|
+
);
|
|
339
|
+
const contagens = await fonte.contagensNaJanela(
|
|
340
|
+
evento.idUsuario,
|
|
341
|
+
desde.toISOString(),
|
|
342
|
+
evento.dtEvento.toISOString(),
|
|
343
|
+
);
|
|
344
|
+
const porTipo = {};
|
|
345
|
+
for (const linha of contagens) porTipo[linha.tipo] = Number(linha.total);
|
|
346
|
+
|
|
347
|
+
const { total, porTipo: decomposicao } = pontuar(
|
|
348
|
+
porTipo,
|
|
349
|
+
catalogo.tipos,
|
|
350
|
+
gatilho.condicao.pesos ?? {},
|
|
351
|
+
);
|
|
352
|
+
const limiar = gatilho.condicao.limiar ?? Number.POSITIVE_INFINITY;
|
|
353
|
+
fecha = total >= limiar;
|
|
354
|
+
detalhe = {
|
|
355
|
+
pontuacao: total,
|
|
356
|
+
limiar,
|
|
357
|
+
pesos_aplicados: decomposicao,
|
|
358
|
+
janela_desde: desde.toISOString(),
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (!fecha) {
|
|
363
|
+
recusas.push({
|
|
364
|
+
gatilho: gatilho.nome,
|
|
365
|
+
motivo: `condição não fechou: ${JSON.stringify(detalhe)}`,
|
|
366
|
+
});
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// A32: fora do horário o gatilho não dispara, e a pontuação continua contando — o próximo evento
|
|
371
|
+
// dentro da janela dispara com o acumulado.
|
|
372
|
+
if (gatilho.condicao.horario_comercial && !janela.dentro) {
|
|
373
|
+
recusas.push({
|
|
374
|
+
gatilho: gatilho.nome,
|
|
375
|
+
motivo: `barrado pelo horário comercial: ${janela.motivo}`,
|
|
376
|
+
});
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (gatilho.cooldownH) {
|
|
381
|
+
const ultimo = await fonte.ultimoDisparo(evento.idUsuario, gatilho.nome);
|
|
382
|
+
if (ultimo?.dt_gatilho) {
|
|
383
|
+
const desdeOUltimo = evento.dtEvento.getTime() - new Date(ultimo.dt_gatilho).getTime();
|
|
384
|
+
if (desdeOUltimo < gatilho.cooldownH * 3600000) {
|
|
385
|
+
recusas.push({
|
|
386
|
+
gatilho: gatilho.nome,
|
|
387
|
+
motivo: `em intervalo de ${gatilho.cooldownH}h desde o último disparo`,
|
|
388
|
+
});
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const contexto = {
|
|
395
|
+
...detalhe,
|
|
396
|
+
publico_casado: publico.motivo,
|
|
397
|
+
especificidade: publico.especificidade,
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
if (gatilho.disparo === "pontuacao") {
|
|
401
|
+
porPontuacao.push({ gatilho, especificidade: publico.especificidade, contexto });
|
|
402
|
+
} else {
|
|
403
|
+
disparos.push({ gatilho, contexto });
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// A24: entre os gatilhos por pontuação que fecharam, vence o de público mais específico; empate
|
|
408
|
+
// resolve por prioridade e, por fim, pelo nome, para ser determinístico.
|
|
409
|
+
if (porPontuacao.length > 0) {
|
|
410
|
+
const ordenados = [...porPontuacao].sort(
|
|
411
|
+
(a, b) =>
|
|
412
|
+
b.especificidade - a.especificidade ||
|
|
413
|
+
(b.gatilho.condicao.prioridade ?? 0) - (a.gatilho.condicao.prioridade ?? 0) ||
|
|
414
|
+
a.gatilho.nome.localeCompare(b.gatilho.nome),
|
|
415
|
+
);
|
|
416
|
+
const vencedor = ordenados[0];
|
|
417
|
+
const preteridos = ordenados.slice(1).map((p) => p.gatilho.nome);
|
|
418
|
+
for (const p of preteridos) {
|
|
419
|
+
recusas.push({
|
|
420
|
+
gatilho: p,
|
|
421
|
+
motivo: `preterido por ${vencedor.gatilho.nome}, de público mais específico`,
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
disparos.push({
|
|
425
|
+
gatilho: vencedor.gatilho,
|
|
426
|
+
contexto: {
|
|
427
|
+
...vencedor.contexto,
|
|
428
|
+
...(preteridos.length ? { gatilhos_preteridos: preteridos } : {}),
|
|
429
|
+
},
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
return { disparos, recusas };
|
|
434
|
+
};
|
|
435
|
+
|
|
436
|
+
module.exports = {
|
|
437
|
+
avaliarGatilhos,
|
|
438
|
+
pontuar,
|
|
439
|
+
inicioDaJanela,
|
|
440
|
+
casaPublico,
|
|
441
|
+
normalizarPorte,
|
|
442
|
+
horarioComercial,
|
|
443
|
+
};
|