@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
|
@@ -0,0 +1,842 @@
|
|
|
1
|
+
import {
|
|
2
|
+
collection,
|
|
3
|
+
doc,
|
|
4
|
+
getDoc,
|
|
5
|
+
getDocs,
|
|
6
|
+
query,
|
|
7
|
+
where,
|
|
8
|
+
updateDoc,
|
|
9
|
+
setDoc,
|
|
10
|
+
deleteDoc,
|
|
11
|
+
Timestamp,
|
|
12
|
+
serverTimestamp,
|
|
13
|
+
DocumentData,
|
|
14
|
+
writeBatch,
|
|
15
|
+
} from "firebase/firestore";
|
|
16
|
+
import { BaseService } from "../base.service";
|
|
17
|
+
import {
|
|
18
|
+
Review,
|
|
19
|
+
ClinicReview,
|
|
20
|
+
PractitionerReview,
|
|
21
|
+
ProcedureReview,
|
|
22
|
+
REVIEWS_COLLECTION,
|
|
23
|
+
ClinicReviewInfo,
|
|
24
|
+
PractitionerReviewInfo,
|
|
25
|
+
ProcedureReviewInfo,
|
|
26
|
+
} from "../../types/reviews";
|
|
27
|
+
import {
|
|
28
|
+
createReviewSchema,
|
|
29
|
+
reviewSchema,
|
|
30
|
+
} from "../../validations/reviews.schema";
|
|
31
|
+
import { z } from "zod";
|
|
32
|
+
import { Auth } from "firebase/auth";
|
|
33
|
+
import { Firestore } from "firebase/firestore";
|
|
34
|
+
import { FirebaseApp } from "firebase/app";
|
|
35
|
+
import { CLINICS_COLLECTION } from "../../types/clinic";
|
|
36
|
+
import { PRACTITIONERS_COLLECTION } from "../../types/practitioner";
|
|
37
|
+
import { PROCEDURES_COLLECTION } from "../../types/procedure";
|
|
38
|
+
|
|
39
|
+
export class ReviewService extends BaseService {
|
|
40
|
+
constructor(db: Firestore, auth: Auth, app: FirebaseApp) {
|
|
41
|
+
super(db, auth, app);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Creates a new review and updates related entities
|
|
46
|
+
* @param data - The review data to create
|
|
47
|
+
* @param appointmentId - ID of the completed appointment
|
|
48
|
+
* @returns The created review
|
|
49
|
+
*/
|
|
50
|
+
async createReview(
|
|
51
|
+
data: Omit<
|
|
52
|
+
Review,
|
|
53
|
+
"id" | "createdAt" | "updatedAt" | "appointmentId" | "overallRating"
|
|
54
|
+
>,
|
|
55
|
+
appointmentId: string
|
|
56
|
+
): Promise<Review> {
|
|
57
|
+
try {
|
|
58
|
+
// Validate input data
|
|
59
|
+
const validatedData = createReviewSchema.parse(data);
|
|
60
|
+
|
|
61
|
+
// Calculate overall rating based on all provided reviews
|
|
62
|
+
const ratings: number[] = [];
|
|
63
|
+
|
|
64
|
+
if (data.clinicReview) {
|
|
65
|
+
const clinicRatings = [
|
|
66
|
+
data.clinicReview.cleanliness,
|
|
67
|
+
data.clinicReview.facilities,
|
|
68
|
+
data.clinicReview.staffFriendliness,
|
|
69
|
+
data.clinicReview.waitingTime,
|
|
70
|
+
data.clinicReview.accessibility,
|
|
71
|
+
];
|
|
72
|
+
const clinicAverage = this.calculateAverage(clinicRatings);
|
|
73
|
+
data.clinicReview.overallRating = clinicAverage;
|
|
74
|
+
ratings.push(clinicAverage);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (data.practitionerReview) {
|
|
78
|
+
const practitionerRatings = [
|
|
79
|
+
data.practitionerReview.knowledgeAndExpertise,
|
|
80
|
+
data.practitionerReview.communicationSkills,
|
|
81
|
+
data.practitionerReview.bedSideManner,
|
|
82
|
+
data.practitionerReview.thoroughness,
|
|
83
|
+
data.practitionerReview.trustworthiness,
|
|
84
|
+
];
|
|
85
|
+
const practitionerAverage = this.calculateAverage(practitionerRatings);
|
|
86
|
+
data.practitionerReview.overallRating = practitionerAverage;
|
|
87
|
+
ratings.push(practitionerAverage);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (data.procedureReview) {
|
|
91
|
+
const procedureRatings = [
|
|
92
|
+
data.procedureReview.effectivenessOfTreatment,
|
|
93
|
+
data.procedureReview.outcomeExplanation,
|
|
94
|
+
data.procedureReview.painManagement,
|
|
95
|
+
data.procedureReview.followUpCare,
|
|
96
|
+
data.procedureReview.valueForMoney,
|
|
97
|
+
];
|
|
98
|
+
const procedureAverage = this.calculateAverage(procedureRatings);
|
|
99
|
+
data.procedureReview.overallRating = procedureAverage;
|
|
100
|
+
ratings.push(procedureAverage);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const overallRating = this.calculateAverage(ratings);
|
|
104
|
+
|
|
105
|
+
// Generate a unique ID for the main review
|
|
106
|
+
const reviewId = this.generateId();
|
|
107
|
+
|
|
108
|
+
// Add IDs to each review component
|
|
109
|
+
if (data.clinicReview) {
|
|
110
|
+
data.clinicReview.id = this.generateId();
|
|
111
|
+
data.clinicReview.fullReviewId = reviewId;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (data.practitionerReview) {
|
|
115
|
+
data.practitionerReview.id = this.generateId();
|
|
116
|
+
data.practitionerReview.fullReviewId = reviewId;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (data.procedureReview) {
|
|
120
|
+
data.procedureReview.id = this.generateId();
|
|
121
|
+
data.procedureReview.fullReviewId = reviewId;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Create the review object with timestamps
|
|
125
|
+
const now = new Date();
|
|
126
|
+
const review: Review = {
|
|
127
|
+
id: reviewId,
|
|
128
|
+
appointmentId,
|
|
129
|
+
patientId: data.patientId,
|
|
130
|
+
clinicReview: data.clinicReview,
|
|
131
|
+
practitionerReview: data.practitionerReview,
|
|
132
|
+
procedureReview: data.procedureReview,
|
|
133
|
+
overallComment: data.overallComment,
|
|
134
|
+
overallRating,
|
|
135
|
+
createdAt: now,
|
|
136
|
+
updatedAt: now,
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
// Validate complete review object
|
|
140
|
+
reviewSchema.parse(review);
|
|
141
|
+
|
|
142
|
+
// Save the review to Firestore
|
|
143
|
+
const docRef = doc(this.db, REVIEWS_COLLECTION, reviewId);
|
|
144
|
+
await setDoc(docRef, {
|
|
145
|
+
...review,
|
|
146
|
+
createdAt: serverTimestamp(),
|
|
147
|
+
updatedAt: serverTimestamp(),
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// Update related entities
|
|
151
|
+
const updatePromises: Promise<any>[] = [];
|
|
152
|
+
|
|
153
|
+
// Update clinic if clinic review exists
|
|
154
|
+
if (data.clinicReview) {
|
|
155
|
+
updatePromises.push(
|
|
156
|
+
this.updateClinicReviewInfo(data.clinicReview.clinicId)
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Update practitioner if practitioner review exists
|
|
161
|
+
if (data.practitionerReview) {
|
|
162
|
+
updatePromises.push(
|
|
163
|
+
this.updatePractitionerReviewInfo(
|
|
164
|
+
data.practitionerReview.practitionerId
|
|
165
|
+
)
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Update procedure if procedure review exists
|
|
170
|
+
if (data.procedureReview) {
|
|
171
|
+
updatePromises.push(
|
|
172
|
+
this.updateProcedureReviewInfo(data.procedureReview.procedureId)
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Wait for all updates to complete
|
|
177
|
+
await Promise.all(updatePromises);
|
|
178
|
+
|
|
179
|
+
return review;
|
|
180
|
+
} catch (error) {
|
|
181
|
+
if (error instanceof z.ZodError) {
|
|
182
|
+
throw new Error(`Invalid review data: ${error.message}`);
|
|
183
|
+
}
|
|
184
|
+
throw error;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Gets a review by ID
|
|
190
|
+
* @param reviewId The ID of the review to get
|
|
191
|
+
* @returns The review if found, null otherwise
|
|
192
|
+
*/
|
|
193
|
+
async getReview(reviewId: string): Promise<Review | null> {
|
|
194
|
+
const docRef = doc(this.db, REVIEWS_COLLECTION, reviewId);
|
|
195
|
+
const docSnap = await getDoc(docRef);
|
|
196
|
+
|
|
197
|
+
if (!docSnap.exists()) {
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return docSnap.data() as Review;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Gets all reviews for a specific patient
|
|
206
|
+
* @param patientId The ID of the patient
|
|
207
|
+
* @returns Array of reviews for the patient
|
|
208
|
+
*/
|
|
209
|
+
async getReviewsByPatient(patientId: string): Promise<Review[]> {
|
|
210
|
+
const q = query(
|
|
211
|
+
collection(this.db, REVIEWS_COLLECTION),
|
|
212
|
+
where("patientId", "==", patientId)
|
|
213
|
+
);
|
|
214
|
+
const snapshot = await getDocs(q);
|
|
215
|
+
return snapshot.docs.map((doc) => doc.data() as Review);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Gets all reviews for a specific clinic
|
|
220
|
+
* @param clinicId The ID of the clinic
|
|
221
|
+
* @returns Array of reviews containing clinic reviews
|
|
222
|
+
*/
|
|
223
|
+
async getReviewsByClinic(clinicId: string): Promise<Review[]> {
|
|
224
|
+
const q = query(
|
|
225
|
+
collection(this.db, REVIEWS_COLLECTION),
|
|
226
|
+
where("clinicReview.clinicId", "==", clinicId)
|
|
227
|
+
);
|
|
228
|
+
const snapshot = await getDocs(q);
|
|
229
|
+
return snapshot.docs.map((doc) => doc.data() as Review);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Gets all reviews for a specific practitioner
|
|
234
|
+
* @param practitionerId The ID of the practitioner
|
|
235
|
+
* @returns Array of reviews containing practitioner reviews
|
|
236
|
+
*/
|
|
237
|
+
async getReviewsByPractitioner(practitionerId: string): Promise<Review[]> {
|
|
238
|
+
const q = query(
|
|
239
|
+
collection(this.db, REVIEWS_COLLECTION),
|
|
240
|
+
where("practitionerReview.practitionerId", "==", practitionerId)
|
|
241
|
+
);
|
|
242
|
+
const snapshot = await getDocs(q);
|
|
243
|
+
return snapshot.docs.map((doc) => doc.data() as Review);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Gets all reviews for a specific procedure
|
|
248
|
+
* @param procedureId The ID of the procedure
|
|
249
|
+
* @returns Array of reviews containing procedure reviews
|
|
250
|
+
*/
|
|
251
|
+
async getReviewsByProcedure(procedureId: string): Promise<Review[]> {
|
|
252
|
+
const q = query(
|
|
253
|
+
collection(this.db, REVIEWS_COLLECTION),
|
|
254
|
+
where("procedureReview.procedureId", "==", procedureId)
|
|
255
|
+
);
|
|
256
|
+
const snapshot = await getDocs(q);
|
|
257
|
+
return snapshot.docs.map((doc) => doc.data() as Review);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Gets all reviews for a specific appointment
|
|
262
|
+
* @param appointmentId The ID of the appointment
|
|
263
|
+
* @returns The review for the appointment if found, null otherwise
|
|
264
|
+
*/
|
|
265
|
+
async getReviewByAppointment(appointmentId: string): Promise<Review | null> {
|
|
266
|
+
const q = query(
|
|
267
|
+
collection(this.db, REVIEWS_COLLECTION),
|
|
268
|
+
where("appointmentId", "==", appointmentId)
|
|
269
|
+
);
|
|
270
|
+
const snapshot = await getDocs(q);
|
|
271
|
+
|
|
272
|
+
if (snapshot.empty) {
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return snapshot.docs[0].data() as Review;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Deletes a review and updates related entities
|
|
281
|
+
* @param reviewId The ID of the review to delete
|
|
282
|
+
*/
|
|
283
|
+
async deleteReview(reviewId: string): Promise<void> {
|
|
284
|
+
const review = await this.getReview(reviewId);
|
|
285
|
+
if (!review) {
|
|
286
|
+
throw new Error(`Review with ID ${reviewId} not found`);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Delete the review
|
|
290
|
+
await deleteDoc(doc(this.db, REVIEWS_COLLECTION, reviewId));
|
|
291
|
+
|
|
292
|
+
// Update related entities after deletion
|
|
293
|
+
const updatePromises: Promise<any>[] = [];
|
|
294
|
+
|
|
295
|
+
if (review.clinicReview) {
|
|
296
|
+
updatePromises.push(
|
|
297
|
+
this.updateClinicReviewInfo(
|
|
298
|
+
review.clinicReview.clinicId,
|
|
299
|
+
review.clinicReview,
|
|
300
|
+
true
|
|
301
|
+
)
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (review.practitionerReview) {
|
|
306
|
+
updatePromises.push(
|
|
307
|
+
this.updatePractitionerReviewInfo(
|
|
308
|
+
review.practitionerReview.practitionerId,
|
|
309
|
+
review.practitionerReview,
|
|
310
|
+
true
|
|
311
|
+
)
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (review.procedureReview) {
|
|
316
|
+
updatePromises.push(
|
|
317
|
+
this.updateProcedureReviewInfo(
|
|
318
|
+
review.procedureReview.procedureId,
|
|
319
|
+
review.procedureReview,
|
|
320
|
+
true
|
|
321
|
+
)
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Wait for all updates to complete
|
|
326
|
+
await Promise.all(updatePromises);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Updates the review info for a clinic
|
|
331
|
+
* @param clinicId The ID of the clinic to update
|
|
332
|
+
* @param newReview Optional new review being added or removed
|
|
333
|
+
* @param isRemoval Whether this update is for a review removal
|
|
334
|
+
* @returns The updated clinic review info
|
|
335
|
+
*/
|
|
336
|
+
async updateClinicReviewInfo(
|
|
337
|
+
clinicId: string,
|
|
338
|
+
newReview?: ClinicReview,
|
|
339
|
+
isRemoval: boolean = false
|
|
340
|
+
): Promise<ClinicReviewInfo> {
|
|
341
|
+
// Get the current clinic document
|
|
342
|
+
const clinicDoc = await getDoc(doc(this.db, CLINICS_COLLECTION, clinicId));
|
|
343
|
+
|
|
344
|
+
if (!clinicDoc.exists()) {
|
|
345
|
+
throw new Error(`Clinic with ID ${clinicId} not found`);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const clinicData = clinicDoc.data();
|
|
349
|
+
const currentReviewInfo = (clinicData.reviewInfo as ClinicReviewInfo) || {
|
|
350
|
+
totalReviews: 0,
|
|
351
|
+
averageRating: 0,
|
|
352
|
+
cleanliness: 0,
|
|
353
|
+
facilities: 0,
|
|
354
|
+
staffFriendliness: 0,
|
|
355
|
+
waitingTime: 0,
|
|
356
|
+
accessibility: 0,
|
|
357
|
+
recommendationPercentage: 0,
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
// If we have no reviews and aren't adding a new one, return default values
|
|
361
|
+
if (currentReviewInfo.totalReviews === 0 && !newReview) {
|
|
362
|
+
await updateDoc(doc(this.db, CLINICS_COLLECTION, clinicId), {
|
|
363
|
+
reviewInfo: currentReviewInfo,
|
|
364
|
+
updatedAt: serverTimestamp(),
|
|
365
|
+
});
|
|
366
|
+
return currentReviewInfo;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
let updatedReviewInfo: ClinicReviewInfo;
|
|
370
|
+
|
|
371
|
+
if (newReview) {
|
|
372
|
+
// Calculate new values based on existing averages and the new review
|
|
373
|
+
const oldTotal = currentReviewInfo.totalReviews;
|
|
374
|
+
const newTotal = isRemoval ? oldTotal - 1 : oldTotal + 1;
|
|
375
|
+
|
|
376
|
+
// If removing the last review, set all values to 0
|
|
377
|
+
if (newTotal === 0) {
|
|
378
|
+
updatedReviewInfo = {
|
|
379
|
+
totalReviews: 0,
|
|
380
|
+
averageRating: 0,
|
|
381
|
+
cleanliness: 0,
|
|
382
|
+
facilities: 0,
|
|
383
|
+
staffFriendliness: 0,
|
|
384
|
+
waitingTime: 0,
|
|
385
|
+
accessibility: 0,
|
|
386
|
+
recommendationPercentage: 0,
|
|
387
|
+
};
|
|
388
|
+
} else {
|
|
389
|
+
// Calculate new averages
|
|
390
|
+
const updateAverage = (
|
|
391
|
+
currentAvg: number,
|
|
392
|
+
newValue: number
|
|
393
|
+
): number => {
|
|
394
|
+
const currentSum = currentAvg * oldTotal;
|
|
395
|
+
const newSum = isRemoval
|
|
396
|
+
? currentSum - newValue
|
|
397
|
+
: currentSum + newValue;
|
|
398
|
+
const newAvg = newSum / newTotal;
|
|
399
|
+
return Math.round(newAvg * 10) / 10; // Round to 1 decimal
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
// Update recommendation percentage
|
|
403
|
+
const currentRecommendations =
|
|
404
|
+
(currentReviewInfo.recommendationPercentage / 100) * oldTotal;
|
|
405
|
+
const newRecommendations = isRemoval
|
|
406
|
+
? newReview.wouldRecommend
|
|
407
|
+
? currentRecommendations - 1
|
|
408
|
+
: currentRecommendations
|
|
409
|
+
: newReview.wouldRecommend
|
|
410
|
+
? currentRecommendations + 1
|
|
411
|
+
: currentRecommendations;
|
|
412
|
+
const newRecommendationPercentage =
|
|
413
|
+
(newRecommendations / newTotal) * 100;
|
|
414
|
+
|
|
415
|
+
updatedReviewInfo = {
|
|
416
|
+
totalReviews: newTotal,
|
|
417
|
+
averageRating: updateAverage(
|
|
418
|
+
currentReviewInfo.averageRating,
|
|
419
|
+
newReview.overallRating
|
|
420
|
+
),
|
|
421
|
+
cleanliness: updateAverage(
|
|
422
|
+
currentReviewInfo.cleanliness,
|
|
423
|
+
newReview.cleanliness
|
|
424
|
+
),
|
|
425
|
+
facilities: updateAverage(
|
|
426
|
+
currentReviewInfo.facilities,
|
|
427
|
+
newReview.facilities
|
|
428
|
+
),
|
|
429
|
+
staffFriendliness: updateAverage(
|
|
430
|
+
currentReviewInfo.staffFriendliness,
|
|
431
|
+
newReview.staffFriendliness
|
|
432
|
+
),
|
|
433
|
+
waitingTime: updateAverage(
|
|
434
|
+
currentReviewInfo.waitingTime,
|
|
435
|
+
newReview.waitingTime
|
|
436
|
+
),
|
|
437
|
+
accessibility: updateAverage(
|
|
438
|
+
currentReviewInfo.accessibility,
|
|
439
|
+
newReview.accessibility
|
|
440
|
+
),
|
|
441
|
+
recommendationPercentage:
|
|
442
|
+
Math.round(newRecommendationPercentage * 10) / 10,
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
} else {
|
|
446
|
+
// If no new review provided, keep the current info
|
|
447
|
+
updatedReviewInfo = { ...currentReviewInfo };
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// Update the clinic with the new review info
|
|
451
|
+
await updateDoc(doc(this.db, CLINICS_COLLECTION, clinicId), {
|
|
452
|
+
reviewInfo: updatedReviewInfo,
|
|
453
|
+
updatedAt: serverTimestamp(),
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
return updatedReviewInfo;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Updates the review info for a practitioner
|
|
461
|
+
* @param practitionerId The ID of the practitioner to update
|
|
462
|
+
* @param newReview Optional new review being added or removed
|
|
463
|
+
* @param isRemoval Whether this update is for a review removal
|
|
464
|
+
* @returns The updated practitioner review info
|
|
465
|
+
*/
|
|
466
|
+
async updatePractitionerReviewInfo(
|
|
467
|
+
practitionerId: string,
|
|
468
|
+
newReview?: PractitionerReview,
|
|
469
|
+
isRemoval: boolean = false
|
|
470
|
+
): Promise<PractitionerReviewInfo> {
|
|
471
|
+
// Get the current practitioner document
|
|
472
|
+
const practitionerDoc = await getDoc(
|
|
473
|
+
doc(this.db, PRACTITIONERS_COLLECTION, practitionerId)
|
|
474
|
+
);
|
|
475
|
+
|
|
476
|
+
if (!practitionerDoc.exists()) {
|
|
477
|
+
throw new Error(`Practitioner with ID ${practitionerId} not found`);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const practitionerData = practitionerDoc.data();
|
|
481
|
+
const currentReviewInfo =
|
|
482
|
+
(practitionerData.reviewInfo as PractitionerReviewInfo) || {
|
|
483
|
+
totalReviews: 0,
|
|
484
|
+
averageRating: 0,
|
|
485
|
+
knowledgeAndExpertise: 0,
|
|
486
|
+
communicationSkills: 0,
|
|
487
|
+
bedSideManner: 0,
|
|
488
|
+
thoroughness: 0,
|
|
489
|
+
trustworthiness: 0,
|
|
490
|
+
recommendationPercentage: 0,
|
|
491
|
+
};
|
|
492
|
+
|
|
493
|
+
// If we have no reviews and aren't adding a new one, return default values
|
|
494
|
+
if (currentReviewInfo.totalReviews === 0 && !newReview) {
|
|
495
|
+
await updateDoc(doc(this.db, PRACTITIONERS_COLLECTION, practitionerId), {
|
|
496
|
+
reviewInfo: currentReviewInfo,
|
|
497
|
+
updatedAt: serverTimestamp(),
|
|
498
|
+
});
|
|
499
|
+
return currentReviewInfo;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
let updatedReviewInfo: PractitionerReviewInfo;
|
|
503
|
+
|
|
504
|
+
if (newReview) {
|
|
505
|
+
// Calculate new values based on existing averages and the new review
|
|
506
|
+
const oldTotal = currentReviewInfo.totalReviews;
|
|
507
|
+
const newTotal = isRemoval ? oldTotal - 1 : oldTotal + 1;
|
|
508
|
+
|
|
509
|
+
// If removing the last review, set all values to 0
|
|
510
|
+
if (newTotal === 0) {
|
|
511
|
+
updatedReviewInfo = {
|
|
512
|
+
totalReviews: 0,
|
|
513
|
+
averageRating: 0,
|
|
514
|
+
knowledgeAndExpertise: 0,
|
|
515
|
+
communicationSkills: 0,
|
|
516
|
+
bedSideManner: 0,
|
|
517
|
+
thoroughness: 0,
|
|
518
|
+
trustworthiness: 0,
|
|
519
|
+
recommendationPercentage: 0,
|
|
520
|
+
};
|
|
521
|
+
} else {
|
|
522
|
+
// Calculate new averages
|
|
523
|
+
const updateAverage = (
|
|
524
|
+
currentAvg: number,
|
|
525
|
+
newValue: number
|
|
526
|
+
): number => {
|
|
527
|
+
const currentSum = currentAvg * oldTotal;
|
|
528
|
+
const newSum = isRemoval
|
|
529
|
+
? currentSum - newValue
|
|
530
|
+
: currentSum + newValue;
|
|
531
|
+
const newAvg = newSum / newTotal;
|
|
532
|
+
return Math.round(newAvg * 10) / 10; // Round to 1 decimal
|
|
533
|
+
};
|
|
534
|
+
|
|
535
|
+
// Update recommendation percentage
|
|
536
|
+
const currentRecommendations =
|
|
537
|
+
(currentReviewInfo.recommendationPercentage / 100) * oldTotal;
|
|
538
|
+
const newRecommendations = isRemoval
|
|
539
|
+
? newReview.wouldRecommend
|
|
540
|
+
? currentRecommendations - 1
|
|
541
|
+
: currentRecommendations
|
|
542
|
+
: newReview.wouldRecommend
|
|
543
|
+
? currentRecommendations + 1
|
|
544
|
+
: currentRecommendations;
|
|
545
|
+
const newRecommendationPercentage =
|
|
546
|
+
(newRecommendations / newTotal) * 100;
|
|
547
|
+
|
|
548
|
+
updatedReviewInfo = {
|
|
549
|
+
totalReviews: newTotal,
|
|
550
|
+
averageRating: updateAverage(
|
|
551
|
+
currentReviewInfo.averageRating,
|
|
552
|
+
newReview.overallRating
|
|
553
|
+
),
|
|
554
|
+
knowledgeAndExpertise: updateAverage(
|
|
555
|
+
currentReviewInfo.knowledgeAndExpertise,
|
|
556
|
+
newReview.knowledgeAndExpertise
|
|
557
|
+
),
|
|
558
|
+
communicationSkills: updateAverage(
|
|
559
|
+
currentReviewInfo.communicationSkills,
|
|
560
|
+
newReview.communicationSkills
|
|
561
|
+
),
|
|
562
|
+
bedSideManner: updateAverage(
|
|
563
|
+
currentReviewInfo.bedSideManner,
|
|
564
|
+
newReview.bedSideManner
|
|
565
|
+
),
|
|
566
|
+
thoroughness: updateAverage(
|
|
567
|
+
currentReviewInfo.thoroughness,
|
|
568
|
+
newReview.thoroughness
|
|
569
|
+
),
|
|
570
|
+
trustworthiness: updateAverage(
|
|
571
|
+
currentReviewInfo.trustworthiness,
|
|
572
|
+
newReview.trustworthiness
|
|
573
|
+
),
|
|
574
|
+
recommendationPercentage:
|
|
575
|
+
Math.round(newRecommendationPercentage * 10) / 10,
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
} else {
|
|
579
|
+
// If no new review provided, keep the current info
|
|
580
|
+
updatedReviewInfo = { ...currentReviewInfo };
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// Update the practitioner with the new review info
|
|
584
|
+
await updateDoc(doc(this.db, PRACTITIONERS_COLLECTION, practitionerId), {
|
|
585
|
+
reviewInfo: updatedReviewInfo,
|
|
586
|
+
updatedAt: serverTimestamp(),
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
// Also update doctor info in procedures with the new rating
|
|
590
|
+
await this.updateDoctorInfoInProcedures(
|
|
591
|
+
practitionerId,
|
|
592
|
+
updatedReviewInfo.averageRating
|
|
593
|
+
);
|
|
594
|
+
|
|
595
|
+
return updatedReviewInfo;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* Updates the review info for a procedure
|
|
600
|
+
* @param procedureId The ID of the procedure to update
|
|
601
|
+
* @param newReview Optional new review being added or removed
|
|
602
|
+
* @param isRemoval Whether this update is for a review removal
|
|
603
|
+
* @returns The updated procedure review info
|
|
604
|
+
*/
|
|
605
|
+
async updateProcedureReviewInfo(
|
|
606
|
+
procedureId: string,
|
|
607
|
+
newReview?: ProcedureReview,
|
|
608
|
+
isRemoval: boolean = false
|
|
609
|
+
): Promise<ProcedureReviewInfo> {
|
|
610
|
+
// Get the current procedure document
|
|
611
|
+
const procedureDoc = await getDoc(
|
|
612
|
+
doc(this.db, PROCEDURES_COLLECTION, procedureId)
|
|
613
|
+
);
|
|
614
|
+
|
|
615
|
+
if (!procedureDoc.exists()) {
|
|
616
|
+
throw new Error(`Procedure with ID ${procedureId} not found`);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
const procedureData = procedureDoc.data();
|
|
620
|
+
const currentReviewInfo =
|
|
621
|
+
(procedureData.reviewInfo as ProcedureReviewInfo) || {
|
|
622
|
+
totalReviews: 0,
|
|
623
|
+
averageRating: 0,
|
|
624
|
+
effectivenessOfTreatment: 0,
|
|
625
|
+
outcomeExplanation: 0,
|
|
626
|
+
painManagement: 0,
|
|
627
|
+
followUpCare: 0,
|
|
628
|
+
valueForMoney: 0,
|
|
629
|
+
recommendationPercentage: 0,
|
|
630
|
+
};
|
|
631
|
+
|
|
632
|
+
// If we have no reviews and aren't adding a new one, return default values
|
|
633
|
+
if (currentReviewInfo.totalReviews === 0 && !newReview) {
|
|
634
|
+
await updateDoc(doc(this.db, PROCEDURES_COLLECTION, procedureId), {
|
|
635
|
+
reviewInfo: currentReviewInfo,
|
|
636
|
+
updatedAt: serverTimestamp(),
|
|
637
|
+
});
|
|
638
|
+
return currentReviewInfo;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
let updatedReviewInfo: ProcedureReviewInfo;
|
|
642
|
+
|
|
643
|
+
if (newReview) {
|
|
644
|
+
// Calculate new values based on existing averages and the new review
|
|
645
|
+
const oldTotal = currentReviewInfo.totalReviews;
|
|
646
|
+
const newTotal = isRemoval ? oldTotal - 1 : oldTotal + 1;
|
|
647
|
+
|
|
648
|
+
// If removing the last review, set all values to 0
|
|
649
|
+
if (newTotal === 0) {
|
|
650
|
+
updatedReviewInfo = {
|
|
651
|
+
totalReviews: 0,
|
|
652
|
+
averageRating: 0,
|
|
653
|
+
effectivenessOfTreatment: 0,
|
|
654
|
+
outcomeExplanation: 0,
|
|
655
|
+
painManagement: 0,
|
|
656
|
+
followUpCare: 0,
|
|
657
|
+
valueForMoney: 0,
|
|
658
|
+
recommendationPercentage: 0,
|
|
659
|
+
};
|
|
660
|
+
} else {
|
|
661
|
+
// Calculate new averages
|
|
662
|
+
const updateAverage = (
|
|
663
|
+
currentAvg: number,
|
|
664
|
+
newValue: number
|
|
665
|
+
): number => {
|
|
666
|
+
const currentSum = currentAvg * oldTotal;
|
|
667
|
+
const newSum = isRemoval
|
|
668
|
+
? currentSum - newValue
|
|
669
|
+
: currentSum + newValue;
|
|
670
|
+
const newAvg = newSum / newTotal;
|
|
671
|
+
return Math.round(newAvg * 10) / 10; // Round to 1 decimal
|
|
672
|
+
};
|
|
673
|
+
|
|
674
|
+
// Update recommendation percentage
|
|
675
|
+
const currentRecommendations =
|
|
676
|
+
(currentReviewInfo.recommendationPercentage / 100) * oldTotal;
|
|
677
|
+
const newRecommendations = isRemoval
|
|
678
|
+
? newReview.wouldRecommend
|
|
679
|
+
? currentRecommendations - 1
|
|
680
|
+
: currentRecommendations
|
|
681
|
+
: newReview.wouldRecommend
|
|
682
|
+
? currentRecommendations + 1
|
|
683
|
+
: currentRecommendations;
|
|
684
|
+
const newRecommendationPercentage =
|
|
685
|
+
(newRecommendations / newTotal) * 100;
|
|
686
|
+
|
|
687
|
+
updatedReviewInfo = {
|
|
688
|
+
totalReviews: newTotal,
|
|
689
|
+
averageRating: updateAverage(
|
|
690
|
+
currentReviewInfo.averageRating,
|
|
691
|
+
newReview.overallRating
|
|
692
|
+
),
|
|
693
|
+
effectivenessOfTreatment: updateAverage(
|
|
694
|
+
currentReviewInfo.effectivenessOfTreatment,
|
|
695
|
+
newReview.effectivenessOfTreatment
|
|
696
|
+
),
|
|
697
|
+
outcomeExplanation: updateAverage(
|
|
698
|
+
currentReviewInfo.outcomeExplanation,
|
|
699
|
+
newReview.outcomeExplanation
|
|
700
|
+
),
|
|
701
|
+
painManagement: updateAverage(
|
|
702
|
+
currentReviewInfo.painManagement,
|
|
703
|
+
newReview.painManagement
|
|
704
|
+
),
|
|
705
|
+
followUpCare: updateAverage(
|
|
706
|
+
currentReviewInfo.followUpCare,
|
|
707
|
+
newReview.followUpCare
|
|
708
|
+
),
|
|
709
|
+
valueForMoney: updateAverage(
|
|
710
|
+
currentReviewInfo.valueForMoney,
|
|
711
|
+
newReview.valueForMoney
|
|
712
|
+
),
|
|
713
|
+
recommendationPercentage:
|
|
714
|
+
Math.round(newRecommendationPercentage * 10) / 10,
|
|
715
|
+
};
|
|
716
|
+
}
|
|
717
|
+
} else {
|
|
718
|
+
// If no new review provided, keep the current info
|
|
719
|
+
updatedReviewInfo = { ...currentReviewInfo };
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// Update the procedure with the new review info
|
|
723
|
+
await updateDoc(doc(this.db, PROCEDURES_COLLECTION, procedureId), {
|
|
724
|
+
reviewInfo: updatedReviewInfo,
|
|
725
|
+
updatedAt: serverTimestamp(),
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
return updatedReviewInfo;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* Updates doctorInfo rating in all procedures for a practitioner
|
|
733
|
+
* @param practitionerId The ID of the practitioner
|
|
734
|
+
* @param rating The new rating to set
|
|
735
|
+
*/
|
|
736
|
+
private async updateDoctorInfoInProcedures(
|
|
737
|
+
practitionerId: string,
|
|
738
|
+
rating: number
|
|
739
|
+
): Promise<void> {
|
|
740
|
+
// Find all procedures for this practitioner
|
|
741
|
+
const q = query(
|
|
742
|
+
collection(this.db, PROCEDURES_COLLECTION),
|
|
743
|
+
where("practitionerId", "==", practitionerId)
|
|
744
|
+
);
|
|
745
|
+
|
|
746
|
+
const snapshot = await getDocs(q);
|
|
747
|
+
|
|
748
|
+
if (snapshot.empty) {
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// Batch update all procedures
|
|
753
|
+
const batch = writeBatch(this.db);
|
|
754
|
+
|
|
755
|
+
snapshot.docs.forEach((docSnapshot) => {
|
|
756
|
+
const procedureRef = doc(this.db, PROCEDURES_COLLECTION, docSnapshot.id);
|
|
757
|
+
batch.update(procedureRef, {
|
|
758
|
+
"doctorInfo.rating": rating,
|
|
759
|
+
updatedAt: serverTimestamp(),
|
|
760
|
+
});
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
await batch.commit();
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
/**
|
|
767
|
+
* Verifies a review as checked by admin/staff
|
|
768
|
+
* @param reviewId The ID of the review to verify
|
|
769
|
+
*/
|
|
770
|
+
async verifyReview(reviewId: string): Promise<void> {
|
|
771
|
+
const review = await this.getReview(reviewId);
|
|
772
|
+
if (!review) {
|
|
773
|
+
throw new Error(`Review with ID ${reviewId} not found`);
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
const batch = writeBatch(this.db);
|
|
777
|
+
|
|
778
|
+
// Update main review document
|
|
779
|
+
batch.update(doc(this.db, REVIEWS_COLLECTION, reviewId), {
|
|
780
|
+
updatedAt: serverTimestamp(),
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
// Update clinic review if it exists
|
|
784
|
+
if (review.clinicReview) {
|
|
785
|
+
review.clinicReview.isVerified = true;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// Update practitioner review if it exists
|
|
789
|
+
if (review.practitionerReview) {
|
|
790
|
+
review.practitionerReview.isVerified = true;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
// Update procedure review if it exists
|
|
794
|
+
if (review.procedureReview) {
|
|
795
|
+
review.procedureReview.isVerified = true;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
await batch.commit();
|
|
799
|
+
|
|
800
|
+
// Update all related entities
|
|
801
|
+
const updatePromises: Promise<any>[] = [];
|
|
802
|
+
|
|
803
|
+
if (review.clinicReview) {
|
|
804
|
+
updatePromises.push(
|
|
805
|
+
this.updateClinicReviewInfo(review.clinicReview.clinicId)
|
|
806
|
+
);
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
if (review.practitionerReview) {
|
|
810
|
+
updatePromises.push(
|
|
811
|
+
this.updatePractitionerReviewInfo(
|
|
812
|
+
review.practitionerReview.practitionerId
|
|
813
|
+
)
|
|
814
|
+
);
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
if (review.procedureReview) {
|
|
818
|
+
updatePromises.push(
|
|
819
|
+
this.updateProcedureReviewInfo(review.procedureReview.procedureId)
|
|
820
|
+
);
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
await Promise.all(updatePromises);
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
/**
|
|
827
|
+
* Calculates the average of an array of numbers
|
|
828
|
+
* @param numbers Array of numbers to average
|
|
829
|
+
* @returns The average, or 0 if the array is empty
|
|
830
|
+
*/
|
|
831
|
+
private calculateAverage(numbers: number[]): number {
|
|
832
|
+
if (numbers.length === 0) {
|
|
833
|
+
return 0;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
const sum = numbers.reduce((a, b) => a + b, 0);
|
|
837
|
+
const avg = sum / numbers.length;
|
|
838
|
+
|
|
839
|
+
// Round to 1 decimal place
|
|
840
|
+
return Math.round(avg * 10) / 10;
|
|
841
|
+
}
|
|
842
|
+
}
|