@blackcode_sa/metaestetics-api 1.8.11 → 1.8.13

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.
@@ -263,6 +263,7 @@ export class ProcedureService extends BaseService {
263
263
  const newProcedure: Omit<Procedure, "createdAt" | "updatedAt"> = {
264
264
  id: procedureId,
265
265
  ...validatedData,
266
+ nameLower: validatedData.nameLower || validatedData.name.toLowerCase(),
266
267
  photos: processedPhotos,
267
268
  category, // Embed full objects
268
269
  subcategory,
@@ -430,6 +431,7 @@ export class ProcedureService extends BaseService {
430
431
  const newProcedure: Omit<Procedure, "createdAt" | "updatedAt"> = {
431
432
  id: procedureId,
432
433
  ...validatedData,
434
+ nameLower: validatedData.nameLower || validatedData.name.toLowerCase(),
433
435
  practitionerId: practitionerId, // Override practitionerId with the correct one
434
436
  photos: processedPhotos,
435
437
  category,
@@ -657,6 +659,9 @@ export class ProcedureService extends BaseService {
657
659
 
658
660
  // Handle Category/Subcategory/Technology/Product Changes
659
661
  let finalCategoryId = existingProcedure.category.id;
662
+ if (validatedData.name) {
663
+ updatedProcedureData.nameLower = validatedData.name.toLowerCase();
664
+ }
660
665
  if (validatedData.categoryId) {
661
666
  const category = await this.categoryService.getById(
662
667
  validatedData.categoryId
@@ -853,6 +858,8 @@ export class ProcedureService extends BaseService {
853
858
  /**
854
859
  * Searches and filters procedures based on multiple criteria
855
860
  *
861
+ * @note Frontend MORA da šalje ceo snapshot (ili barem sva polja po kojima sortiraš, npr. nameLower) kao lastDoc za paginaciju, a ne samo id!
862
+ *
856
863
  * @param filters - Various filters to apply
857
864
  * @param filters.nameSearch - Optional search text for procedure name
858
865
  * @param filters.treatmentBenefits - Optional array of treatment benefits to filter by
@@ -892,11 +899,6 @@ export class ProcedureService extends BaseService {
892
899
  lastDoc: any;
893
900
  }> {
894
901
  try {
895
- console.log(
896
- "[PROCEDURE_SERVICE] Starting procedure filtering with criteria:",
897
- filters
898
- );
899
-
900
902
  // Determine if we're doing a geo query or a regular query
901
903
  const isGeoQuery =
902
904
  filters.location && filters.radiusInKm && filters.radiusInKm > 0;
@@ -915,291 +917,131 @@ export class ProcedureService extends BaseService {
915
917
  if (filters.procedureFamily) {
916
918
  constraints.push(where("family", "==", filters.procedureFamily));
917
919
  }
918
-
919
- // Add ordering to make pagination consistent
920
- constraints.push(orderBy("clinicInfo.location.geohash"));
921
-
922
- // Add pagination if specified
923
- if (filters.pagination && filters.pagination > 0 && filters.lastDoc) {
924
- constraints.push(startAfter(filters.lastDoc));
925
- constraints.push(limit(filters.pagination));
926
- } else if (filters.pagination && filters.pagination > 0) {
920
+ if (filters.procedureCategory) {
921
+ constraints.push(where("category.id", "==", filters.procedureCategory));
922
+ }
923
+ if (filters.procedureSubcategory) {
924
+ constraints.push(where("subcategory.id", "==", filters.procedureSubcategory));
925
+ }
926
+ if (filters.procedureTechnology) {
927
+ constraints.push(where("technology.id", "==", filters.procedureTechnology));
928
+ }
929
+ if (filters.minPrice !== undefined) {
930
+ constraints.push(where("price", ">=", filters.minPrice));
931
+ }
932
+ if (filters.maxPrice !== undefined) {
933
+ constraints.push(where("price", "<=", filters.maxPrice));
934
+ }
935
+ if (filters.minRating !== undefined) {
936
+ constraints.push(where("reviewInfo.averageRating", ">=", filters.minRating));
937
+ }
938
+ if (filters.maxRating !== undefined) {
939
+ constraints.push(where("reviewInfo.averageRating", "<=", filters.maxRating));
940
+ }
941
+ if (filters.treatmentBenefits && filters.treatmentBenefits.length > 0) {
942
+ // Firestore ne podržava array-contains-all, koristi array-contains-any
943
+ constraints.push(where("treatmentBenefits", "array-contains-any", filters.treatmentBenefits));
944
+ }
945
+ // Text search by name (case-sensitive, Firestore limitation)
946
+ let useNameLower = false;
947
+ let searchTerm = "";
948
+ if (filters.nameSearch && filters.nameSearch.trim() !== "") {
949
+ searchTerm = filters.nameSearch.trim().toLowerCase();
950
+ useNameLower = true;
951
+ constraints.push(where("nameLower", ">=", searchTerm));
952
+ constraints.push(where("nameLower", "<=", searchTerm + "\uf8ff"));
953
+ constraints.push(orderBy("nameLower"));
954
+ } else {
955
+ constraints.push(orderBy("nameLower"));
956
+ }
957
+ if (filters.lastDoc) {
958
+ // Firestore pagination: support both QueryDocumentSnapshot and value arrays
959
+ if (typeof filters.lastDoc.data === "function") {
960
+ // QueryDocumentSnapshot
961
+ constraints.push(startAfter(filters.lastDoc));
962
+ } else if (Array.isArray(filters.lastDoc)) {
963
+ // Array of values for multiple orderBy fields
964
+ constraints.push(startAfter(...filters.lastDoc));
965
+ } else {
966
+ // Single value
967
+ constraints.push(startAfter(filters.lastDoc));
968
+ }
969
+ }
970
+ if (filters.pagination && filters.pagination > 0) {
927
971
  constraints.push(limit(filters.pagination));
928
972
  }
929
973
 
930
- let proceduresResult: (Procedure & { distance?: number })[] = [];
931
- let lastVisibleDoc = null;
932
-
933
- // For geo queries, we need a different approach
974
+ // Geo-query: special handling
934
975
  if (isGeoQuery) {
976
+ // For geo queries, use geohash bounds and add all other constraints
935
977
  const center = filters.location!;
936
978
  const radiusInKm = filters.radiusInKm!;
937
-
938
- // Get the geohash query bounds
939
979
  const bounds = geohashQueryBounds(
940
980
  [center.latitude, center.longitude],
941
981
  radiusInKm * 1000 // Convert to meters
942
982
  );
943
-
944
- // Collect matching procedures from all bounds
945
- const matchingProcedures: (Procedure & { distance: number })[] = [];
946
-
947
- // Execute queries for each bound
983
+ let allDocs: (Procedure & { distance: number })[] = [];
948
984
  for (const bound of bounds) {
949
- // Create a geo query for this bound
950
985
  const geoConstraints = [
951
- ...constraints,
986
+ ...constraints.filter(c => !(c as any).fieldPath || (c as any).fieldPath !== "name"), // Remove name orderBy for geo
952
987
  where("clinicInfo.location.geohash", ">=", bound[0]),
953
988
  where("clinicInfo.location.geohash", "<=", bound[1]),
989
+ orderBy("clinicInfo.location.geohash"),
954
990
  ];
955
-
956
- const q = query(
957
- collection(this.db, PROCEDURES_COLLECTION),
958
- ...geoConstraints
959
- );
991
+ const q = query(collection(this.db, PROCEDURES_COLLECTION), ...geoConstraints);
960
992
  const querySnapshot = await getDocs(q);
961
-
962
- console.log(
963
- `[PROCEDURE_SERVICE] Found ${querySnapshot.docs.length} procedures in geo bound`
964
- );
965
-
966
- // Process results and filter by actual distance
967
993
  for (const doc of querySnapshot.docs) {
968
994
  const procedure = { ...doc.data(), id: doc.id } as Procedure;
969
-
970
- // Calculate actual distance
971
995
  const distance = distanceBetween(
972
996
  [center.latitude, center.longitude],
973
- [
974
- procedure.clinicInfo.location.latitude,
975
- procedure.clinicInfo.location.longitude,
976
- ]
997
+ [procedure.clinicInfo.location.latitude, procedure.clinicInfo.location.longitude]
977
998
  );
978
-
979
- // Convert to kilometers
980
999
  const distanceInKm = distance / 1000;
981
-
982
- // Check if within radius
983
1000
  if (distanceInKm <= radiusInKm) {
984
- // Add distance to procedure object
985
- matchingProcedures.push({
986
- ...procedure,
987
- distance: distanceInKm,
988
- });
1001
+ allDocs.push({ ...procedure, distance: distanceInKm });
989
1002
  }
990
1003
  }
991
1004
  }
992
-
993
- // Apply additional filters that couldn't be applied in the query
994
- let filteredProcedures = matchingProcedures;
995
-
996
- // Apply remaining filters in memory
997
- filteredProcedures = this.applyInMemoryFilters(
998
- filteredProcedures,
999
- filters
1000
- );
1001
-
1002
1005
  // Sort by distance
1003
- filteredProcedures.sort((a, b) => a.distance - b.distance);
1004
-
1005
- // Apply pagination after all filters have been applied
1006
+ allDocs.sort((a, b) => a.distance - b.distance);
1007
+ // Paginate
1008
+ let paginated = allDocs;
1006
1009
  if (filters.pagination && filters.pagination > 0) {
1007
- // If we have a lastDoc, find its index
1008
1010
  let startIndex = 0;
1009
1011
  if (filters.lastDoc) {
1010
- const lastDocIndex = filteredProcedures.findIndex(
1011
- (procedure) => procedure.id === filters.lastDoc.id
1012
- );
1013
- if (lastDocIndex !== -1) {
1014
- startIndex = lastDocIndex + 1;
1015
- }
1012
+ const lastDocIndex = allDocs.findIndex(p => p.id === filters.lastDoc.id);
1013
+ if (lastDocIndex !== -1) startIndex = lastDocIndex + 1;
1016
1014
  }
1017
-
1018
- // Get paginated subset
1019
- const paginatedProcedures = filteredProcedures.slice(
1020
- startIndex,
1021
- startIndex + filters.pagination
1022
- );
1023
-
1024
- // Set last document for next pagination
1025
- lastVisibleDoc =
1026
- paginatedProcedures.length > 0
1027
- ? paginatedProcedures[paginatedProcedures.length - 1]
1028
- : null;
1029
-
1030
- proceduresResult = paginatedProcedures;
1031
- } else {
1032
- proceduresResult = filteredProcedures;
1015
+ paginated = allDocs.slice(startIndex, startIndex + filters.pagination);
1033
1016
  }
1017
+ const lastVisibleDoc = paginated.length > 0 ? paginated[paginated.length - 1] : null;
1018
+ return { procedures: paginated, lastDoc: lastVisibleDoc };
1034
1019
  } else {
1035
- // For non-geo queries, execute a single query with all constraints
1036
- const q = query(
1037
- collection(this.db, PROCEDURES_COLLECTION),
1038
- ...constraints
1039
- );
1040
- const querySnapshot = await getDocs(q);
1041
-
1042
- console.log(
1043
- `[PROCEDURE_SERVICE] Found ${querySnapshot.docs.length} procedures with regular query`
1044
- );
1045
-
1046
- // Convert docs to procedures
1047
- const procedures = querySnapshot.docs.map((doc) => {
1048
- return { ...doc.data(), id: doc.id } as Procedure;
1049
- });
1050
-
1051
- // Calculate distance for each procedure if location is provided
1052
- if (filters.location) {
1053
- const center = filters.location;
1054
- const proceduresWithDistance: (Procedure & { distance: number })[] =
1055
- [];
1056
-
1057
- procedures.forEach((procedure) => {
1058
- const distance = distanceBetween(
1059
- [center.latitude, center.longitude],
1060
- [
1061
- procedure.clinicInfo.location.latitude,
1062
- procedure.clinicInfo.location.longitude,
1063
- ]
1064
- );
1065
-
1066
- proceduresWithDistance.push({
1067
- ...procedure,
1068
- distance: distance / 1000, // Convert to kilometers
1069
- });
1070
- });
1071
-
1072
- // Replace procedures with version that includes distances
1073
- let filteredProcedures = proceduresWithDistance;
1074
-
1075
- // Apply in-memory filters
1076
- filteredProcedures = this.applyInMemoryFilters(
1077
- filteredProcedures,
1078
- filters
1079
- );
1080
-
1081
- // Sort by distance
1082
- filteredProcedures.sort((a, b) => a.distance - b.distance);
1083
-
1084
- proceduresResult = filteredProcedures;
1085
- } else {
1086
- // Apply filters that couldn't be applied in the query
1087
- let filteredProcedures = this.applyInMemoryFilters(
1088
- procedures,
1089
- filters
1090
- );
1091
- proceduresResult = filteredProcedures;
1020
+ // Regular query
1021
+ let q = query(collection(this.db, PROCEDURES_COLLECTION), ...constraints);
1022
+ let querySnapshot = await getDocs(q);
1023
+ // Fallback na name ako nema rezultata i koristi se nameLower
1024
+ if (useNameLower && querySnapshot.empty && searchTerm) {
1025
+ // Ukloni poslednja 3 constraints (nameLower >=, nameLower <=, orderBy nameLower)
1026
+ constraints.pop();
1027
+ constraints.pop();
1028
+ constraints.pop();
1029
+ constraints.push(where("name", ">=", searchTerm));
1030
+ constraints.push(where("name", "<=", searchTerm + "\uf8ff"));
1031
+ constraints.push(orderBy("name"));
1032
+ q = query(collection(this.db, PROCEDURES_COLLECTION), ...constraints);
1033
+ querySnapshot = await getDocs(q);
1092
1034
  }
1093
-
1094
- // Set last document for pagination
1095
- lastVisibleDoc =
1096
- querySnapshot.docs.length > 0
1097
- ? querySnapshot.docs[querySnapshot.docs.length - 1]
1098
- : null;
1035
+ const procedures = querySnapshot.docs.map((doc) => ({ ...doc.data(), id: doc.id } as Procedure));
1036
+ const lastVisibleDoc = querySnapshot.docs.length > 0 ? querySnapshot.docs[querySnapshot.docs.length - 1] : null;
1037
+ return { procedures, lastDoc: lastVisibleDoc };
1099
1038
  }
1100
-
1101
- return {
1102
- procedures: proceduresResult,
1103
- lastDoc: lastVisibleDoc,
1104
- };
1105
1039
  } catch (error) {
1106
1040
  console.error("[PROCEDURE_SERVICE] Error filtering procedures:", error);
1107
1041
  throw error;
1108
1042
  }
1109
1043
  }
1110
1044
 
1111
- /**
1112
- * Helper method to apply in-memory filters to procedures
1113
- * Used by getProceduresByFilters to apply filters that can't be done in Firestore queries
1114
- *
1115
- * @param procedures - The procedures to filter
1116
- * @param filters - The filters to apply
1117
- * @returns Filtered procedures
1118
- */
1119
- private applyInMemoryFilters<T extends Procedure & { distance?: number }>(
1120
- procedures: T[],
1121
- filters: {
1122
- nameSearch?: string;
1123
- treatmentBenefits?: TreatmentBenefit[];
1124
- procedureCategory?: string;
1125
- procedureSubcategory?: string;
1126
- procedureTechnology?: string;
1127
- minPrice?: number;
1128
- maxPrice?: number;
1129
- minRating?: number;
1130
- maxRating?: number;
1131
- }
1132
- ): T[] {
1133
- let filteredProcedures = procedures;
1134
-
1135
- // Filter by name search if specified
1136
- if (filters.nameSearch && filters.nameSearch.trim() !== "") {
1137
- const searchTerm = filters.nameSearch.toLowerCase().trim();
1138
- filteredProcedures = filteredProcedures.filter((procedure) => {
1139
- return procedure.name.toLowerCase().includes(searchTerm);
1140
- });
1141
- }
1142
-
1143
- // Filter by treatment benefits if specified
1144
- if (filters.treatmentBenefits && filters.treatmentBenefits.length > 0) {
1145
- filteredProcedures = filteredProcedures.filter((procedure) => {
1146
- // Check if procedure has all specified treatment benefits
1147
- return filters.treatmentBenefits!.every((benefit) =>
1148
- procedure.treatmentBenefits.includes(benefit)
1149
- );
1150
- });
1151
- }
1152
-
1153
- // Filter by procedure category if specified
1154
- if (filters.procedureCategory) {
1155
- filteredProcedures = filteredProcedures.filter(
1156
- (procedure) => procedure.category.id === filters.procedureCategory
1157
- );
1158
- }
1159
-
1160
- // Filter by procedure subcategory if specified
1161
- if (filters.procedureSubcategory) {
1162
- filteredProcedures = filteredProcedures.filter(
1163
- (procedure) => procedure.subcategory.id === filters.procedureSubcategory
1164
- );
1165
- }
1166
-
1167
- // Filter by procedure technology if specified
1168
- if (filters.procedureTechnology) {
1169
- filteredProcedures = filteredProcedures.filter(
1170
- (procedure) => procedure.technology.id === filters.procedureTechnology
1171
- );
1172
- }
1173
-
1174
- // Filter by price range if specified
1175
- if (filters.minPrice !== undefined) {
1176
- filteredProcedures = filteredProcedures.filter(
1177
- (procedure) => procedure.price >= filters.minPrice!
1178
- );
1179
- }
1180
-
1181
- if (filters.maxPrice !== undefined) {
1182
- filteredProcedures = filteredProcedures.filter(
1183
- (procedure) => procedure.price <= filters.maxPrice!
1184
- );
1185
- }
1186
-
1187
- // Filter by rating if specified
1188
- if (filters.minRating !== undefined) {
1189
- filteredProcedures = filteredProcedures.filter(
1190
- (procedure) => procedure.reviewInfo.averageRating >= filters.minRating!
1191
- );
1192
- }
1193
-
1194
- if (filters.maxRating !== undefined) {
1195
- filteredProcedures = filteredProcedures.filter(
1196
- (procedure) => procedure.reviewInfo.averageRating <= filters.maxRating!
1197
- );
1198
- }
1199
-
1200
- return filteredProcedures;
1201
- }
1202
-
1203
1045
  /**
1204
1046
  * Creates a consultation procedure without requiring a product
1205
1047
  * This is a special method for consultation procedures that don't use products
@@ -1301,6 +1143,7 @@ export class ProcedureService extends BaseService {
1301
1143
  const newProcedure: Omit<Procedure, "createdAt" | "updatedAt"> = {
1302
1144
  id: procedureId,
1303
1145
  ...data,
1146
+ nameLower: (data as any).nameLower || data.name.toLowerCase(),
1304
1147
  photos: processedPhotos,
1305
1148
  category,
1306
1149
  subcategory,
@@ -1340,4 +1183,35 @@ export class ProcedureService extends BaseService {
1340
1183
  const savedDoc = await getDoc(procedureRef);
1341
1184
  return savedDoc.data() as Procedure;
1342
1185
  }
1186
+
1187
+ /**
1188
+ * Gets all procedures with minimal info for map display (id, name, clinicId, clinicName, address, latitude, longitude)
1189
+ * This is optimized for mobile map usage to reduce payload size.
1190
+ * @returns Array of minimal procedure info for map
1191
+ */
1192
+ async getProceduresForMap(): Promise<{
1193
+ id: string;
1194
+ name: string;
1195
+ clinicId: string | undefined;
1196
+ clinicName: string | undefined;
1197
+ address: string;
1198
+ latitude: number | undefined;
1199
+ longitude: number | undefined;
1200
+ }[]> {
1201
+ const proceduresRef = collection(this.db, PROCEDURES_COLLECTION);
1202
+ const snapshot = await getDocs(proceduresRef);
1203
+ const proceduresForMap = snapshot.docs.map(doc => {
1204
+ const data = doc.data();
1205
+ return {
1206
+ id: doc.id,
1207
+ name: data.name,
1208
+ clinicId: data.clinicInfo?.id,
1209
+ clinicName: data.clinicInfo?.name,
1210
+ address: data.clinicInfo?.location?.address || '',
1211
+ latitude: data.clinicInfo?.location?.latitude,
1212
+ longitude: data.clinicInfo?.location?.longitude,
1213
+ };
1214
+ });
1215
+ return proceduresForMap;
1216
+ }
1343
1217
  }
@@ -269,6 +269,7 @@ export interface Clinic {
269
269
  id: string;
270
270
  clinicGroupId: string;
271
271
  name: string;
272
+ nameLower: string; // Add this line
272
273
  description?: string;
273
274
  location: ClinicLocation;
274
275
  contactInfo: ClinicContactInfo;
@@ -89,6 +89,7 @@ export interface Practitioner {
89
89
  id: string;
90
90
  userRef: string;
91
91
  basicInfo: PractitionerBasicInfo;
92
+ fullNameLower: string; // Add this line
92
93
  certification: PractitionerCertification;
93
94
  clinics: string[]; // Reference na klinike gde radi
94
95
  clinicWorkingHours: PractitionerClinicWorkingHours[]; // Radno vreme za svaku kliniku
@@ -31,6 +31,8 @@ export interface Procedure {
31
31
  id: string;
32
32
  /** Name of the procedure */
33
33
  name: string;
34
+ /** Lowercase version of the name for case-insensitive search */
35
+ nameLower: string;
34
36
  /** Photos of the procedure */
35
37
  photos?: MediaResource[];
36
38
  /** Detailed description of the procedure */
@@ -90,6 +92,8 @@ export interface Procedure {
90
92
  */
91
93
  export interface CreateProcedureData {
92
94
  name: string;
95
+ /** Lowercase version of the name for case-insensitive search */
96
+ nameLower: string;
93
97
  description: string;
94
98
  family: ProcedureFamily;
95
99
  categoryId: string;
@@ -110,6 +114,8 @@ export interface CreateProcedureData {
110
114
  */
111
115
  export interface UpdateProcedureData {
112
116
  name?: string;
117
+ /** Lowercase version of the name for case-insensitive search */
118
+ nameLower?: string;
113
119
  description?: string;
114
120
  price?: number;
115
121
  currency?: Currency;
@@ -12,6 +12,7 @@ import { mediaResourceSchema } from "./media.schema";
12
12
  */
13
13
  export const createProcedureSchema = z.object({
14
14
  name: z.string().min(1).max(200),
15
+ nameLower: z.string().min(1).max(200),
15
16
  description: z.string().min(1).max(2000),
16
17
  family: z.nativeEnum(ProcedureFamily),
17
18
  categoryId: z.string().min(1),
@@ -32,6 +33,7 @@ export const createProcedureSchema = z.object({
32
33
  */
33
34
  export const updateProcedureSchema = z.object({
34
35
  name: z.string().min(3).max(100).optional(),
36
+ nameLower: z.string().min(1).max(200).optional(),
35
37
  description: z.string().min(3).max(1000).optional(),
36
38
  price: z.number().min(0).optional(),
37
39
  currency: z.nativeEnum(Currency).optional(),
@@ -52,6 +54,7 @@ export const updateProcedureSchema = z.object({
52
54
  */
53
55
  export const procedureSchema = createProcedureSchema.extend({
54
56
  id: z.string().min(1),
57
+ nameLower: z.string().min(1).max(200),
55
58
  category: z.any(), // We'll validate the full category object separately
56
59
  subcategory: z.any(), // We'll validate the full subcategory object separately
57
60
  technology: z.any(), // We'll validate the full technology object separately