@blackcode_sa/metaestetics-api 1.12.43 → 1.12.45

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@blackcode_sa/metaestetics-api",
3
3
  "private": false,
4
- "version": "1.12.43",
4
+ "version": "1.12.45",
5
5
  "description": "Firebase authentication service with anonymous upgrade support",
6
6
  "main": "dist/index.js",
7
7
  "module": "dist/index.mjs",
@@ -167,90 +167,4 @@ export class BrandService extends BaseService {
167
167
  ...docSnap.data(),
168
168
  } as Brand;
169
169
  }
170
-
171
- /**
172
- * Exports brands to CSV string, suitable for Excel/Sheets.
173
- * Includes headers and optional UTF-8 BOM.
174
- * By default exports only active brands (set includeInactive to true to export all).
175
- */
176
- async exportToCsv(options?: {
177
- includeInactive?: boolean;
178
- includeBom?: boolean;
179
- }): Promise<string> {
180
- const includeInactive = options?.includeInactive ?? false;
181
- const includeBom = options?.includeBom ?? true;
182
-
183
- const headers = [
184
- "id",
185
- "name",
186
- "manufacturer",
187
- "website",
188
- "description",
189
- "isActive",
190
- ];
191
-
192
- const rows: string[] = [];
193
- rows.push(headers.map((h) => this.formatCsvValue(h)).join(","));
194
-
195
- const PAGE_SIZE = 1000;
196
- let cursor: any | undefined;
197
-
198
- // Build base constraints
199
- const baseConstraints: QueryConstraint[] = [];
200
- if (!includeInactive) {
201
- baseConstraints.push(where("isActive", "==", true));
202
- }
203
- baseConstraints.push(orderBy("name_lowercase"));
204
-
205
- // Page through all results
206
- // eslint-disable-next-line no-constant-condition
207
- while (true) {
208
- const constraints: QueryConstraint[] = [...baseConstraints, limit(PAGE_SIZE)];
209
- if (cursor) constraints.push(startAfter(cursor));
210
-
211
- const q = query(this.getBrandsRef(), ...constraints);
212
- const snapshot = await getDocs(q);
213
- if (snapshot.empty) break;
214
-
215
- for (const d of snapshot.docs) {
216
- const brand = ({ id: d.id, ...d.data() } as unknown) as Brand;
217
- rows.push(this.brandToCsvRow(brand));
218
- }
219
-
220
- cursor = snapshot.docs[snapshot.docs.length - 1];
221
- if (snapshot.size < PAGE_SIZE) break;
222
- }
223
-
224
- const csvBody = rows.join("\r\n");
225
- return includeBom ? "\uFEFF" + csvBody : csvBody;
226
- }
227
-
228
- private brandToCsvRow(brand: Brand): string {
229
- const values = [
230
- brand.id ?? "",
231
- brand.name ?? "",
232
- brand.manufacturer ?? "",
233
- brand.website ?? "",
234
- brand.description ?? "",
235
- String(brand.isActive ?? ""),
236
- ];
237
- return values.map((v) => this.formatCsvValue(v)).join(",");
238
- }
239
-
240
- private formatDateIso(value: any): string {
241
- // Firestore timestamps may come back as Date or Timestamp; handle both
242
- if (value instanceof Date) return value.toISOString();
243
- if (value && typeof value.toDate === "function") {
244
- const d = value.toDate();
245
- return d instanceof Date ? d.toISOString() : String(value);
246
- }
247
- return String(value ?? "");
248
- }
249
-
250
- private formatCsvValue(value: any): string {
251
- const str = value === null || value === undefined ? "" : String(value);
252
- // Escape double quotes by doubling them and wrap in quotes
253
- const escaped = str.replace(/"/g, '""');
254
- return `"${escaped}"`;
255
- }
256
170
  }
@@ -231,88 +231,4 @@ 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
- }
318
234
  }
@@ -305,81 +305,4 @@ 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
- }
385
308
  }
@@ -13,8 +13,6 @@ import {
13
13
  startAfter,
14
14
  getCountFromServer,
15
15
  QueryConstraint,
16
- arrayUnion,
17
- arrayRemove,
18
16
  } from 'firebase/firestore';
19
17
  import { Product, PRODUCTS_COLLECTION, IProductService } from '../types/product.types';
20
18
  import { BaseService } from '../../services/base.service';
@@ -22,15 +20,7 @@ import { TECHNOLOGIES_COLLECTION } from '../types/technology.types';
22
20
 
