ng-firebase-table-kxp 1.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/CHANGELOG.md +88 -0
- package/README.md +476 -0
- package/ng-package.json +7 -0
- package/package.json +36 -0
- package/src/lib/components/table/table.component.html +555 -0
- package/src/lib/components/table/table.component.scss +22 -0
- package/src/lib/components/table/table.component.spec.ts +24 -0
- package/src/lib/components/table/table.component.ts +917 -0
- package/src/lib/firebase-table-kxp-lib.component.spec.ts +23 -0
- package/src/lib/firebase-table-kxp-lib.component.ts +15 -0
- package/src/lib/firebase-table-kxp-lib.module.ts +45 -0
- package/src/lib/firebase-table-kxp-lib.service.spec.ts +16 -0
- package/src/lib/firebase-table-kxp-lib.service.ts +9 -0
- package/src/lib/services/table.service.spec.ts +16 -0
- package/src/lib/services/table.service.ts +1235 -0
- package/src/lib/types/Table.ts +142 -0
- package/src/public-api.ts +19 -0
- package/tsconfig.lib.json +14 -0
- package/tsconfig.lib.prod.json +10 -0
- package/tsconfig.spec.json +14 -0
|
@@ -0,0 +1,1235 @@
|
|
|
1
|
+
import { Injectable, Optional } from '@angular/core';
|
|
2
|
+
import {
|
|
3
|
+
AngularFirestore,
|
|
4
|
+
CollectionReference,
|
|
5
|
+
QueryDocumentSnapshot,
|
|
6
|
+
} from '@angular/fire/compat/firestore';
|
|
7
|
+
import firebase from 'firebase/compat/app';
|
|
8
|
+
import { Arrange, Condition, Pagination } from '../types/Table';
|
|
9
|
+
import { firstValueFrom } from 'rxjs';
|
|
10
|
+
import { MatDialog } from '@angular/material/dialog';
|
|
11
|
+
import { ToastrService } from 'ngx-toastr';
|
|
12
|
+
import { AbstractControl, ValidatorFn } from '@angular/forms';
|
|
13
|
+
import * as moment from 'moment';
|
|
14
|
+
|
|
15
|
+
interface PaginationResult {
|
|
16
|
+
items: any[];
|
|
17
|
+
filterLength: number | null;
|
|
18
|
+
firstDoc: firebase.firestore.QueryDocumentSnapshot<unknown> | null;
|
|
19
|
+
lastDoc: firebase.firestore.QueryDocumentSnapshot<unknown> | null;
|
|
20
|
+
hasNextPage: boolean;
|
|
21
|
+
hasPreviousPage?: boolean;
|
|
22
|
+
currentClientPageIndex?: number;
|
|
23
|
+
totalPages?: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
@Injectable({
|
|
27
|
+
providedIn: 'root',
|
|
28
|
+
})
|
|
29
|
+
export class TableService {
|
|
30
|
+
constructor(
|
|
31
|
+
@Optional() private ngFire: AngularFirestore,
|
|
32
|
+
@Optional() private dialog: MatDialog,
|
|
33
|
+
@Optional() private toastr: ToastrService
|
|
34
|
+
) {}
|
|
35
|
+
|
|
36
|
+
async getItems(collection: CollectionReference<unknown>): Promise<any[]> {
|
|
37
|
+
try {
|
|
38
|
+
const querySnapshot = await collection.get();
|
|
39
|
+
return querySnapshot.docs.map(
|
|
40
|
+
(doc: firebase.firestore.QueryDocumentSnapshot<unknown>) => {
|
|
41
|
+
return { ...(doc.data() as any), id: doc.id };
|
|
42
|
+
}
|
|
43
|
+
);
|
|
44
|
+
} catch (error) {
|
|
45
|
+
console.warn('Collection não encontrada:', error);
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
private async executeQuery(params: Pagination): Promise<PaginationResult> {
|
|
51
|
+
if (params.filterFn) {
|
|
52
|
+
// Lógica com filtro no cliente (filterFn)
|
|
53
|
+
const BATCH_FETCH_SIZE = params.batchSize;
|
|
54
|
+
const GOAL_SIZE = params.batchSize + 1;
|
|
55
|
+
|
|
56
|
+
if (params.navigation === 'forward' || params.navigation === 'reload') {
|
|
57
|
+
if (params.navigation === 'reload' && params.doc) {
|
|
58
|
+
params.doc.lastDoc = null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let lastDocCursor: firebase.firestore.QueryDocumentSnapshot<unknown> | null =
|
|
62
|
+
params.doc ? params.doc.lastDoc : null;
|
|
63
|
+
let pageResults: any[] = [];
|
|
64
|
+
let allFetchedDocs: firebase.firestore.QueryDocumentSnapshot<unknown>[] =
|
|
65
|
+
[];
|
|
66
|
+
let hasMoreDocsInDb = true;
|
|
67
|
+
|
|
68
|
+
while (pageResults.length < GOAL_SIZE && hasMoreDocsInDb) {
|
|
69
|
+
let query: firebase.firestore.Query<unknown> = this.ngFire.collection(
|
|
70
|
+
params.collection
|
|
71
|
+
).ref;
|
|
72
|
+
query = this.applyFilters(query, params.arrange, params.conditions);
|
|
73
|
+
|
|
74
|
+
if (lastDocCursor) {
|
|
75
|
+
query = query.startAfter(lastDocCursor);
|
|
76
|
+
}
|
|
77
|
+
query = query.limit(BATCH_FETCH_SIZE);
|
|
78
|
+
|
|
79
|
+
const snapshot = await query.get();
|
|
80
|
+
|
|
81
|
+
if (snapshot.empty) {
|
|
82
|
+
hasMoreDocsInDb = false;
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
lastDocCursor = snapshot.docs[snapshot.docs.length - 1];
|
|
87
|
+
allFetchedDocs.push(...snapshot.docs);
|
|
88
|
+
|
|
89
|
+
const batchUsers = snapshot.docs
|
|
90
|
+
.map((doc: firebase.firestore.QueryDocumentSnapshot<unknown>) => ({
|
|
91
|
+
id: doc.id,
|
|
92
|
+
...(doc.data() as any),
|
|
93
|
+
}))
|
|
94
|
+
.filter(params.filterFn);
|
|
95
|
+
|
|
96
|
+
pageResults.push(...batchUsers);
|
|
97
|
+
|
|
98
|
+
if (snapshot.size < BATCH_FETCH_SIZE) {
|
|
99
|
+
hasMoreDocsInDb = false;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const hasNextPage = pageResults.length > params.batchSize;
|
|
104
|
+
const finalItems = pageResults.slice(0, params.batchSize);
|
|
105
|
+
|
|
106
|
+
const firstDocOfPage =
|
|
107
|
+
allFetchedDocs.find(
|
|
108
|
+
(doc: firebase.firestore.QueryDocumentSnapshot<unknown>) =>
|
|
109
|
+
doc.id === finalItems[0]?.id
|
|
110
|
+
) || null;
|
|
111
|
+
const lastDocOfPage =
|
|
112
|
+
allFetchedDocs.find(
|
|
113
|
+
(doc: firebase.firestore.QueryDocumentSnapshot<unknown>) =>
|
|
114
|
+
doc.id === finalItems[finalItems.length - 1]?.id
|
|
115
|
+
) || null;
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
items: finalItems,
|
|
119
|
+
filterLength: null,
|
|
120
|
+
firstDoc: firstDocOfPage,
|
|
121
|
+
lastDoc: lastDocOfPage,
|
|
122
|
+
hasNextPage: hasNextPage,
|
|
123
|
+
hasPreviousPage:
|
|
124
|
+
!!(params.doc && params.doc.lastDoc) &&
|
|
125
|
+
params.navigation !== 'reload',
|
|
126
|
+
currentClientPageIndex: undefined,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
// Lógica para trás (backward)
|
|
130
|
+
else if (params.navigation === 'backward') {
|
|
131
|
+
if (!params.doc || !params.doc.firstDoc) {
|
|
132
|
+
return {
|
|
133
|
+
items: [],
|
|
134
|
+
filterLength: null,
|
|
135
|
+
firstDoc: null,
|
|
136
|
+
lastDoc: null,
|
|
137
|
+
hasNextPage: true,
|
|
138
|
+
hasPreviousPage: false,
|
|
139
|
+
currentClientPageIndex: undefined,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
let pageResults: any[] = [];
|
|
144
|
+
let allFetchedDocs: firebase.firestore.QueryDocumentSnapshot<unknown>[] =
|
|
145
|
+
[];
|
|
146
|
+
let hasMoreDocsInDb = true;
|
|
147
|
+
|
|
148
|
+
let boundaryDoc = params.doc.firstDoc;
|
|
149
|
+
|
|
150
|
+
while (pageResults.length < GOAL_SIZE && hasMoreDocsInDb) {
|
|
151
|
+
let query: firebase.firestore.Query<unknown> = this.ngFire.collection(
|
|
152
|
+
params.collection
|
|
153
|
+
).ref;
|
|
154
|
+
|
|
155
|
+
query = this.applyFilters(query, params.arrange, params.conditions);
|
|
156
|
+
|
|
157
|
+
query = query.endBefore(boundaryDoc);
|
|
158
|
+
query = query.limitToLast(BATCH_FETCH_SIZE);
|
|
159
|
+
|
|
160
|
+
const snapshot = await query.get();
|
|
161
|
+
|
|
162
|
+
if (snapshot.empty) {
|
|
163
|
+
hasMoreDocsInDb = false;
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
boundaryDoc = snapshot.docs[0] as any;
|
|
168
|
+
|
|
169
|
+
allFetchedDocs = [...snapshot.docs, ...allFetchedDocs];
|
|
170
|
+
|
|
171
|
+
const batchUsers = snapshot.docs
|
|
172
|
+
.map((doc: firebase.firestore.QueryDocumentSnapshot<unknown>) => ({
|
|
173
|
+
id: doc.id,
|
|
174
|
+
...(doc.data() as any),
|
|
175
|
+
}))
|
|
176
|
+
.filter(params.filterFn);
|
|
177
|
+
|
|
178
|
+
pageResults = [...batchUsers, ...pageResults];
|
|
179
|
+
|
|
180
|
+
if (snapshot.size < BATCH_FETCH_SIZE) {
|
|
181
|
+
hasMoreDocsInDb = false;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const finalItems = pageResults.slice(0, params.batchSize);
|
|
186
|
+
|
|
187
|
+
const firstDocOfPage =
|
|
188
|
+
allFetchedDocs.find(
|
|
189
|
+
(doc: firebase.firestore.QueryDocumentSnapshot<unknown>) =>
|
|
190
|
+
doc.id === finalItems[0]?.id
|
|
191
|
+
) || null;
|
|
192
|
+
const lastDocOfPage =
|
|
193
|
+
allFetchedDocs.find(
|
|
194
|
+
(doc: firebase.firestore.QueryDocumentSnapshot<unknown>) =>
|
|
195
|
+
doc.id === finalItems[finalItems.length - 1]?.id
|
|
196
|
+
) || null;
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
items: finalItems,
|
|
200
|
+
filterLength: null,
|
|
201
|
+
firstDoc: firstDocOfPage,
|
|
202
|
+
lastDoc: lastDocOfPage,
|
|
203
|
+
hasNextPage: true,
|
|
204
|
+
currentClientPageIndex: undefined,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
} else {
|
|
208
|
+
let items: any[] = [];
|
|
209
|
+
let docs: firebase.firestore.QueryDocumentSnapshot<unknown>[] = [];
|
|
210
|
+
let hasNextPage = false;
|
|
211
|
+
let filterLength: null | number = null;
|
|
212
|
+
|
|
213
|
+
let query: firebase.firestore.Query<unknown> = this.ngFire.collection(
|
|
214
|
+
params.collection
|
|
215
|
+
).ref;
|
|
216
|
+
|
|
217
|
+
if (params.conditions) {
|
|
218
|
+
params.conditions.forEach((c: Condition) => {
|
|
219
|
+
if (c.operator === '!=') {
|
|
220
|
+
query = query.orderBy(c.firestoreProperty);
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
query = this.applyFilters(query, params.arrange, params.conditions);
|
|
226
|
+
|
|
227
|
+
if (params.navigation === 'reload') {
|
|
228
|
+
query = query.limit(params.batchSize + 1);
|
|
229
|
+
if (params.doc && params.doc.firstDoc) {
|
|
230
|
+
query = query.startAt(params.doc.firstDoc);
|
|
231
|
+
}
|
|
232
|
+
} else if (params.navigation === 'forward') {
|
|
233
|
+
query = query.limit(params.batchSize + 1);
|
|
234
|
+
|
|
235
|
+
if (params.doc && params.doc.lastDoc) {
|
|
236
|
+
query = query.startAfter(params.doc.lastDoc);
|
|
237
|
+
}
|
|
238
|
+
} else {
|
|
239
|
+
// backward
|
|
240
|
+
query = query.limitToLast(params.batchSize + 1);
|
|
241
|
+
if (params.doc && params.doc.firstDoc) {
|
|
242
|
+
query = query.endBefore(params.doc.firstDoc);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const itemCol = await query.get();
|
|
247
|
+
itemCol.docs.forEach(
|
|
248
|
+
(doc: firebase.firestore.QueryDocumentSnapshot<unknown>) =>
|
|
249
|
+
docs.push(doc)
|
|
250
|
+
);
|
|
251
|
+
const itemPromises = docs.map(
|
|
252
|
+
async (item: firebase.firestore.QueryDocumentSnapshot<unknown>) => {
|
|
253
|
+
const itemData = item.data() as any;
|
|
254
|
+
items.push({ id: item.id, ...itemData });
|
|
255
|
+
}
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
let lastDoc: firebase.firestore.QueryDocumentSnapshot<unknown> | null =
|
|
259
|
+
docs[docs.length - 1] || null;
|
|
260
|
+
let firstDoc: firebase.firestore.QueryDocumentSnapshot<unknown> | null =
|
|
261
|
+
docs[0];
|
|
262
|
+
|
|
263
|
+
if (
|
|
264
|
+
(items.length > params.batchSize && params.navigation === 'forward') ||
|
|
265
|
+
(params.navigation === 'reload' && items.length > params.batchSize)
|
|
266
|
+
) {
|
|
267
|
+
lastDoc = docs[docs.length - 2] || null;
|
|
268
|
+
items.pop();
|
|
269
|
+
hasNextPage = true;
|
|
270
|
+
}
|
|
271
|
+
if (items.length > params.batchSize && params.navigation === 'backward') {
|
|
272
|
+
firstDoc = docs[1];
|
|
273
|
+
items.shift();
|
|
274
|
+
hasNextPage = true;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
await Promise.all(itemPromises);
|
|
278
|
+
return {
|
|
279
|
+
items,
|
|
280
|
+
filterLength,
|
|
281
|
+
lastDoc,
|
|
282
|
+
firstDoc,
|
|
283
|
+
hasNextPage,
|
|
284
|
+
currentClientPageIndex: undefined,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Fallback para garantir que sempre retornamos algo
|
|
289
|
+
return {
|
|
290
|
+
items: [],
|
|
291
|
+
filterLength: null,
|
|
292
|
+
firstDoc: null,
|
|
293
|
+
lastDoc: null,
|
|
294
|
+
hasNextPage: false,
|
|
295
|
+
currentClientPageIndex: undefined,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
applyFilters(
|
|
300
|
+
query: firebase.firestore.Query<unknown>,
|
|
301
|
+
arrange: Arrange,
|
|
302
|
+
conditions: Condition[] | undefined
|
|
303
|
+
): firebase.firestore.Query<unknown> {
|
|
304
|
+
if (conditions) {
|
|
305
|
+
conditions.map((cond: Condition) => {
|
|
306
|
+
query = query.where(
|
|
307
|
+
cond.firestoreProperty,
|
|
308
|
+
cond.operator,
|
|
309
|
+
cond.dashProperty
|
|
310
|
+
);
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
let hasFilterSpecificOrderBy = false;
|
|
315
|
+
let appliedOrderByField: string | null = null;
|
|
316
|
+
|
|
317
|
+
const equalsFilters = arrange.filters.filter(
|
|
318
|
+
(f: any) => f.arrange === 'equals' && f.filter
|
|
319
|
+
);
|
|
320
|
+
const otherFilters = arrange.filters.filter(
|
|
321
|
+
(f: any) => f.arrange !== 'equals'
|
|
322
|
+
);
|
|
323
|
+
|
|
324
|
+
const equalsGroupedByProperty = equalsFilters.reduce(
|
|
325
|
+
(acc: any, current: any) => {
|
|
326
|
+
const prop = current.filter.property;
|
|
327
|
+
if (!acc[prop]) {
|
|
328
|
+
acc[prop] = [];
|
|
329
|
+
}
|
|
330
|
+
acc[prop].push(current.filter.filtering);
|
|
331
|
+
return acc;
|
|
332
|
+
},
|
|
333
|
+
{}
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
for (const prop in equalsGroupedByProperty) {
|
|
337
|
+
const values = equalsGroupedByProperty[prop];
|
|
338
|
+
if (values.length > 0) {
|
|
339
|
+
query = query.where(prop, 'in', values);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
otherFilters.forEach((filterItem: any) => {
|
|
344
|
+
// Aplicar filtragem por busca
|
|
345
|
+
if (
|
|
346
|
+
filterItem.filter?.filtering &&
|
|
347
|
+
filterItem.filter?.property !== '' &&
|
|
348
|
+
filterItem.arrange === 'filter'
|
|
349
|
+
) {
|
|
350
|
+
query = query
|
|
351
|
+
.where(
|
|
352
|
+
filterItem.filter.property,
|
|
353
|
+
'>=',
|
|
354
|
+
filterItem.filter.filtering.trim().toUpperCase()
|
|
355
|
+
)
|
|
356
|
+
.where(
|
|
357
|
+
filterItem.filter.property,
|
|
358
|
+
'<=',
|
|
359
|
+
filterItem.filter.filtering.trim().toUpperCase() + '\uf8ff'
|
|
360
|
+
);
|
|
361
|
+
if (!hasFilterSpecificOrderBy) {
|
|
362
|
+
query = query.orderBy(filterItem.filter.property);
|
|
363
|
+
hasFilterSpecificOrderBy = true;
|
|
364
|
+
appliedOrderByField = filterItem.filter.property;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Aplicar filtro do tipo "filterByDate"
|
|
369
|
+
if (filterItem.dateFilter && filterItem.arrange === 'filterByDate') {
|
|
370
|
+
query = query
|
|
371
|
+
.where(arrange.sortBy.field, '>=', filterItem.dateFilter.initial)
|
|
372
|
+
.where(arrange.sortBy.field, '<=', filterItem.dateFilter.final);
|
|
373
|
+
if (!hasFilterSpecificOrderBy) {
|
|
374
|
+
query = query.orderBy(arrange.sortBy.field);
|
|
375
|
+
hasFilterSpecificOrderBy = true;
|
|
376
|
+
appliedOrderByField = arrange.sortBy.field;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
// Aplicar sortBy
|
|
382
|
+
if (arrange.sortBy && arrange.sortBy.field && arrange.sortBy.order) {
|
|
383
|
+
if (appliedOrderByField !== arrange.sortBy.field) {
|
|
384
|
+
query = query.orderBy(arrange.sortBy.field, arrange.sortBy.order);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
return query;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Detecta se a query vai precisar de index composto e deve usar fallback client-side
|
|
392
|
+
*/
|
|
393
|
+
private shouldUseClientSideFallback(params: Pagination): boolean {
|
|
394
|
+
const hasConditions = params.conditions && params.conditions.length > 0;
|
|
395
|
+
const hasArrangeFilters =
|
|
396
|
+
params.arrange?.filters && params.arrange.filters.length > 0;
|
|
397
|
+
const hasSortBy = params.arrange?.sortBy?.field;
|
|
398
|
+
|
|
399
|
+
if (params.filterFn) {
|
|
400
|
+
return false;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
if (hasConditions && hasArrangeFilters && hasSortBy) {
|
|
404
|
+
return true;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
if (hasConditions && hasArrangeFilters) {
|
|
408
|
+
return true;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if (hasArrangeFilters && params.arrange.filters.length > 1 && hasSortBy) {
|
|
412
|
+
return true;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
return false;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
async getPaginated(params: Pagination): Promise<PaginationResult> {
|
|
419
|
+
// Detectar preventivamente se deve usar fallback
|
|
420
|
+
if (this.shouldUseClientSideFallback(params)) {
|
|
421
|
+
await this.trackMissingIndexPreventive(
|
|
422
|
+
params.collection,
|
|
423
|
+
params.arrange,
|
|
424
|
+
params.conditions
|
|
425
|
+
);
|
|
426
|
+
|
|
427
|
+
const result = await this.executeClientSideQuery(params);
|
|
428
|
+
console.log('📊 [TABLE] Resultados paginados via fallback client-side:', {
|
|
429
|
+
totalItems: result.filterLength,
|
|
430
|
+
returnedItems: result.items.length,
|
|
431
|
+
hasNextPage: result.hasNextPage,
|
|
432
|
+
currentPage: (result.currentClientPageIndex || 0) + 1,
|
|
433
|
+
});
|
|
434
|
+
return result;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
try {
|
|
438
|
+
const result = await this.executeQuery(params);
|
|
439
|
+
console.log('📊 [TABLE] Resultados paginados via Firestore:', {
|
|
440
|
+
totalItems: result.filterLength || 'N/A',
|
|
441
|
+
returnedItems: result.items.length,
|
|
442
|
+
hasNextPage: result.hasNextPage,
|
|
443
|
+
});
|
|
444
|
+
return result;
|
|
445
|
+
} catch (error: any) {
|
|
446
|
+
if (error && error.code === 'failed-precondition') {
|
|
447
|
+
await this.trackMissingIndex(
|
|
448
|
+
error,
|
|
449
|
+
params.collection,
|
|
450
|
+
params.arrange,
|
|
451
|
+
params.conditions
|
|
452
|
+
);
|
|
453
|
+
|
|
454
|
+
const result = await this.executeClientSideQuery(params);
|
|
455
|
+
console.log(
|
|
456
|
+
'📊 [TABLE] Resultados paginados via fallback (erro de index):',
|
|
457
|
+
{
|
|
458
|
+
totalItems: result.filterLength,
|
|
459
|
+
returnedItems: result.items.length,
|
|
460
|
+
hasNextPage: result.hasNextPage,
|
|
461
|
+
currentPage: (result.currentClientPageIndex || 0) + 1,
|
|
462
|
+
}
|
|
463
|
+
);
|
|
464
|
+
return result;
|
|
465
|
+
} else if (error && error.code === 'invalid-argument') {
|
|
466
|
+
await this.trackMissingIndex(
|
|
467
|
+
error,
|
|
468
|
+
params.collection,
|
|
469
|
+
params.arrange,
|
|
470
|
+
params.conditions
|
|
471
|
+
);
|
|
472
|
+
|
|
473
|
+
const result = await this.executeClientSideQuery(params);
|
|
474
|
+
console.log(
|
|
475
|
+
'📊 [TABLE] Resultados paginados via fallback (argumento inválido):',
|
|
476
|
+
{
|
|
477
|
+
totalItems: result.filterLength,
|
|
478
|
+
returnedItems: result.items.length,
|
|
479
|
+
hasNextPage: result.hasNextPage,
|
|
480
|
+
currentPage: (result.currentClientPageIndex || 0) + 1,
|
|
481
|
+
}
|
|
482
|
+
);
|
|
483
|
+
return result;
|
|
484
|
+
} else {
|
|
485
|
+
throw error;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
async executeClientSideQuery(params: Pagination): Promise<PaginationResult> {
|
|
491
|
+
// Otimizar usando pelo menos uma cláusula .where() quando possível
|
|
492
|
+
let query: firebase.firestore.Query<unknown> = this.ngFire.collection(
|
|
493
|
+
params.collection
|
|
494
|
+
).ref;
|
|
495
|
+
|
|
496
|
+
let appliedCondition: Condition | null = null;
|
|
497
|
+
let hasAppliedWhereClause = false;
|
|
498
|
+
|
|
499
|
+
// Primeiro, tenta aplicar condições simples
|
|
500
|
+
if (params.conditions && params.conditions.length > 0) {
|
|
501
|
+
const simpleCondition = params.conditions.find((cond: Condition) =>
|
|
502
|
+
['==', '>', '<', '>=', '<='].includes(cond.operator)
|
|
503
|
+
);
|
|
504
|
+
|
|
505
|
+
if (simpleCondition) {
|
|
506
|
+
query = query.where(
|
|
507
|
+
simpleCondition.firestoreProperty,
|
|
508
|
+
simpleCondition.operator,
|
|
509
|
+
simpleCondition.dashProperty
|
|
510
|
+
);
|
|
511
|
+
appliedCondition = simpleCondition;
|
|
512
|
+
hasAppliedWhereClause = true;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Se não há condições disponíveis, tenta aplicar filtros do arrange
|
|
517
|
+
let appliedFirestoreFilter: any = null;
|
|
518
|
+
if (!hasAppliedWhereClause && params.arrange?.filters) {
|
|
519
|
+
const equalsFilter = params.arrange.filters.find(
|
|
520
|
+
(f: any) => f.arrange === 'equals' && f.filter?.filtering
|
|
521
|
+
);
|
|
522
|
+
|
|
523
|
+
if (equalsFilter && equalsFilter.filter) {
|
|
524
|
+
query = query.where(
|
|
525
|
+
equalsFilter.filter.property,
|
|
526
|
+
'==',
|
|
527
|
+
equalsFilter.filter.filtering
|
|
528
|
+
);
|
|
529
|
+
hasAppliedWhereClause = true;
|
|
530
|
+
appliedFirestoreFilter = equalsFilter;
|
|
531
|
+
} else {
|
|
532
|
+
const otherFilter = params.arrange.filters.find(
|
|
533
|
+
(f: any) =>
|
|
534
|
+
(f.arrange === 'filter' &&
|
|
535
|
+
f.filter?.filtering &&
|
|
536
|
+
f.filter?.property) ||
|
|
537
|
+
(f.arrange === 'filterByDate' &&
|
|
538
|
+
f.dateFilter?.initial &&
|
|
539
|
+
f.dateFilter?.final)
|
|
540
|
+
);
|
|
541
|
+
|
|
542
|
+
if (otherFilter) {
|
|
543
|
+
if (otherFilter.arrange === 'filter' && otherFilter.filter) {
|
|
544
|
+
const filterValue = otherFilter.filter.filtering
|
|
545
|
+
.trim()
|
|
546
|
+
.toUpperCase();
|
|
547
|
+
query = query
|
|
548
|
+
.where(otherFilter.filter.property, '>=', filterValue)
|
|
549
|
+
.where(otherFilter.filter.property, '<=', filterValue + '\uf8ff');
|
|
550
|
+
hasAppliedWhereClause = true;
|
|
551
|
+
appliedFirestoreFilter = otherFilter;
|
|
552
|
+
} else if (
|
|
553
|
+
otherFilter.arrange === 'filterByDate' &&
|
|
554
|
+
otherFilter.dateFilter &&
|
|
555
|
+
params.arrange.sortBy?.field
|
|
556
|
+
) {
|
|
557
|
+
query = query
|
|
558
|
+
.where(
|
|
559
|
+
params.arrange.sortBy.field,
|
|
560
|
+
'>=',
|
|
561
|
+
otherFilter.dateFilter.initial
|
|
562
|
+
)
|
|
563
|
+
.where(
|
|
564
|
+
params.arrange.sortBy.field,
|
|
565
|
+
'<=',
|
|
566
|
+
otherFilter.dateFilter.final
|
|
567
|
+
);
|
|
568
|
+
hasAppliedWhereClause = true;
|
|
569
|
+
appliedFirestoreFilter = otherFilter;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
const allDocsSnapshot = await query.get();
|
|
576
|
+
let items = allDocsSnapshot.docs.map(
|
|
577
|
+
(doc: firebase.firestore.QueryDocumentSnapshot<unknown>) => ({
|
|
578
|
+
id: doc.id,
|
|
579
|
+
...(doc.data() as any),
|
|
580
|
+
})
|
|
581
|
+
);
|
|
582
|
+
|
|
583
|
+
// Aplicar condições restantes
|
|
584
|
+
if (params.conditions) {
|
|
585
|
+
const remainingConditions = params.conditions.filter(
|
|
586
|
+
(cond: Condition) => cond !== appliedCondition
|
|
587
|
+
);
|
|
588
|
+
if (remainingConditions.length > 0) {
|
|
589
|
+
const operators = this.operators;
|
|
590
|
+
items = items.filter((item: any) => {
|
|
591
|
+
return remainingConditions.every((cond: Condition) => {
|
|
592
|
+
const operatorFn =
|
|
593
|
+
operators[cond.operator as keyof typeof operators];
|
|
594
|
+
return operatorFn
|
|
595
|
+
? operatorFn(item[cond.firestoreProperty], cond.dashProperty)
|
|
596
|
+
: false;
|
|
597
|
+
});
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
const { filters, sortBy } = params.arrange;
|
|
603
|
+
|
|
604
|
+
// Track which filter was already applied in Firestore to avoid double filtering
|
|
605
|
+
if (hasAppliedWhereClause && !appliedCondition && params.arrange?.filters) {
|
|
606
|
+
const equalsFilter = params.arrange.filters.find(
|
|
607
|
+
(f: any) => f.arrange === 'equals' && f.filter?.filtering
|
|
608
|
+
);
|
|
609
|
+
if (equalsFilter) {
|
|
610
|
+
appliedFirestoreFilter = equalsFilter;
|
|
611
|
+
} else {
|
|
612
|
+
appliedFirestoreFilter = params.arrange.filters.find(
|
|
613
|
+
(f: any) =>
|
|
614
|
+
(f.arrange === 'filter' &&
|
|
615
|
+
f.filter?.filtering &&
|
|
616
|
+
f.filter?.property) ||
|
|
617
|
+
(f.arrange === 'filterByDate' &&
|
|
618
|
+
f.dateFilter?.initial &&
|
|
619
|
+
f.dateFilter?.final)
|
|
620
|
+
);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
const equalsFilters = filters.filter((f: any) => f.arrange === 'equals');
|
|
625
|
+
const otherFilters = filters.filter((f: any) => f.arrange !== 'equals');
|
|
626
|
+
|
|
627
|
+
const remainingEqualsFilters = equalsFilters.filter(
|
|
628
|
+
(f: any) => f !== appliedFirestoreFilter
|
|
629
|
+
);
|
|
630
|
+
if (remainingEqualsFilters.length > 0) {
|
|
631
|
+
items = items.filter((item: any) => {
|
|
632
|
+
return remainingEqualsFilters.every(
|
|
633
|
+
(f: any) => item[f.filter.property] === f.filter.filtering
|
|
634
|
+
);
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
otherFilters.forEach((filterItem: any) => {
|
|
639
|
+
if (appliedFirestoreFilter === filterItem) {
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
if (
|
|
644
|
+
filterItem.arrange === 'filter' &&
|
|
645
|
+
filterItem.filter?.filtering &&
|
|
646
|
+
filterItem.filter?.property
|
|
647
|
+
) {
|
|
648
|
+
const filterValue = String(filterItem.filter.filtering)
|
|
649
|
+
.trim()
|
|
650
|
+
.toLowerCase();
|
|
651
|
+
items = items.filter((item: any) => {
|
|
652
|
+
const itemValue = String(
|
|
653
|
+
item[filterItem.filter.property]
|
|
654
|
+
).toLowerCase();
|
|
655
|
+
return itemValue.includes(filterValue);
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
if (
|
|
660
|
+
filterItem.arrange === 'filterByDate' &&
|
|
661
|
+
filterItem.dateFilter?.initial &&
|
|
662
|
+
filterItem.dateFilter?.final &&
|
|
663
|
+
sortBy.field
|
|
664
|
+
) {
|
|
665
|
+
items = items.filter((item: any) => {
|
|
666
|
+
try {
|
|
667
|
+
const fieldValue = item[sortBy.field];
|
|
668
|
+
|
|
669
|
+
if (!fieldValue) {
|
|
670
|
+
return false;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
let itemDate;
|
|
674
|
+
if (typeof fieldValue.toDate === 'function') {
|
|
675
|
+
itemDate = fieldValue.toDate();
|
|
676
|
+
} else if (fieldValue instanceof Date) {
|
|
677
|
+
itemDate = fieldValue;
|
|
678
|
+
} else if (typeof fieldValue === 'string') {
|
|
679
|
+
itemDate = new Date(fieldValue);
|
|
680
|
+
if (isNaN(itemDate.getTime())) {
|
|
681
|
+
return false;
|
|
682
|
+
}
|
|
683
|
+
} else if (typeof fieldValue === 'number') {
|
|
684
|
+
itemDate = new Date(fieldValue);
|
|
685
|
+
} else {
|
|
686
|
+
return false;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
return (
|
|
690
|
+
itemDate >= filterItem.dateFilter.initial &&
|
|
691
|
+
itemDate <= filterItem.dateFilter.final
|
|
692
|
+
);
|
|
693
|
+
} catch (error) {
|
|
694
|
+
console.warn(
|
|
695
|
+
'Erro ao processar filtro de data para o item:',
|
|
696
|
+
item.id,
|
|
697
|
+
error
|
|
698
|
+
);
|
|
699
|
+
return false;
|
|
700
|
+
}
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
// Aplicar filterFn se existir
|
|
706
|
+
if (params.filterFn) {
|
|
707
|
+
items = items.filter(params.filterFn);
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
if (sortBy && sortBy.field && sortBy.order) {
|
|
711
|
+
items.sort((a: any, b: any) => {
|
|
712
|
+
const valA = a[sortBy.field];
|
|
713
|
+
const valB = b[sortBy.field];
|
|
714
|
+
|
|
715
|
+
if (valA < valB) {
|
|
716
|
+
return sortBy.order === 'asc' ? -1 : 1;
|
|
717
|
+
}
|
|
718
|
+
if (valA > valB) {
|
|
719
|
+
return sortBy.order === 'asc' ? 1 : -1;
|
|
720
|
+
}
|
|
721
|
+
return 0;
|
|
722
|
+
});
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// Paginação
|
|
726
|
+
let currentClientPageIndex = 0;
|
|
727
|
+
|
|
728
|
+
if (params.navigation === 'reload') {
|
|
729
|
+
currentClientPageIndex = params.clientPageIndex || 0;
|
|
730
|
+
} else if (params.navigation === 'forward') {
|
|
731
|
+
currentClientPageIndex = (params.clientPageIndex || 0) + 1;
|
|
732
|
+
} else if (params.navigation === 'backward') {
|
|
733
|
+
currentClientPageIndex = Math.max(0, (params.clientPageIndex || 0) - 1);
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
const pageSize = params.batchSize;
|
|
737
|
+
const startIndex = currentClientPageIndex * pageSize;
|
|
738
|
+
const endIndex = startIndex + pageSize;
|
|
739
|
+
const paginatedItems = items.slice(startIndex, endIndex);
|
|
740
|
+
|
|
741
|
+
const totalPages = Math.ceil(items.length / pageSize);
|
|
742
|
+
const hasNextPage = currentClientPageIndex < totalPages - 1;
|
|
743
|
+
const hasPreviousPage = currentClientPageIndex > 0;
|
|
744
|
+
|
|
745
|
+
return {
|
|
746
|
+
items: paginatedItems,
|
|
747
|
+
filterLength: items.length,
|
|
748
|
+
lastDoc: null,
|
|
749
|
+
firstDoc: null,
|
|
750
|
+
hasNextPage: hasNextPage,
|
|
751
|
+
hasPreviousPage: hasPreviousPage,
|
|
752
|
+
currentClientPageIndex: currentClientPageIndex,
|
|
753
|
+
totalPages: totalPages,
|
|
754
|
+
};
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
async getItemsData(
|
|
758
|
+
collection: string,
|
|
759
|
+
arrange: Arrange,
|
|
760
|
+
conditions: Condition[] | undefined = undefined
|
|
761
|
+
): Promise<any[]> {
|
|
762
|
+
try {
|
|
763
|
+
let query: firebase.firestore.Query<unknown> =
|
|
764
|
+
this.ngFire.collection(collection).ref;
|
|
765
|
+
|
|
766
|
+
query = this.applyFilters(query, arrange, conditions);
|
|
767
|
+
const snapshot = await query.get();
|
|
768
|
+
return await Promise.all(
|
|
769
|
+
snapshot.docs.map(
|
|
770
|
+
async (doc: firebase.firestore.QueryDocumentSnapshot<unknown>) => {
|
|
771
|
+
const data = doc.data() as any;
|
|
772
|
+
const id = doc.id;
|
|
773
|
+
return {
|
|
774
|
+
id,
|
|
775
|
+
...data,
|
|
776
|
+
};
|
|
777
|
+
}
|
|
778
|
+
)
|
|
779
|
+
);
|
|
780
|
+
} catch (e) {
|
|
781
|
+
throw e;
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
public operators = {
|
|
786
|
+
'==': (a: any, b: any): boolean => a === b,
|
|
787
|
+
'!=': (a: any, b: any): boolean => a !== b,
|
|
788
|
+
'>': (a: any, b: any): boolean => a > b,
|
|
789
|
+
'<': (a: any, b: any): boolean => a < b,
|
|
790
|
+
'>=': (a: any, b: any): boolean => a >= b,
|
|
791
|
+
'<=': (a: any, b: any): boolean => a <= b,
|
|
792
|
+
includes: (a: any, b: any): any => a.includes(b),
|
|
793
|
+
};
|
|
794
|
+
|
|
795
|
+
async deleteIndex(
|
|
796
|
+
id: string,
|
|
797
|
+
col: string,
|
|
798
|
+
dialogComponent: any
|
|
799
|
+
): Promise<boolean> {
|
|
800
|
+
const dialogRef = this.dialog.open(dialogComponent, {
|
|
801
|
+
data: {
|
|
802
|
+
title: 'Você realmente deseja deletar esse item?',
|
|
803
|
+
description: 'Essa ação é irreversível.',
|
|
804
|
+
},
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
const result = await firstValueFrom(dialogRef.afterClosed());
|
|
808
|
+
if (!result === true) {
|
|
809
|
+
return false;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
try {
|
|
813
|
+
const batch = this.ngFire.firestore.batch();
|
|
814
|
+
|
|
815
|
+
const docRef = this.ngFire.collection(col).doc(id);
|
|
816
|
+
const docSnapshot = (await firstValueFrom(
|
|
817
|
+
docRef.get()
|
|
818
|
+
)) as firebase.firestore.DocumentSnapshot<unknown>;
|
|
819
|
+
const doc = docSnapshot.data() as any;
|
|
820
|
+
batch.delete(docRef.ref);
|
|
821
|
+
if (doc && typeof doc.index === 'number') {
|
|
822
|
+
await this.reindex(doc.index, col, batch);
|
|
823
|
+
}
|
|
824
|
+
await batch.commit();
|
|
825
|
+
|
|
826
|
+
this.toastr.success('Item excluído com sucesso!');
|
|
827
|
+
return true;
|
|
828
|
+
} catch (e) {
|
|
829
|
+
const error = e as any;
|
|
830
|
+
console.error('Erro ao deletar item:', error);
|
|
831
|
+
this.toastr.error('Erro ao deletar item.');
|
|
832
|
+
return false;
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
async reindex(
|
|
837
|
+
index: number,
|
|
838
|
+
col: string,
|
|
839
|
+
batch: firebase.firestore.WriteBatch
|
|
840
|
+
): Promise<void> {
|
|
841
|
+
try {
|
|
842
|
+
const snapshot = (await firstValueFrom(
|
|
843
|
+
this.ngFire.collection(col).get()
|
|
844
|
+
)) as firebase.firestore.QuerySnapshot<unknown>;
|
|
845
|
+
const docs = snapshot.docs;
|
|
846
|
+
for (let doc of docs) {
|
|
847
|
+
const data = doc.data() as any;
|
|
848
|
+
if (data && typeof data.index === 'number' && data.index > index) {
|
|
849
|
+
data.index--;
|
|
850
|
+
const docRef = this.ngFire.collection(col).doc(doc.id).ref;
|
|
851
|
+
batch.update(docRef, data);
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
} catch (error) {
|
|
855
|
+
console.error('Erro ao reindexar:', error);
|
|
856
|
+
}
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
dateFormatValidator(): ValidatorFn {
|
|
861
|
+
return (control: AbstractControl): { [key: string]: any } | null => {
|
|
862
|
+
if (!control.value) {
|
|
863
|
+
return null;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
const dateStr = control.value.trim();
|
|
867
|
+
const datePattern = /^(\d{1,2})\/(\d{1,2})\/(\d{4})$/;
|
|
868
|
+
|
|
869
|
+
if (!datePattern.test(dateStr)) {
|
|
870
|
+
return { invalidFormat: true };
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
const parts = dateStr.split('/');
|
|
874
|
+
const day = parts[0].padStart(2, '0');
|
|
875
|
+
const month = parts[1].padStart(2, '0');
|
|
876
|
+
const year = parts[2];
|
|
877
|
+
const normalizedDate = `${day}/${month}/${year}`;
|
|
878
|
+
|
|
879
|
+
const date = moment(normalizedDate, 'DD/MM/YYYY', true);
|
|
880
|
+
|
|
881
|
+
if (!date.isValid()) {
|
|
882
|
+
return { invalidDate: true };
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
return null;
|
|
886
|
+
};
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
async updateIndex(index: number, id: string, col: string): Promise<void> {
|
|
890
|
+
await this.ngFire.collection(col).doc(id).update({ index });
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/**
|
|
894
|
+
* Extrai o link de criação de índice da mensagem de erro do Firestore
|
|
895
|
+
*/
|
|
896
|
+
private extractIndexLink(error: any): string | null {
|
|
897
|
+
if (!error || !error.message) return null;
|
|
898
|
+
|
|
899
|
+
const linkMatch = error.message.match(
|
|
900
|
+
/(https:\/\/console\.firebase\.google\.com\/[^\s]+)/
|
|
901
|
+
);
|
|
902
|
+
return linkMatch ? linkMatch[1] : null;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
/**
|
|
906
|
+
* Rastreia índices ausentes ao usar fallback preventivo
|
|
907
|
+
*/
|
|
908
|
+
private async trackMissingIndexPreventive(
|
|
909
|
+
collection: string,
|
|
910
|
+
arrange: Arrange,
|
|
911
|
+
conditions: Condition[] | undefined = undefined
|
|
912
|
+
): Promise<void> {
|
|
913
|
+
try {
|
|
914
|
+
const querySignature = this.generateQuerySignature(
|
|
915
|
+
collection,
|
|
916
|
+
arrange,
|
|
917
|
+
conditions
|
|
918
|
+
);
|
|
919
|
+
const docId = `${collection}_${querySignature}`;
|
|
920
|
+
|
|
921
|
+
const indexLink = this.generateIndexLink(collection, arrange, conditions);
|
|
922
|
+
|
|
923
|
+
const indexInstructions = this.generateIndexInstructions(
|
|
924
|
+
collection,
|
|
925
|
+
arrange,
|
|
926
|
+
conditions
|
|
927
|
+
);
|
|
928
|
+
|
|
929
|
+
const trackingData: any = {
|
|
930
|
+
collection,
|
|
931
|
+
indexLink,
|
|
932
|
+
indexInstructions,
|
|
933
|
+
arrange: {
|
|
934
|
+
sortBy: arrange.sortBy,
|
|
935
|
+
filters:
|
|
936
|
+
arrange.filters?.map((f: any) => ({
|
|
937
|
+
arrange: f.arrange,
|
|
938
|
+
property: f.filter?.property || null,
|
|
939
|
+
dateField:
|
|
940
|
+
f.arrange === 'filterByDate' ? arrange.sortBy?.field : null,
|
|
941
|
+
})) || [],
|
|
942
|
+
},
|
|
943
|
+
conditions:
|
|
944
|
+
conditions?.map((c: Condition) => ({
|
|
945
|
+
property: c.firestoreProperty,
|
|
946
|
+
operator: c.operator,
|
|
947
|
+
})) || [],
|
|
948
|
+
errorMessage: `Fallback preventivo usado para a collection ${collection}. A query exigiria índice composto.`,
|
|
949
|
+
updatedAt: firebase.firestore.FieldValue.serverTimestamp(),
|
|
950
|
+
};
|
|
951
|
+
|
|
952
|
+
console.log('📄 [INDEX LINK] Dados que serão salvos no documento:', {
|
|
953
|
+
docId,
|
|
954
|
+
collection: trackingData.collection,
|
|
955
|
+
indexLink: trackingData.indexLink,
|
|
956
|
+
arrange: trackingData.arrange,
|
|
957
|
+
conditions: trackingData.conditions,
|
|
958
|
+
errorMessage: trackingData.errorMessage,
|
|
959
|
+
});
|
|
960
|
+
|
|
961
|
+
const docRef = this.ngFire.collection('missingIndexes').doc(docId);
|
|
962
|
+
const doc = await docRef.get().toPromise();
|
|
963
|
+
|
|
964
|
+
if (doc && doc.exists) {
|
|
965
|
+
await docRef.update({
|
|
966
|
+
count: firebase.firestore.FieldValue.increment(1),
|
|
967
|
+
updatedAt: firebase.firestore.FieldValue.serverTimestamp(),
|
|
968
|
+
lastError: trackingData.errorMessage,
|
|
969
|
+
});
|
|
970
|
+
} else {
|
|
971
|
+
await docRef.set({
|
|
972
|
+
...trackingData,
|
|
973
|
+
count: 1,
|
|
974
|
+
createdAt: firebase.firestore.FieldValue.serverTimestamp(),
|
|
975
|
+
});
|
|
976
|
+
}
|
|
977
|
+
} catch (trackingError) {
|
|
978
|
+
console.warn('Falha ao rastrear fallback preventivo:', trackingError);
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
/**
|
|
983
|
+
* Gera uma assinatura única para uma query
|
|
984
|
+
*/
|
|
985
|
+
private generateQuerySignature(
|
|
986
|
+
collection: string,
|
|
987
|
+
arrange: Arrange,
|
|
988
|
+
conditions: Condition[] | undefined = undefined
|
|
989
|
+
): string {
|
|
990
|
+
const signature = {
|
|
991
|
+
collection,
|
|
992
|
+
sortBy: arrange.sortBy,
|
|
993
|
+
filters:
|
|
994
|
+
arrange.filters?.map((f: any) => ({
|
|
995
|
+
arrange: f.arrange,
|
|
996
|
+
property: f.filter?.property || null,
|
|
997
|
+
})) || [],
|
|
998
|
+
conditions:
|
|
999
|
+
conditions?.map((c: Condition) => ({
|
|
1000
|
+
property: c.firestoreProperty,
|
|
1001
|
+
operator: c.operator,
|
|
1002
|
+
})) || [],
|
|
1003
|
+
};
|
|
1004
|
+
|
|
1005
|
+
return btoa(JSON.stringify(signature))
|
|
1006
|
+
.replace(/[^a-zA-Z0-9]/g, '')
|
|
1007
|
+
.substring(0, 20);
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
/**
|
|
1011
|
+
* Gera instruções claras para criar o índice manualmente
|
|
1012
|
+
*/
|
|
1013
|
+
private generateIndexInstructions(
|
|
1014
|
+
collection: string,
|
|
1015
|
+
arrange: Arrange,
|
|
1016
|
+
conditions: Condition[] | undefined = undefined
|
|
1017
|
+
): any {
|
|
1018
|
+
const instructions: any = {
|
|
1019
|
+
summary: '',
|
|
1020
|
+
collection: collection,
|
|
1021
|
+
fields: [] as any[],
|
|
1022
|
+
queryExample: '',
|
|
1023
|
+
stepByStep: [] as string[],
|
|
1024
|
+
notes: [] as string[],
|
|
1025
|
+
};
|
|
1026
|
+
|
|
1027
|
+
const fields: any[] = [];
|
|
1028
|
+
|
|
1029
|
+
if (conditions && conditions.length > 0) {
|
|
1030
|
+
conditions.forEach((condition: Condition) => {
|
|
1031
|
+
if (condition.firestoreProperty) {
|
|
1032
|
+
fields.push({
|
|
1033
|
+
field: condition.firestoreProperty,
|
|
1034
|
+
order: 'Ascending',
|
|
1035
|
+
type: 'WHERE clause',
|
|
1036
|
+
operator: condition.operator,
|
|
1037
|
+
description: `Filtrar por ${condition.firestoreProperty} usando operador ${condition.operator}`,
|
|
1038
|
+
});
|
|
1039
|
+
}
|
|
1040
|
+
});
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
if (arrange.filters && arrange.filters.length > 0) {
|
|
1044
|
+
arrange.filters.forEach((filter: any) => {
|
|
1045
|
+
if (filter.filter?.property) {
|
|
1046
|
+
fields.push({
|
|
1047
|
+
field: filter.filter.property,
|
|
1048
|
+
order: 'Ascending',
|
|
1049
|
+
type: 'WHERE clause (filter)',
|
|
1050
|
+
operator: filter.arrange === 'filter' ? 'CONTAINS' : 'RANGE',
|
|
1051
|
+
description: `Filtrar por ${filter.filter.property} usando filtro ${filter.arrange}`,
|
|
1052
|
+
});
|
|
1053
|
+
}
|
|
1054
|
+
});
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
if (arrange.sortBy?.field) {
|
|
1058
|
+
fields.push({
|
|
1059
|
+
field: arrange.sortBy.field,
|
|
1060
|
+
order: arrange.sortBy.order === 'desc' ? 'Descending' : 'Ascending',
|
|
1061
|
+
type: 'ORDER BY clause',
|
|
1062
|
+
operator: 'N/A',
|
|
1063
|
+
description: `Ordenar resultados por ${arrange.sortBy.field} em ordem ${arrange.sortBy.order}`,
|
|
1064
|
+
});
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
instructions.fields = fields;
|
|
1068
|
+
|
|
1069
|
+
const fieldNames = fields.map((f: any) => f.field).join(' + ');
|
|
1070
|
+
instructions.summary = `Criar índice composto para ${collection}: ${fieldNames}`;
|
|
1071
|
+
|
|
1072
|
+
let queryExample = `db.collection('${collection}')`;
|
|
1073
|
+
|
|
1074
|
+
fields.forEach((field: any, index: number) => {
|
|
1075
|
+
if (field.type.includes('WHERE')) {
|
|
1076
|
+
if (field.operator === '==') {
|
|
1077
|
+
queryExample += `\n .where('${field.field}', '==', 'value')`;
|
|
1078
|
+
} else if (field.operator === 'CONTAINS') {
|
|
1079
|
+
queryExample += `\n .where('${field.field}', '>=', 'searchText')`;
|
|
1080
|
+
} else {
|
|
1081
|
+
queryExample += `\n .where('${field.field}', '${field.operator}', 'value')`;
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
});
|
|
1085
|
+
|
|
1086
|
+
const orderByField = fields.find((f: any) => f.type.includes('ORDER BY'));
|
|
1087
|
+
if (orderByField) {
|
|
1088
|
+
queryExample += `\n .orderBy('${
|
|
1089
|
+
orderByField.field
|
|
1090
|
+
}', '${orderByField.order.toLowerCase()}')`;
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
instructions.queryExample = queryExample;
|
|
1094
|
+
|
|
1095
|
+
instructions.stepByStep = [
|
|
1096
|
+
'1. Ir para Firebase Console → Firestore → Indexes',
|
|
1097
|
+
'2. Clicar em "Create Index"',
|
|
1098
|
+
`3. Definir Collection ID: ${collection}`,
|
|
1099
|
+
'4. Configurar campos nesta ORDEM EXATA:',
|
|
1100
|
+
...fields.map(
|
|
1101
|
+
(field: any, index: number) =>
|
|
1102
|
+
` ${index + 1}. Campo: ${field.field}, Order: ${
|
|
1103
|
+
field.order
|
|
1104
|
+
}, Array: No`
|
|
1105
|
+
),
|
|
1106
|
+
'5. Definir Query scopes: Collection',
|
|
1107
|
+
'6. Clicar em "Create" e aguardar conclusão',
|
|
1108
|
+
];
|
|
1109
|
+
|
|
1110
|
+
instructions.notes = [
|
|
1111
|
+
'⚠️ A ordem dos campos é CRÍTICA - deve corresponder exatamente à ordem da query',
|
|
1112
|
+
'⚠️ As cláusulas WHERE devem vir ANTES do campo ORDER BY',
|
|
1113
|
+
'⚠️ Este índice só funcionará para queries com esta combinação EXATA de campos',
|
|
1114
|
+
'⚠️ A criação do índice pode levar vários minutos',
|
|
1115
|
+
];
|
|
1116
|
+
|
|
1117
|
+
return instructions;
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
/**
|
|
1121
|
+
* Gera um link de índice baseado na estrutura da query
|
|
1122
|
+
*/
|
|
1123
|
+
private generateIndexLink(
|
|
1124
|
+
collection: string,
|
|
1125
|
+
arrange: Arrange,
|
|
1126
|
+
conditions: Condition[] | undefined = undefined
|
|
1127
|
+
): string | null {
|
|
1128
|
+
try {
|
|
1129
|
+
const indexFields: string[] = [];
|
|
1130
|
+
|
|
1131
|
+
if (conditions && conditions.length > 0) {
|
|
1132
|
+
conditions.forEach((condition: Condition) => {
|
|
1133
|
+
if (condition.firestoreProperty) {
|
|
1134
|
+
indexFields.push(condition.firestoreProperty);
|
|
1135
|
+
}
|
|
1136
|
+
});
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
if (arrange.filters && arrange.filters.length > 0) {
|
|
1140
|
+
arrange.filters.forEach((filter: any) => {
|
|
1141
|
+
if (filter.filter?.property) {
|
|
1142
|
+
indexFields.push(filter.filter.property);
|
|
1143
|
+
}
|
|
1144
|
+
});
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
if (arrange.sortBy?.field) {
|
|
1148
|
+
indexFields.push(arrange.sortBy.field);
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
if (indexFields.length > 1) {
|
|
1152
|
+
const baseUrl =
|
|
1153
|
+
'https://console.firebase.google.com/project/toppayy-dev/firestore/indexes';
|
|
1154
|
+
const queryParams = new URLSearchParams({
|
|
1155
|
+
create_composite: `collection=${collection}&fields=${indexFields.join(
|
|
1156
|
+
','
|
|
1157
|
+
)}`,
|
|
1158
|
+
});
|
|
1159
|
+
|
|
1160
|
+
const finalLink = `${baseUrl}?${queryParams.toString()}`;
|
|
1161
|
+
return finalLink;
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
return null;
|
|
1165
|
+
} catch (error) {
|
|
1166
|
+
console.warn('Falha ao gerar link de índice:', error);
|
|
1167
|
+
return null;
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
private async trackMissingIndex(
|
|
1172
|
+
error: any,
|
|
1173
|
+
collection: string,
|
|
1174
|
+
arrange: Arrange,
|
|
1175
|
+
conditions: Condition[] | undefined = undefined
|
|
1176
|
+
): Promise<void> {
|
|
1177
|
+
try {
|
|
1178
|
+
const indexLink = this.extractIndexLink(error);
|
|
1179
|
+
if (!indexLink) return;
|
|
1180
|
+
|
|
1181
|
+
const linkHash = btoa(indexLink)
|
|
1182
|
+
.replace(/[^a-zA-Z0-9]/g, '')
|
|
1183
|
+
.substring(0, 20);
|
|
1184
|
+
const docId = `${collection}_${linkHash}`;
|
|
1185
|
+
|
|
1186
|
+
const indexInstructions = this.generateIndexInstructions(
|
|
1187
|
+
collection,
|
|
1188
|
+
arrange,
|
|
1189
|
+
conditions
|
|
1190
|
+
);
|
|
1191
|
+
|
|
1192
|
+
const trackingData: any = {
|
|
1193
|
+
collection,
|
|
1194
|
+
indexLink,
|
|
1195
|
+
indexInstructions,
|
|
1196
|
+
arrange: {
|
|
1197
|
+
sortBy: arrange.sortBy,
|
|
1198
|
+
filters:
|
|
1199
|
+
arrange.filters?.map((f: any) => ({
|
|
1200
|
+
arrange: f.arrange,
|
|
1201
|
+
property: f.filter?.property || null,
|
|
1202
|
+
dateField:
|
|
1203
|
+
f.arrange === 'filterByDate' ? arrange.sortBy?.field : null,
|
|
1204
|
+
})) || [],
|
|
1205
|
+
},
|
|
1206
|
+
conditions:
|
|
1207
|
+
conditions?.map((c: Condition) => ({
|
|
1208
|
+
property: c.firestoreProperty,
|
|
1209
|
+
operator: c.operator,
|
|
1210
|
+
})) || [],
|
|
1211
|
+
errorMessage: error.message,
|
|
1212
|
+
updatedAt: firebase.firestore.FieldValue.serverTimestamp(),
|
|
1213
|
+
};
|
|
1214
|
+
|
|
1215
|
+
const docRef = this.ngFire.collection('missingIndexes').doc(docId);
|
|
1216
|
+
const doc = await docRef.get().toPromise();
|
|
1217
|
+
|
|
1218
|
+
if (doc && doc.exists) {
|
|
1219
|
+
await docRef.update({
|
|
1220
|
+
count: firebase.firestore.FieldValue.increment(1),
|
|
1221
|
+
updatedAt: firebase.firestore.FieldValue.serverTimestamp(),
|
|
1222
|
+
lastError: error.message,
|
|
1223
|
+
});
|
|
1224
|
+
} else {
|
|
1225
|
+
await docRef.set({
|
|
1226
|
+
...trackingData,
|
|
1227
|
+
count: 1,
|
|
1228
|
+
createdAt: firebase.firestore.FieldValue.serverTimestamp(),
|
|
1229
|
+
});
|
|
1230
|
+
}
|
|
1231
|
+
} catch (trackingError) {
|
|
1232
|
+
console.warn('Falha ao rastrear índice ausente:', trackingError);
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
}
|