@blackcode_sa/metaestetics-api 1.12.41 → 1.12.43
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/admin/index.d.mts +4 -4
- package/dist/admin/index.d.ts +4 -4
- package/dist/backoffice/index.d.mts +105 -7
- package/dist/backoffice/index.d.ts +105 -7
- package/dist/backoffice/index.js +552 -8
- package/dist/backoffice/index.mjs +566 -21
- package/dist/index.d.mts +93 -7
- package/dist/index.d.ts +93 -7
- package/dist/index.js +493 -8
- package/dist/index.mjs +493 -8
- package/package.json +1 -1
- package/src/backoffice/services/brand.service.ts +86 -0
- package/src/backoffice/services/category.service.ts +84 -0
- package/src/backoffice/services/constants.service.ts +77 -0
- package/src/backoffice/services/product.service.ts +113 -10
- package/src/backoffice/services/requirement.service.ts +76 -0
- package/src/backoffice/services/subcategory.service.ts +87 -0
- package/src/backoffice/services/technology.service.ts +178 -0
- package/src/backoffice/types/product.types.ts +7 -6
|
@@ -231,4 +231,88 @@ export class CategoryService extends BaseService implements ICategoryService {
|
|
|
231
231
|
...docSnap.data(),
|
|
232
232
|
} as Category;
|
|
233
233
|
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Exports categories to CSV string, suitable for Excel/Sheets.
|
|
237
|
+
* Includes headers and optional UTF-8 BOM.
|
|
238
|
+
* By default exports only active categories (set includeInactive to true to export all).
|
|
239
|
+
*/
|
|
240
|
+
async exportToCsv(options?: {
|
|
241
|
+
includeInactive?: boolean;
|
|
242
|
+
includeBom?: boolean;
|
|
243
|
+
}): Promise<string> {
|
|
244
|
+
const includeInactive = options?.includeInactive ?? false;
|
|
245
|
+
const includeBom = options?.includeBom ?? true;
|
|
246
|
+
|
|
247
|
+
const headers = [
|
|
248
|
+
"id",
|
|
249
|
+
"name",
|
|
250
|
+
"description",
|
|
251
|
+
"family",
|
|
252
|
+
"isActive",
|
|
253
|
+
];
|
|
254
|
+
|
|
255
|
+
const rows: string[] = [];
|
|
256
|
+
rows.push(headers.map((h) => this.formatCsvValue(h)).join(","));
|
|
257
|
+
|
|
258
|
+
const PAGE_SIZE = 1000;
|
|
259
|
+
let cursor: any | undefined;
|
|
260
|
+
|
|
261
|
+
// Build base constraints
|
|
262
|
+
const constraints: any[] = [];
|
|
263
|
+
if (!includeInactive) {
|
|
264
|
+
constraints.push(where("isActive", "==", true));
|
|
265
|
+
}
|
|
266
|
+
constraints.push(orderBy("name"));
|
|
267
|
+
|
|
268
|
+
// Page through all results
|
|
269
|
+
// eslint-disable-next-line no-constant-condition
|
|
270
|
+
while (true) {
|
|
271
|
+
const queryConstraints: any[] = [...constraints, limit(PAGE_SIZE)];
|
|
272
|
+
if (cursor) queryConstraints.push(startAfter(cursor));
|
|
273
|
+
|
|
274
|
+
const q = query(this.categoriesRef, ...queryConstraints);
|
|
275
|
+
const snapshot = await getDocs(q);
|
|
276
|
+
if (snapshot.empty) break;
|
|
277
|
+
|
|
278
|
+
for (const d of snapshot.docs) {
|
|
279
|
+
const category = ({ id: d.id, ...d.data() } as unknown) as Category;
|
|
280
|
+
rows.push(this.categoryToCsvRow(category));
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
cursor = snapshot.docs[snapshot.docs.length - 1];
|
|
284
|
+
if (snapshot.size < PAGE_SIZE) break;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const csvBody = rows.join("\r\n");
|
|
288
|
+
return includeBom ? "\uFEFF" + csvBody : csvBody;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
private categoryToCsvRow(category: Category): string {
|
|
292
|
+
const values = [
|
|
293
|
+
category.id ?? "",
|
|
294
|
+
category.name ?? "",
|
|
295
|
+
category.description ?? "",
|
|
296
|
+
category.family ?? "",
|
|
297
|
+
String(category.isActive ?? ""),
|
|
298
|
+
];
|
|
299
|
+
return values.map((v) => this.formatCsvValue(v)).join(",");
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
private formatDateIso(value: any): string {
|
|
303
|
+
// Firestore timestamps may come back as Date or Timestamp; handle both
|
|
304
|
+
if (value instanceof Date) return value.toISOString();
|
|
305
|
+
if (value && typeof value.toDate === "function") {
|
|
306
|
+
const d = value.toDate();
|
|
307
|
+
return d instanceof Date ? d.toISOString() : String(value);
|
|
308
|
+
}
|
|
309
|
+
return String(value ?? "");
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
private formatCsvValue(value: any): string {
|
|
313
|
+
const str = value === null || value === undefined ? "" : String(value);
|
|
314
|
+
// Escape double quotes by doubling them and wrap in quotes
|
|
315
|
+
const escaped = str.replace(/"/g, '""');
|
|
316
|
+
return `"${escaped}"`;
|
|
317
|
+
}
|
|
234
318
|
}
|
|
@@ -305,4 +305,81 @@ export class ConstantsService extends BaseService {
|
|
|
305
305
|
contraindications: arrayRemove(toRemove),
|
|
306
306
|
});
|
|
307
307
|
}
|
|
308
|
+
|
|
309
|
+
// =================================================================
|
|
310
|
+
// CSV Export Methods
|
|
311
|
+
// =================================================================
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Exports treatment benefits to CSV string, suitable for Excel/Sheets.
|
|
315
|
+
* Includes headers and optional UTF-8 BOM.
|
|
316
|
+
*/
|
|
317
|
+
async exportBenefitsToCsv(options?: {
|
|
318
|
+
includeBom?: boolean;
|
|
319
|
+
}): Promise<string> {
|
|
320
|
+
const includeBom = options?.includeBom ?? true;
|
|
321
|
+
|
|
322
|
+
const headers = ["id", "name", "description"];
|
|
323
|
+
|
|
324
|
+
const rows: string[] = [];
|
|
325
|
+
rows.push(headers.map((h) => this.formatCsvValue(h)).join(","));
|
|
326
|
+
|
|
327
|
+
const benefits = await this.getAllBenefitsForFilter();
|
|
328
|
+
|
|
329
|
+
for (const benefit of benefits) {
|
|
330
|
+
rows.push(this.benefitToCsvRow(benefit));
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const csvBody = rows.join("\r\n");
|
|
334
|
+
return includeBom ? "\uFEFF" + csvBody : csvBody;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Exports contraindications to CSV string, suitable for Excel/Sheets.
|
|
339
|
+
* Includes headers and optional UTF-8 BOM.
|
|
340
|
+
*/
|
|
341
|
+
async exportContraindicationsToCsv(options?: {
|
|
342
|
+
includeBom?: boolean;
|
|
343
|
+
}): Promise<string> {
|
|
344
|
+
const includeBom = options?.includeBom ?? true;
|
|
345
|
+
|
|
346
|
+
const headers = ["id", "name", "description"];
|
|
347
|
+
|
|
348
|
+
const rows: string[] = [];
|
|
349
|
+
rows.push(headers.map((h) => this.formatCsvValue(h)).join(","));
|
|
350
|
+
|
|
351
|
+
const contraindications = await this.getAllContraindicationsForFilter();
|
|
352
|
+
|
|
353
|
+
for (const contraindication of contraindications) {
|
|
354
|
+
rows.push(this.contraindicationToCsvRow(contraindication));
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const csvBody = rows.join("\r\n");
|
|
358
|
+
return includeBom ? "\uFEFF" + csvBody : csvBody;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
private benefitToCsvRow(benefit: TreatmentBenefitDynamic): string {
|
|
362
|
+
const values = [
|
|
363
|
+
benefit.id ?? "",
|
|
364
|
+
benefit.name ?? "",
|
|
365
|
+
benefit.description ?? "",
|
|
366
|
+
];
|
|
367
|
+
return values.map((v) => this.formatCsvValue(v)).join(",");
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
private contraindicationToCsvRow(contraindication: ContraindicationDynamic): string {
|
|
371
|
+
const values = [
|
|
372
|
+
contraindication.id ?? "",
|
|
373
|
+
contraindication.name ?? "",
|
|
374
|
+
contraindication.description ?? "",
|
|
375
|
+
];
|
|
376
|
+
return values.map((v) => this.formatCsvValue(v)).join(",");
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
private formatCsvValue(value: any): string {
|
|
380
|
+
const str = value === null || value === undefined ? "" : String(value);
|
|
381
|
+
// Escape double quotes by doubling them and wrap in quotes
|
|
382
|
+
const escaped = str.replace(/"/g, '""');
|
|
383
|
+
return `"${escaped}"`;
|
|
384
|
+
}
|
|
308
385
|
}
|
|
@@ -134,9 +134,8 @@ export class ProductService extends BaseService implements IProductService {
|
|
|
134
134
|
}
|
|
135
135
|
|
|
136
136
|
/**
|
|
137
|
-
* Gets counts of active products grouped by technology.
|
|
138
|
-
*
|
|
139
|
-
* Categories/subcategories not available in top-level structure.
|
|
137
|
+
* Gets counts of active products grouped by category, subcategory, and technology.
|
|
138
|
+
* Queries technology subcollections which have the legacy fields synced by Cloud Functions.
|
|
140
139
|
*/
|
|
141
140
|
async getProductCounts(): Promise<{
|
|
142
141
|
byCategory: Record<string, number>;
|
|
@@ -149,18 +148,22 @@ export class ProductService extends BaseService implements IProductService {
|
|
|
149
148
|
byTechnology: {} as Record<string, number>,
|
|
150
149
|
};
|
|
151
150
|
|
|
152
|
-
// Query
|
|
153
|
-
const q = query(this.
|
|
151
|
+
// Query technology subcollections (which have synced legacy fields)
|
|
152
|
+
const q = query(collectionGroup(this.db, PRODUCTS_COLLECTION), where('isActive', '==', true));
|
|
154
153
|
const snapshot = await getDocs(q);
|
|
155
154
|
|
|
156
155
|
snapshot.docs.forEach(doc => {
|
|
157
156
|
const product = doc.data() as Product;
|
|
158
157
|
|
|
159
|
-
//
|
|
160
|
-
if (product.
|
|
161
|
-
product.
|
|
162
|
-
|
|
163
|
-
|
|
158
|
+
// Use legacy fields from subcollections
|
|
159
|
+
if (product.categoryId) {
|
|
160
|
+
counts.byCategory[product.categoryId] = (counts.byCategory[product.categoryId] || 0) + 1;
|
|
161
|
+
}
|
|
162
|
+
if (product.subcategoryId) {
|
|
163
|
+
counts.bySubcategory[product.subcategoryId] = (counts.bySubcategory[product.subcategoryId] || 0) + 1;
|
|
164
|
+
}
|
|
165
|
+
if (product.technologyId) {
|
|
166
|
+
counts.byTechnology[product.technologyId] = (counts.byTechnology[product.technologyId] || 0) + 1;
|
|
164
167
|
}
|
|
165
168
|
});
|
|
166
169
|
|
|
@@ -447,4 +450,104 @@ export class ProductService extends BaseService implements IProductService {
|
|
|
447
450
|
} as Product),
|
|
448
451
|
);
|
|
449
452
|
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Exports products to CSV string, suitable for Excel/Sheets.
|
|
456
|
+
* Includes headers and optional UTF-8 BOM.
|
|
457
|
+
* By default exports only active products (set includeInactive to true to export all).
|
|
458
|
+
*/
|
|
459
|
+
async exportToCsv(options?: {
|
|
460
|
+
includeInactive?: boolean;
|
|
461
|
+
includeBom?: boolean;
|
|
462
|
+
}): Promise<string> {
|
|
463
|
+
const includeInactive = options?.includeInactive ?? false;
|
|
464
|
+
const includeBom = options?.includeBom ?? true;
|
|
465
|
+
|
|
466
|
+
const headers = [
|
|
467
|
+
"id",
|
|
468
|
+
"name",
|
|
469
|
+
"brandId",
|
|
470
|
+
"brandName",
|
|
471
|
+
"assignedTechnologyIds",
|
|
472
|
+
"description",
|
|
473
|
+
"technicalDetails",
|
|
474
|
+
"dosage",
|
|
475
|
+
"composition",
|
|
476
|
+
"indications",
|
|
477
|
+
"contraindications",
|
|
478
|
+
"warnings",
|
|
479
|
+
"isActive",
|
|
480
|
+
];
|
|
481
|
+
|
|
482
|
+
const rows: string[] = [];
|
|
483
|
+
rows.push(headers.map((h) => this.formatCsvValue(h)).join(","));
|
|
484
|
+
|
|
485
|
+
const PAGE_SIZE = 1000;
|
|
486
|
+
let cursor: any | undefined;
|
|
487
|
+
|
|
488
|
+
// Build base constraints
|
|
489
|
+
const constraints: QueryConstraint[] = [];
|
|
490
|
+
if (!includeInactive) {
|
|
491
|
+
constraints.push(where("isActive", "==", true));
|
|
492
|
+
}
|
|
493
|
+
constraints.push(orderBy("name"));
|
|
494
|
+
|
|
495
|
+
// Page through all results from top-level collection
|
|
496
|
+
// eslint-disable-next-line no-constant-condition
|
|
497
|
+
while (true) {
|
|
498
|
+
const queryConstraints: QueryConstraint[] = [...constraints, limit(PAGE_SIZE)];
|
|
499
|
+
if (cursor) queryConstraints.push(startAfter(cursor));
|
|
500
|
+
|
|
501
|
+
const q = query(this.getTopLevelProductsRef(), ...queryConstraints);
|
|
502
|
+
const snapshot = await getDocs(q);
|
|
503
|
+
if (snapshot.empty) break;
|
|
504
|
+
|
|
505
|
+
for (const d of snapshot.docs) {
|
|
506
|
+
const product = ({ id: d.id, ...d.data() } as unknown) as Product;
|
|
507
|
+
rows.push(this.productToCsvRow(product));
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
cursor = snapshot.docs[snapshot.docs.length - 1];
|
|
511
|
+
if (snapshot.size < PAGE_SIZE) break;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const csvBody = rows.join("\r\n");
|
|
515
|
+
return includeBom ? "\uFEFF" + csvBody : csvBody;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
private productToCsvRow(product: Product): string {
|
|
519
|
+
const values = [
|
|
520
|
+
product.id ?? "",
|
|
521
|
+
product.name ?? "",
|
|
522
|
+
product.brandId ?? "",
|
|
523
|
+
product.brandName ?? "",
|
|
524
|
+
product.assignedTechnologyIds?.join(";") ?? "",
|
|
525
|
+
product.description ?? "",
|
|
526
|
+
product.technicalDetails ?? "",
|
|
527
|
+
product.dosage ?? "",
|
|
528
|
+
product.composition ?? "",
|
|
529
|
+
product.indications?.join(";") ?? "",
|
|
530
|
+
product.contraindications?.map(c => c.name).join(";") ?? "",
|
|
531
|
+
product.warnings?.join(";") ?? "",
|
|
532
|
+
String(product.isActive ?? ""),
|
|
533
|
+
];
|
|
534
|
+
return values.map((v) => this.formatCsvValue(v)).join(",");
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
private formatDateIso(value: any): string {
|
|
538
|
+
// Firestore timestamps may come back as Date or Timestamp; handle both
|
|
539
|
+
if (value instanceof Date) return value.toISOString();
|
|
540
|
+
if (value && typeof value.toDate === "function") {
|
|
541
|
+
const d = value.toDate();
|
|
542
|
+
return d instanceof Date ? d.toISOString() : String(value);
|
|
543
|
+
}
|
|
544
|
+
return String(value ?? "");
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
private formatCsvValue(value: any): string {
|
|
548
|
+
const str = value === null || value === undefined ? "" : String(value);
|
|
549
|
+
// Escape double quotes by doubling them and wrap in quotes
|
|
550
|
+
const escaped = str.replace(/"/g, '""');
|
|
551
|
+
return `"${escaped}"`;
|
|
552
|
+
}
|
|
450
553
|
}
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
query,
|
|
8
8
|
updateDoc,
|
|
9
9
|
where,
|
|
10
|
+
orderBy,
|
|
10
11
|
} from "firebase/firestore";
|
|
11
12
|
import {
|
|
12
13
|
Requirement,
|
|
@@ -156,4 +157,79 @@ export class RequirementService extends BaseService {
|
|
|
156
157
|
...docSnap.data(),
|
|
157
158
|
} as Requirement;
|
|
158
159
|
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Exports requirements to CSV string, suitable for Excel/Sheets.
|
|
163
|
+
* Includes headers and optional UTF-8 BOM.
|
|
164
|
+
* By default exports only active requirements (set includeInactive to true to export all).
|
|
165
|
+
*/
|
|
166
|
+
async exportToCsv(options?: {
|
|
167
|
+
includeInactive?: boolean;
|
|
168
|
+
includeBom?: boolean;
|
|
169
|
+
}): Promise<string> {
|
|
170
|
+
const includeInactive = options?.includeInactive ?? false;
|
|
171
|
+
const includeBom = options?.includeBom ?? true;
|
|
172
|
+
|
|
173
|
+
const headers = [
|
|
174
|
+
"id",
|
|
175
|
+
"type",
|
|
176
|
+
"name",
|
|
177
|
+
"description",
|
|
178
|
+
"timeframe_duration",
|
|
179
|
+
"timeframe_unit",
|
|
180
|
+
"timeframe_notifyAt",
|
|
181
|
+
"importance",
|
|
182
|
+
"isActive",
|
|
183
|
+
];
|
|
184
|
+
|
|
185
|
+
const rows: string[] = [];
|
|
186
|
+
rows.push(headers.map((h) => this.formatCsvValue(h)).join(","));
|
|
187
|
+
|
|
188
|
+
// Get all requirements (they're not paginated in the original service)
|
|
189
|
+
const q = includeInactive
|
|
190
|
+
? query(this.requirementsRef, orderBy("name"))
|
|
191
|
+
: query(this.requirementsRef, where("isActive", "==", true), orderBy("name"));
|
|
192
|
+
|
|
193
|
+
const snapshot = await getDocs(q);
|
|
194
|
+
|
|
195
|
+
for (const d of snapshot.docs) {
|
|
196
|
+
const requirement = ({ id: d.id, ...d.data() } as unknown) as Requirement;
|
|
197
|
+
rows.push(this.requirementToCsvRow(requirement));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const csvBody = rows.join("\r\n");
|
|
201
|
+
return includeBom ? "\uFEFF" + csvBody : csvBody;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private requirementToCsvRow(requirement: Requirement): string {
|
|
205
|
+
const values = [
|
|
206
|
+
requirement.id ?? "",
|
|
207
|
+
requirement.type ?? "",
|
|
208
|
+
requirement.name ?? "",
|
|
209
|
+
requirement.description ?? "",
|
|
210
|
+
requirement.timeframe?.duration?.toString() ?? "",
|
|
211
|
+
requirement.timeframe?.unit ?? "",
|
|
212
|
+
requirement.timeframe?.notifyAt?.join(";") ?? "",
|
|
213
|
+
requirement.importance ?? "",
|
|
214
|
+
String(requirement.isActive ?? ""),
|
|
215
|
+
];
|
|
216
|
+
return values.map((v) => this.formatCsvValue(v)).join(",");
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
private formatDateIso(value: any): string {
|
|
220
|
+
// Firestore timestamps may come back as Date or Timestamp; handle both
|
|
221
|
+
if (value instanceof Date) return value.toISOString();
|
|
222
|
+
if (value && typeof value.toDate === "function") {
|
|
223
|
+
const d = value.toDate();
|
|
224
|
+
return d instanceof Date ? d.toISOString() : String(value);
|
|
225
|
+
}
|
|
226
|
+
return String(value ?? "");
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
private formatCsvValue(value: any): string {
|
|
230
|
+
const str = value === null || value === undefined ? "" : String(value);
|
|
231
|
+
// Escape double quotes by doubling them and wrap in quotes
|
|
232
|
+
const escaped = str.replace(/"/g, '""');
|
|
233
|
+
return `"${escaped}"`;
|
|
234
|
+
}
|
|
159
235
|
}
|
|
@@ -305,4 +305,91 @@ export class SubcategoryService extends BaseService {
|
|
|
305
305
|
...docSnap.data(),
|
|
306
306
|
} as Subcategory;
|
|
307
307
|
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Exports subcategories to CSV string, suitable for Excel/Sheets.
|
|
311
|
+
* Includes headers and optional UTF-8 BOM.
|
|
312
|
+
* By default exports only active subcategories (set includeInactive to true to export all).
|
|
313
|
+
*/
|
|
314
|
+
async exportToCsv(options?: {
|
|
315
|
+
includeInactive?: boolean;
|
|
316
|
+
includeBom?: boolean;
|
|
317
|
+
}): Promise<string> {
|
|
318
|
+
const includeInactive = options?.includeInactive ?? false;
|
|
319
|
+
const includeBom = options?.includeBom ?? true;
|
|
320
|
+
|
|
321
|
+
const headers = [
|
|
322
|
+
"id",
|
|
323
|
+
"name",
|
|
324
|
+
"categoryId",
|
|
325
|
+
"description",
|
|
326
|
+
"isActive",
|
|
327
|
+
];
|
|
328
|
+
|
|
329
|
+
const rows: string[] = [];
|
|
330
|
+
rows.push(headers.map((h) => this.formatCsvValue(h)).join(","));
|
|
331
|
+
|
|
332
|
+
const PAGE_SIZE = 1000;
|
|
333
|
+
let cursor: any | undefined;
|
|
334
|
+
|
|
335
|
+
// Build base constraints
|
|
336
|
+
const constraints: any[] = [];
|
|
337
|
+
if (!includeInactive) {
|
|
338
|
+
constraints.push(where("isActive", "==", true));
|
|
339
|
+
}
|
|
340
|
+
constraints.push(orderBy("name"));
|
|
341
|
+
|
|
342
|
+
// Page through all results using collectionGroup
|
|
343
|
+
// eslint-disable-next-line no-constant-condition
|
|
344
|
+
while (true) {
|
|
345
|
+
const queryConstraints: any[] = [...constraints, limit(PAGE_SIZE)];
|
|
346
|
+
if (cursor) queryConstraints.push(startAfter(cursor));
|
|
347
|
+
|
|
348
|
+
const q = query(
|
|
349
|
+
collectionGroup(this.db, SUBCATEGORIES_COLLECTION),
|
|
350
|
+
...queryConstraints
|
|
351
|
+
);
|
|
352
|
+
const snapshot = await getDocs(q);
|
|
353
|
+
if (snapshot.empty) break;
|
|
354
|
+
|
|
355
|
+
for (const d of snapshot.docs) {
|
|
356
|
+
const subcategory = ({ id: d.id, ...d.data() } as unknown) as Subcategory;
|
|
357
|
+
rows.push(this.subcategoryToCsvRow(subcategory));
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
cursor = snapshot.docs[snapshot.docs.length - 1];
|
|
361
|
+
if (snapshot.size < PAGE_SIZE) break;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const csvBody = rows.join("\r\n");
|
|
365
|
+
return includeBom ? "\uFEFF" + csvBody : csvBody;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
private subcategoryToCsvRow(subcategory: Subcategory): string {
|
|
369
|
+
const values = [
|
|
370
|
+
subcategory.id ?? "",
|
|
371
|
+
subcategory.name ?? "",
|
|
372
|
+
subcategory.categoryId ?? "",
|
|
373
|
+
subcategory.description ?? "",
|
|
374
|
+
String(subcategory.isActive ?? ""),
|
|
375
|
+
];
|
|
376
|
+
return values.map((v) => this.formatCsvValue(v)).join(",");
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
private formatDateIso(value: any): string {
|
|
380
|
+
// Firestore timestamps may come back as Date or Timestamp; handle both
|
|
381
|
+
if (value instanceof Date) return value.toISOString();
|
|
382
|
+
if (value && typeof value.toDate === "function") {
|
|
383
|
+
const d = value.toDate();
|
|
384
|
+
return d instanceof Date ? d.toISOString() : String(value);
|
|
385
|
+
}
|
|
386
|
+
return String(value ?? "");
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
private formatCsvValue(value: any): string {
|
|
390
|
+
const str = value === null || value === undefined ? "" : String(value);
|
|
391
|
+
// Escape double quotes by doubling them and wrap in quotes
|
|
392
|
+
const escaped = str.replace(/"/g, '""');
|
|
393
|
+
return `"${escaped}"`;
|
|
394
|
+
}
|
|
308
395
|
}
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
arrayRemove,
|
|
17
17
|
Firestore,
|
|
18
18
|
writeBatch,
|
|
19
|
+
QueryConstraint,
|
|
19
20
|
} from 'firebase/firestore';
|
|
20
21
|
import { Technology, TECHNOLOGIES_COLLECTION, ITechnologyService } from '../types/technology.types';
|
|
21
22
|
import { Requirement, RequirementType } from '../types/requirement.types';
|
|
@@ -242,7 +243,25 @@ export class TechnologyService extends BaseService implements ITechnologyService
|
|
|
242
243
|
updateData.updatedAt = new Date();
|
|
243
244
|
|
|
244
245
|
const docRef = doc(this.technologiesRef, id);
|
|
246
|
+
|
|
247
|
+
// Get the technology before update to check what changed
|
|
248
|
+
const beforeTech = await this.getById(id);
|
|
249
|
+
|
|
245
250
|
await updateDoc(docRef, updateData);
|
|
251
|
+
|
|
252
|
+
// If categoryId, subcategoryId, or name changed, update all products in subcollection
|
|
253
|
+
const categoryChanged = beforeTech && updateData.categoryId && beforeTech.categoryId !== updateData.categoryId;
|
|
254
|
+
const subcategoryChanged = beforeTech && updateData.subcategoryId && beforeTech.subcategoryId !== updateData.subcategoryId;
|
|
255
|
+
const nameChanged = beforeTech && updateData.name && beforeTech.name !== updateData.name;
|
|
256
|
+
|
|
257
|
+
if (categoryChanged || subcategoryChanged || nameChanged) {
|
|
258
|
+
await this.updateProductsInSubcollection(id, {
|
|
259
|
+
categoryId: updateData.categoryId,
|
|
260
|
+
subcategoryId: updateData.subcategoryId,
|
|
261
|
+
technologyName: updateData.name,
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
246
265
|
return this.getById(id);
|
|
247
266
|
}
|
|
248
267
|
|
|
@@ -889,4 +908,163 @@ export class TechnologyService extends BaseService implements ITechnologyService
|
|
|
889
908
|
byBrand,
|
|
890
909
|
};
|
|
891
910
|
}
|
|
911
|
+
|
|
912
|
+
/**
|
|
913
|
+
* Updates products in technology subcollection when technology metadata changes
|
|
914
|
+
* @param technologyId - ID of the technology
|
|
915
|
+
* @param updates - Fields to update (categoryId, subcategoryId, technologyName)
|
|
916
|
+
*/
|
|
917
|
+
private async updateProductsInSubcollection(
|
|
918
|
+
technologyId: string,
|
|
919
|
+
updates: { categoryId?: string; subcategoryId?: string; technologyName?: string }
|
|
920
|
+
): Promise<void> {
|
|
921
|
+
const productsRef = collection(this.db, TECHNOLOGIES_COLLECTION, technologyId, PRODUCTS_COLLECTION);
|
|
922
|
+
const productsSnapshot = await getDocs(productsRef);
|
|
923
|
+
|
|
924
|
+
if (productsSnapshot.empty) {
|
|
925
|
+
return;
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
const batch = writeBatch(this.db);
|
|
929
|
+
|
|
930
|
+
for (const productDoc of productsSnapshot.docs) {
|
|
931
|
+
const productRef = productDoc.ref;
|
|
932
|
+
const updateFields: any = {};
|
|
933
|
+
|
|
934
|
+
if (updates.categoryId !== undefined) {
|
|
935
|
+
updateFields.categoryId = updates.categoryId;
|
|
936
|
+
}
|
|
937
|
+
if (updates.subcategoryId !== undefined) {
|
|
938
|
+
updateFields.subcategoryId = updates.subcategoryId;
|
|
939
|
+
}
|
|
940
|
+
if (updates.technologyName !== undefined) {
|
|
941
|
+
updateFields.technologyName = updates.technologyName;
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
if (Object.keys(updateFields).length > 0) {
|
|
945
|
+
batch.update(productRef, updateFields);
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
await batch.commit();
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
/**
|
|
953
|
+
* Exports technologies to CSV string, suitable for Excel/Sheets.
|
|
954
|
+
* Includes headers and optional UTF-8 BOM.
|
|
955
|
+
* By default exports only active technologies (set includeInactive to true to export all).
|
|
956
|
+
* Includes product names from subcollections.
|
|
957
|
+
*/
|
|
958
|
+
async exportToCsv(options?: {
|
|
959
|
+
includeInactive?: boolean;
|
|
960
|
+
includeBom?: boolean;
|
|
961
|
+
}): Promise<string> {
|
|
962
|
+
const includeInactive = options?.includeInactive ?? false;
|
|
963
|
+
const includeBom = options?.includeBom ?? true;
|
|
964
|
+
|
|
965
|
+
const headers = [
|
|
966
|
+
"id",
|
|
967
|
+
"name",
|
|
968
|
+
"description",
|
|
969
|
+
"family",
|
|
970
|
+
"categoryId",
|
|
971
|
+
"subcategoryId",
|
|
972
|
+
"technicalDetails",
|
|
973
|
+
"requirements_pre",
|
|
974
|
+
"requirements_post",
|
|
975
|
+
"blockingConditions",
|
|
976
|
+
"contraindications",
|
|
977
|
+
"benefits",
|
|
978
|
+
"certificationMinimumLevel",
|
|
979
|
+
"certificationRequiredSpecialties",
|
|
980
|
+
"documentationTemplateIds",
|
|
981
|
+
"productNames",
|
|
982
|
+
"isActive",
|
|
983
|
+
];
|
|
984
|
+
|
|
985
|
+
const rows: string[] = [];
|
|
986
|
+
rows.push(headers.map((h) => this.formatCsvValue(h)).join(","));
|
|
987
|
+
|
|
988
|
+
const PAGE_SIZE = 1000;
|
|
989
|
+
let cursor: any | undefined;
|
|
990
|
+
|
|
991
|
+
// Build base constraints
|
|
992
|
+
const constraints: QueryConstraint[] = [];
|
|
993
|
+
if (!includeInactive) {
|
|
994
|
+
constraints.push(where("isActive", "==", true));
|
|
995
|
+
}
|
|
996
|
+
constraints.push(orderBy("name"));
|
|
997
|
+
|
|
998
|
+
// Page through all results
|
|
999
|
+
// eslint-disable-next-line no-constant-condition
|
|
1000
|
+
while (true) {
|
|
1001
|
+
const queryConstraints: QueryConstraint[] = [...constraints, limit(PAGE_SIZE)];
|
|
1002
|
+
if (cursor) queryConstraints.push(startAfter(cursor));
|
|
1003
|
+
|
|
1004
|
+
const q = query(this.technologiesRef, ...queryConstraints);
|
|
1005
|
+
const snapshot = await getDocs(q);
|
|
1006
|
+
if (snapshot.empty) break;
|
|
1007
|
+
|
|
1008
|
+
for (const d of snapshot.docs) {
|
|
1009
|
+
const technology = ({ id: d.id, ...d.data() } as unknown) as Technology;
|
|
1010
|
+
// Fetch products for this technology
|
|
1011
|
+
const productNames = await this.getProductNamesForTechnology(technology.id!);
|
|
1012
|
+
rows.push(this.technologyToCsvRow(technology, productNames));
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
cursor = snapshot.docs[snapshot.docs.length - 1];
|
|
1016
|
+
if (snapshot.size < PAGE_SIZE) break;
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
const csvBody = rows.join("\r\n");
|
|
1020
|
+
return includeBom ? "\uFEFF" + csvBody : csvBody;
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
/**
|
|
1024
|
+
* Gets product names from the technology's product subcollection
|
|
1025
|
+
*/
|
|
1026
|
+
private async getProductNamesForTechnology(technologyId: string): Promise<string[]> {
|
|
1027
|
+
try {
|
|
1028
|
+
const productsRef = collection(this.db, TECHNOLOGIES_COLLECTION, technologyId, PRODUCTS_COLLECTION);
|
|
1029
|
+
const q = query(productsRef, where("isActive", "==", true));
|
|
1030
|
+
const snapshot = await getDocs(q);
|
|
1031
|
+
return snapshot.docs.map(doc => {
|
|
1032
|
+
const product = doc.data() as Product;
|
|
1033
|
+
return product.name || "";
|
|
1034
|
+
}).filter(name => name); // Filter out empty names
|
|
1035
|
+
} catch (error) {
|
|
1036
|
+
console.error(`Error fetching products for technology ${technologyId}:`, error);
|
|
1037
|
+
return [];
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
private technologyToCsvRow(technology: Technology, productNames: string[] = []): string {
|
|
1042
|
+
const values = [
|
|
1043
|
+
technology.id ?? "",
|
|
1044
|
+
technology.name ?? "",
|
|
1045
|
+
technology.description ?? "",
|
|
1046
|
+
technology.family ?? "",
|
|
1047
|
+
technology.categoryId ?? "",
|
|
1048
|
+
technology.subcategoryId ?? "",
|
|
1049
|
+
technology.technicalDetails ?? "",
|
|
1050
|
+
technology.requirements?.pre?.map(r => r.name).join(";") ?? "",
|
|
1051
|
+
technology.requirements?.post?.map(r => r.name).join(";") ?? "",
|
|
1052
|
+
technology.blockingConditions?.join(";") ?? "",
|
|
1053
|
+
technology.contraindications?.map(c => c.name).join(";") ?? "",
|
|
1054
|
+
technology.benefits?.map(b => b.name).join(";") ?? "",
|
|
1055
|
+
technology.certificationRequirement?.minimumLevel ?? "",
|
|
1056
|
+
technology.certificationRequirement?.requiredSpecialties?.join(";") ?? "",
|
|
1057
|
+
technology.documentationTemplates?.map(t => t.templateId).join(";") ?? "",
|
|
1058
|
+
productNames.join(";"),
|
|
1059
|
+
String(technology.isActive ?? ""),
|
|
1060
|
+
];
|
|
1061
|
+
return values.map((v) => this.formatCsvValue(v)).join(",");
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
private formatCsvValue(value: any): string {
|
|
1065
|
+
const str = value === null || value === undefined ? "" : String(value);
|
|
1066
|
+
// Escape double quotes by doubling them and wrap in quotes
|
|
1067
|
+
const escaped = str.replace(/"/g, '""');
|
|
1068
|
+
return `"${escaped}"`;
|
|
1069
|
+
}
|
|
892
1070
|
}
|