@blackcode_sa/metaestetics-api 1.12.50 → 1.12.51

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.
@@ -15,6 +15,8 @@ import {
15
15
  arrayUnion,
16
16
  arrayRemove,
17
17
  Firestore,
18
+ writeBatch,
19
+ QueryConstraint,
18
20
  } from 'firebase/firestore';
19
21
  import { Technology, TECHNOLOGIES_COLLECTION, ITechnologyService } from '../types/technology.types';
20
22
  import { Requirement, RequirementType } from '../types/requirement.types';
@@ -29,6 +31,7 @@ import {
29
31
  import { BaseService } from '../../services/base.service';
30
32
  import { ProcedureFamily } from '../types/static/procedure-family.types';
31
33
  import { Practitioner, PractitionerCertification } from '../../types/practitioner';
34
+ import { Product, PRODUCTS_COLLECTION } from '../types/product.types';
32
35
 
33
36
  /**
34
37
  * Default vrednosti za sertifikaciju
@@ -240,7 +243,25 @@ export class TechnologyService extends BaseService implements ITechnologyService
240
243
  updateData.updatedAt = new Date();
241
244
 
242
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
+
243
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
+
244
265
  return this.getById(id);
245
266
  }
246
267
 
@@ -778,4 +799,272 @@ export class TechnologyService extends BaseService implements ITechnologyService
778
799
  } as Technology),
779
800
  );
780
801
  }
802
+
803
+ // ==========================================
804
+ // NEW METHODS: Product assignment management
805
+ // ==========================================
806
+
807
+ /**
808
+ * Assigns multiple products to a technology
809
+ * Updates each product's assignedTechnologyIds array
810
+ */
811
+ async assignProducts(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: arrayUnion(technologyId),
818
+ updatedAt: new Date(),
819
+ });
820
+ }
821
+
822
+ await batch.commit();
823
+ // Cloud Function will handle syncing to subcollections
824
+ }
825
+
826
+ /**
827
+ * Unassigns multiple products from a technology
828
+ * Updates each product's assignedTechnologyIds array
829
+ */
830
+ async unassignProducts(technologyId: string, productIds: string[]): Promise<void> {
831
+ const batch = writeBatch(this.db);
832
+
833
+ for (const productId of productIds) {
834
+ const productRef = doc(this.db, PRODUCTS_COLLECTION, productId);
835
+ batch.update(productRef, {
836
+ assignedTechnologyIds: arrayRemove(technologyId),
837
+ updatedAt: new Date(),
838
+ });
839
+ }
840
+
841
+ await batch.commit();
842
+ // Cloud Function will handle removing from subcollections
843
+ }
844
+
845
+ /**
846
+ * Gets products assigned to a specific technology
847
+ * Reads from top-level collection for immediate consistency (Cloud Functions may lag)
848
+ */
849
+ async getAssignedProducts(technologyId: string): Promise<Product[]> {
850
+ const q = query(
851
+ collection(this.db, PRODUCTS_COLLECTION),
852
+ where('assignedTechnologyIds', 'array-contains', technologyId),
853
+ where('isActive', '==', true),
854
+ orderBy('name'),
855
+ );
856
+ const snapshot = await getDocs(q);
857
+
858
+ return snapshot.docs.map(
859
+ doc =>
860
+ ({
861
+ id: doc.id,
862
+ ...doc.data(),
863
+ } as Product),
864
+ );
865
+ }
866
+
867
+ /**
868
+ * Gets products NOT assigned to a specific technology
869
+ */
870
+ async getUnassignedProducts(technologyId: string): Promise<Product[]> {
871
+ const q = query(
872
+ collection(this.db, PRODUCTS_COLLECTION),
873
+ where('isActive', '==', true),
874
+ orderBy('name'),
875
+ );
876
+ const snapshot = await getDocs(q);
877
+
878
+ const allProducts = snapshot.docs.map(
879
+ doc =>
880
+ ({
881
+ id: doc.id,
882
+ ...doc.data(),
883
+ } as Product),
884
+ );
885
+
886
+ // Filter out products already assigned to this technology
887
+ return allProducts.filter(product =>
888
+ !product.assignedTechnologyIds?.includes(technologyId)
889
+ );
890
+ }
891
+
892
+ /**
893
+ * Gets product assignment statistics for a technology
894
+ */
895
+ async getProductStats(technologyId: string): Promise<{
896
+ totalAssigned: number;
897
+ byBrand: Record<string, number>;
898
+ }> {
899
+ const products = await this.getAssignedProducts(technologyId);
900
+
901
+ const byBrand: Record<string, number> = {};
902
+ products.forEach(product => {
903
+ byBrand[product.brandName] = (byBrand[product.brandName] || 0) + 1;
904
+ });
905
+
906
+ return {
907
+ totalAssigned: products.length,
908
+ byBrand,
909
+ };
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
+ }
781
1070
  }
