@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.
Files changed (49) hide show
  1. package/dist/admin/index.d.mts +1324 -1
  2. package/dist/admin/index.d.ts +1324 -1
  3. package/dist/admin/index.js +1674 -2
  4. package/dist/admin/index.mjs +1668 -2
  5. package/dist/backoffice/index.d.mts +99 -7
  6. package/dist/backoffice/index.d.ts +99 -7
  7. package/dist/index.d.mts +4036 -2372
  8. package/dist/index.d.ts +4036 -2372
  9. package/dist/index.js +2331 -2009
  10. package/dist/index.mjs +2279 -1954
  11. package/package.json +2 -1
  12. package/src/admin/aggregation/README.md +79 -0
  13. package/src/admin/aggregation/clinic/README.md +52 -0
  14. package/src/admin/aggregation/clinic/clinic.aggregation.service.ts +642 -0
  15. package/src/admin/aggregation/patient/README.md +27 -0
  16. package/src/admin/aggregation/patient/patient.aggregation.service.ts +141 -0
  17. package/src/admin/aggregation/practitioner/README.md +42 -0
  18. package/src/admin/aggregation/practitioner/practitioner.aggregation.service.ts +433 -0
  19. package/src/admin/aggregation/procedure/README.md +43 -0
  20. package/src/admin/aggregation/procedure/procedure.aggregation.service.ts +508 -0
  21. package/src/admin/index.ts +60 -4
  22. package/src/admin/mailing/README.md +95 -0
  23. package/src/admin/mailing/base.mailing.service.ts +131 -0
  24. package/src/admin/mailing/index.ts +2 -0
  25. package/src/admin/mailing/practitionerInvite/index.ts +1 -0
  26. package/src/admin/mailing/practitionerInvite/practitionerInvite.mailing.ts +256 -0
  27. package/src/admin/mailing/practitionerInvite/templates/invitation.template.ts +101 -0
  28. package/src/index.ts +28 -4
  29. package/src/services/README.md +106 -0
  30. package/src/services/clinic/README.md +87 -0
  31. package/src/services/clinic/clinic.service.ts +197 -107
  32. package/src/services/clinic/utils/clinic.utils.ts +68 -119
  33. package/src/services/clinic/utils/filter.utils.d.ts +23 -0
  34. package/src/services/clinic/utils/filter.utils.ts +264 -0
  35. package/src/services/practitioner/README.md +145 -0
  36. package/src/services/practitioner/practitioner.service.ts +439 -104
  37. package/src/services/procedure/README.md +88 -0
  38. package/src/services/procedure/procedure.service.ts +521 -311
  39. package/src/services/reviews/reviews.service.ts +842 -0
  40. package/src/types/clinic/index.ts +24 -56
  41. package/src/types/practitioner/index.ts +34 -33
  42. package/src/types/procedure/index.ts +32 -0
  43. package/src/types/profile/index.ts +1 -1
  44. package/src/types/reviews/index.ts +126 -0
  45. package/src/validations/clinic.schema.ts +37 -64
  46. package/src/validations/practitioner.schema.ts +42 -32
  47. package/src/validations/procedure.schema.ts +11 -3
  48. package/src/validations/reviews.schema.ts +189 -0
  49. 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 { geohashForLocation } from "geofire-common";
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
- services: [],
277
- servicesInfo: [],
278
- reviews: [],
279
- reviewsInfo: [],
280
- rating: { average: 0, count: 0 },
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
- * @param filters - Optional filters to apply to the search (isActive, tags, etc.)
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
- try {
878
- const { distanceBetween } = require("geofire-common");
879
- const centerLat = center.latitude;
880
- const centerLng = center.longitude;
881
+ const bounds = geohashQueryBounds(
882
+ [center.latitude, center.longitude],
883
+ rangeInKm * 1000
884
+ );
881
885
 
882
- // We'll need to get all clinics and filter them by distance
883
- const clinicsCollection = collection(db, CLINICS_COLLECTION);
884
- let clinicsQuery = query(clinicsCollection);
886
+ const matchingClinics: (Clinic & { distance: number })[] = [];
887
+ let lastDocSnapshot = null;
885
888
 
886
- // Add active filter if specified
887
- if (filters?.isActive !== undefined) {
888
- clinicsQuery = query(
889
- clinicsCollection,
890
- where("isActive", "==", filters.isActive)
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 querySnapshot = await getDocs(clinicsQuery);
895
- console.log(
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
- // Skip clinics without proper location data
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 km and check if within range
920
- const distanceInKm = distanceInM / 1000;
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
- if (hasAllTags) {
933
- // Add distance to clinic object for reference
934
- filteredDocs.push({
935
- doc,
936
- distance: distanceInKm,
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
- console.log(
950
- `[CLINIC_UTILS] Filtered to ${filteredDocs.length} clinics within ${rangeInKm}km`
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
- if (pagination && pagination > 0) {
961
- // If we have a lastDoc, find its index in our sorted results
962
- let startIndex = 0;
963
- if (lastDoc) {
964
- const lastDocIndex = filteredDocs.findIndex(
965
- (item) => item.doc.id === lastDoc.id
966
- );
967
- if (lastDocIndex !== -1) {
968
- startIndex = lastDocIndex + 1;
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
- // Map to Clinic objects with distance information
983
- const clinics = paginatedDocs.map((item) => {
984
- const data = item.doc.data() as Clinic;
985
- return {
986
- ...data,
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: lastVisible,
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.