@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/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__ */ ((PaymentStatus4) => {
60
- PaymentStatus4["UNPAID"] = "unpaid";
61
- PaymentStatus4["PAID"] = "paid";
62
- PaymentStatus4["PARTIALLY_PAID"] = "partially_paid";
63
- PaymentStatus4["REFUNDED"] = "refunded";
64
- PaymentStatus4["NOT_APPLICABLE"] = "not_applicable";
65
- return PaymentStatus4;
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 pastAppointments.appointments) {
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
- const procedures = querySnapshot.docs.map(
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
- const procedures = querySnapshot.docs.map(
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, results: ${filteredProcedures.length}`
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 data = doc45.data();
18501
- return { ...data, id: doc45.id };
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 data = doc45.data();
18577
- return { ...data, id: doc45.id };
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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@blackcode_sa/metaestetics-api",
3
3
  "private": false,
4
- "version": "1.12.68",
4
+ "version": "1.12.70",
5
5
  "description": "Firebase authentication service with anonymous upgrade support",
6
6
  "main": "dist/index.js",
7
7
  "module": "dist/index.mjs",
@@ -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