@@ -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,18 @@ export interface Product {
38
40
  composition?: string;
39
41
  indications?: string[];
40
42
  contraindications?: ContraindicationDynamic[];
43
+
44
+ // LEGACY FIELDS: Only present in technology subcollections (/technologies/{id}/products/)
45
+ // These fields are synced by Cloud Functions for backward compatibility
46
+ // NOT stored in top-level /products collection
47
+ /** Present only in subcollections - synced from technology metadata */
48
+ technologyId?: string;
49
+ /** Present only in subcollections - synced from technology name */
50
+ technologyName?: string;
51
+ /** Present only in subcollections - synced from technology categoryId */
52
+ categoryId?: string;
53
+ /** Present only in subcollections - synced from technology subcategoryId */
54
+ subcategoryId?: string;
41
55
  }
42
56
 
43
57
  /**
@@ -47,9 +61,97 @@ export const PRODUCTS_COLLECTION = 'products';
47
61
 
48
62
  /**
49
63
  * Interface for the ProductService class
64
+ *
65
+ * NOTE: This interface maintains backward compatibility while adding new top-level collection methods.
66
+ * Old methods using technologyId are kept for existing code, new methods work with top-level collection.
50
67
  */
51
68
  export interface IProductService {
69
+ // ==========================================
70
+ // NEW METHODS: Top-level collection (preferred)
71
+ // ==========================================
72
+
73
+ /**
74
+ * Creates a new product in the top-level collection
75
+ * @param brandId - ID of the brand that manufactures this product
76
+ * @param product - Product data
77
+ * @param technologyIds - Optional array of technology IDs to assign this product to
78
+ */
79
+ createTopLevel(
80
+ brandId: string,
81
+ product: Omit<Product, 'id' | 'createdAt' | 'updatedAt' | 'brandId' | 'assignedTechnologyIds'>,
82
+ technologyIds?: string[],
83
+ ): Promise<Product>;
84
+
85
+ /**
86
+ * Gets all products from the top-level collection
87
+ * @param options - Query options
88
+ */
89
+ getAllTopLevel(options: {
90
+ rowsPerPage: number;
91
+ lastVisible?: any;
92
+ brandId?: string;
93
+ }): Promise<{ products: Product[]; lastVisible: any }>;
94
+
95
+ /**
96
+ * Gets a product by ID from the top-level collection
97
+ * @param productId - ID of the product
98
+ */
99
+ getByIdTopLevel(productId: string): Promise<Product | null>;
100
+
101
+ /**
102
+ * Updates a product in the top-level collection
103
+ * @param productId - ID of the product to update
104
+ * @param product - Updated product data
105
+ */
106
+ updateTopLevel(
107
+ productId: string,
108
+ product: Partial<Omit<Product, 'id' | 'createdAt' | 'brandId'>>,
109
+ ): Promise<Product | null>;
110
+
111
+ /**
112
+ * Deletes a product from the top-level collection (soft delete)
113
+ * @param productId - ID of the product to delete
114
+ */
115
+ deleteTopLevel(productId: string): Promise<void>;
116
+
117
+ /**
118
+ * Assigns a product to a technology
119
+ * @param productId - ID of the product
120
+ * @param technologyId - ID of the technology
121
+ */
122
+ assignToTechnology(productId: string, technologyId: string): Promise<void>;
123
+
124
+ /**
125
+ * Unassigns a product from a technology
126
+ * @param productId - ID of the product
127
+ * @param technologyId - ID of the technology
128
+ */
129
+ unassignFromTechnology(productId: string, technologyId: string): Promise<void>;
130
+
131
+ /**
132
+ * Gets products assigned to a specific technology
133
+ * @param technologyId - ID of the technology
134
+ */
135
+ getAssignedProducts(technologyId: string): Promise<Product[]>;
136
+
137
+ /**
138
+ * Gets products NOT assigned to a specific technology
139
+ * @param technologyId - ID of the technology
140
+ */
141
+ getUnassignedProducts(technologyId: string): Promise<Product[]>;
142
+
143
+ /**
144
+ * Gets all products for a brand
145
+ * @param brandId - ID of the brand
146
+ */
147
+ getByBrand(brandId: string): Promise<Product[]>;
148
+
149
+ // ==========================================
150
+ // DEPRECATED METHODS: Kept for backward compatibility
151
+ // ==========================================
152
+
52
153
  /**
154
+ * @deprecated Use createTopLevel instead
53
155
  * Creates a new product
54
156
  * @param technologyId - ID of the technology this product is used with
55
157
  * @param brandId - ID of the brand that manufactures this product
@@ -62,6 +164,7 @@ export interface IProductService {
62
164
  ): Promise<Product>;
63
165
 
64
166
  /**
167
+ * @deprecated Use getAllTopLevel instead
65
168
  * Gets a paginated list of all products, with optional filters.
66
169
  */
67
170
  getAll(options: {
@@ -73,6 +176,7 @@ export interface IProductService {
73
176
  }): Promise<{ products: Product[]; lastVisible: any }>;
74
177
 
75
178
  /**
179
+ * @deprecated Use alternative counting methods
76
180
  * Gets the total count of active products, with optional filters.
77
181
  */
78
182
  getProductsCount(options: {
@@ -82,6 +186,7 @@ export interface IProductService {
82
186
  }): Promise<number>;
83
187
 
84
188
  /**
189
+ * @deprecated Use alternative counting methods
85
190
  * Gets counts of active products grouped by category, subcategory, and technology.
86
191
  */
87
192
  getProductCounts(): Promise<{
@@ -91,18 +196,21 @@ export interface IProductService {
91
196
  }>;
92
197
 
93
198
  /**
199
+ * @deprecated Use getAssignedProducts instead
94
200
  * Gets all products for a specific technology (non-paginated, for filters/dropdowns)
95
201
  * @param technologyId - ID of the technology
96
202
  */
97
203
  getAllByTechnology(technologyId: string): Promise<Product[]>;
98
204
 
99
205
  /**
206
+ * @deprecated Use getByBrand instead
100
207
  * Gets all products for a brand
101
208
  * @param brandId - ID of the brand
102
209
  */
103
210
  getAllByBrand(brandId: string): Promise<Product[]>;
104
211
 
105
212
  /**
213
+ * @deprecated Use updateTopLevel instead
106
214
  * Updates a product
107
215
  * @param technologyId - ID of the technology
108
216
  * @param productId - ID of the product to update
@@ -115,6 +223,7 @@ export interface IProductService {
115
223
  ): Promise<Product | null>;
116
224
 
117
225
  /**
226
+ * @deprecated Use deleteTopLevel instead
118
227
  * Deletes a product (soft delete)
119
228
  * @param technologyId - ID of the technology
120
229
  * @param productId - ID of the product to delete
@@ -122,6 +231,7 @@ export interface IProductService {
122
231
  delete(technologyId: string, productId: string): Promise<void>;
123
232
 
124
233
  /**
234
+ * @deprecated Use getByIdTopLevel instead
125
235
  * Gets a product by ID
126
236
  * @param technologyId - ID of the technology
127
237
  * @param productId - ID of the product
@@ -201,7 +201,7 @@ export class ProcedureService extends BaseService {
201
201
  const procedureId = this.generateId();
202
202
 
203
203
  // Get references to related entities (Category, Subcategory, Technology, and optionally Product)
204
- const baseEntitiesPromises = [
204
+ const baseEntitiesPromises: Promise<Category | Subcategory | Technology | Product | null>[] = [
205
205
  this.categoryService.getById(validatedData.categoryId),
206
206
  this.subcategoryService.getById(validatedData.categoryId, validatedData.subcategoryId),
207
207
  this.technologyService.getById(validatedData.technologyId),
@@ -366,7 +366,7 @@ export class ProcedureService extends BaseService {
366
366
  const validatedData = createProcedureSchema.parse(validationData);
367
367
 
368
368
  // 2. Fetch common data once to avoid redundant reads
369
- const baseEntitiesPromises = [
369
+ const baseEntitiesPromises: Promise<Category | Subcategory | Technology | Product | null>[] = [
370
370
  this.categoryService.getById(validatedData.categoryId),
371
371
  this.subcategoryService.getById(validatedData.categoryId, validatedData.subcategoryId),
372
372
  this.technologyService.getById(validatedData.technologyId),