23
21
  export class ProductService extends BaseService implements IProductService {
24
22
  /**
25
- * Gets reference to top-level products collection (source of truth)
26
- * @returns Firestore collection reference
27
- */
28
- private getTopLevelProductsRef() {
29
- return collection(this.db, PRODUCTS_COLLECTION);
30
- }
31
-
32
- /**
33
- * Gets reference to products collection under a technology (backward compatibility)
23
+ * Gets reference to products collection under a technology
34
24
  * @param technologyId - ID of the technology
35
25
  * @returns Firestore collection reference
36
26
  */
@@ -47,11 +37,11 @@ export class ProductService extends BaseService implements IProductService {
47
37
  product: Omit<Product, 'id' | 'createdAt' | 'updatedAt' | 'brandId' | 'technologyId'>,
48
38
  ): Promise<Product> {
49
39
  const now = new Date();
50
- // Create product with legacy structure for subcollection compatibility
40
+ // categoryId and subcategoryId are now expected to be part of the product object
51
41
  const newProduct: Omit<Product, 'id'> = {
52
42
  ...product,
53
43
  brandId,
54
- technologyId, // Required for old subcollection structure
44
+ technologyId,
55
45
  createdAt: now,
56
46
  updatedAt: now,
57
47
  isActive: true,
@@ -135,35 +125,38 @@ export class ProductService extends BaseService implements IProductService {
135
125
 
136
126
  /**
137
127
  * Gets counts of active products grouped by category, subcategory, and technology.
138
- * Queries technology subcollections which have the legacy fields synced by Cloud Functions.
128
+ * This uses a single collectionGroup query for efficiency.
139
129
  */
140
130
  async getProductCounts(): Promise<{
141
131
  byCategory: Record<string, number>;
142
132
  bySubcategory: Record<string, number>;
143
133
  byTechnology: Record<string, number>;
144
134
  }> {
135
+ const q = query(collectionGroup(this.db, PRODUCTS_COLLECTION), where('isActive', '==', true));
136
+ const snapshot = await getDocs(q);
137
+
145
138
  const counts = {
146
139
  byCategory: {} as Record<string, number>,
147
140
  bySubcategory: {} as Record<string, number>,
148
141
  byTechnology: {} as Record<string, number>,
149
142
  };
150
143
 
151
- // Query technology subcollections (which have synced legacy fields)
152
- const q = query(collectionGroup(this.db, PRODUCTS_COLLECTION), where('isActive', '==', true));
153
- const snapshot = await getDocs(q);
144
+ if (snapshot.empty) {
145
+ return counts;
146
+ }
154
147
 
155
148
  snapshot.docs.forEach(doc => {
156
149
  const product = doc.data() as Product;
157
-
158
- // Use legacy fields from subcollections
159
- if (product.categoryId) {
160
- counts.byCategory[product.categoryId] = (counts.byCategory[product.categoryId] || 0) + 1;
150
+ const { categoryId, subcategoryId, technologyId } = product;
151
+
152
+ if (categoryId) {
153
+ counts.byCategory[categoryId] = (counts.byCategory[categoryId] || 0) + 1;
161
154
  }
162
- if (product.subcategoryId) {
163
- counts.bySubcategory[product.subcategoryId] = (counts.bySubcategory[product.subcategoryId] || 0) + 1;
155
+ if (subcategoryId) {
156
+ counts.bySubcategory[subcategoryId] = (counts.bySubcategory[subcategoryId] || 0) + 1;
164
157
  }
165
- if (product.technologyId) {
166
- counts.byTechnology[product.technologyId] = (counts.byTechnology[product.technologyId] || 0) + 1;
158
+ if (technologyId) {
159
+ counts.byTechnology[technologyId] = (counts.byTechnology[technologyId] || 0) + 1;
167
160
  }
168
161
  });
169
162
 
@@ -259,295 +252,4 @@ export class ProductService extends BaseService implements IProductService {
259
252
  ...docSnap.data(),
260
253
  } as Product;
261
254
  }
262
-
263
- // ==========================================
264
- // NEW METHODS: Top-level collection (preferred)
265
- // ==========================================
266
-
267
- /**
268
- * Creates a new product in the top-level collection
269
- */
270
- async createTopLevel(
271
- brandId: string,
272
- product: Omit<Product, 'id' | 'createdAt' | 'updatedAt' | 'brandId' | 'assignedTechnologyIds'>,
273
- technologyIds: string[] = [],
274
- ): Promise<Product> {
275
- const now = new Date();
276
- const newProduct: Omit<Product, 'id'> = {
277
- ...product,
278
- brandId,
279
- assignedTechnologyIds: technologyIds,
280
- createdAt: now,
281
- updatedAt: now,
282
- isActive: true,
283
- };
284
-
285
- const productRef = await addDoc(this.getTopLevelProductsRef(), newProduct);
286
- return { id: productRef.id, ...newProduct };
287
- }
288
-
289
- /**
290
- * Gets all products from the top-level collection
291
- */
292
- async getAllTopLevel(options: {
293
- rowsPerPage: number;
294
- lastVisible?: any;
295
- brandId?: string;
296
- }): Promise<{ products: Product[]; lastVisible: any }> {
297
- const { rowsPerPage, lastVisible, brandId } = options;
298
-
299
- const constraints: QueryConstraint[] = [where('isActive', '==', true), orderBy('name')];
300
-
301
- if (brandId) {
302
- constraints.push(where('brandId', '==', brandId));
303
- }
304
-
305
- if (lastVisible) {
306
- constraints.push(startAfter(lastVisible));
307
- }
308
- constraints.push(limit(rowsPerPage));
309
-
310
- const q = query(this.getTopLevelProductsRef(), ...constraints);
311
- const snapshot = await getDocs(q);
312
-
313
- const products = snapshot.docs.map(
314
- doc =>
315
- ({
316
- id: doc.id,
317
- ...doc.data(),
318
- } as Product),
319
- );
320
- const newLastVisible = snapshot.docs[snapshot.docs.length - 1];
321
-
322
- return { products, lastVisible: newLastVisible };
323
- }
324
-
325
- /**
326
- * Gets a product by ID from the top-level collection
327
- */
328
- async getByIdTopLevel(productId: string): Promise<Product | null> {
329
- const docRef = doc(this.getTopLevelProductsRef(), productId);
330
- const docSnap = await getDoc(docRef);
331
- if (!docSnap.exists()) return null;
332
- return {
333
- id: docSnap.id,
334
- ...docSnap.data(),
335
- } as Product;
336
- }
337
-
338
- /**
339
- * Updates a product in the top-level collection
340
- */
341
- async updateTopLevel(
342
- productId: string,
343
- product: Partial<Omit<Product, 'id' | 'createdAt' | 'brandId'>>,
344
- ): Promise<Product | null> {
345
- const updateData = {
346
- ...product,
347
- updatedAt: new Date(),
348
- };
349
-
350
- const docRef = doc(this.getTopLevelProductsRef(), productId);
351
- await updateDoc(docRef, updateData);
352
-
353
- return this.getByIdTopLevel(productId);
354
- }
355
-
356
- /**
357
- * Deletes a product from the top-level collection (soft delete)
358
- */
359
- async deleteTopLevel(productId: string): Promise<void> {
360
- await this.updateTopLevel(productId, {
361
- isActive: false,
362
- });
363
- }
364
-
365
- /**
366
- * Assigns a product to a technology
367
- */
368
- async assignToTechnology(productId: string, technologyId: string): Promise<void> {
369
- const docRef = doc(this.getTopLevelProductsRef(), productId);
370
- await updateDoc(docRef, {
371
- assignedTechnologyIds: arrayUnion(technologyId),
372
- updatedAt: new Date(),
373
- });
374
- // Cloud Function will handle syncing to subcollection
375
- }
376
-
377
- /**
378
- * Unassigns a product from a technology
379
- */
380
- async unassignFromTechnology(productId: string, technologyId: string): Promise<void> {
381
- const docRef = doc(this.getTopLevelProductsRef(), productId);
382
- await updateDoc(docRef, {
383
- assignedTechnologyIds: arrayRemove(technologyId),
384
- updatedAt: new Date(),
385
- });
386
- // Cloud Function will handle removing from subcollection
387
- }
388
-
389
- /**
390
- * Gets products assigned to a specific technology
391
- */
392
- async getAssignedProducts(technologyId: string): Promise<Product[]> {
393
- const q = query(
394
- this.getTopLevelProductsRef(),
395
- where('assignedTechnologyIds', 'array-contains', technologyId),
396
- where('isActive', '==', true),
397
- orderBy('name'),
398
- );
399
- const snapshot = await getDocs(q);
400
- return snapshot.docs.map(
401
- doc =>
402
- ({
403
- id: doc.id,
404
- ...doc.data(),
405
- } as Product),
406
- );
407
- }
408
-
409
- /**
410
- * Gets products NOT assigned to a specific technology
411
- */
412
- async getUnassignedProducts(technologyId: string): Promise<Product[]> {
413
- const q = query(
414
- this.getTopLevelProductsRef(),
415
- where('isActive', '==', true),
416
- orderBy('name'),
417
- );
418
- const snapshot = await getDocs(q);
419
-
420
- const allProducts = snapshot.docs.map(
421
- doc =>
422
- ({
423
- id: doc.id,
424
- ...doc.data(),
425
- } as Product),
426
- );
427
-
428
- // Filter out products already assigned to this technology
429
- return allProducts.filter(product =>
430
- !product.assignedTechnologyIds?.includes(technologyId)
431
- );
432
- }
433
-
434
- /**
435
- * Gets all products for a brand (from top-level collection)
436
- */
437
- async getByBrand(brandId: string): Promise<Product[]> {
438
- const q = query(
439
- this.getTopLevelProductsRef(),
440
- where('brandId', '==', brandId),
441
- where('isActive', '==', true),
442
- orderBy('name'),
443
- );
444
- const snapshot = await getDocs(q);
445
- return snapshot.docs.map(
446
- doc =>
447
- ({
448
- id: doc.id,
449
- ...doc.data(),
450
- } as Product),
451
- );
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
- }
553
255
  }