@blackcode_sa/metaestetics-api 1.5.28 → 1.5.30
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 +1324 -1
- package/dist/admin/index.d.ts +1324 -1
- package/dist/admin/index.js +1674 -2
- package/dist/admin/index.mjs +1668 -2
- package/dist/backoffice/index.d.mts +99 -7
- package/dist/backoffice/index.d.ts +99 -7
- package/dist/index.d.mts +4036 -2372
- package/dist/index.d.ts +4036 -2372
- package/dist/index.js +2331 -2009
- package/dist/index.mjs +2279 -1954
- package/package.json +2 -1
- package/src/admin/aggregation/README.md +79 -0
- package/src/admin/aggregation/clinic/README.md +52 -0
- package/src/admin/aggregation/clinic/clinic.aggregation.service.ts +642 -0
- package/src/admin/aggregation/patient/README.md +27 -0
- package/src/admin/aggregation/patient/patient.aggregation.service.ts +141 -0
- package/src/admin/aggregation/practitioner/README.md +42 -0
- package/src/admin/aggregation/practitioner/practitioner.aggregation.service.ts +433 -0
- package/src/admin/aggregation/procedure/README.md +43 -0
- package/src/admin/aggregation/procedure/procedure.aggregation.service.ts +508 -0
- package/src/admin/index.ts +60 -4
- package/src/admin/mailing/README.md +95 -0
- package/src/admin/mailing/base.mailing.service.ts +131 -0
- package/src/admin/mailing/index.ts +2 -0
- package/src/admin/mailing/practitionerInvite/index.ts +1 -0
- package/src/admin/mailing/practitionerInvite/practitionerInvite.mailing.ts +256 -0
- package/src/admin/mailing/practitionerInvite/templates/invitation.template.ts +101 -0
- package/src/index.ts +28 -4
- package/src/services/README.md +106 -0
- package/src/services/clinic/README.md +87 -0
- package/src/services/clinic/clinic.service.ts +197 -107
- package/src/services/clinic/utils/clinic.utils.ts +68 -119
- package/src/services/clinic/utils/filter.utils.d.ts +23 -0
- package/src/services/clinic/utils/filter.utils.ts +264 -0
- package/src/services/practitioner/README.md +145 -0
- package/src/services/practitioner/practitioner.service.ts +439 -104
- package/src/services/procedure/README.md +88 -0
- package/src/services/procedure/procedure.service.ts +521 -311
- package/src/services/reviews/reviews.service.ts +842 -0
- package/src/types/clinic/index.ts +24 -56
- package/src/types/practitioner/index.ts +34 -33
- package/src/types/procedure/index.ts +32 -0
- package/src/types/profile/index.ts +1 -1
- package/src/types/reviews/index.ts +126 -0
- package/src/validations/clinic.schema.ts +37 -64
- package/src/validations/practitioner.schema.ts +42 -32
- package/src/validations/procedure.schema.ts +11 -3
- package/src/validations/reviews.schema.ts +189 -0
- package/src/services/clinic/utils/review.utils.ts +0 -93
|
@@ -13,22 +13,26 @@ import {
|
|
|
13
13
|
QueryConstraint,
|
|
14
14
|
addDoc,
|
|
15
15
|
writeBatch,
|
|
16
|
+
limit,
|
|
17
|
+
startAfter,
|
|
16
18
|
} from "firebase/firestore";
|
|
17
19
|
import {
|
|
18
20
|
Clinic,
|
|
19
21
|
CreateClinicData,
|
|
20
22
|
CLINICS_COLLECTION,
|
|
21
|
-
ClinicReview,
|
|
22
23
|
ClinicTag,
|
|
23
24
|
ClinicGroup,
|
|
24
25
|
ClinicBranchSetupData,
|
|
25
26
|
ClinicLocation,
|
|
26
27
|
} from "../../../types/clinic";
|
|
27
|
-
import {
|
|
28
|
+
import {
|
|
29
|
+
geohashForLocation,
|
|
30
|
+
distanceBetween,
|
|
31
|
+
geohashQueryBounds,
|
|
32
|
+
} from "geofire-common";
|
|
28
33
|
import {
|
|
29
34
|
clinicSchema,
|
|
30
35
|
createClinicSchema,
|
|
31
|
-
clinicReviewSchema,
|
|
32
36
|
} from "../../../validations/clinic.schema";
|
|
33
37
|
import { z } from "zod";
|
|
34
38
|
import { FirebaseApp } from "firebase/app";
|
|
@@ -273,11 +277,18 @@ export async function createClinic(
|
|
|
273
277
|
photosWithTags: processedPhotosWithTags,
|
|
274
278
|
doctors: [],
|
|
275
279
|
doctorsInfo: [],
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
280
|
+
procedures: [],
|
|
281
|
+
proceduresInfo: [],
|
|
282
|
+
reviewInfo: {
|
|
283
|
+
totalReviews: 0,
|
|
284
|
+
averageRating: 0,
|
|
285
|
+
cleanliness: 0,
|
|
286
|
+
facilities: 0,
|
|
287
|
+
staffFriendliness: 0,
|
|
288
|
+
waitingTime: 0,
|
|
289
|
+
accessibility: 0,
|
|
290
|
+
recommendationPercentage: 0,
|
|
291
|
+
},
|
|
281
292
|
admins: [creatorAdminId],
|
|
282
293
|
createdAt: now,
|
|
283
294
|
updatedAt: now,
|
|
@@ -818,8 +829,6 @@ export async function getAllClinics(
|
|
|
818
829
|
|
|
819
830
|
// If pagination is specified and greater than 0, limit the query
|
|
820
831
|
if (pagination && pagination > 0) {
|
|
821
|
-
const { limit, startAfter } = require("firebase/firestore");
|
|
822
|
-
|
|
823
832
|
if (lastDoc) {
|
|
824
833
|
clinicsQuery = query(
|
|
825
834
|
clinicsCollection,
|
|
@@ -860,141 +869,81 @@ export async function getAllClinics(
|
|
|
860
869
|
* @param rangeInKm - The range in kilometers to search within
|
|
861
870
|
* @param pagination - Optional number of clinics per page (0 or undefined returns all)
|
|
862
871
|
* @param lastDoc - Optional last document for pagination (if continuing from a previous page)
|
|
863
|
-
* @
|
|
864
|
-
* @returns Array of clinics within range and the last document for pagination
|
|
872
|
+
* @returns Array of clinics with distance information and the last document for pagination
|
|
865
873
|
*/
|
|
866
874
|
export async function getAllClinicsInRange(
|
|
867
875
|
db: Firestore,
|
|
868
876
|
center: { latitude: number; longitude: number },
|
|
869
877
|
rangeInKm: number,
|
|
870
878
|
pagination?: number,
|
|
871
|
-
lastDoc?: any
|
|
872
|
-
filters?: {
|
|
873
|
-
isActive?: boolean;
|
|
874
|
-
tags?: ClinicTag[];
|
|
875
|
-
}
|
|
879
|
+
lastDoc?: any
|
|
876
880
|
): Promise<{ clinics: (Clinic & { distance: number })[]; lastDoc: any }> {
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
+
const bounds = geohashQueryBounds(
|
|
882
|
+
[center.latitude, center.longitude],
|
|
883
|
+
rangeInKm * 1000
|
|
884
|
+
);
|
|
881
885
|
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
let clinicsQuery = query(clinicsCollection);
|
|
886
|
+
const matchingClinics: (Clinic & { distance: number })[] = [];
|
|
887
|
+
let lastDocSnapshot = null;
|
|
885
888
|
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
}
|
|
889
|
+
for (const b of bounds) {
|
|
890
|
+
const constraints: QueryConstraint[] = [
|
|
891
|
+
where("location.geohash", ">=", b[0]),
|
|
892
|
+
where("location.geohash", "<=", b[1]),
|
|
893
|
+
where("isActive", "==", true),
|
|
894
|
+
];
|
|
893
895
|
|
|
894
|
-
const
|
|
895
|
-
|
|
896
|
-
`[CLINIC_UTILS] Found ${querySnapshot.docs.length} total clinics to filter by distance`
|
|
897
|
-
);
|
|
896
|
+
const q = query(collection(db, CLINICS_COLLECTION), ...constraints);
|
|
897
|
+
const querySnapshot = await getDocs(q);
|
|
898
898
|
|
|
899
|
-
// Filter the results by distance
|
|
900
|
-
const filteredDocs = [];
|
|
901
899
|
for (const doc of querySnapshot.docs) {
|
|
902
900
|
const clinic = doc.data() as Clinic;
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
if (
|
|
906
|
-
!clinic.location ||
|
|
907
|
-
!clinic.location.latitude ||
|
|
908
|
-
!clinic.location.longitude
|
|
909
|
-
) {
|
|
910
|
-
continue;
|
|
911
|
-
}
|
|
912
|
-
|
|
913
|
-
// Calculate distance
|
|
914
|
-
const distanceInM = distanceBetween(
|
|
915
|
-
[centerLat, centerLng],
|
|
901
|
+
const distance = distanceBetween(
|
|
902
|
+
[center.latitude, center.longitude],
|
|
916
903
|
[clinic.location.latitude, clinic.location.longitude]
|
|
917
904
|
);
|
|
918
905
|
|
|
919
|
-
// Convert to
|
|
920
|
-
const distanceInKm =
|
|
921
|
-
if (distanceInKm <= rangeInKm) {
|
|
922
|
-
// If tags filter exists, apply it
|
|
923
|
-
if (filters?.tags && filters.tags.length > 0) {
|
|
924
|
-
const hasAllTags = filters.tags.every((filterTag) =>
|
|
925
|
-
clinic.tags.some(
|
|
926
|
-
(clinicTag) =>
|
|
927
|
-
(filterTag as any).id === (clinicTag as any).id ||
|
|
928
|
-
(filterTag as any).name === (clinicTag as any).name
|
|
929
|
-
)
|
|
930
|
-
);
|
|
906
|
+
// Convert to kilometers
|
|
907
|
+
const distanceInKm = distance / 1000;
|
|
931
908
|
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
});
|
|
938
|
-
}
|
|
939
|
-
} else {
|
|
940
|
-
// Add distance to clinic object for reference
|
|
941
|
-
filteredDocs.push({
|
|
942
|
-
doc,
|
|
943
|
-
distance: distanceInKm,
|
|
944
|
-
});
|
|
945
|
-
}
|
|
909
|
+
if (distanceInKm <= rangeInKm) {
|
|
910
|
+
matchingClinics.push({
|
|
911
|
+
...clinic,
|
|
912
|
+
distance: distanceInKm,
|
|
913
|
+
});
|
|
946
914
|
}
|
|
947
915
|
}
|
|
916
|
+
}
|
|
948
917
|
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
);
|
|
952
|
-
|
|
953
|
-
// Sort results by distance
|
|
954
|
-
filteredDocs.sort((a, b) => a.distance - b.distance);
|
|
955
|
-
|
|
956
|
-
// Apply pagination if needed
|
|
957
|
-
let paginatedDocs = filteredDocs;
|
|
958
|
-
let lastVisible = null;
|
|
918
|
+
// Sort by distance
|
|
919
|
+
matchingClinics.sort((a, b) => a.distance - b.distance);
|
|
959
920
|
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
}
|
|
921
|
+
if (pagination && pagination > 0) {
|
|
922
|
+
// Paginate results
|
|
923
|
+
let result = matchingClinics;
|
|
924
|
+
if (lastDoc && matchingClinics.length > 0) {
|
|
925
|
+
const lastIndex = matchingClinics.findIndex(
|
|
926
|
+
(clinic) => clinic.id === lastDoc.id
|
|
927
|
+
);
|
|
928
|
+
if (lastIndex !== -1) {
|
|
929
|
+
result = matchingClinics.slice(lastIndex + 1);
|
|
970
930
|
}
|
|
971
|
-
|
|
972
|
-
// Get the paginated subset
|
|
973
|
-
paginatedDocs = filteredDocs.slice(startIndex, startIndex + pagination);
|
|
974
|
-
|
|
975
|
-
// Set the last document for the next pagination
|
|
976
|
-
lastVisible =
|
|
977
|
-
paginatedDocs.length > 0
|
|
978
|
-
? paginatedDocs[paginatedDocs.length - 1].doc
|
|
979
|
-
: null;
|
|
980
931
|
}
|
|
981
932
|
|
|
982
|
-
|
|
983
|
-
const
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
id: item.doc.id,
|
|
988
|
-
distance: item.distance, // Include distance in response
|
|
989
|
-
} as Clinic & { distance: number };
|
|
990
|
-
});
|
|
933
|
+
const paginatedClinics = result.slice(0, pagination);
|
|
934
|
+
const newLastDoc =
|
|
935
|
+
paginatedClinics.length > 0
|
|
936
|
+
? paginatedClinics[paginatedClinics.length - 1]
|
|
937
|
+
: null;
|
|
991
938
|
|
|
992
939
|
return {
|
|
993
|
-
clinics,
|
|
994
|
-
lastDoc:
|
|
940
|
+
clinics: paginatedClinics,
|
|
941
|
+
lastDoc: newLastDoc,
|
|
995
942
|
};
|
|
996
|
-
} catch (error) {
|
|
997
|
-
console.error("[CLINIC_UTILS] Error getting clinics in range:", error);
|
|
998
|
-
throw error;
|
|
999
943
|
}
|
|
944
|
+
|
|
945
|
+
return {
|
|
946
|
+
clinics: matchingClinics,
|
|
947
|
+
lastDoc: null,
|
|
948
|
+
};
|
|
1000
949
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { Firestore } from "firebase/firestore";
|
|
2
|
+
import { Clinic, ClinicTag } from "../../../types/clinic";
|
|
3
|
+
|
|
4
|
+
export function getClinicsByFilters(
|
|
5
|
+
db: Firestore,
|
|
6
|
+
filters: {
|
|
7
|
+
center?: { latitude: number; longitude: number };
|
|
8
|
+
radiusInKm?: number;
|
|
9
|
+
tags?: ClinicTag[];
|
|
10
|
+
procedureFamily?: string;
|
|
11
|
+
procedureCategory?: string;
|
|
12
|
+
procedureSubcategory?: string;
|
|
13
|
+
procedureTechnology?: string;
|
|
14
|
+
minRating?: number;
|
|
15
|
+
maxRating?: number;
|
|
16
|
+
pagination?: number;
|
|
17
|
+
lastDoc?: any;
|
|
18
|
+
isActive?: boolean;
|
|
19
|
+
}
|
|
20
|
+
): Promise<{
|
|
21
|
+
clinics: (Clinic & { distance?: number })[];
|
|
22
|
+
lastDoc: any;
|
|
23
|
+
}>;
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import {
|
|
2
|
+
collection,
|
|
3
|
+
query,
|
|
4
|
+
where,
|
|
5
|
+
getDocs,
|
|
6
|
+
Firestore,
|
|
7
|
+
QueryConstraint,
|
|
8
|
+
startAfter,
|
|
9
|
+
limit,
|
|
10
|
+
documentId,
|
|
11
|
+
orderBy,
|
|
12
|
+
} from "firebase/firestore";
|
|
13
|
+
import { Clinic, ClinicTag, CLINICS_COLLECTION } from "../../../types/clinic";
|
|
14
|
+
import { geohashQueryBounds, distanceBetween } from "geofire-common";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Get clinics based on multiple filtering criteria
|
|
18
|
+
*
|
|
19
|
+
* @param db - Firestore database instance
|
|
20
|
+
* @param filters - Various filters to apply
|
|
21
|
+
* @returns Filtered clinics and the last document for pagination
|
|
22
|
+
*/
|
|
23
|
+
export async function getClinicsByFilters(
|
|
24
|
+
db: Firestore,
|
|
25
|
+
filters: {
|
|
26
|
+
center?: { latitude: number; longitude: number };
|
|
27
|
+
radiusInKm?: number;
|
|
28
|
+
tags?: ClinicTag[];
|
|
29
|
+
procedureFamily?: string;
|
|
30
|
+
procedureCategory?: string;
|
|
31
|
+
procedureSubcategory?: string;
|
|
32
|
+
procedureTechnology?: string;
|
|
33
|
+
minRating?: number;
|
|
34
|
+
maxRating?: number;
|
|
35
|
+
pagination?: number;
|
|
36
|
+
lastDoc?: any;
|
|
37
|
+
isActive?: boolean;
|
|
38
|
+
}
|
|
39
|
+
): Promise<{
|
|
40
|
+
clinics: (Clinic & { distance?: number })[];
|
|
41
|
+
lastDoc: any;
|
|
42
|
+
}> {
|
|
43
|
+
console.log(
|
|
44
|
+
"[FILTER_UTILS] Starting clinic filtering with criteria:",
|
|
45
|
+
filters
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
// Determine if we're doing a geo query or a regular query
|
|
49
|
+
const isGeoQuery =
|
|
50
|
+
filters.center && filters.radiusInKm && filters.radiusInKm > 0;
|
|
51
|
+
|
|
52
|
+
// Initialize base constraints
|
|
53
|
+
const constraints: QueryConstraint[] = [];
|
|
54
|
+
|
|
55
|
+
// Add active status filter (default to active if not specified)
|
|
56
|
+
if (filters.isActive !== undefined) {
|
|
57
|
+
constraints.push(where("isActive", "==", filters.isActive));
|
|
58
|
+
} else {
|
|
59
|
+
constraints.push(where("isActive", "==", true));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Add tag filtering if specified
|
|
63
|
+
if (filters.tags && filters.tags.length > 0) {
|
|
64
|
+
// Note: We can only use array-contains-any once per query, so we're selecting one tag
|
|
65
|
+
// for the query and we'll filter the remaining tags in memory
|
|
66
|
+
constraints.push(where("tags", "array-contains", filters.tags[0]));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Hierarchical procedure filter - only apply the most specific one provided
|
|
70
|
+
// Order of specificity: technology > subcategory > category > family
|
|
71
|
+
if (filters.procedureTechnology) {
|
|
72
|
+
constraints.push(
|
|
73
|
+
where("servicesInfo.technology", "==", filters.procedureTechnology)
|
|
74
|
+
);
|
|
75
|
+
} else if (filters.procedureSubcategory) {
|
|
76
|
+
constraints.push(
|
|
77
|
+
where("servicesInfo.subCategory", "==", filters.procedureSubcategory)
|
|
78
|
+
);
|
|
79
|
+
} else if (filters.procedureCategory) {
|
|
80
|
+
constraints.push(
|
|
81
|
+
where("servicesInfo.category", "==", filters.procedureCategory)
|
|
82
|
+
);
|
|
83
|
+
} else if (filters.procedureFamily) {
|
|
84
|
+
constraints.push(
|
|
85
|
+
where("servicesInfo.procedureFamily", "==", filters.procedureFamily)
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Add pagination if specified
|
|
90
|
+
if (filters.pagination && filters.pagination > 0 && filters.lastDoc) {
|
|
91
|
+
constraints.push(startAfter(filters.lastDoc));
|
|
92
|
+
constraints.push(limit(filters.pagination));
|
|
93
|
+
} else if (filters.pagination && filters.pagination > 0) {
|
|
94
|
+
constraints.push(limit(filters.pagination));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Add ordering to make pagination consistent
|
|
98
|
+
constraints.push(orderBy(documentId()));
|
|
99
|
+
|
|
100
|
+
let clinicsResult: (Clinic & { distance?: number })[] = [];
|
|
101
|
+
let lastVisibleDoc = null;
|
|
102
|
+
|
|
103
|
+
// For geo queries, we need a different approach
|
|
104
|
+
if (isGeoQuery) {
|
|
105
|
+
const center = filters.center!;
|
|
106
|
+
const radiusInKm = filters.radiusInKm!;
|
|
107
|
+
|
|
108
|
+
// Get the geohash query bounds
|
|
109
|
+
const bounds = geohashQueryBounds(
|
|
110
|
+
[center.latitude, center.longitude],
|
|
111
|
+
radiusInKm * 1000 // Convert to meters
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
// Collect matching clinics from all bounds
|
|
115
|
+
const matchingClinics: (Clinic & { distance: number })[] = [];
|
|
116
|
+
|
|
117
|
+
// Execute queries for each bound
|
|
118
|
+
for (const bound of bounds) {
|
|
119
|
+
// Create a geo query for this bound
|
|
120
|
+
const geoConstraints = [
|
|
121
|
+
...constraints,
|
|
122
|
+
where("location.geohash", ">=", bound[0]),
|
|
123
|
+
where("location.geohash", "<=", bound[1]),
|
|
124
|
+
];
|
|
125
|
+
|
|
126
|
+
const q = query(collection(db, CLINICS_COLLECTION), ...geoConstraints);
|
|
127
|
+
const querySnapshot = await getDocs(q);
|
|
128
|
+
|
|
129
|
+
console.log(
|
|
130
|
+
`[FILTER_UTILS] Found ${querySnapshot.docs.length} clinics in geo bound`
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
// Process results and filter by actual distance
|
|
134
|
+
for (const doc of querySnapshot.docs) {
|
|
135
|
+
const clinic = { ...doc.data(), id: doc.id } as Clinic;
|
|
136
|
+
|
|
137
|
+
// Calculate actual distance
|
|
138
|
+
const distance = distanceBetween(
|
|
139
|
+
[center.latitude, center.longitude],
|
|
140
|
+
[clinic.location.latitude, clinic.location.longitude]
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
// Convert to kilometers
|
|
144
|
+
const distanceInKm = distance / 1000;
|
|
145
|
+
|
|
146
|
+
// Check if within radius
|
|
147
|
+
if (distanceInKm <= radiusInKm) {
|
|
148
|
+
// Add distance to clinic object
|
|
149
|
+
matchingClinics.push({
|
|
150
|
+
...clinic,
|
|
151
|
+
distance: distanceInKm,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Apply additional filters that couldn't be applied in the query
|
|
158
|
+
let filteredClinics = matchingClinics;
|
|
159
|
+
|
|
160
|
+
// Filter by multiple tags if more than one tag was specified
|
|
161
|
+
if (filters.tags && filters.tags.length > 1) {
|
|
162
|
+
filteredClinics = filteredClinics.filter((clinic) => {
|
|
163
|
+
// Check if clinic has all specified tags
|
|
164
|
+
return filters.tags!.every((tag) => clinic.tags.includes(tag));
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Filter by rating
|
|
169
|
+
if (filters.minRating !== undefined) {
|
|
170
|
+
filteredClinics = filteredClinics.filter(
|
|
171
|
+
(clinic) => clinic.reviewInfo.averageRating >= filters.minRating!
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (filters.maxRating !== undefined) {
|
|
176
|
+
filteredClinics = filteredClinics.filter(
|
|
177
|
+
(clinic) => clinic.reviewInfo.averageRating <= filters.maxRating!
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Sort by distance
|
|
182
|
+
filteredClinics.sort((a, b) => a.distance - b.distance);
|
|
183
|
+
|
|
184
|
+
// Apply pagination after all filters have been applied
|
|
185
|
+
if (filters.pagination && filters.pagination > 0) {
|
|
186
|
+
// If we have a lastDoc, find its index
|
|
187
|
+
let startIndex = 0;
|
|
188
|
+
if (filters.lastDoc) {
|
|
189
|
+
const lastDocIndex = filteredClinics.findIndex(
|
|
190
|
+
(clinic) => clinic.id === filters.lastDoc.id
|
|
191
|
+
);
|
|
192
|
+
if (lastDocIndex !== -1) {
|
|
193
|
+
startIndex = lastDocIndex + 1;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Get paginated subset
|
|
198
|
+
const paginatedClinics = filteredClinics.slice(
|
|
199
|
+
startIndex,
|
|
200
|
+
startIndex + filters.pagination
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
// Set last document for next pagination
|
|
204
|
+
lastVisibleDoc =
|
|
205
|
+
paginatedClinics.length > 0
|
|
206
|
+
? paginatedClinics[paginatedClinics.length - 1]
|
|
207
|
+
: null;
|
|
208
|
+
|
|
209
|
+
clinicsResult = paginatedClinics;
|
|
210
|
+
} else {
|
|
211
|
+
clinicsResult = filteredClinics;
|
|
212
|
+
}
|
|
213
|
+
} else {
|
|
214
|
+
// For non-geo queries, execute a single query with all constraints
|
|
215
|
+
const q = query(collection(db, CLINICS_COLLECTION), ...constraints);
|
|
216
|
+
const querySnapshot = await getDocs(q);
|
|
217
|
+
|
|
218
|
+
console.log(
|
|
219
|
+
`[FILTER_UTILS] Found ${querySnapshot.docs.length} clinics with regular query`
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
// Convert docs to clinics
|
|
223
|
+
const clinics = querySnapshot.docs.map((doc) => {
|
|
224
|
+
return { ...doc.data(), id: doc.id } as Clinic;
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// Apply filters that couldn't be applied in the query
|
|
228
|
+
let filteredClinics = clinics;
|
|
229
|
+
|
|
230
|
+
// Filter by multiple tags if more than one tag was specified
|
|
231
|
+
if (filters.tags && filters.tags.length > 1) {
|
|
232
|
+
filteredClinics = filteredClinics.filter((clinic) => {
|
|
233
|
+
// Check if clinic has all specified tags
|
|
234
|
+
return filters.tags!.every((tag) => clinic.tags.includes(tag));
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Filter by rating
|
|
239
|
+
if (filters.minRating !== undefined) {
|
|
240
|
+
filteredClinics = filteredClinics.filter(
|
|
241
|
+
(clinic) => clinic.reviewInfo.averageRating >= filters.minRating!
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (filters.maxRating !== undefined) {
|
|
246
|
+
filteredClinics = filteredClinics.filter(
|
|
247
|
+
(clinic) => clinic.reviewInfo.averageRating <= filters.maxRating!
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Set last document for pagination
|
|
252
|
+
lastVisibleDoc =
|
|
253
|
+
querySnapshot.docs.length > 0
|
|
254
|
+
? querySnapshot.docs[querySnapshot.docs.length - 1]
|
|
255
|
+
: null;
|
|
256
|
+
|
|
257
|
+
clinicsResult = filteredClinics;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return {
|
|
261
|
+
clinics: clinicsResult,
|
|
262
|
+
lastDoc: lastVisibleDoc,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# Practitioner Service
|
|
2
|
+
|
|
3
|
+
This service manages practitioner (doctor, therapist, etc.) data within the Firestore database. It handles practitioner profiles, draft profiles, registration tokens, and associations with clinics.
|
|
4
|
+
|
|
5
|
+
**Note:** Data aggregation into related entities (Clinics, Procedures) is handled by Cloud Functions triggered by Firestore events.
|
|
6
|
+
|
|
7
|
+
## `PractitionerService` Class
|
|
8
|
+
|
|
9
|
+
Extends `BaseService`.
|
|
10
|
+
|
|
11
|
+
### Constructor
|
|
12
|
+
|
|
13
|
+
```typescript
|
|
14
|
+
constructor(
|
|
15
|
+
db: Firestore,
|
|
16
|
+
auth: Auth,
|
|
17
|
+
app: FirebaseApp,
|
|
18
|
+
clinicService?: ClinicService // Optional dependency injection
|
|
19
|
+
)
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Initializes the service with Firestore, Auth, App instances. Optionally accepts a `ClinicService` instance for operations requiring clinic data (like draft creation or token validation).
|
|
23
|
+
|
|
24
|
+
### Methods
|
|
25
|
+
|
|
26
|
+
- **`createPractitioner(data: CreatePractitionerData): Promise<Practitioner>`**
|
|
27
|
+
|
|
28
|
+
- Creates a new, fully active practitioner profile.
|
|
29
|
+
- Validates data using `createPractitionerSchema`.
|
|
30
|
+
- Generates a unique ID using `this.generateId()`.
|
|
31
|
+
- Initializes default review info.
|
|
32
|
+
- Sets default values for `isActive` (true), `isVerified` (false), and `status` (ACTIVE).
|
|
33
|
+
- Saves the practitioner document.
|
|
34
|
+
- **Aggregation Note:** Adding practitioner info to associated `Clinics` is handled by Cloud Functions.
|
|
35
|
+
|
|
36
|
+
- **`createDraftPractitioner(data: CreateDraftPractitionerData, createdBy: string, clinicId: string): Promise<{ practitioner: Practitioner; token: PractitionerToken }>`**
|
|
37
|
+
|
|
38
|
+
- Creates a draft practitioner profile (status `DRAFT`) not yet linked to a user (`userRef` is empty). Typically used by clinic admins.
|
|
39
|
+
- Validates data using `createDraftPractitionerSchema`.
|
|
40
|
+
- Verifies that the specified `clinicId` exists.
|
|
41
|
+
- Associates the practitioner with the specified `clinicId` (and potentially others in `data.clinics`).
|
|
42
|
+
- Sets `isActive` and `isVerified` to `false` by default.
|
|
43
|
+
- Saves the draft practitioner document.
|
|
44
|
+
- Automatically creates a registration token (`PractitionerToken`) for this draft profile.
|
|
45
|
+
- Returns both the created draft practitioner and the registration token.
|
|
46
|
+
|
|
47
|
+
- **`createPractitionerToken(data: CreatePractitionerTokenData, createdBy: string): Promise<PractitionerToken>`**
|
|
48
|
+
|
|
49
|
+
- Creates a registration token for an existing `DRAFT` practitioner.
|
|
50
|
+
- Validates input data using `createPractitionerTokenSchema`.
|
|
51
|
+
- Ensures the practitioner exists, is in `DRAFT` status, and belongs to the specified `clinicId`.
|
|
52
|
+
- Generates a unique, short, uppercase token string.
|
|
53
|
+
- Sets a default expiration of 7 days if not provided.
|
|
54
|
+
- Saves the token in the `register_tokens` subcollection of the practitioner document.
|
|
55
|
+
|
|
56
|
+
- **`getPractitionerActiveTokens(practitionerId: string): Promise<PractitionerToken[]>`**
|
|
57
|
+
|
|
58
|
+
- Retrieves all `ACTIVE` and non-expired registration tokens for a specific practitioner.
|
|
59
|
+
|
|
60
|
+
- **`validateToken(tokenString: string): Promise<PractitionerToken | null>`**
|
|
61
|
+
|
|
62
|
+
- Finds an `ACTIVE`, non-expired token matching the `tokenString` across _all_ practitioners' `register_tokens` subcollections. Returns the token object or `null`.
|
|
63
|
+
|
|
64
|
+
- **`markTokenAsUsed(tokenId: string, practitionerId: string, userId: string): Promise<void>`**
|
|
65
|
+
|
|
66
|
+
- Updates a specific token's status to `USED` and records who used it (`userId`) and when.
|
|
67
|
+
|
|
68
|
+
- **`getPractitioner(practitionerId: string): Promise<Practitioner | null>`**
|
|
69
|
+
|
|
70
|
+
- Retrieves a single practitioner document by its ID.
|
|
71
|
+
|
|
72
|
+
- **`getPractitionerByUserRef(userRef: string): Promise<Practitioner | null>`**
|
|
73
|
+
|
|
74
|
+
- Finds and retrieves a practitioner document based on the linked user ID (`userRef`).
|
|
75
|
+
|
|
76
|
+
- **`getPractitionersByClinic(clinicId: string): Promise<Practitioner[]>`**
|
|
77
|
+
|
|
78
|
+
- Retrieves all `ACTIVE` practitioners associated with a specific `clinicId`.
|
|
79
|
+
|
|
80
|
+
- **`getAllPractitionersByClinic(clinicId: string): Promise<Practitioner[]>`**
|
|
81
|
+
|
|
82
|
+
- Retrieves all `ACTIVE` practitioners (regardless of status like DRAFT) associated with a `clinicId`.
|
|
83
|
+
|
|
84
|
+
- **`getDraftPractitionersByClinic(clinicId: string): Promise<Practitioner[]>`**
|
|
85
|
+
|
|
86
|
+
- Retrieves all practitioners with `DRAFT` status associated with a specific `clinicId`.
|
|
87
|
+
|
|
88
|
+
- **`updatePractitioner(practitionerId: string, data: UpdatePractitionerData): Promise<Practitioner>`**
|
|
89
|
+
|
|
90
|
+
- Updates an existing practitioner document with partial data.
|
|
91
|
+
- Sets the `updatedAt` timestamp.
|
|
92
|
+
- **Aggregation Note:** Updates to aggregated data in `Clinics` and `Procedures` are handled by Cloud Functions.
|
|
93
|
+
|
|
94
|
+
- **`addClinic(practitionerId: string, clinicId: string): Promise<void>`**
|
|
95
|
+
|
|
96
|
+
- Adds a `clinicId` to the practitioner's `clinics` array.
|
|
97
|
+
- Prevents duplicates.
|
|
98
|
+
- **Aggregation Note:** Updating the clinic's `doctors`/`doctorsInfo` is handled by Cloud Functions.
|
|
99
|
+
|
|
100
|
+
- **`removeClinic(practitionerId: string, clinicId: string): Promise<void>`**
|
|
101
|
+
|
|
102
|
+
- Removes a `clinicId` from the practitioner's `clinics` array.
|
|
103
|
+
- **Aggregation Note:** Updating the clinic's `doctors`/`doctorsInfo` is handled by Cloud Functions.
|
|
104
|
+
|
|
105
|
+
- **`deactivatePractitioner(practitionerId: string): Promise<void>`**
|
|
106
|
+
|
|
107
|
+
- Sets the practitioner's `isActive` flag to `false` using `updatePractitioner`.
|
|
108
|
+
- **Aggregation Note:** Related updates are handled by Cloud Functions.
|
|
109
|
+
|
|
110
|
+
- **`activatePractitioner(practitionerId: string): Promise<void>`**
|
|
111
|
+
|
|
112
|
+
- Sets the practitioner's `isActive` flag to `true` using `updatePractitioner`.
|
|
113
|
+
- **Aggregation Note:** Related updates are handled by Cloud Functions.
|
|
114
|
+
|
|
115
|
+
- **`deletePractitioner(practitionerId: string): Promise<void>`**
|
|
116
|
+
|
|
117
|
+
- Permanently deletes a practitioner document.
|
|
118
|
+
- **Aggregation Note:** Removal of associated data from `Clinics`, `Procedures`, etc., is handled by Cloud Functions.
|
|
119
|
+
|
|
120
|
+
- **`validateTokenAndClaimProfile(tokenString: string, userId: string): Promise<Practitioner | null>`**
|
|
121
|
+
|
|
122
|
+
- Orchestrates the process of a user claiming a draft profile:
|
|
123
|
+
1. Validates the `tokenString` using `validateToken`.
|
|
124
|
+
2. Retrieves the associated `DRAFT` practitioner.
|
|
125
|
+
3. Checks if the user (`userId`) already has a profile.
|
|
126
|
+
4. Updates the practitioner's `userRef` to the `userId` and status to `ACTIVE` using `updatePractitioner`.
|
|
127
|
+
5. Marks the token as `USED` using `markTokenAsUsed`.
|
|
128
|
+
6. Returns the now-claimed practitioner profile.
|
|
129
|
+
|
|
130
|
+
- **`getAllPractitioners(options?: { pagination?: number; lastDoc?: any; includeDraftPractitioners?: boolean }): Promise<{ practitioners: Practitioner[]; lastDoc: any }>`**
|
|
131
|
+
|
|
132
|
+
- Retrieves a list of practitioners, ordered by name.
|
|
133
|
+
- Supports pagination (`pagination`, `lastDoc`).
|
|
134
|
+
- Optionally includes `DRAFT` practitioners.
|
|
135
|
+
|
|
136
|
+
- **`getPractitionersByFilters(filters: { ... }): Promise<{ practitioners: Practitioner[]; lastDoc: any }>`**
|
|
137
|
+
- Retrieves practitioners based on complex filter criteria:
|
|
138
|
+
- Name (first/last)
|
|
139
|
+
- Certifications, Specialties
|
|
140
|
+
- Procedures (Family, Category, Subcategory, Technology)
|
|
141
|
+
- Location/Radius - Filters based on associated clinics
|
|
142
|
+
- Rating (`reviewInfo.averageRating`)
|
|
143
|
+
- Combines Firestore queries with in-memory filtering for non-indexed fields.
|
|
144
|
+
- Supports pagination.
|
|
145
|
+
- Optionally includes `DRAFT` practitioners.
|