@blackcode_sa/metaestetics-api 1.12.68 → 1.12.70
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 +34 -1
- package/dist/admin/index.d.ts +34 -1
- package/dist/admin/index.js +104 -0
- package/dist/admin/index.mjs +104 -0
- package/dist/index.d.mts +33 -2
- package/dist/index.d.ts +33 -2
- package/dist/index.js +189 -23
- package/dist/index.mjs +189 -23
- package/package.json +1 -1
- package/src/admin/aggregation/appointment/appointment.aggregation.service.ts +140 -0
- package/src/backoffice/services/README.md +17 -0
- package/src/backoffice/services/analytics.service.proposal.md +859 -0
- package/src/backoffice/services/analytics.service.summary.md +143 -0
- package/src/services/appointment/appointment.service.ts +59 -6
- package/src/services/procedure/procedure.service.ts +117 -4
- package/src/services/reviews/reviews.service.ts +83 -7
- package/src/types/notifications/index.ts +21 -0
package/dist/index.mjs
CHANGED
|
@@ -56,13 +56,13 @@ var AppointmentStatus = /* @__PURE__ */ ((AppointmentStatus2) => {
|
|
|
56
56
|
AppointmentStatus2["RESCHEDULED_BY_CLINIC"] = "rescheduled_by_clinic";
|
|
57
57
|
return AppointmentStatus2;
|
|
58
58
|
})(AppointmentStatus || {});
|
|
59
|
-
var PaymentStatus = /* @__PURE__ */ ((
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
return
|
|
59
|
+
var PaymentStatus = /* @__PURE__ */ ((PaymentStatus3) => {
|
|
60
|
+
PaymentStatus3["UNPAID"] = "unpaid";
|
|
61
|
+
PaymentStatus3["PAID"] = "paid";
|
|
62
|
+
PaymentStatus3["PARTIALLY_PAID"] = "partially_paid";
|
|
63
|
+
PaymentStatus3["REFUNDED"] = "refunded";
|
|
64
|
+
PaymentStatus3["NOT_APPLICABLE"] = "not_applicable";
|
|
65
|
+
return PaymentStatus3;
|
|
66
66
|
})(PaymentStatus || {});
|
|
67
67
|
var MediaType = /* @__PURE__ */ ((MediaType2) => {
|
|
68
68
|
MediaType2["BEFORE_PHOTO"] = "before_photo";
|
|
@@ -3329,6 +3329,7 @@ var AppointmentService = class extends BaseService {
|
|
|
3329
3329
|
finalbilling: null,
|
|
3330
3330
|
finalizationNotes: null
|
|
3331
3331
|
};
|
|
3332
|
+
const shouldUpdatePaymentStatus = finalbilling.finalPrice > 0 && appointment.paymentStatus === "not_applicable" /* NOT_APPLICABLE */;
|
|
3332
3333
|
const updateData = {
|
|
3333
3334
|
metadata: {
|
|
3334
3335
|
selectedZones: currentMetadata.selectedZones,
|
|
@@ -3344,6 +3345,9 @@ var AppointmentService = class extends BaseService {
|
|
|
3344
3345
|
finalbilling,
|
|
3345
3346
|
finalizationNotes: currentMetadata.finalizationNotes
|
|
3346
3347
|
},
|
|
3348
|
+
...shouldUpdatePaymentStatus && {
|
|
3349
|
+
paymentStatus: "unpaid" /* UNPAID */
|
|
3350
|
+
},
|
|
3347
3351
|
updatedAt: serverTimestamp7()
|
|
3348
3352
|
};
|
|
3349
3353
|
return await this.updateAppointment(appointmentId, updateData);
|
|
@@ -3557,8 +3561,36 @@ var AppointmentService = class extends BaseService {
|
|
|
3557
3561
|
showCanceled: false,
|
|
3558
3562
|
showNoShow: false
|
|
3559
3563
|
});
|
|
3564
|
+
const now = /* @__PURE__ */ new Date();
|
|
3565
|
+
const allPastAppointments = await this.getPatientAppointments(patientId, {
|
|
3566
|
+
endDate: now,
|
|
3567
|
+
status: [
|
|
3568
|
+
"completed" /* COMPLETED */,
|
|
3569
|
+
"confirmed" /* CONFIRMED */,
|
|
3570
|
+
"checked_in" /* CHECKED_IN */,
|
|
3571
|
+
"in_progress" /* IN_PROGRESS */
|
|
3572
|
+
]
|
|
3573
|
+
});
|
|
3574
|
+
const appointmentsWithRecommendations = allPastAppointments.appointments.filter(
|
|
3575
|
+
(appointment) => {
|
|
3576
|
+
var _a2, _b2, _c2, _d;
|
|
3577
|
+
const endTime = ((_a2 = appointment.appointmentEndTime) == null ? void 0 : _a2.toMillis) ? appointment.appointmentEndTime.toMillis() : ((_b2 = appointment.appointmentEndTime) == null ? void 0 : _b2.seconds) ? appointment.appointmentEndTime.seconds * 1e3 : null;
|
|
3578
|
+
if (!endTime) return false;
|
|
3579
|
+
const isPastEndTime = endTime < now.getTime();
|
|
3580
|
+
const hasRecommendations = (((_d = (_c2 = appointment.metadata) == null ? void 0 : _c2.recommendedProcedures) == null ? void 0 : _d.length) || 0) > 0;
|
|
3581
|
+
return isPastEndTime && hasRecommendations;
|
|
3582
|
+
}
|
|
3583
|
+
);
|
|
3584
|
+
const allAppointmentsMap = /* @__PURE__ */ new Map();
|
|
3585
|
+
pastAppointments.appointments.forEach((apt) => {
|
|
3586
|
+
allAppointmentsMap.set(apt.id, apt);
|
|
3587
|
+
});
|
|
3588
|
+
appointmentsWithRecommendations.forEach((apt) => {
|
|
3589
|
+
allAppointmentsMap.set(apt.id, apt);
|
|
3590
|
+
});
|
|
3591
|
+
const allAppointments = Array.from(allAppointmentsMap.values());
|
|
3560
3592
|
const recommendations = [];
|
|
3561
|
-
for (const appointment of
|
|
3593
|
+
for (const appointment of allAppointments) {
|
|
3562
3594
|
if ((options == null ? void 0 : options.clinicBranchId) && appointment.clinicBranchId !== options.clinicBranchId) {
|
|
3563
3595
|
continue;
|
|
3564
3596
|
}
|
|
@@ -3595,9 +3627,6 @@ var AppointmentService = class extends BaseService {
|
|
|
3595
3627
|
return dateB - dateA;
|
|
3596
3628
|
});
|
|
3597
3629
|
const limitedRecommendations = (options == null ? void 0 : options.limit) ? recommendations.slice(0, options.limit) : recommendations;
|
|
3598
|
-
console.log(
|
|
3599
|
-
`[APPOINTMENT_SERVICE] Found ${limitedRecommendations.length} next steps recommendations for patient ${patientId}`
|
|
3600
|
-
);
|
|
3601
3630
|
return limitedRecommendations;
|
|
3602
3631
|
} catch (error) {
|
|
3603
3632
|
console.error(
|
|
@@ -3885,6 +3914,7 @@ var NotificationType = /* @__PURE__ */ ((NotificationType3) => {
|
|
|
3885
3914
|
NotificationType3["FORM_REMINDER"] = "formReminder";
|
|
3886
3915
|
NotificationType3["FORM_SUBMISSION_CONFIRMATION"] = "formSubmissionConfirmation";
|
|
3887
3916
|
NotificationType3["REVIEW_REQUEST"] = "reviewRequest";
|
|
3917
|
+
NotificationType3["PROCEDURE_RECOMMENDATION"] = "procedureRecommendation";
|
|
3888
3918
|
NotificationType3["PAYMENT_DUE"] = "paymentDue";
|
|
3889
3919
|
NotificationType3["PAYMENT_CONFIRMATION"] = "paymentConfirmation";
|
|
3890
3920
|
NotificationType3["PAYMENT_FAILED"] = "paymentFailed";
|
|
@@ -17739,6 +17769,10 @@ var ProcedureService = class extends BaseService {
|
|
|
17739
17769
|
console.log("[PROCEDURE_SERVICE] Strategy 1: Trying nameLower search");
|
|
17740
17770
|
const searchTerm = filters.nameSearch.trim().toLowerCase();
|
|
17741
17771
|
const constraints = getBaseConstraints();
|
|
17772
|
+
const hasNestedFilters = !!(filters.procedureTechnology || filters.procedureCategory || filters.procedureSubcategory);
|
|
17773
|
+
if (hasNestedFilters) {
|
|
17774
|
+
console.log("[PROCEDURE_SERVICE] Strategy 1: Has nested filters, will apply client-side after query");
|
|
17775
|
+
}
|
|
17742
17776
|
constraints.push(where31("nameLower", ">=", searchTerm));
|
|
17743
17777
|
constraints.push(where31("nameLower", "<=", searchTerm + "\uF8FF"));
|
|
17744
17778
|
constraints.push(orderBy18("nameLower"));
|
|
@@ -17754,9 +17788,12 @@ var ProcedureService = class extends BaseService {
|
|
|
17754
17788
|
constraints.push(limit16(filters.pagination || 10));
|
|
17755
17789
|
const q = query31(collection31(this.db, PROCEDURES_COLLECTION), ...constraints);
|
|
17756
17790
|
const querySnapshot = await getDocs31(q);
|
|
17757
|
-
|
|
17791
|
+
let procedures = querySnapshot.docs.map(
|
|
17758
17792
|
(doc45) => ({ ...doc45.data(), id: doc45.id })
|
|
17759
17793
|
);
|
|
17794
|
+
if (hasNestedFilters) {
|
|
17795
|
+
procedures = this.applyInMemoryFilters(procedures, filters);
|
|
17796
|
+
}
|
|
17760
17797
|
const lastDoc = querySnapshot.docs.length > 0 ? querySnapshot.docs[querySnapshot.docs.length - 1] : null;
|
|
17761
17798
|
console.log(`[PROCEDURE_SERVICE] Strategy 1 success: ${procedures.length} procedures`);
|
|
17762
17799
|
if (procedures.length < (filters.pagination || 10)) {
|
|
@@ -17772,6 +17809,10 @@ var ProcedureService = class extends BaseService {
|
|
|
17772
17809
|
console.log("[PROCEDURE_SERVICE] Strategy 2: Trying name field search");
|
|
17773
17810
|
const searchTerm = filters.nameSearch.trim().toLowerCase();
|
|
17774
17811
|
const constraints = getBaseConstraints();
|
|
17812
|
+
const hasNestedFilters = !!(filters.procedureTechnology || filters.procedureCategory || filters.procedureSubcategory);
|
|
17813
|
+
if (hasNestedFilters) {
|
|
17814
|
+
console.log("[PROCEDURE_SERVICE] Strategy 2: Has nested filters, will apply client-side after query");
|
|
17815
|
+
}
|
|
17775
17816
|
constraints.push(where31("name", ">=", searchTerm));
|
|
17776
17817
|
constraints.push(where31("name", "<=", searchTerm + "\uF8FF"));
|
|
17777
17818
|
constraints.push(orderBy18("name"));
|
|
@@ -17787,9 +17828,12 @@ var ProcedureService = class extends BaseService {
|
|
|
17787
17828
|
constraints.push(limit16(filters.pagination || 10));
|
|
17788
17829
|
const q = query31(collection31(this.db, PROCEDURES_COLLECTION), ...constraints);
|
|
17789
17830
|
const querySnapshot = await getDocs31(q);
|
|
17790
|
-
|
|
17831
|
+
let procedures = querySnapshot.docs.map(
|
|
17791
17832
|
(doc45) => ({ ...doc45.data(), id: doc45.id })
|
|
17792
17833
|
);
|
|
17834
|
+
if (hasNestedFilters) {
|
|
17835
|
+
procedures = this.applyInMemoryFilters(procedures, filters);
|
|
17836
|
+
}
|
|
17793
17837
|
const lastDoc = querySnapshot.docs.length > 0 ? querySnapshot.docs[querySnapshot.docs.length - 1] : null;
|
|
17794
17838
|
console.log(`[PROCEDURE_SERVICE] Strategy 2 success: ${procedures.length} procedures`);
|
|
17795
17839
|
if (procedures.length < (filters.pagination || 10)) {
|
|
@@ -17802,9 +17846,47 @@ var ProcedureService = class extends BaseService {
|
|
|
17802
17846
|
}
|
|
17803
17847
|
try {
|
|
17804
17848
|
console.log(
|
|
17805
|
-
"[PROCEDURE_SERVICE] Strategy 3: Using createdAt orderBy with client-side filtering"
|
|
17849
|
+
"[PROCEDURE_SERVICE] Strategy 3: Using createdAt orderBy with client-side filtering",
|
|
17850
|
+
{
|
|
17851
|
+
procedureTechnology: filters.procedureTechnology,
|
|
17852
|
+
hasTechnologyFilter: !!filters.procedureTechnology
|
|
17853
|
+
}
|
|
17854
|
+
);
|
|
17855
|
+
const constraints = [];
|
|
17856
|
+
if (filters.isActive !== void 0) {
|
|
17857
|
+
constraints.push(where31("isActive", "==", filters.isActive));
|
|
17858
|
+
} else {
|
|
17859
|
+
constraints.push(where31("isActive", "==", true));
|
|
17860
|
+
}
|
|
17861
|
+
if (filters.procedureFamily) {
|
|
17862
|
+
constraints.push(where31("family", "==", filters.procedureFamily));
|
|
17863
|
+
}
|
|
17864
|
+
if (filters.practitionerId) {
|
|
17865
|
+
constraints.push(where31("practitionerId", "==", filters.practitionerId));
|
|
17866
|
+
}
|
|
17867
|
+
if (filters.clinicId) {
|
|
17868
|
+
constraints.push(where31("clinicBranchId", "==", filters.clinicId));
|
|
17869
|
+
}
|
|
17870
|
+
if (filters.minPrice !== void 0) {
|
|
17871
|
+
constraints.push(where31("price", ">=", filters.minPrice));
|
|
17872
|
+
}
|
|
17873
|
+
if (filters.maxPrice !== void 0) {
|
|
17874
|
+
constraints.push(where31("price", "<=", filters.maxPrice));
|
|
17875
|
+
}
|
|
17876
|
+
if (filters.minRating !== void 0) {
|
|
17877
|
+
constraints.push(where31("reviewInfo.averageRating", ">=", filters.minRating));
|
|
17878
|
+
}
|
|
17879
|
+
if (filters.maxRating !== void 0) {
|
|
17880
|
+
constraints.push(where31("reviewInfo.averageRating", "<=", filters.maxRating));
|
|
17881
|
+
}
|
|
17882
|
+
if (filters.treatmentBenefits && filters.treatmentBenefits.length > 0) {
|
|
17883
|
+
const benefitIdsToMatch = filters.treatmentBenefits;
|
|
17884
|
+
constraints.push(where31("treatmentBenefitIds", "array-contains-any", benefitIdsToMatch));
|
|
17885
|
+
}
|
|
17886
|
+
console.log(
|
|
17887
|
+
"[PROCEDURE_SERVICE] Strategy 3 Firestore constraints (nested filters excluded):",
|
|
17888
|
+
constraints.map((c) => c.fieldPath || "unknown")
|
|
17806
17889
|
);
|
|
17807
|
-
const constraints = getBaseConstraints();
|
|
17808
17890
|
constraints.push(orderBy18("createdAt", "desc"));
|
|
17809
17891
|
if (filters.lastDoc) {
|
|
17810
17892
|
if (typeof filters.lastDoc.data === "function") {
|
|
@@ -17821,7 +17903,20 @@ var ProcedureService = class extends BaseService {
|
|
|
17821
17903
|
let procedures = querySnapshot.docs.map(
|
|
17822
17904
|
(doc45) => ({ ...doc45.data(), id: doc45.id })
|
|
17823
17905
|
);
|
|
17906
|
+
console.log("[PROCEDURE_SERVICE] Before applyInMemoryFilters (Strategy 3):", {
|
|
17907
|
+
procedureCount: procedures.length,
|
|
17908
|
+
procedureTechnology: filters.procedureTechnology,
|
|
17909
|
+
filtersObject: {
|
|
17910
|
+
procedureTechnology: filters.procedureTechnology,
|
|
17911
|
+
procedureFamily: filters.procedureFamily,
|
|
17912
|
+
procedureCategory: filters.procedureCategory,
|
|
17913
|
+
procedureSubcategory: filters.procedureSubcategory
|
|
17914
|
+
}
|
|
17915
|
+
});
|
|
17824
17916
|
procedures = this.applyInMemoryFilters(procedures, filters);
|
|
17917
|
+
console.log("[PROCEDURE_SERVICE] After applyInMemoryFilters (Strategy 3):", {
|
|
17918
|
+
procedureCount: procedures.length
|
|
17919
|
+
});
|
|
17825
17920
|
const lastDoc = querySnapshot.docs.length > 0 ? querySnapshot.docs[querySnapshot.docs.length - 1] : null;
|
|
17826
17921
|
console.log(`[PROCEDURE_SERVICE] Strategy 3 success: ${procedures.length} procedures`);
|
|
17827
17922
|
if (procedures.length < (filters.pagination || 10)) {
|
|
@@ -17872,6 +17967,12 @@ var ProcedureService = class extends BaseService {
|
|
|
17872
17967
|
*/
|
|
17873
17968
|
applyInMemoryFilters(procedures, filters) {
|
|
17874
17969
|
let filteredProcedures = [...procedures];
|
|
17970
|
+
console.log("[PROCEDURE_SERVICE] applyInMemoryFilters called:", {
|
|
17971
|
+
procedureCount: procedures.length,
|
|
17972
|
+
procedureTechnology: filters.procedureTechnology,
|
|
17973
|
+
hasTechnologyFilter: !!filters.procedureTechnology,
|
|
17974
|
+
allFilterKeys: Object.keys(filters).filter((k) => filters[k] !== void 0 && filters[k] !== null)
|
|
17975
|
+
});
|
|
17875
17976
|
if (filters.nameSearch && filters.nameSearch.trim()) {
|
|
17876
17977
|
const searchTerm = filters.nameSearch.trim().toLowerCase();
|
|
17877
17978
|
filteredProcedures = filteredProcedures.filter((procedure) => {
|
|
@@ -17947,6 +18048,7 @@ var ProcedureService = class extends BaseService {
|
|
|
17947
18048
|
);
|
|
17948
18049
|
}
|
|
17949
18050
|
if (filters.procedureTechnology) {
|
|
18051
|
+
const beforeCount = filteredProcedures.length;
|
|
17950
18052
|
filteredProcedures = filteredProcedures.filter(
|
|
17951
18053
|
(procedure) => {
|
|
17952
18054
|
var _a;
|
|
@@ -17954,8 +18056,18 @@ var ProcedureService = class extends BaseService {
|
|
|
17954
18056
|
}
|
|
17955
18057
|
);
|
|
17956
18058
|
console.log(
|
|
17957
|
-
`[PROCEDURE_SERVICE] Applied technology filter,
|
|
18059
|
+
`[PROCEDURE_SERVICE] Applied technology filter (${filters.procedureTechnology}), before: ${beforeCount}, after: ${filteredProcedures.length}`
|
|
17958
18060
|
);
|
|
18061
|
+
if (beforeCount > filteredProcedures.length) {
|
|
18062
|
+
const filteredOut = procedures.filter((p) => {
|
|
18063
|
+
var _a;
|
|
18064
|
+
return ((_a = p.technology) == null ? void 0 : _a.id) !== filters.procedureTechnology;
|
|
18065
|
+
}).slice(0, 3).map((p) => {
|
|
18066
|
+
var _a;
|
|
18067
|
+
return { id: p.id, techId: (_a = p.technology) == null ? void 0 : _a.id, name: p.name };
|
|
18068
|
+
});
|
|
18069
|
+
console.log("[PROCEDURE_SERVICE] Filtered out sample procedures:", filteredOut);
|
|
18070
|
+
}
|
|
17959
18071
|
}
|
|
17960
18072
|
if (filters.practitionerId) {
|
|
17961
18073
|
filteredProcedures = filteredProcedures.filter(
|
|
@@ -18234,6 +18346,60 @@ var ReviewService = class extends BaseService {
|
|
|
18234
18346
|
constructor(db, auth, app) {
|
|
18235
18347
|
super(db, auth, app);
|
|
18236
18348
|
}
|
|
18349
|
+
/**
|
|
18350
|
+
* Helper function to convert Firestore Timestamps to Date objects
|
|
18351
|
+
* @param timestamp The timestamp to convert
|
|
18352
|
+
* @returns A JavaScript Date object or null
|
|
18353
|
+
*/
|
|
18354
|
+
convertTimestamp(timestamp) {
|
|
18355
|
+
if (!timestamp) return /* @__PURE__ */ new Date();
|
|
18356
|
+
if (timestamp && timestamp.__isTimestamp === true && typeof timestamp.seconds === "number") {
|
|
18357
|
+
return new Date(timestamp.seconds * 1e3 + (timestamp.nanoseconds || 0) / 1e6);
|
|
18358
|
+
}
|
|
18359
|
+
if (timestamp && timestamp.toDate && typeof timestamp.toDate === "function") {
|
|
18360
|
+
return timestamp.toDate();
|
|
18361
|
+
}
|
|
18362
|
+
if (timestamp instanceof Date) {
|
|
18363
|
+
return timestamp;
|
|
18364
|
+
}
|
|
18365
|
+
if (typeof timestamp === "string" || typeof timestamp === "number") {
|
|
18366
|
+
const date = new Date(timestamp);
|
|
18367
|
+
if (!isNaN(date.getTime())) {
|
|
18368
|
+
return date;
|
|
18369
|
+
}
|
|
18370
|
+
}
|
|
18371
|
+
return /* @__PURE__ */ new Date();
|
|
18372
|
+
}
|
|
18373
|
+
/**
|
|
18374
|
+
* Converts a Firestore document to a Review object with proper date handling
|
|
18375
|
+
* @param docData The raw Firestore document data
|
|
18376
|
+
* @returns A Review object with properly converted dates
|
|
18377
|
+
*/
|
|
18378
|
+
convertDocToReview(docData) {
|
|
18379
|
+
const review = docData;
|
|
18380
|
+
review.createdAt = this.convertTimestamp(docData.createdAt);
|
|
18381
|
+
review.updatedAt = this.convertTimestamp(docData.updatedAt);
|
|
18382
|
+
if (review.clinicReview) {
|
|
18383
|
+
review.clinicReview.createdAt = this.convertTimestamp(review.clinicReview.createdAt);
|
|
18384
|
+
review.clinicReview.updatedAt = this.convertTimestamp(review.clinicReview.updatedAt);
|
|
18385
|
+
}
|
|
18386
|
+
if (review.practitionerReview) {
|
|
18387
|
+
review.practitionerReview.createdAt = this.convertTimestamp(review.practitionerReview.createdAt);
|
|
18388
|
+
review.practitionerReview.updatedAt = this.convertTimestamp(review.practitionerReview.updatedAt);
|
|
18389
|
+
}
|
|
18390
|
+
if (review.procedureReview) {
|
|
18391
|
+
review.procedureReview.createdAt = this.convertTimestamp(review.procedureReview.createdAt);
|
|
18392
|
+
review.procedureReview.updatedAt = this.convertTimestamp(review.procedureReview.updatedAt);
|
|
18393
|
+
}
|
|
18394
|
+
if (review.extendedProcedureReviews && Array.isArray(review.extendedProcedureReviews)) {
|
|
18395
|
+
review.extendedProcedureReviews = review.extendedProcedureReviews.map((extReview) => ({
|
|
18396
|
+
...extReview,
|
|
18397
|
+
createdAt: this.convertTimestamp(extReview.createdAt),
|
|
18398
|
+
updatedAt: this.convertTimestamp(extReview.updatedAt)
|
|
18399
|
+
}));
|
|
18400
|
+
}
|
|
18401
|
+
return review;
|
|
18402
|
+
}
|
|
18237
18403
|
/**
|
|
18238
18404
|
* Creates a new review
|
|
18239
18405
|
* @param data - The review data to create
|
|
@@ -18377,7 +18543,7 @@ var ReviewService = class extends BaseService {
|
|
|
18377
18543
|
console.log("\u274C ReviewService.getReview - Review not found:", reviewId);
|
|
18378
18544
|
return null;
|
|
18379
18545
|
}
|
|
18380
|
-
const review = { ...docSnap.data(), id: reviewId };
|
|
18546
|
+
const review = { ...this.convertDocToReview(docSnap.data()), id: reviewId };
|
|
18381
18547
|
try {
|
|
18382
18548
|
const appointmentDoc = await getDoc39(
|
|
18383
18549
|
doc38(this.db, APPOINTMENTS_COLLECTION, review.appointmentId)
|
|
@@ -18442,7 +18608,7 @@ var ReviewService = class extends BaseService {
|
|
|
18442
18608
|
async getReviewsByPatient(patientId) {
|
|
18443
18609
|
const q = query32(collection32(this.db, REVIEWS_COLLECTION), where32("patientId", "==", patientId));
|
|
18444
18610
|
const snapshot = await getDocs32(q);
|
|
18445
|
-
const reviews = snapshot.docs.map((doc45) => doc45.data());
|
|
18611
|
+
const reviews = snapshot.docs.map((doc45) => this.convertDocToReview(doc45.data()));
|
|
18446
18612
|
const enhancedReviews = await Promise.all(
|
|
18447
18613
|
reviews.map(async (review) => {
|
|
18448
18614
|
try {
|
|
@@ -18497,8 +18663,8 @@ var ReviewService = class extends BaseService {
|
|
|
18497
18663
|
);
|
|
18498
18664
|
const snapshot = await getDocs32(q);
|
|
18499
18665
|
const reviews = snapshot.docs.map((doc45) => {
|
|
18500
|
-
const
|
|
18501
|
-
return { ...
|
|
18666
|
+
const review = this.convertDocToReview(doc45.data());
|
|
18667
|
+
return { ...review, id: doc45.id };
|
|
18502
18668
|
});
|
|
18503
18669
|
console.log("\u{1F50D} ReviewService.getReviewsByClinic - Found reviews before enhancement:", {
|
|
18504
18670
|
clinicId,
|
|
@@ -18573,8 +18739,8 @@ var ReviewService = class extends BaseService {
|
|
|
18573
18739
|
);
|
|
18574
18740
|
const snapshot = await getDocs32(q);
|
|
18575
18741
|
const reviews = snapshot.docs.map((doc45) => {
|
|
18576
|
-
const
|
|
18577
|
-
return { ...
|
|
18742
|
+
const review = this.convertDocToReview(doc45.data());
|
|
18743
|
+
return { ...review, id: doc45.id };
|
|
18578
18744
|
});
|
|
18579
18745
|
console.log("\u{1F50D} ReviewService.getReviewsByPractitioner - Found reviews before enhancement:", {
|
|
18580
18746
|
practitionerId,
|
|
@@ -18720,7 +18886,7 @@ var ReviewService = class extends BaseService {
|
|
|
18720
18886
|
if (snapshot.empty) {
|
|
18721
18887
|
return null;
|
|
18722
18888
|
}
|
|
18723
|
-
return snapshot.docs[0].data();
|
|
18889
|
+
return this.convertDocToReview(snapshot.docs[0].data());
|
|
18724
18890
|
}
|
|
18725
18891
|
/**
|
|
18726
18892
|
* Deletes a review
|
package/package.json
CHANGED
|
@@ -37,6 +37,7 @@ import { AppointmentMailingService } from '../../mailing/appointment/appointment
|
|
|
37
37
|
import { Logger } from '../../logger';
|
|
38
38
|
import { UserRole } from '../../../types';
|
|
39
39
|
import { CalendarEventStatus } from '../../../types/calendar';
|
|
40
|
+
import { NotificationType } from '../../../types/notifications';
|
|
40
41
|
|
|
41
42
|
// Mailgun client will be injected via constructor
|
|
42
43
|
|
|
@@ -540,6 +541,13 @@ export class AppointmentAggregationService {
|
|
|
540
541
|
await this.handleZonePhotosUpdate(before, after);
|
|
541
542
|
}
|
|
542
543
|
|
|
544
|
+
// Handle Recommended Procedures Added
|
|
545
|
+
const recommendationsChanged = this.hasRecommendationsChanged(before, after);
|
|
546
|
+
if (recommendationsChanged) {
|
|
547
|
+
Logger.info(`[AggService] Recommended procedures changed for appointment ${after.id}`);
|
|
548
|
+
await this.handleRecommendedProceduresUpdate(before, after, patientProfile);
|
|
549
|
+
}
|
|
550
|
+
|
|
543
551
|
// TODO: Handle Review Added
|
|
544
552
|
// const reviewAdded = !before.reviewInfo && after.reviewInfo;
|
|
545
553
|
// if (reviewAdded) { ... }
|
|
@@ -1841,4 +1849,136 @@ export class AppointmentAggregationService {
|
|
|
1841
1849
|
// Don't throw - this is a side effect and shouldn't break the main update flow
|
|
1842
1850
|
}
|
|
1843
1851
|
}
|
|
1852
|
+
|
|
1853
|
+
/**
|
|
1854
|
+
* Checks if recommended procedures have changed between two appointment states
|
|
1855
|
+
* @param before - The appointment state before update
|
|
1856
|
+
* @param after - The appointment state after update
|
|
1857
|
+
* @returns True if recommendations have changed, false otherwise
|
|
1858
|
+
*/
|
|
1859
|
+
private hasRecommendationsChanged(before: Appointment, after: Appointment): boolean {
|
|
1860
|
+
const beforeRecommendations = before.metadata?.recommendedProcedures || [];
|
|
1861
|
+
const afterRecommendations = after.metadata?.recommendedProcedures || [];
|
|
1862
|
+
|
|
1863
|
+
// If lengths differ, there's a change
|
|
1864
|
+
if (beforeRecommendations.length !== afterRecommendations.length) {
|
|
1865
|
+
return true;
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
// Compare each recommendation (simple comparison - if any differ, return true)
|
|
1869
|
+
// For simplicity, we compare by procedure ID and note
|
|
1870
|
+
for (let i = 0; i < afterRecommendations.length; i++) {
|
|
1871
|
+
const beforeRec = beforeRecommendations[i];
|
|
1872
|
+
const afterRec = afterRecommendations[i];
|
|
1873
|
+
|
|
1874
|
+
if (!beforeRec || !afterRec) {
|
|
1875
|
+
return true;
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
if (
|
|
1879
|
+
beforeRec.procedure.procedureId !== afterRec.procedure.procedureId ||
|
|
1880
|
+
beforeRec.note !== afterRec.note ||
|
|
1881
|
+
beforeRec.timeframe.value !== afterRec.timeframe.value ||
|
|
1882
|
+
beforeRec.timeframe.unit !== afterRec.timeframe.unit
|
|
1883
|
+
) {
|
|
1884
|
+
return true;
|
|
1885
|
+
}
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
return false;
|
|
1889
|
+
}
|
|
1890
|
+
|
|
1891
|
+
/**
|
|
1892
|
+
* Handles recommended procedures update - creates notifications for newly added recommendations
|
|
1893
|
+
* @param before - The appointment state before update
|
|
1894
|
+
* @param after - The appointment state after update
|
|
1895
|
+
* @param patientProfile - The patient profile (for expo tokens)
|
|
1896
|
+
*/
|
|
1897
|
+
private async handleRecommendedProceduresUpdate(
|
|
1898
|
+
before: Appointment,
|
|
1899
|
+
after: Appointment,
|
|
1900
|
+
patientProfile: PatientProfile | null,
|
|
1901
|
+
): Promise<void> {
|
|
1902
|
+
try {
|
|
1903
|
+
const beforeRecommendations = before.metadata?.recommendedProcedures || [];
|
|
1904
|
+
const afterRecommendations = after.metadata?.recommendedProcedures || [];
|
|
1905
|
+
|
|
1906
|
+
// Find newly added recommendations
|
|
1907
|
+
const newRecommendations = afterRecommendations.slice(beforeRecommendations.length);
|
|
1908
|
+
|
|
1909
|
+
if (newRecommendations.length === 0) {
|
|
1910
|
+
Logger.info(
|
|
1911
|
+
`[AggService] No new recommendations detected for appointment ${after.id}`,
|
|
1912
|
+
);
|
|
1913
|
+
return;
|
|
1914
|
+
}
|
|
1915
|
+
|
|
1916
|
+
Logger.info(
|
|
1917
|
+
`[AggService] Found ${newRecommendations.length} new recommendation(s) for appointment ${after.id}`,
|
|
1918
|
+
);
|
|
1919
|
+
|
|
1920
|
+
// Create notifications for each new recommendation
|
|
1921
|
+
for (let i = 0; i < newRecommendations.length; i++) {
|
|
1922
|
+
const recommendation = newRecommendations[i];
|
|
1923
|
+
const recommendationIndex = beforeRecommendations.length + i;
|
|
1924
|
+
const recommendationId = `${after.id}:${recommendationIndex}`;
|
|
1925
|
+
|
|
1926
|
+
// Format timeframe for display
|
|
1927
|
+
const timeframeText = `${recommendation.timeframe.value} ${recommendation.timeframe.unit}${recommendation.timeframe.value > 1 ? 's' : ''}`;
|
|
1928
|
+
|
|
1929
|
+
// Create notification
|
|
1930
|
+
const notificationPayload: Omit<
|
|
1931
|
+
any,
|
|
1932
|
+
'id' | 'createdAt' | 'updatedAt' | 'status' | 'isRead'
|
|
1933
|
+
> = {
|
|
1934
|
+
userId: after.patientId,
|
|
1935
|
+
userRole: UserRole.PATIENT,
|
|
1936
|
+
notificationType: NotificationType.PROCEDURE_RECOMMENDATION,
|
|
1937
|
+
notificationTime: admin.firestore.Timestamp.now(),
|
|
1938
|
+
notificationTokens: patientProfile?.expoTokens || [],
|
|
1939
|
+
title: 'New Procedure Recommendation',
|
|
1940
|
+
body: `${after.practitionerInfo?.name || 'Your doctor'} recommended "${recommendation.procedure.procedureName}" for you. Suggested timeframe: in ${timeframeText}`,
|
|
1941
|
+
appointmentId: after.id,
|
|
1942
|
+
recommendationId,
|
|
1943
|
+
procedureId: recommendation.procedure.procedureId,
|
|
1944
|
+
procedureName: recommendation.procedure.procedureName,
|
|
1945
|
+
practitionerName: after.practitionerInfo?.name || 'Unknown Practitioner',
|
|
1946
|
+
clinicName: after.clinicInfo?.name || 'Unknown Clinic',
|
|
1947
|
+
note: recommendation.note,
|
|
1948
|
+
timeframe: recommendation.timeframe,
|
|
1949
|
+
};
|
|
1950
|
+
|
|
1951
|
+
try {
|
|
1952
|
+
const notificationId = await this.notificationsAdmin.createNotification(
|
|
1953
|
+
notificationPayload as any,
|
|
1954
|
+
);
|
|
1955
|
+
|
|
1956
|
+
Logger.info(
|
|
1957
|
+
`[AggService] Created notification ${notificationId} for recommendation ${recommendationId}`,
|
|
1958
|
+
);
|
|
1959
|
+
|
|
1960
|
+
// Send push notification immediately if patient has tokens
|
|
1961
|
+
if (patientProfile?.expoTokens && patientProfile.expoTokens.length > 0) {
|
|
1962
|
+
const notification = await this.notificationsAdmin.getNotification(notificationId);
|
|
1963
|
+
if (notification) {
|
|
1964
|
+
await this.notificationsAdmin.sendPushNotification(notification);
|
|
1965
|
+
Logger.info(
|
|
1966
|
+
`[AggService] Sent push notification for recommendation ${recommendationId}`,
|
|
1967
|
+
);
|
|
1968
|
+
}
|
|
1969
|
+
}
|
|
1970
|
+
} catch (error) {
|
|
1971
|
+
Logger.error(
|
|
1972
|
+
`[AggService] Error creating notification for recommendation ${recommendationId}:`,
|
|
1973
|
+
error,
|
|
1974
|
+
);
|
|
1975
|
+
}
|
|
1976
|
+
}
|
|
1977
|
+
} catch (error) {
|
|
1978
|
+
Logger.error(
|
|
1979
|
+
`[AggService] Error handling recommended procedures update for appointment ${after.id}:`,
|
|
1980
|
+
error,
|
|
1981
|
+
);
|
|
1982
|
+
}
|
|
1983
|
+
}
|
|
1844
1984
|
}
|
|
@@ -38,3 +38,20 @@ Manages administrative constants like treatment benefits and contraindications.
|
|
|
38
38
|
- **`addContraindication(contraindication)`**: Adds a new contraindication.
|
|
39
39
|
- **`updateContraindication(contraindication)`**: Updates an existing contraindication.
|
|
40
40
|
- **`deleteContraindication(contraindicationId)`**: Deletes a contraindication.
|
|
41
|
+
|
|
42
|
+
### `AnalyticsService` (Proposed)
|
|
43
|
+
|
|
44
|
+
Comprehensive financial and analytical intelligence service for the Clinic Admin app. Provides insights about doctors, procedures, appointments, patients, products, and clinic operations.
|
|
45
|
+
|
|
46
|
+
**Status**: Proposal phase - See [analytics.service.proposal.md](./analytics.service.proposal.md) for detailed design and implementation plan.
|
|
47
|
+
|
|
48
|
+
**Planned Features**:
|
|
49
|
+
- Practitioner performance analytics (appointments, cancellations, time efficiency, revenue)
|
|
50
|
+
- Procedure analytics (popularity, profitability, product usage)
|
|
51
|
+
- Appointment time analytics (booked vs actual time, efficiency metrics)
|
|
52
|
+
- Cancellation & no-show analytics (by clinic, practitioner, patient, procedure)
|
|
53
|
+
- Financial analytics (revenue, costs, payment status, trends)
|
|
54
|
+
- Product usage analytics (usage patterns, revenue contribution)
|
|
55
|
+
- Patient analytics (lifetime value, retention, appointment frequency)
|
|
56
|
+
- Clinic analytics (performance metrics, comparisons)
|
|
57
|
+
- Comprehensive dashboard data aggregation
|