@horizon-framework/api-nextjs-docs 2.3.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/docs/BATCH_TOTALS_API.md +200 -0
- package/docs/DOMINIO_ENTIDADES.md +328 -0
- package/docs/MODULE_CREATION_PATTERN.md +624 -0
- package/docs/SEARCH_ENGINE_API.md +1038 -0
- package/docs/SSOT_SPECIFICATION_V2.md +333 -0
- package/docs/SYNC_API_PATTERN.md +268 -0
- package/package.json +10 -0
|
@@ -0,0 +1,1038 @@
|
|
|
1
|
+
# Search Engine API - Motor Genérico de Busca
|
|
2
|
+
|
|
3
|
+
> **Motor de busca reutilizável** que funciona IDENTICAMENTE para qualquer entidade.
|
|
4
|
+
>
|
|
5
|
+
> **Implementado atualmente em:**
|
|
6
|
+
> - `Property` (Imóveis) → `/api/property/search`
|
|
7
|
+
>
|
|
8
|
+
> **Futuramente:**
|
|
9
|
+
> - 🔜 `Broker` (Corretores) → `/api/broker/search`
|
|
10
|
+
> - 🔜 `Condominium` (Condomínios) → `/api/condominium/search`
|
|
11
|
+
> - 🔜 Qualquer outra entidade...
|
|
12
|
+
>
|
|
13
|
+
> **A estrutura de request/response é IDÊNTICA** - só muda o endpoint e os campos disponíveis.
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## Quick Decision Tree
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
Preciso de...
|
|
21
|
+
├─ Scroll infinito (mobile/feed)? → CURSOR pagination
|
|
22
|
+
├─ Botões de página (1, 2, 3...)? → OFFSET pagination
|
|
23
|
+
├─ Filtros checkbox (múltipla seleção)? → FACETS com operator='or'
|
|
24
|
+
├─ Filtros hierárquicos (intersecção)? → FACETS com operator='and'
|
|
25
|
+
└─ Apenas contagem rápida? → meta: {}
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## CURSOR vs OFFSET - DECISÃO CRÍTICA
|
|
31
|
+
|
|
32
|
+
### **Como a API detecta o modo?**
|
|
33
|
+
|
|
34
|
+
```typescript
|
|
35
|
+
// CURSOR MODE (infinite scroll)
|
|
36
|
+
{
|
|
37
|
+
list: {
|
|
38
|
+
limit: 20,
|
|
39
|
+
// SEM 'page' = CURSOR MODE
|
|
40
|
+
cursor: "123" // opcional - só na 2ª+ requisição
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// OFFSET MODE (paginação tradicional)
|
|
45
|
+
{
|
|
46
|
+
list: {
|
|
47
|
+
page: 1, // COM 'page' = OFFSET MODE
|
|
48
|
+
limit: 20
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
**Regra:** `Se request.list.page === undefined → CURSOR mode`
|
|
54
|
+
|
|
55
|
+
### **Comparação Rápida**
|
|
56
|
+
|
|
57
|
+
| Feature | CURSOR | OFFSET |
|
|
58
|
+
|---------|---------|---------|
|
|
59
|
+
| **Use quando** | Scroll infinito, feeds | Botões de página (1, 2, 3) |
|
|
60
|
+
| **Performance** | Excelente | Degrada com páginas altas |
|
|
61
|
+
| **Request** | `{ limit, cursor }` | `{ page, limit }` |
|
|
62
|
+
| **Response** | `{ nextCursor, hasNextPage }` | `{ page, total, totalPages, hasNextPage }` |
|
|
63
|
+
| **Vantagens** | Rápido, consistente | Pode pular páginas, mostra total |
|
|
64
|
+
| **Desvantagens** | Não mostra total, só avança | Lento em páginas altas (ex: página 100) |
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
## SCROLL INFINITO (Cursor-based Pagination)
|
|
69
|
+
|
|
70
|
+
### **1ª Requisição (inicial)**
|
|
71
|
+
|
|
72
|
+
```json
|
|
73
|
+
POST /api/property/search
|
|
74
|
+
{
|
|
75
|
+
"filters": {
|
|
76
|
+
"tipo": "Apartamento"
|
|
77
|
+
},
|
|
78
|
+
"list": {
|
|
79
|
+
"limit": 20
|
|
80
|
+
// NÃO enviar 'page'
|
|
81
|
+
// NÃO enviar 'cursor' na primeira vez
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
**Response:**
|
|
87
|
+
```json
|
|
88
|
+
{
|
|
89
|
+
"results": {
|
|
90
|
+
"list": {
|
|
91
|
+
"data": [
|
|
92
|
+
{ "id": 101, "title": "Apto 1..." },
|
|
93
|
+
{ "id": 102, "title": "Apto 2..." },
|
|
94
|
+
// ... 18 mais
|
|
95
|
+
{ "id": 120, "title": "Apto 20..." } // ← último item
|
|
96
|
+
],
|
|
97
|
+
"meta": {
|
|
98
|
+
"limit": 20,
|
|
99
|
+
"hasNextPage": true,
|
|
100
|
+
"nextCursor": 120 // ← ID do último item
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### **2ª+ Requisições (scroll)**
|
|
108
|
+
|
|
109
|
+
```json
|
|
110
|
+
POST /api/property/search
|
|
111
|
+
{
|
|
112
|
+
"filters": {
|
|
113
|
+
"tipo": "Apartamento" // MESMOS filtros
|
|
114
|
+
},
|
|
115
|
+
"list": {
|
|
116
|
+
"limit": 20,
|
|
117
|
+
"cursor": 120 // ← nextCursor da resposta anterior
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
**Response:**
|
|
123
|
+
```json
|
|
124
|
+
{
|
|
125
|
+
"results": {
|
|
126
|
+
"list": {
|
|
127
|
+
"data": [
|
|
128
|
+
{ "id": 121, "title": "Apto 21..." },
|
|
129
|
+
// ... mais 19
|
|
130
|
+
{ "id": 140, "title": "Apto 40..." }
|
|
131
|
+
],
|
|
132
|
+
"meta": {
|
|
133
|
+
"limit": 20,
|
|
134
|
+
"hasNextPage": true,
|
|
135
|
+
"nextCursor": 140 // ← novo cursor
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
### **Última Requisição (fim dos dados)**
|
|
143
|
+
|
|
144
|
+
```json
|
|
145
|
+
{
|
|
146
|
+
"results": {
|
|
147
|
+
"list": {
|
|
148
|
+
"data": [
|
|
149
|
+
{ "id": 141, "title": "Apto 41..." },
|
|
150
|
+
{ "id": 142, "title": "Apto 42..." } // só 2 itens restantes
|
|
151
|
+
],
|
|
152
|
+
"meta": {
|
|
153
|
+
"limit": 20,
|
|
154
|
+
"hasNextPage": false, // ← SEM próxima página
|
|
155
|
+
"nextCursor": undefined // ← SEM cursor
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
### **Implementação Frontend - Checklist**
|
|
163
|
+
|
|
164
|
+
```typescript
|
|
165
|
+
// Estado
|
|
166
|
+
const [items, setItems] = useState([]);
|
|
167
|
+
const [cursor, setCursor] = useState(undefined);
|
|
168
|
+
const [hasMore, setHasMore] = useState(true);
|
|
169
|
+
|
|
170
|
+
// Primeira carga
|
|
171
|
+
async function loadInitial() {
|
|
172
|
+
const response = await fetch('/api/property/search', {
|
|
173
|
+
method: 'POST',
|
|
174
|
+
body: JSON.stringify({
|
|
175
|
+
filters: { tipo: "Apartamento" },
|
|
176
|
+
list: { limit: 20 } // SEM cursor, SEM page
|
|
177
|
+
})
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
const data = await response.json();
|
|
181
|
+
setItems(data.results.list.data);
|
|
182
|
+
setCursor(data.results.list.meta.nextCursor);
|
|
183
|
+
setHasMore(data.results.list.meta.hasNextPage);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Scroll infinito
|
|
187
|
+
async function loadMore() {
|
|
188
|
+
if (!hasMore) return;
|
|
189
|
+
|
|
190
|
+
const response = await fetch('/api/property/search', {
|
|
191
|
+
method: 'POST',
|
|
192
|
+
body: JSON.stringify({
|
|
193
|
+
filters: { tipo: "Apartamento" }, // MESMOS filtros
|
|
194
|
+
list: {
|
|
195
|
+
limit: 20,
|
|
196
|
+
cursor: cursor // ← cursor da última resposta
|
|
197
|
+
}
|
|
198
|
+
})
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
const data = await response.json();
|
|
202
|
+
setItems(prev => [...prev, ...data.results.list.data]); // append
|
|
203
|
+
setCursor(data.results.list.meta.nextCursor);
|
|
204
|
+
setHasMore(data.results.list.meta.hasNextPage);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Quando filtros mudam - RESETAR
|
|
208
|
+
function onFiltersChange(newFilters) {
|
|
209
|
+
setItems([]);
|
|
210
|
+
setCursor(undefined); // ← CRÍTICO: resetar cursor
|
|
211
|
+
setHasMore(true);
|
|
212
|
+
// carregar com novos filtros
|
|
213
|
+
}
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
### **ERROS COMUNS - Scroll Infinito**
|
|
217
|
+
|
|
218
|
+
#### **Erro 1: Enviando `page` junto com `cursor`**
|
|
219
|
+
```json
|
|
220
|
+
// ERRADO
|
|
221
|
+
{
|
|
222
|
+
"list": {
|
|
223
|
+
"page": 1, // ← Remove isso!
|
|
224
|
+
"cursor": 120
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// CORRETO
|
|
229
|
+
{
|
|
230
|
+
"list": {
|
|
231
|
+
"cursor": 120 // Só cursor
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
#### **Erro 2: Não resetar cursor ao mudar filtros**
|
|
237
|
+
```typescript
|
|
238
|
+
// ERRADO - vai trazer dados errados
|
|
239
|
+
function onFilterChange() {
|
|
240
|
+
// cursor ainda tem valor 120 dos filtros antigos!
|
|
241
|
+
loadMore(); // Dados misturados
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// CORRETO
|
|
245
|
+
function onFilterChange() {
|
|
246
|
+
setCursor(undefined); // Reset
|
|
247
|
+
setItems([]);
|
|
248
|
+
loadInitial(); // Nova busca do zero
|
|
249
|
+
}
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
#### **Erro 3: Cursor na primeira requisição**
|
|
253
|
+
```json
|
|
254
|
+
// ERRADO
|
|
255
|
+
{
|
|
256
|
+
"list": {
|
|
257
|
+
"cursor": null // ← Não enviar
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// CORRETO - primeira vez
|
|
262
|
+
{
|
|
263
|
+
"list": {
|
|
264
|
+
"limit": 20
|
|
265
|
+
// sem cursor
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
---
|
|
271
|
+
|
|
272
|
+
## PAGINAÇÃO TRADICIONAL (Offset-based)
|
|
273
|
+
|
|
274
|
+
### **Request**
|
|
275
|
+
```json
|
|
276
|
+
POST /api/property/search
|
|
277
|
+
{
|
|
278
|
+
"filters": {
|
|
279
|
+
"tipo": "Casa"
|
|
280
|
+
},
|
|
281
|
+
"list": {
|
|
282
|
+
"page": 2, // ← Presença de 'page' ativa OFFSET mode
|
|
283
|
+
"limit": 20
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
### **Response**
|
|
289
|
+
```json
|
|
290
|
+
{
|
|
291
|
+
"results": {
|
|
292
|
+
"list": {
|
|
293
|
+
"data": [...],
|
|
294
|
+
"meta": {
|
|
295
|
+
"page": 2,
|
|
296
|
+
"limit": 20,
|
|
297
|
+
"total": 156, // total de resultados
|
|
298
|
+
"totalPages": 8, // total de páginas
|
|
299
|
+
"hasNextPage": true,
|
|
300
|
+
"hasPrevPage": true
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
---
|
|
308
|
+
|
|
309
|
+
## FACETS - Filtros Dinâmicos
|
|
310
|
+
|
|
311
|
+
### **O que são Facets?**
|
|
312
|
+
|
|
313
|
+
Facets retornam **valores possíveis** para campos + **contagens**, permitindo criar filtros dinâmicos tipo:
|
|
314
|
+
|
|
315
|
+
```
|
|
316
|
+
□ Apartamento (450)
|
|
317
|
+
□ Casa (280)
|
|
318
|
+
□ Terreno (89)
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
### **Facets Operators - Quando usar cada um**
|
|
322
|
+
|
|
323
|
+
#### **1. `operator: 'or'` - Checkbox (múltipla seleção)**
|
|
324
|
+
|
|
325
|
+
**Use quando:** Usuário pode selecionar múltiplas opções **do mesmo campo**
|
|
326
|
+
|
|
327
|
+
**Comportamento:** Remove filtro do campo para mostrar **todas opções disponíveis**
|
|
328
|
+
|
|
329
|
+
```json
|
|
330
|
+
// Request - usuário JÁ filtrou tipo=Apartamento
|
|
331
|
+
{
|
|
332
|
+
"filters": {
|
|
333
|
+
"tipo": "Apartamento" // ← filtro aplicado
|
|
334
|
+
},
|
|
335
|
+
"facets": {
|
|
336
|
+
"fields": [
|
|
337
|
+
{
|
|
338
|
+
"type": "terms",
|
|
339
|
+
"field": "tipo",
|
|
340
|
+
"operator": "or" // ← Remove filtro de 'tipo' só para facet
|
|
341
|
+
}
|
|
342
|
+
]
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Response - mostra TODAS opções (não só Apartamento)
|
|
347
|
+
{
|
|
348
|
+
"facets": {
|
|
349
|
+
"data": {
|
|
350
|
+
"tipo": [
|
|
351
|
+
{ "value": "Apartamento", "count": 450 }, // ← filtrado
|
|
352
|
+
{ "value": "Casa", "count": 280 }, // ← também aparece
|
|
353
|
+
{ "value": "Terreno", "count": 89 } // ← também aparece
|
|
354
|
+
]
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
**UI típica:**
|
|
361
|
+
```
|
|
362
|
+
Tipo de Imóvel:
|
|
363
|
+
☑ Apartamento (450) ← selecionado
|
|
364
|
+
□ Casa (280) ← pode selecionar também
|
|
365
|
+
□ Terreno (89) ← pode selecionar também
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
#### **2. `operator: 'and'` - Intersecção (arrays)**
|
|
369
|
+
|
|
370
|
+
**Use quando:** Valores são arrays e você quer mostrar **intersecções**
|
|
371
|
+
|
|
372
|
+
**Comportamento:** Mantém filtro, mostra quais outros valores coexistem
|
|
373
|
+
|
|
374
|
+
```json
|
|
375
|
+
// Request - usuário filtrou características=["piscina"]
|
|
376
|
+
{
|
|
377
|
+
"filters": {
|
|
378
|
+
"caracteristicas": ["piscina"] // ← filtro aplicado
|
|
379
|
+
},
|
|
380
|
+
"facets": {
|
|
381
|
+
"fields": [
|
|
382
|
+
{
|
|
383
|
+
"type": "terms",
|
|
384
|
+
"field": "caracteristicas",
|
|
385
|
+
"operator": "and" // ← Mantém filtro, mostra intersecções
|
|
386
|
+
}
|
|
387
|
+
]
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// Response - mostra características que TÊM piscina
|
|
392
|
+
{
|
|
393
|
+
"facets": {
|
|
394
|
+
"data": {
|
|
395
|
+
"caracteristicas": [
|
|
396
|
+
{ "value": "piscina", "count": 120 }, // ← filtrado
|
|
397
|
+
{ "value": "churrasqueira", "count": 80 }, // ← 80 imóveis têm AMBOS
|
|
398
|
+
{ "value": "academia", "count": 45 } // ← 45 imóveis têm AMBOS
|
|
399
|
+
]
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
**UI típica:**
|
|
406
|
+
```
|
|
407
|
+
Imóveis com piscina também têm:
|
|
408
|
+
☑ Piscina (120)
|
|
409
|
+
□ Churrasqueira (80) ← 80 imóveis têm piscina + churrasqueira
|
|
410
|
+
□ Academia (45) ← 45 imóveis têm piscina + academia
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
#### **3. `operator: 'equals'` - Filtro único**
|
|
414
|
+
|
|
415
|
+
**Use quando:** Usuário pode selecionar **apenas 1 opção** (radio button)
|
|
416
|
+
|
|
417
|
+
**Comportamento:** Mantém filtro completo, mostra **só o selecionado**
|
|
418
|
+
|
|
419
|
+
```json
|
|
420
|
+
// Request
|
|
421
|
+
{
|
|
422
|
+
"filters": {
|
|
423
|
+
"tipo": "Apartamento"
|
|
424
|
+
},
|
|
425
|
+
"facets": {
|
|
426
|
+
"fields": [
|
|
427
|
+
{
|
|
428
|
+
"type": "terms",
|
|
429
|
+
"field": "tipo",
|
|
430
|
+
"operator": "equals" // ← Mantém filtro
|
|
431
|
+
}
|
|
432
|
+
]
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// Response - mostra SÓ o filtrado
|
|
437
|
+
{
|
|
438
|
+
"facets": {
|
|
439
|
+
"data": {
|
|
440
|
+
"tipo": [
|
|
441
|
+
{ "value": "Apartamento", "count": 450 } // ← só este
|
|
442
|
+
]
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
### **Range Facets - Faixas Numéricas**
|
|
449
|
+
|
|
450
|
+
#### **Buckets Automáticos**
|
|
451
|
+
```json
|
|
452
|
+
{
|
|
453
|
+
"facets": {
|
|
454
|
+
"fields": [
|
|
455
|
+
{
|
|
456
|
+
"type": "range",
|
|
457
|
+
"field": "valor_venda",
|
|
458
|
+
"bucketCount": 4 // gera 4 faixas automaticamente
|
|
459
|
+
}
|
|
460
|
+
]
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
```
|
|
464
|
+
|
|
465
|
+
**Response:**
|
|
466
|
+
```json
|
|
467
|
+
{
|
|
468
|
+
"facets": {
|
|
469
|
+
"data": {
|
|
470
|
+
"valor_venda": [
|
|
471
|
+
{ "from": 0, "to": 300000, "count": 150 },
|
|
472
|
+
{ "from": 300000, "to": 600000, "count": 280 },
|
|
473
|
+
{ "from": 600000, "to": 1000000, "count": 100 },
|
|
474
|
+
{ "from": 1000000, "to": 5000000, "count": 39 }
|
|
475
|
+
]
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
```
|
|
480
|
+
|
|
481
|
+
#### **Buckets Customizados (com labels)**
|
|
482
|
+
```json
|
|
483
|
+
{
|
|
484
|
+
"facets": {
|
|
485
|
+
"fields": [
|
|
486
|
+
{
|
|
487
|
+
"type": "range",
|
|
488
|
+
"field": "valor_venda",
|
|
489
|
+
"buckets": [
|
|
490
|
+
{ "from": 0, "to": 300000, "label": "Econômico" },
|
|
491
|
+
{ "from": 300000, "to": 700000, "label": "Médio" },
|
|
492
|
+
{ "from": 700000, "to": 1500000, "label": "Alto" },
|
|
493
|
+
{ "from": 1500000, "to": 999999999, "label": "Premium" }
|
|
494
|
+
]
|
|
495
|
+
}
|
|
496
|
+
]
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
```
|
|
500
|
+
|
|
501
|
+
**Response:**
|
|
502
|
+
```json
|
|
503
|
+
{
|
|
504
|
+
"facets": {
|
|
505
|
+
"data": {
|
|
506
|
+
"valor_venda": [
|
|
507
|
+
{ "from": 0, "to": 300000, "label": "Econômico", "count": 150 },
|
|
508
|
+
{ "from": 300000, "to": 700000, "label": "Médio", "count": 280 },
|
|
509
|
+
{ "from": 700000, "to": 1500000, "label": "Alto", "count": 100 },
|
|
510
|
+
{ "from": 1500000, "to": 999999999, "label": "Premium", "count": 39 }
|
|
511
|
+
]
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
```
|
|
516
|
+
|
|
517
|
+
### **Range Facets - Comportamento Especial**
|
|
518
|
+
|
|
519
|
+
**Range facets SEMPRE removem o filtro do campo** para mostrar todos os buckets disponíveis.
|
|
520
|
+
|
|
521
|
+
```json
|
|
522
|
+
// Request - usuário já filtrou valor_venda
|
|
523
|
+
{
|
|
524
|
+
"filters": {
|
|
525
|
+
"valor_venda": { "gte": 300000, "lte": 700000 } // ← filtro aplicado
|
|
526
|
+
},
|
|
527
|
+
"facets": {
|
|
528
|
+
"fields": [
|
|
529
|
+
{
|
|
530
|
+
"type": "range",
|
|
531
|
+
"field": "valor_venda",
|
|
532
|
+
"bucketCount": 4
|
|
533
|
+
}
|
|
534
|
+
]
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// Response - mostra TODOS os buckets (não só 300k-700k)
|
|
539
|
+
{
|
|
540
|
+
"facets": {
|
|
541
|
+
"data": {
|
|
542
|
+
"valor_venda": [
|
|
543
|
+
{ "from": 0, "to": 300000, "count": 150 }, // ← aparece
|
|
544
|
+
{ "from": 300000, "to": 600000, "count": 280 }, // ← filtrado
|
|
545
|
+
{ "from": 600000, "to": 1000000, "count": 100 }, // ← aparece
|
|
546
|
+
{ "from": 1000000, "to": 5000000, "count": 39 } // ← aparece
|
|
547
|
+
]
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
```
|
|
552
|
+
|
|
553
|
+
**Por quê?** Para permitir que usuário veja outras faixas e mude a seleção.
|
|
554
|
+
|
|
555
|
+
---
|
|
556
|
+
|
|
557
|
+
## Casos de Uso Completos
|
|
558
|
+
|
|
559
|
+
### **1. Scroll Infinito + Facets**
|
|
560
|
+
```json
|
|
561
|
+
POST /api/property/search
|
|
562
|
+
{
|
|
563
|
+
"filters": {
|
|
564
|
+
"tipo": "Apartamento"
|
|
565
|
+
},
|
|
566
|
+
"list": {
|
|
567
|
+
"limit": 20,
|
|
568
|
+
"cursor": 120 // omitir na primeira vez
|
|
569
|
+
},
|
|
570
|
+
"facets": {
|
|
571
|
+
"fields": [
|
|
572
|
+
{ "type": "terms", "field": "dormitorios" },
|
|
573
|
+
{ "type": "range", "field": "valor_venda", "bucketCount": 4 }
|
|
574
|
+
]
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
```
|
|
578
|
+
|
|
579
|
+
### **2. Paginação + Total + Mapa**
|
|
580
|
+
```json
|
|
581
|
+
POST /api/property/search
|
|
582
|
+
{
|
|
583
|
+
"filters": {
|
|
584
|
+
"operacao": { "or": ["venda", "locacao"] },
|
|
585
|
+
"endereco_cidade": "Torres"
|
|
586
|
+
},
|
|
587
|
+
"list": {
|
|
588
|
+
"page": 1,
|
|
589
|
+
"limit": 20,
|
|
590
|
+
"select": {
|
|
591
|
+
"reference": true,
|
|
592
|
+
"title": true,
|
|
593
|
+
"valor_venda": true
|
|
594
|
+
}
|
|
595
|
+
},
|
|
596
|
+
"map": {
|
|
597
|
+
"select": {
|
|
598
|
+
"reference": true,
|
|
599
|
+
"lat": true,
|
|
600
|
+
"lng": true
|
|
601
|
+
},
|
|
602
|
+
"limit": 100
|
|
603
|
+
},
|
|
604
|
+
"meta": {}
|
|
605
|
+
}
|
|
606
|
+
```
|
|
607
|
+
|
|
608
|
+
### **3. Full Text Search + Filtros Geo**
|
|
609
|
+
```json
|
|
610
|
+
POST /api/property/search
|
|
611
|
+
{
|
|
612
|
+
"fts": {
|
|
613
|
+
"value": "apartamento centro piscina",
|
|
614
|
+
"operator": "websearch"
|
|
615
|
+
},
|
|
616
|
+
"geom": {
|
|
617
|
+
"operator": "within",
|
|
618
|
+
"value": {
|
|
619
|
+
"type": "bbox",
|
|
620
|
+
"bounds": {
|
|
621
|
+
"north": -29.3,
|
|
622
|
+
"south": -29.4,
|
|
623
|
+
"east": -49.65,
|
|
624
|
+
"west": -49.8
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
},
|
|
628
|
+
"list": {
|
|
629
|
+
"limit": 20
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
```
|
|
633
|
+
|
|
634
|
+
### **4. Apenas Contagem (super rápido)**
|
|
635
|
+
```json
|
|
636
|
+
POST /api/property/search
|
|
637
|
+
{
|
|
638
|
+
"filters": {
|
|
639
|
+
"tipo": "Apartamento",
|
|
640
|
+
"dormitorios": { "gte": 2 }
|
|
641
|
+
},
|
|
642
|
+
"meta": {}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// Response em ~15ms
|
|
646
|
+
{
|
|
647
|
+
"results": {
|
|
648
|
+
"meta": { "total": 691 }
|
|
649
|
+
},
|
|
650
|
+
"metadata": {
|
|
651
|
+
"executionTime": 15
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
```
|
|
655
|
+
|
|
656
|
+
### **5. Batch Search (múltiplas queries paralelas)**
|
|
657
|
+
```json
|
|
658
|
+
POST /api/property/batch-search
|
|
659
|
+
{
|
|
660
|
+
"queries": [
|
|
661
|
+
{
|
|
662
|
+
"key": "apt-2q",
|
|
663
|
+
"filters": { "tipo": "Apartamento", "dormitorios": 2 },
|
|
664
|
+
"meta": {}
|
|
665
|
+
},
|
|
666
|
+
{
|
|
667
|
+
"key": "apt-3q",
|
|
668
|
+
"filters": { "tipo": "Apartamento", "dormitorios": 3 },
|
|
669
|
+
"meta": {}
|
|
670
|
+
},
|
|
671
|
+
{
|
|
672
|
+
"key": "casas",
|
|
673
|
+
"filters": { "tipo": "Casa" },
|
|
674
|
+
"list": { "limit": 5 }
|
|
675
|
+
}
|
|
676
|
+
]
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// Response com resultados separados por key
|
|
680
|
+
{
|
|
681
|
+
"results": {
|
|
682
|
+
"apt-2q": {
|
|
683
|
+
"results": { "meta": { "total": 321 } },
|
|
684
|
+
"metadata": { "executionTime": 30 }
|
|
685
|
+
},
|
|
686
|
+
"apt-3q": {
|
|
687
|
+
"results": { "meta": { "total": 370 } },
|
|
688
|
+
"metadata": { "executionTime": 28 }
|
|
689
|
+
},
|
|
690
|
+
"casas": {
|
|
691
|
+
"results": { "list": {...}, "meta": {...} },
|
|
692
|
+
"metadata": { "executionTime": 45 }
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
```
|
|
697
|
+
|
|
698
|
+
---
|
|
699
|
+
|
|
700
|
+
## TypeScript Schemas
|
|
701
|
+
|
|
702
|
+
### **SearchRequest**
|
|
703
|
+
```typescript
|
|
704
|
+
interface SearchRequest {
|
|
705
|
+
// Filtros
|
|
706
|
+
filters?: Record<string, FilterValue>;
|
|
707
|
+
|
|
708
|
+
// Full Text Search
|
|
709
|
+
fts?: {
|
|
710
|
+
value: string;
|
|
711
|
+
operator?: 'websearch' | 'plainto' | 'phrase' | 'boolean';
|
|
712
|
+
};
|
|
713
|
+
|
|
714
|
+
// Busca geográfica
|
|
715
|
+
geom?: {
|
|
716
|
+
operator: 'within' | 'intersects' | 'near';
|
|
717
|
+
value: {
|
|
718
|
+
type: 'bbox' | 'circle' | 'polygon';
|
|
719
|
+
bounds?: { north: number; south: number; east: number; west: number; };
|
|
720
|
+
center?: { lat: number; lng: number; };
|
|
721
|
+
radius?: number;
|
|
722
|
+
coordinates?: number[][];
|
|
723
|
+
};
|
|
724
|
+
};
|
|
725
|
+
|
|
726
|
+
// Serviços (escolha quais quer)
|
|
727
|
+
list?: {
|
|
728
|
+
// Offset-based
|
|
729
|
+
page?: number;
|
|
730
|
+
limit?: number;
|
|
731
|
+
|
|
732
|
+
// Cursor-based (mutuamente exclusivo com page)
|
|
733
|
+
cursor?: string | number;
|
|
734
|
+
|
|
735
|
+
// Comum
|
|
736
|
+
select?: Record<string, any>;
|
|
737
|
+
include?: Record<string, any>;
|
|
738
|
+
sort?: Record<string, 'asc' | 'desc'>;
|
|
739
|
+
};
|
|
740
|
+
|
|
741
|
+
map?: {
|
|
742
|
+
select?: Record<string, any>;
|
|
743
|
+
limit?: number;
|
|
744
|
+
bounds?: { north: number; south: number; east: number; west: number; };
|
|
745
|
+
};
|
|
746
|
+
|
|
747
|
+
meta?: {};
|
|
748
|
+
|
|
749
|
+
facets?: {
|
|
750
|
+
includeCount?: boolean;
|
|
751
|
+
fields: Array<{
|
|
752
|
+
type: 'terms' | 'range';
|
|
753
|
+
field: string;
|
|
754
|
+
operator?: 'or' | 'and' | 'equals';
|
|
755
|
+
buckets?: number[] | Array<{ from: number; to: number; label: string; }>;
|
|
756
|
+
bucketCount?: number;
|
|
757
|
+
}>;
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
```
|
|
761
|
+
|
|
762
|
+
### **SearchResponse**
|
|
763
|
+
```typescript
|
|
764
|
+
interface SearchResponse {
|
|
765
|
+
results: {
|
|
766
|
+
list?: {
|
|
767
|
+
data: any[];
|
|
768
|
+
meta: {
|
|
769
|
+
limit: number;
|
|
770
|
+
hasNextPage: boolean;
|
|
771
|
+
|
|
772
|
+
// Offset-based
|
|
773
|
+
page?: number;
|
|
774
|
+
total?: number;
|
|
775
|
+
totalPages?: number;
|
|
776
|
+
hasPrevPage?: boolean;
|
|
777
|
+
|
|
778
|
+
// Cursor-based
|
|
779
|
+
nextCursor?: string | number;
|
|
780
|
+
};
|
|
781
|
+
};
|
|
782
|
+
|
|
783
|
+
map?: {
|
|
784
|
+
data: any[];
|
|
785
|
+
meta: {
|
|
786
|
+
totalWithCoordinates: number;
|
|
787
|
+
};
|
|
788
|
+
};
|
|
789
|
+
|
|
790
|
+
meta?: {
|
|
791
|
+
total: number;
|
|
792
|
+
};
|
|
793
|
+
|
|
794
|
+
facets?: {
|
|
795
|
+
data: Record<string, Array<{
|
|
796
|
+
// Terms facet
|
|
797
|
+
value?: any;
|
|
798
|
+
label?: string;
|
|
799
|
+
count?: number;
|
|
800
|
+
|
|
801
|
+
// Range facet
|
|
802
|
+
from?: number;
|
|
803
|
+
to?: number;
|
|
804
|
+
}>>;
|
|
805
|
+
};
|
|
806
|
+
};
|
|
807
|
+
|
|
808
|
+
metadata: {
|
|
809
|
+
executionTime: number;
|
|
810
|
+
};
|
|
811
|
+
}
|
|
812
|
+
```
|
|
813
|
+
|
|
814
|
+
### **FilterValue**
|
|
815
|
+
```typescript
|
|
816
|
+
type FilterValue =
|
|
817
|
+
| string // Valor simples
|
|
818
|
+
| number
|
|
819
|
+
| boolean
|
|
820
|
+
| { or: any[] } // OR lógico
|
|
821
|
+
| { and: any[] } // AND lógico
|
|
822
|
+
| { gte: number } // Maior ou igual
|
|
823
|
+
| { lte: number } // Menor ou igual
|
|
824
|
+
| { gt: number } // Maior que
|
|
825
|
+
| { lt: number } // Menor que
|
|
826
|
+
| { between: [number, number] } // Entre valores
|
|
827
|
+
```
|
|
828
|
+
|
|
829
|
+
---
|
|
830
|
+
|
|
831
|
+
## Campos Disponíveis (Property)
|
|
832
|
+
|
|
833
|
+
### **Principais campos para filtros/select**
|
|
834
|
+
```typescript
|
|
835
|
+
{
|
|
836
|
+
// Identificação
|
|
837
|
+
reference: string;
|
|
838
|
+
title: string;
|
|
839
|
+
description: string;
|
|
840
|
+
|
|
841
|
+
// Comercial
|
|
842
|
+
operacao: string[]; // ["venda", "locacao", "temporada"]
|
|
843
|
+
tipo: string; // "Apartamento", "Casa", etc.
|
|
844
|
+
subtipo: string;
|
|
845
|
+
valor_venda: number;
|
|
846
|
+
valor_locacao: number;
|
|
847
|
+
valor_diaria: number;
|
|
848
|
+
|
|
849
|
+
// Estrutura
|
|
850
|
+
area_total: number;
|
|
851
|
+
dormitorios: number;
|
|
852
|
+
suites: number;
|
|
853
|
+
banheiros: number;
|
|
854
|
+
vagas_garagem: number;
|
|
855
|
+
|
|
856
|
+
// Localização
|
|
857
|
+
endereco_cidade: string;
|
|
858
|
+
endereco_bairro: string;
|
|
859
|
+
lat: number;
|
|
860
|
+
lng: number;
|
|
861
|
+
|
|
862
|
+
// Características
|
|
863
|
+
caracteristicas: string[];
|
|
864
|
+
mobiliado: boolean;
|
|
865
|
+
|
|
866
|
+
// Sistema
|
|
867
|
+
created_at: string;
|
|
868
|
+
updated_at: string;
|
|
869
|
+
}
|
|
870
|
+
```
|
|
871
|
+
|
|
872
|
+
---
|
|
873
|
+
|
|
874
|
+
## Reference Rápida
|
|
875
|
+
|
|
876
|
+
### **Operadores de Filtro**
|
|
877
|
+
| Operador | Exemplo |
|
|
878
|
+
|----------|---------|
|
|
879
|
+
| Simples | `"tipo": "Apartamento"` |
|
|
880
|
+
| OR | `"operacao": { "or": ["venda", "locacao"] }` |
|
|
881
|
+
| AND | `"caracteristicas": { "and": ["piscina", "churrasqueira"] }` |
|
|
882
|
+
| GTE | `"dormitorios": { "gte": 2 }` |
|
|
883
|
+
| LTE | `"valor_venda": { "lte": 500000 }` |
|
|
884
|
+
| BETWEEN | `"valor_venda": { "between": [300000, 800000] }` |
|
|
885
|
+
|
|
886
|
+
### **Endpoints Disponíveis**
|
|
887
|
+
```
|
|
888
|
+
POST /api/property/search # Busca multi-parte
|
|
889
|
+
POST /api/property/batch-search # Múltiplas buscas paralelas
|
|
890
|
+
```
|
|
891
|
+
|
|
892
|
+
### **Teste Rápido (curl)**
|
|
893
|
+
```bash
|
|
894
|
+
curl -X POST http://localhost:5000/api/property/search \
|
|
895
|
+
-H "Content-Type: application/json" \
|
|
896
|
+
-d '{
|
|
897
|
+
"filters": { "tipo": "Apartamento" },
|
|
898
|
+
"list": { "limit": 5 },
|
|
899
|
+
"meta": {}
|
|
900
|
+
}'
|
|
901
|
+
```
|
|
902
|
+
|
|
903
|
+
---
|
|
904
|
+
|
|
905
|
+
## Checklist Final
|
|
906
|
+
|
|
907
|
+
Antes de fazer request, verifique:
|
|
908
|
+
|
|
909
|
+
**Paginação:**
|
|
910
|
+
- [ ] Decidiu cursor vs offset?
|
|
911
|
+
- [ ] Cursor: NÃO enviar `page`
|
|
912
|
+
- [ ] Offset: SEMPRE enviar `page`
|
|
913
|
+
- [ ] Cursor: resetar ao mudar filtros
|
|
914
|
+
|
|
915
|
+
**Facets:**
|
|
916
|
+
- [ ] Checkbox múltipla? → `operator: 'or'`
|
|
917
|
+
- [ ] Intersecção (arrays)? → `operator: 'and'`
|
|
918
|
+
- [ ] Radio button (1 opção)? → `operator: 'equals'`
|
|
919
|
+
- [ ] Range: vai mostrar TODOS os buckets
|
|
920
|
+
|
|
921
|
+
**Geral:**
|
|
922
|
+
- [ ] Content-Type: `application/json`
|
|
923
|
+
- [ ] Campos válidos da entidade
|
|
924
|
+
- [ ] Operadores corretos (`or`, `and`, `gte`, `lte`)
|
|
925
|
+
|
|
926
|
+
---
|
|
927
|
+
|
|
928
|
+
## RELATIONS - Campos Virtualizados
|
|
929
|
+
|
|
930
|
+
### **O que são Relations?**
|
|
931
|
+
|
|
932
|
+
Relations são campos que representam **relacionamentos entre entidades** definidos no Entity Schema. Por exemplo:
|
|
933
|
+
- Property → Broker (imóvel pertence a um corretor)
|
|
934
|
+
- Broker → Properties (corretor tem vários imóveis)
|
|
935
|
+
|
|
936
|
+
### **Definindo Relations no Entity Schema**
|
|
937
|
+
|
|
938
|
+
```typescript
|
|
939
|
+
// apps/api/src/modules/property/schemas/entity-schema.ts
|
|
940
|
+
{
|
|
941
|
+
key: "broker",
|
|
942
|
+
type: "Relation",
|
|
943
|
+
categories: ["relations"],
|
|
944
|
+
relation: {
|
|
945
|
+
model: "Broker", // Nome do modelo Prisma
|
|
946
|
+
field: "key", // Campo no modelo relacionado
|
|
947
|
+
labelField: "name", // Campo para exibição
|
|
948
|
+
displayTemplate: "{{name}}",
|
|
949
|
+
foreignKey: "corretor_key" // FK nesta entidade
|
|
950
|
+
},
|
|
951
|
+
ui: { label: "Corretor" },
|
|
952
|
+
audit: { origin: "local-relation" }
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
// apps/api/src/modules/broker/schemas/entity-schema.ts
|
|
956
|
+
{
|
|
957
|
+
key: "properties",
|
|
958
|
+
type: "Relation",
|
|
959
|
+
categories: ["relations"],
|
|
960
|
+
relation: {
|
|
961
|
+
model: "Property",
|
|
962
|
+
field: "corretor_key", // Campo no Property que referencia broker
|
|
963
|
+
labelField: "title",
|
|
964
|
+
displayTemplate: "{{title}}"
|
|
965
|
+
},
|
|
966
|
+
ui: { label: "Imóveis do Corretor" },
|
|
967
|
+
audit: { origin: "local-relation" }
|
|
968
|
+
}
|
|
969
|
+
```
|
|
970
|
+
|
|
971
|
+
### **resolveVirtualFilters - Filtros Virtuais**
|
|
972
|
+
|
|
973
|
+
O sistema suporta filtros em campos virtuais (Relations) através do `resolveVirtualFilters`.
|
|
974
|
+
|
|
975
|
+
**Exemplo de uso:**
|
|
976
|
+
```json
|
|
977
|
+
POST /api/property/search
|
|
978
|
+
{
|
|
979
|
+
"filters": {
|
|
980
|
+
"broker": "joao-silva" // Filtro virtual pelo slug do corretor
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
```
|
|
984
|
+
|
|
985
|
+
**Como funciona internamente:**
|
|
986
|
+
1. Search Engine detecta `broker` como campo Relation
|
|
987
|
+
2. `resolveVirtualFilters` transforma em query real
|
|
988
|
+
3. Busca broker pelo slug/key
|
|
989
|
+
4. Converte para filtro: `{ corretor_key: "key-do-broker" }`
|
|
990
|
+
|
|
991
|
+
### **Facets em Relations**
|
|
992
|
+
|
|
993
|
+
Relations também suportam facets para criar filtros dinâmicos:
|
|
994
|
+
|
|
995
|
+
```json
|
|
996
|
+
{
|
|
997
|
+
"facets": {
|
|
998
|
+
"fields": [
|
|
999
|
+
{
|
|
1000
|
+
"type": "terms",
|
|
1001
|
+
"field": "broker",
|
|
1002
|
+
"operator": "or"
|
|
1003
|
+
}
|
|
1004
|
+
]
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
// Response
|
|
1009
|
+
{
|
|
1010
|
+
"facets": {
|
|
1011
|
+
"data": {
|
|
1012
|
+
"broker": [
|
|
1013
|
+
{ "value": "joao-silva", "label": "João Silva", "count": 45 },
|
|
1014
|
+
{ "value": "maria-santos", "label": "Maria Santos", "count": 32 }
|
|
1015
|
+
]
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
```
|
|
1020
|
+
|
|
1021
|
+
---
|
|
1022
|
+
|
|
1023
|
+
## Arquivos de Referência
|
|
1024
|
+
|
|
1025
|
+
### **Código Fonte**
|
|
1026
|
+
- **Controller**: `/apps/api/src/modules/property/controllers/search.controller.ts`
|
|
1027
|
+
- **Service**: `/apps/api/src/modules/property/services/search.service.ts`
|
|
1028
|
+
- **Types**: `/apps/api/src/lib/advancedSearch/types.ts`
|
|
1029
|
+
- **Cursor Pagination**: `/apps/api/src/lib/advancedSearch/executeList.ts` (linha 46)
|
|
1030
|
+
|
|
1031
|
+
### **Biblioteca Genérica**
|
|
1032
|
+
- **Advanced Search**: `/apps/api/src/lib/advancedSearch/index.ts`
|
|
1033
|
+
- **Facets System**: `/apps/api/src/lib/advancedSearch/facets/index.ts`
|
|
1034
|
+
- **Relations Enricher**: `/apps/api/src/lib/advancedSearch/facets/enrichers/relation.ts`
|
|
1035
|
+
|
|
1036
|
+
### **Entity Schemas**
|
|
1037
|
+
- **Property**: `/apps/api/src/modules/property/schemas/entity-schema.ts`
|
|
1038
|
+
- **Broker**: `/apps/api/src/modules/broker/schemas/entity-schema.ts`
|