@blackcode_sa/metaestetics-api 1.5.27 → 1.5.29
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 +1199 -1
- package/dist/admin/index.d.ts +1199 -1
- package/dist/admin/index.js +1337 -2
- package/dist/admin/index.mjs +1333 -2
- package/dist/backoffice/index.d.mts +99 -7
- package/dist/backoffice/index.d.ts +99 -7
- package/dist/index.d.mts +4184 -2426
- package/dist/index.d.ts +4184 -2426
- package/dist/index.js +2692 -1546
- package/dist/index.mjs +2663 -1502
- package/package.json +1 -1
- package/src/admin/aggregation/clinic/clinic.aggregation.service.ts +642 -0
- package/src/admin/aggregation/patient/patient.aggregation.service.ts +141 -0
- package/src/admin/aggregation/practitioner/practitioner.aggregation.service.ts +433 -0
- package/src/admin/aggregation/procedure/procedure.aggregation.service.ts +508 -0
- package/src/admin/index.ts +53 -4
- package/src/index.ts +28 -4
- package/src/services/calendar/calendar-refactored.service.ts +1 -1
- package/src/services/clinic/clinic.service.ts +344 -77
- package/src/services/clinic/utils/clinic.utils.ts +187 -8
- 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/practitioner.service.ts +616 -5
- package/src/services/procedure/procedure.service.ts +678 -52
- 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 +39 -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 +14 -3
- package/src/validations/reviews.schema.ts +189 -0
- package/src/services/clinic/utils/review.utils.ts +0 -93
|
@@ -10,6 +10,13 @@ import {
|
|
|
10
10
|
deleteDoc,
|
|
11
11
|
Timestamp,
|
|
12
12
|
serverTimestamp,
|
|
13
|
+
limit,
|
|
14
|
+
startAfter,
|
|
15
|
+
orderBy,
|
|
16
|
+
writeBatch,
|
|
17
|
+
arrayUnion,
|
|
18
|
+
arrayRemove,
|
|
19
|
+
FieldValue,
|
|
13
20
|
} from "firebase/firestore";
|
|
14
21
|
import { BaseService } from "../base.service";
|
|
15
22
|
import {
|
|
@@ -23,7 +30,9 @@ import {
|
|
|
23
30
|
PractitionerToken,
|
|
24
31
|
CreatePractitionerTokenData,
|
|
25
32
|
PractitionerTokenStatus,
|
|
33
|
+
PractitionerBasicInfo,
|
|
26
34
|
} from "../../types/practitioner";
|
|
35
|
+
import { ProcedureSummaryInfo } from "../../types/procedure";
|
|
27
36
|
import { ClinicService } from "../clinic/clinic.service";
|
|
28
37
|
import {
|
|
29
38
|
practitionerSchema,
|
|
@@ -36,6 +45,11 @@ import { z } from "zod";
|
|
|
36
45
|
import { Auth } from "firebase/auth";
|
|
37
46
|
import { Firestore } from "firebase/firestore";
|
|
38
47
|
import { FirebaseApp } from "firebase/app";
|
|
48
|
+
import { PractitionerReviewInfo } from "../../types/reviews";
|
|
49
|
+
import { distanceBetween } from "geofire-common";
|
|
50
|
+
import { CertificationSpecialty } from "../../backoffice/types/static/certification.types";
|
|
51
|
+
import { Clinic, DoctorInfo, CLINICS_COLLECTION } from "../../types/clinic";
|
|
52
|
+
import { ClinicInfo } from "../../types/profile";
|
|
39
53
|
|
|
40
54
|
export class PractitionerService extends BaseService {
|
|
41
55
|
private clinicService?: ClinicService;
|
|
@@ -64,6 +78,78 @@ export class PractitionerService extends BaseService {
|
|
|
64
78
|
this.clinicService = clinicService;
|
|
65
79
|
}
|
|
66
80
|
|
|
81
|
+
/**
|
|
82
|
+
* Aggregates clinic information for a practitioner
|
|
83
|
+
* @param clinicIds Array of clinic IDs the practitioner works at
|
|
84
|
+
* @returns Array of ClinicInfo objects
|
|
85
|
+
*/
|
|
86
|
+
private async aggregateClinicInfo(
|
|
87
|
+
clinicIds: string[]
|
|
88
|
+
): Promise<ClinicInfo[]> {
|
|
89
|
+
const clinicsInfo: ClinicInfo[] = [];
|
|
90
|
+
|
|
91
|
+
for (const clinicId of clinicIds) {
|
|
92
|
+
const clinic = await this.getClinicService().getClinic(clinicId);
|
|
93
|
+
if (!clinic) continue;
|
|
94
|
+
|
|
95
|
+
clinicsInfo.push({
|
|
96
|
+
id: clinic.id,
|
|
97
|
+
featuredPhoto:
|
|
98
|
+
clinic.featuredPhotos && clinic.featuredPhotos.length > 0
|
|
99
|
+
? clinic.featuredPhotos[0]
|
|
100
|
+
: clinic.coverPhoto || "",
|
|
101
|
+
name: clinic.name,
|
|
102
|
+
description: clinic.description || "",
|
|
103
|
+
location: clinic.location,
|
|
104
|
+
contactInfo: clinic.contactInfo,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return clinicsInfo;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* @deprecated Aggregation of procedure info is now handled by ProcedureService.
|
|
113
|
+
*/
|
|
114
|
+
private async aggregateProcedureInfo(
|
|
115
|
+
clinicIds: string[],
|
|
116
|
+
practitionerId: string
|
|
117
|
+
): Promise<ProcedureSummaryInfo[]> {
|
|
118
|
+
console.warn("PractitionerService.aggregateProcedureInfo is deprecated.");
|
|
119
|
+
return [];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Updates aggregated data (clinics and procedures) for a practitioner
|
|
124
|
+
* @param practitionerId ID of the practitioner to update
|
|
125
|
+
* @returns Updated practitioner
|
|
126
|
+
*/
|
|
127
|
+
async updateAggregatedData(
|
|
128
|
+
practitionerId: string
|
|
129
|
+
): Promise<Practitioner | null> {
|
|
130
|
+
const practitioner = await this.getPractitioner(practitionerId);
|
|
131
|
+
if (!practitioner) {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Aggregate clinic info
|
|
136
|
+
const clinicsInfo = await this.aggregateClinicInfo(practitioner.clinics);
|
|
137
|
+
|
|
138
|
+
// Aggregate procedure info
|
|
139
|
+
const proceduresInfo = await this.aggregateProcedureInfo(
|
|
140
|
+
practitioner.clinics,
|
|
141
|
+
practitionerId
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
// Update the practitioner with aggregated data
|
|
145
|
+
const updatedPractitioner = await this.updatePractitioner(practitionerId, {
|
|
146
|
+
clinicsInfo: clinicsInfo,
|
|
147
|
+
proceduresInfo: proceduresInfo,
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
return updatedPractitioner;
|
|
151
|
+
}
|
|
152
|
+
|
|
67
153
|
/**
|
|
68
154
|
* Kreira novog zdravstvenog radnika
|
|
69
155
|
*/
|
|
@@ -92,16 +178,49 @@ export class PractitionerService extends BaseService {
|
|
|
92
178
|
}
|
|
93
179
|
}
|
|
94
180
|
|
|
181
|
+
// Initialize default review info for new practitioners
|
|
182
|
+
const defaultReviewInfo: PractitionerReviewInfo = {
|
|
183
|
+
totalReviews: 0,
|
|
184
|
+
averageRating: 0,
|
|
185
|
+
knowledgeAndExpertise: 0,
|
|
186
|
+
communicationSkills: 0,
|
|
187
|
+
bedSideManner: 0,
|
|
188
|
+
thoroughness: 0,
|
|
189
|
+
trustworthiness: 0,
|
|
190
|
+
recommendationPercentage: 0,
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
// Generate ID for the new practitioner
|
|
194
|
+
const practitionerId = this.generateId();
|
|
195
|
+
|
|
196
|
+
// Aggregate clinic info if not provided
|
|
197
|
+
let clinicsInfo = validatedData.clinicsInfo || [];
|
|
198
|
+
if (
|
|
199
|
+
clinicsInfo.length === 0 &&
|
|
200
|
+
validatedData.clinics &&
|
|
201
|
+
validatedData.clinics.length > 0
|
|
202
|
+
) {
|
|
203
|
+
clinicsInfo = await this.aggregateClinicInfo(validatedData.clinics);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Can't aggregate procedures at creation time since the practitioner ID doesn't exist yet
|
|
207
|
+
// We'll initialize with an empty array and update after creation if needed
|
|
208
|
+
const proceduresInfo: ProcedureSummaryInfo[] = [];
|
|
209
|
+
|
|
95
210
|
const practitionerData: Omit<Practitioner, "createdAt" | "updatedAt"> & {
|
|
96
211
|
createdAt: ReturnType<typeof serverTimestamp>;
|
|
97
212
|
updatedAt: ReturnType<typeof serverTimestamp>;
|
|
98
213
|
} = {
|
|
99
|
-
id:
|
|
214
|
+
id: practitionerId,
|
|
100
215
|
userRef: validatedData.userRef,
|
|
101
216
|
basicInfo: validatedData.basicInfo,
|
|
102
217
|
certification: validatedData.certification,
|
|
103
218
|
clinics: validatedData.clinics || [],
|
|
104
219
|
clinicWorkingHours: validatedData.clinicWorkingHours || [],
|
|
220
|
+
clinicsInfo: clinicsInfo,
|
|
221
|
+
procedures: [],
|
|
222
|
+
proceduresInfo: proceduresInfo,
|
|
223
|
+
reviewInfo: defaultReviewInfo,
|
|
105
224
|
isActive: validatedData.isActive,
|
|
106
225
|
isVerified: validatedData.isVerified,
|
|
107
226
|
status: validatedData.status || PractitionerStatus.ACTIVE,
|
|
@@ -122,11 +241,24 @@ export class PractitionerService extends BaseService {
|
|
|
122
241
|
practitionerData
|
|
123
242
|
);
|
|
124
243
|
|
|
125
|
-
|
|
244
|
+
// Get the saved practitioner
|
|
245
|
+
let savedPractitioner = await this.getPractitioner(practitionerData.id);
|
|
126
246
|
if (!savedPractitioner) {
|
|
127
247
|
throw new Error("Failed to create practitioner profile");
|
|
128
248
|
}
|
|
129
249
|
|
|
250
|
+
// If procedures weren't provided and the practitioner is associated with clinics,
|
|
251
|
+
// update the aggregated procedure info
|
|
252
|
+
if (
|
|
253
|
+
proceduresInfo.length === 0 &&
|
|
254
|
+
validatedData.clinics &&
|
|
255
|
+
validatedData.clinics.length > 0
|
|
256
|
+
) {
|
|
257
|
+
savedPractitioner =
|
|
258
|
+
(await this.updateAggregatedData(savedPractitioner.id)) ||
|
|
259
|
+
savedPractitioner;
|
|
260
|
+
}
|
|
261
|
+
|
|
130
262
|
return savedPractitioner;
|
|
131
263
|
} catch (error) {
|
|
132
264
|
if (error instanceof z.ZodError) {
|
|
@@ -174,7 +306,30 @@ export class PractitionerService extends BaseService {
|
|
|
174
306
|
}
|
|
175
307
|
}
|
|
176
308
|
|
|
309
|
+
// Initialize default review info for new practitioners
|
|
310
|
+
const defaultReviewInfo: PractitionerReviewInfo = {
|
|
311
|
+
totalReviews: 0,
|
|
312
|
+
averageRating: 0,
|
|
313
|
+
knowledgeAndExpertise: 0,
|
|
314
|
+
communicationSkills: 0,
|
|
315
|
+
bedSideManner: 0,
|
|
316
|
+
thoroughness: 0,
|
|
317
|
+
trustworthiness: 0,
|
|
318
|
+
recommendationPercentage: 0,
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
// Generate ID for the new practitioner
|
|
177
322
|
const practitionerId = this.generateId();
|
|
323
|
+
|
|
324
|
+
// Aggregate clinic info if not provided
|
|
325
|
+
let clinicsInfo = validatedData.clinicsInfo || [];
|
|
326
|
+
if (clinicsInfo.length === 0 && clinics.length > 0) {
|
|
327
|
+
clinicsInfo = await this.aggregateClinicInfo(clinics);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Can't aggregate procedures for draft practitioners yet
|
|
331
|
+
const proceduresInfo: ProcedureSummaryInfo[] = [];
|
|
332
|
+
|
|
178
333
|
const practitionerData: Omit<Practitioner, "createdAt" | "updatedAt"> & {
|
|
179
334
|
createdAt: ReturnType<typeof serverTimestamp>;
|
|
180
335
|
updatedAt: ReturnType<typeof serverTimestamp>;
|
|
@@ -185,6 +340,10 @@ export class PractitionerService extends BaseService {
|
|
|
185
340
|
certification: validatedData.certification,
|
|
186
341
|
clinics: clinics,
|
|
187
342
|
clinicWorkingHours: validatedData.clinicWorkingHours || [],
|
|
343
|
+
clinicsInfo: clinicsInfo,
|
|
344
|
+
procedures: [],
|
|
345
|
+
proceduresInfo: proceduresInfo,
|
|
346
|
+
reviewInfo: defaultReviewInfo,
|
|
188
347
|
isActive:
|
|
189
348
|
validatedData.isActive !== undefined ? validatedData.isActive : false,
|
|
190
349
|
isVerified:
|
|
@@ -506,7 +665,10 @@ export class PractitionerService extends BaseService {
|
|
|
506
665
|
}
|
|
507
666
|
|
|
508
667
|
try {
|
|
668
|
+
const currentPractitioner = practitionerDoc.data() as Practitioner;
|
|
669
|
+
|
|
509
670
|
// Ako se ažurira lista klinika, proveravamo da li sve postoje
|
|
671
|
+
// i agregiramo informacije o klinikama
|
|
510
672
|
if (data.clinics) {
|
|
511
673
|
for (const clinicId of data.clinics) {
|
|
512
674
|
const clinic = await this.getClinicService().getClinic(clinicId);
|
|
@@ -514,6 +676,19 @@ export class PractitionerService extends BaseService {
|
|
|
514
676
|
throw new Error(`Clinic ${clinicId} not found`);
|
|
515
677
|
}
|
|
516
678
|
}
|
|
679
|
+
|
|
680
|
+
// If clinics changed and clinicsInfo wasn't explicitly provided, update clinicsInfo
|
|
681
|
+
if (!data.clinicsInfo) {
|
|
682
|
+
data.clinicsInfo = await this.aggregateClinicInfo(data.clinics);
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// If clinics changed, update procedures info
|
|
686
|
+
if (!data.proceduresInfo) {
|
|
687
|
+
data.proceduresInfo = await this.aggregateProcedureInfo(
|
|
688
|
+
data.clinics,
|
|
689
|
+
practitionerId
|
|
690
|
+
);
|
|
691
|
+
}
|
|
517
692
|
}
|
|
518
693
|
|
|
519
694
|
const updateData = {
|
|
@@ -523,7 +698,7 @@ export class PractitionerService extends BaseService {
|
|
|
523
698
|
|
|
524
699
|
// Validiramo kompletan objekat
|
|
525
700
|
practitionerSchema.parse({
|
|
526
|
-
...
|
|
701
|
+
...currentPractitioner,
|
|
527
702
|
...data,
|
|
528
703
|
updatedAt: Timestamp.now(),
|
|
529
704
|
});
|
|
@@ -562,9 +737,32 @@ export class PractitionerService extends BaseService {
|
|
|
562
737
|
throw new Error("Practitioner is already associated with this clinic");
|
|
563
738
|
}
|
|
564
739
|
|
|
740
|
+
// Add clinic to the list
|
|
741
|
+
const updatedClinics = [...practitioner.clinics, clinicId];
|
|
742
|
+
|
|
743
|
+
// Update clinicsInfo array
|
|
744
|
+
const clinicInfo: ClinicInfo = {
|
|
745
|
+
id: clinic.id,
|
|
746
|
+
name: clinic.name,
|
|
747
|
+
description: clinic.description || "",
|
|
748
|
+
featuredPhoto:
|
|
749
|
+
clinic.featuredPhotos && clinic.featuredPhotos.length > 0
|
|
750
|
+
? clinic.featuredPhotos[0]
|
|
751
|
+
: clinic.coverPhoto || "",
|
|
752
|
+
location: clinic.location,
|
|
753
|
+
contactInfo: clinic.contactInfo,
|
|
754
|
+
};
|
|
755
|
+
|
|
756
|
+
const updatedClinicsInfo = [...practitioner.clinicsInfo, clinicInfo];
|
|
757
|
+
|
|
758
|
+
// Update practitioner with new clinic information
|
|
565
759
|
await this.updatePractitioner(practitionerId, {
|
|
566
|
-
clinics:
|
|
760
|
+
clinics: updatedClinics,
|
|
761
|
+
clinicsInfo: updatedClinicsInfo,
|
|
567
762
|
});
|
|
763
|
+
|
|
764
|
+
// After adding clinic, update aggregated procedure info
|
|
765
|
+
await this.updateAggregatedData(practitionerId);
|
|
568
766
|
}
|
|
569
767
|
|
|
570
768
|
/**
|
|
@@ -580,8 +778,24 @@ export class PractitionerService extends BaseService {
|
|
|
580
778
|
throw new Error("Practitioner is not associated with this clinic");
|
|
581
779
|
}
|
|
582
780
|
|
|
781
|
+
// Remove clinic from the list
|
|
782
|
+
const updatedClinics = practitioner.clinics.filter((id) => id !== clinicId);
|
|
783
|
+
|
|
784
|
+
// Update clinicsInfo array
|
|
785
|
+
const updatedClinicsInfo = practitioner.clinicsInfo.filter(
|
|
786
|
+
(clinic) => clinic.id !== clinicId
|
|
787
|
+
);
|
|
788
|
+
|
|
789
|
+
// Update proceduresInfo array - remove procedures from this clinic
|
|
790
|
+
const updatedProceduresInfo = practitioner.proceduresInfo.filter(
|
|
791
|
+
(procedure) => procedure.clinicId !== clinicId
|
|
792
|
+
);
|
|
793
|
+
|
|
794
|
+
// Update practitioner with updated information
|
|
583
795
|
await this.updatePractitioner(practitionerId, {
|
|
584
|
-
clinics:
|
|
796
|
+
clinics: updatedClinics,
|
|
797
|
+
clinicsInfo: updatedClinicsInfo,
|
|
798
|
+
proceduresInfo: updatedProceduresInfo,
|
|
585
799
|
});
|
|
586
800
|
}
|
|
587
801
|
|
|
@@ -661,4 +875,401 @@ export class PractitionerService extends BaseService {
|
|
|
661
875
|
|
|
662
876
|
return updatedPractitioner;
|
|
663
877
|
}
|
|
878
|
+
|
|
879
|
+
/**
|
|
880
|
+
* Retrieves all practitioners with optional pagination and draft inclusion
|
|
881
|
+
*
|
|
882
|
+
* @param options - Search options
|
|
883
|
+
* @param options.pagination - Optional limit for number of results per page
|
|
884
|
+
* @param options.lastDoc - Optional last document for pagination
|
|
885
|
+
* @param options.includeDraftPractitioners - Whether to include draft practitioners
|
|
886
|
+
* @returns Array of practitioners and the last document for pagination
|
|
887
|
+
*/
|
|
888
|
+
async getAllPractitioners(options?: {
|
|
889
|
+
pagination?: number;
|
|
890
|
+
lastDoc?: any;
|
|
891
|
+
includeDraftPractitioners?: boolean;
|
|
892
|
+
}): Promise<{ practitioners: Practitioner[]; lastDoc: any }> {
|
|
893
|
+
try {
|
|
894
|
+
const constraints = [];
|
|
895
|
+
|
|
896
|
+
// Filter by status if not including drafts
|
|
897
|
+
if (!options?.includeDraftPractitioners) {
|
|
898
|
+
constraints.push(where("status", "==", PractitionerStatus.ACTIVE));
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
// Add ordering for consistent pagination
|
|
902
|
+
constraints.push(orderBy("basicInfo.lastName", "asc"));
|
|
903
|
+
constraints.push(orderBy("basicInfo.firstName", "asc"));
|
|
904
|
+
|
|
905
|
+
// Add pagination if specified
|
|
906
|
+
if (options?.pagination && options.pagination > 0) {
|
|
907
|
+
if (options.lastDoc) {
|
|
908
|
+
constraints.push(startAfter(options.lastDoc));
|
|
909
|
+
}
|
|
910
|
+
constraints.push(limit(options.pagination));
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
const q = query(
|
|
914
|
+
collection(this.db, PRACTITIONERS_COLLECTION),
|
|
915
|
+
...constraints
|
|
916
|
+
);
|
|
917
|
+
|
|
918
|
+
const querySnapshot = await getDocs(q);
|
|
919
|
+
|
|
920
|
+
const practitioners = querySnapshot.docs.map(
|
|
921
|
+
(doc) => doc.data() as Practitioner
|
|
922
|
+
);
|
|
923
|
+
|
|
924
|
+
// Get last document for pagination
|
|
925
|
+
const lastDoc =
|
|
926
|
+
querySnapshot.docs.length > 0
|
|
927
|
+
? querySnapshot.docs[querySnapshot.docs.length - 1]
|
|
928
|
+
: null;
|
|
929
|
+
|
|
930
|
+
return {
|
|
931
|
+
practitioners,
|
|
932
|
+
lastDoc,
|
|
933
|
+
};
|
|
934
|
+
} catch (error) {
|
|
935
|
+
console.error(
|
|
936
|
+
"[PRACTITIONER_SERVICE] Error getting all practitioners:",
|
|
937
|
+
error
|
|
938
|
+
);
|
|
939
|
+
throw error;
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
/**
|
|
944
|
+
* Searches and filters practitioners based on multiple criteria
|
|
945
|
+
*
|
|
946
|
+
* @param filters - Various filters to apply
|
|
947
|
+
* @param filters.nameSearch - Optional search text for first/last name
|
|
948
|
+
* @param filters.certifications - Optional array of certifications to filter by
|
|
949
|
+
* @param filters.specialties - Optional array of specialties to filter by
|
|
950
|
+
* @param filters.procedureFamily - Optional procedure family practitioners provide
|
|
951
|
+
* @param filters.procedureCategory - Optional procedure category practitioners provide
|
|
952
|
+
* @param filters.procedureSubcategory - Optional procedure subcategory practitioners provide
|
|
953
|
+
* @param filters.procedureTechnology - Optional procedure technology practitioners provide
|
|
954
|
+
* @param filters.location - Optional location for distance-based search
|
|
955
|
+
* @param filters.radiusInKm - Optional radius in kilometers (required if location is provided)
|
|
956
|
+
* @param filters.minRating - Optional minimum rating (0-5)
|
|
957
|
+
* @param filters.maxRating - Optional maximum rating (0-5)
|
|
958
|
+
* @param filters.pagination - Optional number of results per page
|
|
959
|
+
* @param filters.lastDoc - Optional last document for pagination
|
|
960
|
+
* @param filters.includeDraftPractitioners - Whether to include draft practitioners
|
|
961
|
+
* @returns Filtered practitioners and the last document for pagination
|
|
962
|
+
*/
|
|
963
|
+
async getPractitionersByFilters(filters: {
|
|
964
|
+
nameSearch?: string;
|
|
965
|
+
certifications?: string[];
|
|
966
|
+
specialties?: CertificationSpecialty[];
|
|
967
|
+
procedureFamily?: string;
|
|
968
|
+
procedureCategory?: string;
|
|
969
|
+
procedureSubcategory?: string;
|
|
970
|
+
procedureTechnology?: string;
|
|
971
|
+
location?: { latitude: number; longitude: number };
|
|
972
|
+
radiusInKm?: number;
|
|
973
|
+
minRating?: number;
|
|
974
|
+
maxRating?: number;
|
|
975
|
+
pagination?: number;
|
|
976
|
+
lastDoc?: any;
|
|
977
|
+
includeDraftPractitioners?: boolean;
|
|
978
|
+
}): Promise<{ practitioners: Practitioner[]; lastDoc: any }> {
|
|
979
|
+
try {
|
|
980
|
+
console.log(
|
|
981
|
+
"[PRACTITIONER_SERVICE] Starting practitioner filtering with criteria:",
|
|
982
|
+
filters
|
|
983
|
+
);
|
|
984
|
+
|
|
985
|
+
const constraints = [];
|
|
986
|
+
|
|
987
|
+
// Filter by status if not including drafts
|
|
988
|
+
if (!filters.includeDraftPractitioners) {
|
|
989
|
+
constraints.push(where("status", "==", PractitionerStatus.ACTIVE));
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
// Filter by active status
|
|
993
|
+
constraints.push(where("isActive", "==", true));
|
|
994
|
+
|
|
995
|
+
// Add certifications filter if specified
|
|
996
|
+
if (filters.certifications && filters.certifications.length > 0) {
|
|
997
|
+
constraints.push(
|
|
998
|
+
where(
|
|
999
|
+
"certification.certifications",
|
|
1000
|
+
"array-contains-any",
|
|
1001
|
+
filters.certifications
|
|
1002
|
+
)
|
|
1003
|
+
);
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
// Add ordering for consistent pagination
|
|
1007
|
+
constraints.push(orderBy("basicInfo.lastName", "asc"));
|
|
1008
|
+
constraints.push(orderBy("basicInfo.firstName", "asc"));
|
|
1009
|
+
|
|
1010
|
+
// Add pagination if specified
|
|
1011
|
+
if (filters.pagination && filters.pagination > 0) {
|
|
1012
|
+
if (filters.lastDoc) {
|
|
1013
|
+
constraints.push(startAfter(filters.lastDoc));
|
|
1014
|
+
}
|
|
1015
|
+
constraints.push(limit(filters.pagination));
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
// Execute the query
|
|
1019
|
+
const q = query(
|
|
1020
|
+
collection(this.db, PRACTITIONERS_COLLECTION),
|
|
1021
|
+
...constraints
|
|
1022
|
+
);
|
|
1023
|
+
const querySnapshot = await getDocs(q);
|
|
1024
|
+
|
|
1025
|
+
console.log(
|
|
1026
|
+
`[PRACTITIONER_SERVICE] Found ${querySnapshot.docs.length} practitioners with base query`
|
|
1027
|
+
);
|
|
1028
|
+
|
|
1029
|
+
// Convert docs to practitioners
|
|
1030
|
+
let practitioners = querySnapshot.docs.map((doc) => {
|
|
1031
|
+
return { ...doc.data(), id: doc.id } as Practitioner;
|
|
1032
|
+
});
|
|
1033
|
+
|
|
1034
|
+
// Get last document for pagination
|
|
1035
|
+
const lastDoc =
|
|
1036
|
+
querySnapshot.docs.length > 0
|
|
1037
|
+
? querySnapshot.docs[querySnapshot.docs.length - 1]
|
|
1038
|
+
: null;
|
|
1039
|
+
|
|
1040
|
+
// Further filter results in memory
|
|
1041
|
+
|
|
1042
|
+
// Filter by name search if specified
|
|
1043
|
+
if (filters.nameSearch && filters.nameSearch.trim() !== "") {
|
|
1044
|
+
const searchTerm = filters.nameSearch.toLowerCase().trim();
|
|
1045
|
+
practitioners = practitioners.filter((practitioner) => {
|
|
1046
|
+
const fullName =
|
|
1047
|
+
`${practitioner.basicInfo.firstName} ${practitioner.basicInfo.lastName}`.toLowerCase();
|
|
1048
|
+
return fullName.includes(searchTerm);
|
|
1049
|
+
});
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
// Filter by specialties
|
|
1053
|
+
if (filters.specialties && filters.specialties.length > 0) {
|
|
1054
|
+
practitioners = practitioners.filter((practitioner) => {
|
|
1055
|
+
return filters.specialties!.every((specialty) =>
|
|
1056
|
+
practitioner.certification.specialties.includes(specialty)
|
|
1057
|
+
);
|
|
1058
|
+
});
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
// Filter by procedure attributes using the aggregated proceduresInfo
|
|
1062
|
+
if (
|
|
1063
|
+
filters.procedureTechnology ||
|
|
1064
|
+
filters.procedureSubcategory ||
|
|
1065
|
+
filters.procedureCategory ||
|
|
1066
|
+
filters.procedureFamily
|
|
1067
|
+
) {
|
|
1068
|
+
practitioners = practitioners.filter((practitioner) => {
|
|
1069
|
+
const procedures = practitioner.proceduresInfo || [];
|
|
1070
|
+
return procedures.some((procedure: ProcedureSummaryInfo) => {
|
|
1071
|
+
// Apply hierarchical filter - most specific first
|
|
1072
|
+
if (filters.procedureTechnology) {
|
|
1073
|
+
return procedure.technologyName === filters.procedureTechnology;
|
|
1074
|
+
}
|
|
1075
|
+
if (filters.procedureSubcategory) {
|
|
1076
|
+
return procedure.subcategoryName === filters.procedureSubcategory;
|
|
1077
|
+
}
|
|
1078
|
+
if (filters.procedureCategory) {
|
|
1079
|
+
return procedure.categoryName === filters.procedureCategory;
|
|
1080
|
+
}
|
|
1081
|
+
if (filters.procedureFamily) {
|
|
1082
|
+
return procedure.family === filters.procedureFamily;
|
|
1083
|
+
}
|
|
1084
|
+
return false;
|
|
1085
|
+
});
|
|
1086
|
+
});
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
// Filter by location/distance if specified
|
|
1090
|
+
if (filters.location && filters.radiusInKm && filters.radiusInKm > 0) {
|
|
1091
|
+
const location = filters.location;
|
|
1092
|
+
const radiusInKm = filters.radiusInKm;
|
|
1093
|
+
|
|
1094
|
+
practitioners = practitioners.filter((practitioner) => {
|
|
1095
|
+
// Use the aggregated clinicsInfo to check if any clinic is within range
|
|
1096
|
+
const clinics = practitioner.clinicsInfo || [];
|
|
1097
|
+
|
|
1098
|
+
// Check if any clinic is within the specified radius
|
|
1099
|
+
return clinics.some((clinic) => {
|
|
1100
|
+
// Calculate distance
|
|
1101
|
+
const distance = distanceBetween(
|
|
1102
|
+
[location.latitude, location.longitude],
|
|
1103
|
+
[clinic.location.latitude, clinic.location.longitude]
|
|
1104
|
+
);
|
|
1105
|
+
|
|
1106
|
+
// Convert to kilometers
|
|
1107
|
+
const distanceInKm = distance / 1000;
|
|
1108
|
+
|
|
1109
|
+
// Check if within radius
|
|
1110
|
+
return distanceInKm <= radiusInKm;
|
|
1111
|
+
});
|
|
1112
|
+
});
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
// Filter by rating
|
|
1116
|
+
if (filters.minRating !== undefined) {
|
|
1117
|
+
practitioners = practitioners.filter(
|
|
1118
|
+
(p) => p.reviewInfo.averageRating >= filters.minRating!
|
|
1119
|
+
);
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
if (filters.maxRating !== undefined) {
|
|
1123
|
+
practitioners = practitioners.filter(
|
|
1124
|
+
(p) => p.reviewInfo.averageRating <= filters.maxRating!
|
|
1125
|
+
);
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
console.log(
|
|
1129
|
+
`[PRACTITIONER_SERVICE] Filtered to ${practitioners.length} practitioners`
|
|
1130
|
+
);
|
|
1131
|
+
|
|
1132
|
+
// Apply pagination after all filters have been applied
|
|
1133
|
+
// This is a secondary pagination for in-memory filtered results
|
|
1134
|
+
if (filters.pagination && filters.pagination > 0) {
|
|
1135
|
+
practitioners = practitioners.slice(0, filters.pagination);
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
return {
|
|
1139
|
+
practitioners,
|
|
1140
|
+
lastDoc,
|
|
1141
|
+
};
|
|
1142
|
+
} catch (error) {
|
|
1143
|
+
console.error(
|
|
1144
|
+
"[PRACTITIONER_SERVICE] Error filtering practitioners:",
|
|
1145
|
+
error
|
|
1146
|
+
);
|
|
1147
|
+
throw error;
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
// --- Helper Functions ---
|
|
1152
|
+
|
|
1153
|
+
/**
|
|
1154
|
+
* Aggregates essential clinic information for embedding in Practitioner.
|
|
1155
|
+
* @param clinicIds Array of clinic IDs the practitioner works at
|
|
1156
|
+
* @returns Array of ClinicInfo objects
|
|
1157
|
+
*/
|
|
1158
|
+
private async _aggregateClinicInfoForPractitioner(
|
|
1159
|
+
clinicIds: string[]
|
|
1160
|
+
): Promise<ClinicInfo[]> {
|
|
1161
|
+
const clinicsInfo: ClinicInfo[] = [];
|
|
1162
|
+
const clinicService = this.getClinicService(); // Get service instance
|
|
1163
|
+
|
|
1164
|
+
for (const clinicId of clinicIds) {
|
|
1165
|
+
try {
|
|
1166
|
+
const clinic = await clinicService.getClinic(clinicId);
|
|
1167
|
+
if (!clinic) {
|
|
1168
|
+
console.warn(
|
|
1169
|
+
`Clinic ${clinicId} not found during practitioner aggregation.`
|
|
1170
|
+
);
|
|
1171
|
+
continue;
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
clinicsInfo.push({
|
|
1175
|
+
id: clinic.id,
|
|
1176
|
+
featuredPhoto:
|
|
1177
|
+
clinic.featuredPhotos && clinic.featuredPhotos.length > 0
|
|
1178
|
+
? clinic.featuredPhotos[0]
|
|
1179
|
+
: clinic.coverPhoto || "",
|
|
1180
|
+
name: clinic.name,
|
|
1181
|
+
description: clinic.description || "",
|
|
1182
|
+
location: clinic.location,
|
|
1183
|
+
contactInfo: clinic.contactInfo,
|
|
1184
|
+
});
|
|
1185
|
+
} catch (error) {
|
|
1186
|
+
console.error(
|
|
1187
|
+
`Error fetching clinic ${clinicId} for practitioner aggregation:`,
|
|
1188
|
+
error
|
|
1189
|
+
);
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
return clinicsInfo;
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
/**
|
|
1196
|
+
* Creates an aggregated DoctorInfo object from Practitioner data.
|
|
1197
|
+
* @param practitioner The practitioner object
|
|
1198
|
+
* @returns DoctorInfo object
|
|
1199
|
+
*/
|
|
1200
|
+
private _createDoctorInfoForClinic(practitioner: Practitioner): DoctorInfo {
|
|
1201
|
+
return {
|
|
1202
|
+
id: practitioner.id,
|
|
1203
|
+
name: `${practitioner.basicInfo.firstName} ${practitioner.basicInfo.lastName}`,
|
|
1204
|
+
description: practitioner.basicInfo.bio || "",
|
|
1205
|
+
photo: practitioner.basicInfo.profileImageUrl || "",
|
|
1206
|
+
rating: practitioner.reviewInfo?.averageRating || 0,
|
|
1207
|
+
services: practitioner.procedures || [], // List of procedure IDs
|
|
1208
|
+
};
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
/**
|
|
1212
|
+
* Updates the DoctorInfo within the doctorsInfo array for multiple clinics.
|
|
1213
|
+
* @param clinicIds IDs of clinics to update
|
|
1214
|
+
* @param doctorInfo The updated DoctorInfo object
|
|
1215
|
+
*/
|
|
1216
|
+
private async _updateDoctorInfoInClinics(
|
|
1217
|
+
clinicIds: string[],
|
|
1218
|
+
doctorInfo: DoctorInfo
|
|
1219
|
+
): Promise<void> {
|
|
1220
|
+
const batch = writeBatch(this.db);
|
|
1221
|
+
const practitionerId = doctorInfo.id;
|
|
1222
|
+
|
|
1223
|
+
for (const clinicId of clinicIds) {
|
|
1224
|
+
const clinicRef = doc(this.db, CLINICS_COLLECTION, clinicId);
|
|
1225
|
+
// First, remove the old doctor info based on ID
|
|
1226
|
+
batch.update(clinicRef, {
|
|
1227
|
+
doctorsInfo: arrayRemove(...[{ id: practitionerId }]),
|
|
1228
|
+
updatedAt: serverTimestamp(),
|
|
1229
|
+
});
|
|
1230
|
+
// Then, add the updated doctor info
|
|
1231
|
+
batch.update(clinicRef, {
|
|
1232
|
+
doctorsInfo: arrayUnion(doctorInfo),
|
|
1233
|
+
updatedAt: serverTimestamp(),
|
|
1234
|
+
});
|
|
1235
|
+
}
|
|
1236
|
+
try {
|
|
1237
|
+
await batch.commit();
|
|
1238
|
+
} catch (error) {
|
|
1239
|
+
console.error(
|
|
1240
|
+
`Error updating doctor info in clinics for practitioner ${practitionerId}:`,
|
|
1241
|
+
error
|
|
1242
|
+
);
|
|
1243
|
+
// Decide on error handling: throw, retry, log?
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
/**
|
|
1248
|
+
* Removes DoctorInfo from the doctorsInfo array for multiple clinics.
|
|
1249
|
+
* @param clinicIds IDs of clinics to update
|
|
1250
|
+
* @param practitionerId ID of the practitioner whose info should be removed
|
|
1251
|
+
*/
|
|
1252
|
+
private async _removeDoctorInfoFromClinics(
|
|
1253
|
+
clinicIds: string[],
|
|
1254
|
+
practitionerId: string
|
|
1255
|
+
): Promise<void> {
|
|
1256
|
+
const batch = writeBatch(this.db);
|
|
1257
|
+
|
|
1258
|
+
for (const clinicId of clinicIds) {
|
|
1259
|
+
const clinicRef = doc(this.db, CLINICS_COLLECTION, clinicId);
|
|
1260
|
+
batch.update(clinicRef, {
|
|
1261
|
+
doctors: arrayRemove(practitionerId), // Also remove from simple ID list
|
|
1262
|
+
doctorsInfo: arrayRemove(...[{ id: practitionerId }]), // Remove by ID matcher
|
|
1263
|
+
updatedAt: serverTimestamp(),
|
|
1264
|
+
});
|
|
1265
|
+
}
|
|
1266
|
+
try {
|
|
1267
|
+
await batch.commit();
|
|
1268
|
+
} catch (error) {
|
|
1269
|
+
console.error(
|
|
1270
|
+
`Error removing doctor info from clinics for practitioner ${practitionerId}:`,
|
|
1271
|
+
error
|
|
1272
|
+
);
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
664
1275
|
}
|