@blackcode_sa/metaestetics-api 1.12.39 → 1.12.41

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.
@@ -13,6 +13,8 @@ import {
13
13
  startAfter,
14
14
  getCountFromServer,
15
15
  QueryConstraint,
16
+ arrayUnion,
17
+ arrayRemove,
16
18
  } from 'firebase/firestore';
17
19
  import { Product, PRODUCTS_COLLECTION, IProductService } from '../types/product.types';
18
20
  import { BaseService } from '../../services/base.service';
@@ -20,7 +22,15 @@ import { TECHNOLOGIES_COLLECTION } from '../types/technology.types';
20
22
 
21
23
  export class ProductService extends BaseService implements IProductService {
22
24
  /**
23
- * Gets reference to products collection under a technology
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)
24
34
  * @param technologyId - ID of the technology
25
35
  * @returns Firestore collection reference
26
36
  */
@@ -37,11 +47,11 @@ export class ProductService extends BaseService implements IProductService {
37
47
  product: Omit<Product, 'id' | 'createdAt' | 'updatedAt' | 'brandId' | 'technologyId'>,
38
48
  ): Promise<Product> {
39
49
  const now = new Date();
40
- // categoryId and subcategoryId are now expected to be part of the product object
50
+ // Create product with legacy structure for subcollection compatibility
41
51
  const newProduct: Omit<Product, 'id'> = {
42
52
  ...product,
43
53
  brandId,
44
- technologyId,
54
+ technologyId, // Required for old subcollection structure
45
55
  createdAt: now,
46
56
  updatedAt: now,
47
57
  isActive: true,
@@ -124,39 +134,33 @@ export class ProductService extends BaseService implements IProductService {
124
134
  }
125
135
 
126
136
  /**
127
- * Gets counts of active products grouped by category, subcategory, and technology.
128
- * This uses a single collectionGroup query for efficiency.
137
+ * Gets counts of active products grouped by technology.
138
+ * NOTE: Only counts top-level collection to avoid duplication during migration.
139
+ * Categories/subcategories not available in top-level structure.
129
140
  */
130
141
  async getProductCounts(): Promise<{
131
142
  byCategory: Record<string, number>;
132
143
  bySubcategory: Record<string, number>;
133
144
  byTechnology: Record<string, number>;
134
145
  }> {
135
- const q = query(collectionGroup(this.db, PRODUCTS_COLLECTION), where('isActive', '==', true));
136
- const snapshot = await getDocs(q);
137
-
138
146
  const counts = {
139
147
  byCategory: {} as Record<string, number>,
140
148
  bySubcategory: {} as Record<string, number>,
141
149
  byTechnology: {} as Record<string, number>,
142
150
  };
143
151
 
144
- if (snapshot.empty) {
145
- return counts;
146
- }
152
+ // Query top-level collection only (to avoid duplication during migration)
153
+ const q = query(this.getTopLevelProductsRef(), where('isActive', '==', true));
154
+ const snapshot = await getDocs(q);
147
155
 
148
156
  snapshot.docs.forEach(doc => {
149
157
  const product = doc.data() as Product;
150
- const { categoryId, subcategoryId, technologyId } = product;
151
-
152
- if (categoryId) {
153
- counts.byCategory[categoryId] = (counts.byCategory[categoryId] || 0) + 1;
154
- }
155
- if (subcategoryId) {
156
- counts.bySubcategory[subcategoryId] = (counts.bySubcategory[subcategoryId] || 0) + 1;
157
- }
158
- if (technologyId) {
159
- counts.byTechnology[technologyId] = (counts.byTechnology[technologyId] || 0) + 1;
158
+
159
+ // Count by technology using assignedTechnologyIds
160
+ if (product.assignedTechnologyIds && Array.isArray(product.assignedTechnologyIds)) {
161
+ product.assignedTechnologyIds.forEach(techId => {
162
+ counts.byTechnology[techId] = (counts.byTechnology[techId] || 0) + 1;
163
+ });
160
164
  }
161
165
  });
162
166
 
@@ -252,4 +256,195 @@ export class ProductService extends BaseService implements IProductService {
252
256
  ...docSnap.data(),
253
257
  } as Product;
254
258
  }
259
+
260
+ // ==========================================
261
+ // NEW METHODS: Top-level collection (preferred)
262
+ // ==========================================
263
+
264
+ /**
265
+ * Creates a new product in the top-level collection
266
+ */
267
+ async createTopLevel(
268
+ brandId: string,
269
+ product: Omit<Product, 'id' | 'createdAt' | 'updatedAt' | 'brandId' | 'assignedTechnologyIds'>,
270
+ technologyIds: string[] = [],
271
+ ): Promise<Product> {
272
+ const now = new Date();
273
+ const newProduct: Omit<Product, 'id'> = {
274
+ ...product,
275
+ brandId,
276
+ assignedTechnologyIds: technologyIds,
277
+ createdAt: now,
278
+ updatedAt: now,
279
+ isActive: true,
280
+ };
281
+
282
+ const productRef = await addDoc(this.getTopLevelProductsRef(), newProduct);
283
+ return { id: productRef.id, ...newProduct };
284
+ }
285
+
286
+ /**
287
+ * Gets all products from the top-level collection
288
+ */
289
+ async getAllTopLevel(options: {
290
+ rowsPerPage: number;
291
+ lastVisible?: any;
292
+ brandId?: string;
293
+ }): Promise<{ products: Product[]; lastVisible: any }> {
294
+ const { rowsPerPage, lastVisible, brandId } = options;
295
+
296
+ const constraints: QueryConstraint[] = [where('isActive', '==', true), orderBy('name')];
297
+
298
+ if (brandId) {
299
+ constraints.push(where('brandId', '==', brandId));
300
+ }
301
+
302
+ if (lastVisible) {
303
+ constraints.push(startAfter(lastVisible));
304
+ }
305
+ constraints.push(limit(rowsPerPage));
306
+
307
+ const q = query(this.getTopLevelProductsRef(), ...constraints);
308
+ const snapshot = await getDocs(q);
309
+
310
+ const products = snapshot.docs.map(
311
+ doc =>
312
+ ({
313
+ id: doc.id,
314
+ ...doc.data(),
315
+ } as Product),
316
+ );
317
+ const newLastVisible = snapshot.docs[snapshot.docs.length - 1];
318
+
319
+ return { products, lastVisible: newLastVisible };
320
+ }
321
+
322
+ /**
323
+ * Gets a product by ID from the top-level collection
324
+ */
325
+ async getByIdTopLevel(productId: string): Promise<Product | null> {
326
+ const docRef = doc(this.getTopLevelProductsRef(), productId);
327
+ const docSnap = await getDoc(docRef);
328
+ if (!docSnap.exists()) return null;
329
+ return {
330
+ id: docSnap.id,
331
+ ...docSnap.data(),
332
+ } as Product;
333
+ }
334
+
335
+ /**
336
+ * Updates a product in the top-level collection
337
+ */
338
+ async updateTopLevel(
339
+ productId: string,
340
+ product: Partial<Omit<Product, 'id' | 'createdAt' | 'brandId'>>,
341
+ ): Promise<Product | null> {
342
+ const updateData = {
343
+ ...product,
344
+ updatedAt: new Date(),
345
+ };
346
+
347
+ const docRef = doc(this.getTopLevelProductsRef(), productId);
348
+ await updateDoc(docRef, updateData);
349
+
350
+ return this.getByIdTopLevel(productId);
351
+ }
352
+
353
+ /**
354
+ * Deletes a product from the top-level collection (soft delete)
355
+ */
356
+ async deleteTopLevel(productId: string): Promise<void> {
357
+ await this.updateTopLevel(productId, {
358
+ isActive: false,
359
+ });
360
+ }
361
+
362
+ /**
363
+ * Assigns a product to a technology
364
+ */
365
+ async assignToTechnology(productId: string, technologyId: string): Promise<void> {
366
+ const docRef = doc(this.getTopLevelProductsRef(), productId);
367
+ await updateDoc(docRef, {
368
+ assignedTechnologyIds: arrayUnion(technologyId),
369
+ updatedAt: new Date(),
370
+ });
371
+ // Cloud Function will handle syncing to subcollection
372
+ }
373
+
374
+ /**
375
+ * Unassigns a product from a technology
376
+ */
377
+ async unassignFromTechnology(productId: string, technologyId: string): Promise<void> {
378
+ const docRef = doc(this.getTopLevelProductsRef(), productId);
379
+ await updateDoc(docRef, {
380
+ assignedTechnologyIds: arrayRemove(technologyId),
381
+ updatedAt: new Date(),
382
+ });
383
+ // Cloud Function will handle removing from subcollection
384
+ }
385
+
386
+ /**
387
+ * Gets products assigned to a specific technology
388
+ */
389
+ async getAssignedProducts(technologyId: string): Promise<Product[]> {
390
+ const q = query(
391
+ this.getTopLevelProductsRef(),
392
+ where('assignedTechnologyIds', 'array-contains', technologyId),
393
+ where('isActive', '==', true),
394
+ orderBy('name'),
395
+ );
396
+ const snapshot = await getDocs(q);
397
+ return snapshot.docs.map(
398
+ doc =>
399
+ ({
400
+ id: doc.id,
401
+ ...doc.data(),
402
+ } as Product),
403
+ );
404
+ }
405
+
406
+ /**
407
+ * Gets products NOT assigned to a specific technology
408
+ */
409
+ async getUnassignedProducts(technologyId: string): Promise<Product[]> {
410
+ const q = query(
411
+ this.getTopLevelProductsRef(),
412
+ where('isActive', '==', true),
413
+ orderBy('name'),
414
+ );
415
+ const snapshot = await getDocs(q);
416
+
417
+ const allProducts = snapshot.docs.map(
418
+ doc =>
419
+ ({
420
+ id: doc.id,
421
+ ...doc.data(),
422
+ } as Product),
423
+ );
424
+
425
+ // Filter out products already assigned to this technology
426
+ return allProducts.filter(product =>
427
+ !product.assignedTechnologyIds?.includes(technologyId)
428
+ );
429
+ }
430
+
431
+ /**
432
+ * Gets all products for a brand (from top-level collection)
433
+ */
434
+ async getByBrand(brandId: string): Promise<Product[]> {
435
+ const q = query(
436
+ this.getTopLevelProductsRef(),
437
+ where('brandId', '==', brandId),
438
+ where('isActive', '==', true),
439
+ orderBy('name'),
440
+ );
441
+ const snapshot = await getDocs(q);
442
+ return snapshot.docs.map(
443
+ doc =>
444
+ ({
445
+ id: doc.id,
446
+ ...doc.data(),
447
+ } as Product),
448
+ );
449
+ }
255
450
  }
@@ -15,6 +15,7 @@ import {
15
15
  arrayUnion,
16
16
  arrayRemove,
17
17
  Firestore,
18
+ writeBatch,
18
19
  } from 'firebase/firestore';
19
20
  import { Technology, TECHNOLOGIES_COLLECTION, ITechnologyService } from '../types/technology.types';
20
21
  import { Requirement, RequirementType } from '../types/requirement.types';
@@ -29,6 +30,7 @@ import {
29
30
  import { BaseService } from '../../services/base.service';
30
31
  import { ProcedureFamily } from '../types/static/procedure-family.types';
31
32
  import { Practitioner, PractitionerCertification } from '../../types/practitioner';
33
+ import { Product, PRODUCTS_COLLECTION } from '../types/product.types';
32
34
 
33
35
  /**
34
36
  * Default vrednosti za sertifikaciju
@@ -778,4 +780,113 @@ export class TechnologyService extends BaseService implements ITechnologyService
778
780
  } as Technology),
779
781
  );
780
782
  }
783
+
784
+ // ==========================================
785
+ // NEW METHODS: Product assignment management
786
+ // ==========================================
787
+
788
+ /**
789
+ * Assigns multiple products to a technology
790
+ * Updates each product's assignedTechnologyIds array
791
+ */
792
+ async assignProducts(technologyId: string, productIds: string[]): Promise<void> {
793
+ const batch = writeBatch(this.db);
794
+
795
+ for (const productId of productIds) {
796
+ const productRef = doc(this.db, PRODUCTS_COLLECTION, productId);
797
+ batch.update(productRef, {
798
+ assignedTechnologyIds: arrayUnion(technologyId),
799
+ updatedAt: new Date(),
800
+ });
801
+ }
802
+
803
+ await batch.commit();
804
+ // Cloud Function will handle syncing to subcollections
805
+ }
806
+
807
+ /**
808
+ * Unassigns multiple products from a technology
809
+ * Updates each product's assignedTechnologyIds array
810
+ */
811
+ async unassignProducts(technologyId: string, productIds: string[]): Promise<void> {
812
+ const batch = writeBatch(this.db);
813
+
814
+ for (const productId of productIds) {
815
+ const productRef = doc(this.db, PRODUCTS_COLLECTION, productId);
816
+ batch.update(productRef, {
817
+ assignedTechnologyIds: arrayRemove(technologyId),
818
+ updatedAt: new Date(),
819
+ });
820
+ }
821
+
822
+ await batch.commit();
823
+ // Cloud Function will handle removing from subcollections
824
+ }
825
+
826
+ /**
827
+ * Gets products assigned to a specific technology
828
+ * Reads from top-level collection for immediate consistency (Cloud Functions may lag)
829
+ */
830
+ async getAssignedProducts(technologyId: string): Promise<Product[]> {
831
+ const q = query(
832
+ collection(this.db, PRODUCTS_COLLECTION),
833
+ where('assignedTechnologyIds', 'array-contains', technologyId),
834
+ where('isActive', '==', true),
835
+ orderBy('name'),
836
+ );
837
+ const snapshot = await getDocs(q);
838
+
839
+ return snapshot.docs.map(
840
+ doc =>
841
+ ({
842
+ id: doc.id,
843
+ ...doc.data(),
844
+ } as Product),
845
+ );
846
+ }
847
+
848
+ /**
849
+ * Gets products NOT assigned to a specific technology
850
+ */
851
+ async getUnassignedProducts(technologyId: string): Promise<Product[]> {
852
+ const q = query(
853
+ collection(this.db, PRODUCTS_COLLECTION),
854
+ where('isActive', '==', true),
855
+ orderBy('name'),
856
+ );
857
+ const snapshot = await getDocs(q);
858
+
859
+ const allProducts = snapshot.docs.map(
860
+ doc =>
861
+ ({
862
+ id: doc.id,
863
+ ...doc.data(),
864
+ } as Product),
865
+ );
866
+
867
+ // Filter out products already assigned to this technology
868
+ return allProducts.filter(product =>
869
+ !product.assignedTechnologyIds?.includes(technologyId)
870
+ );
871
+ }
872
+
873
+ /**
874
+ * Gets product assignment statistics for a technology
875
+ */
876
+ async getProductStats(technologyId: string): Promise<{
877
+ totalAssigned: number;
878
+ byBrand: Record<string, number>;
879
+ }> {
880
+ const products = await this.getAssignedProducts(technologyId);
881
+
882
+ const byBrand: Record<string, number> = {};
883
+ products.forEach(product => {
884
+ byBrand[product.brandName] = (byBrand[product.brandName] || 0) + 1;
885
+ });
886
+
887
+ return {
888
+ totalAssigned: products.length,
889
+ byBrand,
890
+ };
891
+ }
781
892
  }
@@ -6,9 +6,10 @@ import type { ContraindicationDynamic } from './admin-constants.types';
6
6
  *
7
7
  * @property id - Unique identifier of the product
8
8
  * @property name - Name of the product
9
- * @property description - Detailed description of the product and its purpose
10
9
  * @property brandId - ID of the brand that manufactures this product
11
- * @property technologyId - ID of the technology this product is used with
10
+ * @property brandName - Name of the brand (denormalized for display)
11
+ * @property assignedTechnologyIds - Array of technology IDs this product is assigned to
12
+ * @property description - Detailed description of the product and its purpose
12
13
  * @property technicalDetails - Technical details and specifications
13
14
  * @property warnings - List of warnings related to product use
14
15
  * @property dosage - Dosage information (if applicable)
@@ -24,10 +25,11 @@ export interface Product {
24
25
  name: string;
25
26
  brandId: string;
26
27
  brandName: string;
27
- technologyId: string;
28
- technologyName: string;
29
- categoryId: string;
30
- subcategoryId: string;
28
+
29
+ // NEW: Technology assignment tracking
30
+ assignedTechnologyIds?: string[];
31
+
32
+ // Product details
31
33
  createdAt: Date;
32
34
  updatedAt: Date;
33
35
  isActive: boolean;
@@ -38,6 +40,17 @@ export interface Product {
38
40
  composition?: string;
39
41
  indications?: string[];
40
42
  contraindications?: ContraindicationDynamic[];
43
+
44
+ // DEPRECATED: Kept for backward compatibility with subcollection structure
45
+ // These fields exist in old products in /technologies/{id}/products/
46
+ /** @deprecated Use assignedTechnologyIds instead */
47
+ technologyId?: string;
48
+ /** @deprecated Will be removed in future version */
49
+ technologyName?: string;
50
+ /** @deprecated Not needed in top-level collection */
51
+ categoryId?: string;
52
+ /** @deprecated Not needed in top-level collection */
53
+ subcategoryId?: string;
41
54
  }
42
55
 
43
56
  /**
@@ -47,9 +60,97 @@ export const PRODUCTS_COLLECTION = 'products';
47
60
 
48
61
  /**
49
62
  * Interface for the ProductService class
63
+ *
64
+ * NOTE: This interface maintains backward compatibility while adding new top-level collection methods.
65
+ * Old methods using technologyId are kept for existing code, new methods work with top-level collection.
50
66
  */
51
67
  export interface IProductService {
68
+ // ==========================================
69
+ // NEW METHODS: Top-level collection (preferred)
70
+ // ==========================================
71
+
72
+ /**
73
+ * Creates a new product in the top-level collection
74
+ * @param brandId - ID of the brand that manufactures this product
75
+ * @param product - Product data
76
+ * @param technologyIds - Optional array of technology IDs to assign this product to
77
+ */
78
+ createTopLevel(
79
+ brandId: string,
80
+ product: Omit<Product, 'id' | 'createdAt' | 'updatedAt' | 'brandId' | 'assignedTechnologyIds'>,
81
+ technologyIds?: string[],
82
+ ): Promise<Product>;
83
+
84
+ /**
85
+ * Gets all products from the top-level collection
86
+ * @param options - Query options
87
+ */
88
+ getAllTopLevel(options: {
89
+ rowsPerPage: number;
90
+ lastVisible?: any;
91
+ brandId?: string;
92
+ }): Promise<{ products: Product[]; lastVisible: any }>;
93
+
94
+ /**
95
+ * Gets a product by ID from the top-level collection
96
+ * @param productId - ID of the product
97
+ */
98
+ getByIdTopLevel(productId: string): Promise<Product | null>;
99
+
100
+ /**
101
+ * Updates a product in the top-level collection
102
+ * @param productId - ID of the product to update
103
+ * @param product - Updated product data
104
+ */
105
+ updateTopLevel(
106
+ productId: string,
107
+ product: Partial<Omit<Product, 'id' | 'createdAt' | 'brandId'>>,
108
+ ): Promise<Product | null>;
109
+
110
+ /**
111
+ * Deletes a product from the top-level collection (soft delete)
112
+ * @param productId - ID of the product to delete
113
+ */
114
+ deleteTopLevel(productId: string): Promise<void>;
115
+
116
+ /**
117
+ * Assigns a product to a technology
118
+ * @param productId - ID of the product
119
+ * @param technologyId - ID of the technology
120
+ */
121
+ assignToTechnology(productId: string, technologyId: string): Promise<void>;
122
+
123
+ /**
124
+ * Unassigns a product from a technology
125
+ * @param productId - ID of the product
126
+ * @param technologyId - ID of the technology
127
+ */
128
+ unassignFromTechnology(productId: string, technologyId: string): Promise<void>;
129
+
130
+ /**
131
+ * Gets products assigned to a specific technology
132
+ * @param technologyId - ID of the technology
133
+ */
134
+ getAssignedProducts(technologyId: string): Promise<Product[]>;
135
+
136
+ /**
137
+ * Gets products NOT assigned to a specific technology
138
+ * @param technologyId - ID of the technology
139
+ */
140
+ getUnassignedProducts(technologyId: string): Promise<Product[]>;
141
+
142
+ /**
143
+ * Gets all products for a brand
144
+ * @param brandId - ID of the brand
145
+ */
146
+ getByBrand(brandId: string): Promise<Product[]>;
147
+
148
+ // ==========================================
149
+ // DEPRECATED METHODS: Kept for backward compatibility
150
+ // ==========================================
151
+
52
152
  /**
153
+ * @deprecated Use createTopLevel instead
53
154
  * Creates a new product
54
155
  * @param technologyId - ID of the technology this product is used with
55
156
  * @param brandId - ID of the brand that manufactures this product
@@ -62,6 +163,7 @@ export interface IProductService {
62
163
  ): Promise<Product>;
63
164
 
64
165
  /**
166
+ * @deprecated Use getAllTopLevel instead
65
167
  * Gets a paginated list of all products, with optional filters.
66
168
  */
67
169
  getAll(options: {
@@ -73,6 +175,7 @@ export interface IProductService {
73
175
  }): Promise<{ products: Product[]; lastVisible: any }>;
74
176
 
75
177
  /**
178
+ * @deprecated Use alternative counting methods
76
179
  * Gets the total count of active products, with optional filters.
77
180
  */
78
181
  getProductsCount(options: {
@@ -82,6 +185,7 @@ export interface IProductService {
82
185
  }): Promise<number>;
83
186
 
84
187
  /**
188
+ * @deprecated Use alternative counting methods
85
189
  * Gets counts of active products grouped by category, subcategory, and technology.
86
190
  */
87
191
  getProductCounts(): Promise<{
@@ -91,18 +195,21 @@ export interface IProductService {
91
195
  }>;
92
196
 
93
197
  /**
198
+ * @deprecated Use getAssignedProducts instead
94
199
  * Gets all products for a specific technology (non-paginated, for filters/dropdowns)
95
200
  * @param technologyId - ID of the technology
96
201
  */
97
202
  getAllByTechnology(technologyId: string): Promise<Product[]>;
98
203
 
99
204
  /**
205
+ * @deprecated Use getByBrand instead
100
206
  * Gets all products for a brand
101
207
  * @param brandId - ID of the brand
102
208
  */
103
209
  getAllByBrand(brandId: string): Promise<Product[]>;
104
210
 
105
211
  /**
212
+ * @deprecated Use updateTopLevel instead
106
213
  * Updates a product
107
214
  * @param technologyId - ID of the technology
108
215
  * @param productId - ID of the product to update
@@ -115,6 +222,7 @@ export interface IProductService {
115
222
  ): Promise<Product | null>;
116
223
 
117
224
  /**
225
+ * @deprecated Use deleteTopLevel instead
118
226
  * Deletes a product (soft delete)
119
227
  * @param technologyId - ID of the technology
120
228
  * @param productId - ID of the product to delete
@@ -122,6 +230,7 @@ export interface IProductService {
122
230
  delete(technologyId: string, productId: string): Promise<void>;
123
231
 
124
232
  /**
233
+ * @deprecated Use getByIdTopLevel instead
125
234
  * Gets a product by ID
126
235
  * @param technologyId - ID of the technology
127
236
  * @param productId - ID of the product