@blackcode_sa/metaestetics-api 1.13.5 → 1.13.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (295) hide show
  1. package/dist/admin/index.d.mts +20 -1
  2. package/dist/admin/index.d.ts +20 -1
  3. package/dist/admin/index.js +217 -1
  4. package/dist/admin/index.mjs +217 -1
  5. package/dist/index.d.mts +26 -3
  6. package/dist/index.d.ts +26 -3
  7. package/dist/index.js +168 -6
  8. package/dist/index.mjs +168 -6
  9. package/package.json +121 -121
  10. package/src/__mocks__/firstore.ts +10 -10
  11. package/src/admin/aggregation/README.md +79 -79
  12. package/src/admin/aggregation/appointment/README.md +128 -128
  13. package/src/admin/aggregation/appointment/appointment.aggregation.service.ts +1984 -1984
  14. package/src/admin/aggregation/appointment/index.ts +1 -1
  15. package/src/admin/aggregation/clinic/README.md +52 -52
  16. package/src/admin/aggregation/clinic/clinic.aggregation.service.ts +966 -703
  17. package/src/admin/aggregation/clinic/index.ts +1 -1
  18. package/src/admin/aggregation/forms/README.md +13 -13
  19. package/src/admin/aggregation/forms/filled-forms.aggregation.service.ts +322 -322
  20. package/src/admin/aggregation/forms/index.ts +1 -1
  21. package/src/admin/aggregation/index.ts +8 -8
  22. package/src/admin/aggregation/patient/README.md +27 -27
  23. package/src/admin/aggregation/patient/index.ts +1 -1
  24. package/src/admin/aggregation/patient/patient.aggregation.service.ts +141 -141
  25. package/src/admin/aggregation/practitioner/README.md +42 -42
  26. package/src/admin/aggregation/practitioner/index.ts +1 -1
  27. package/src/admin/aggregation/practitioner/practitioner.aggregation.service.ts +433 -433
  28. package/src/admin/aggregation/practitioner-invite/index.ts +1 -1
  29. package/src/admin/aggregation/practitioner-invite/practitioner-invite.aggregation.service.ts +961 -961
  30. package/src/admin/aggregation/procedure/README.md +43 -43
  31. package/src/admin/aggregation/procedure/index.ts +1 -1
  32. package/src/admin/aggregation/procedure/procedure.aggregation.service.ts +702 -702
  33. package/src/admin/aggregation/reviews/index.ts +1 -1
  34. package/src/admin/aggregation/reviews/reviews.aggregation.service.ts +689 -689
  35. package/src/admin/analytics/analytics.admin.service.ts +278 -278
  36. package/src/admin/analytics/index.ts +2 -2
  37. package/src/admin/booking/README.md +125 -125
  38. package/src/admin/booking/booking.admin.ts +1037 -1037
  39. package/src/admin/booking/booking.calculator.ts +712 -712
  40. package/src/admin/booking/booking.types.ts +59 -59
  41. package/src/admin/booking/index.ts +3 -3
  42. package/src/admin/booking/timezones-problem.md +185 -185
  43. package/src/admin/calendar/README.md +7 -7
  44. package/src/admin/calendar/calendar.admin.service.ts +345 -345
  45. package/src/admin/calendar/index.ts +1 -1
  46. package/src/admin/documentation-templates/document-manager.admin.ts +260 -260
  47. package/src/admin/documentation-templates/index.ts +1 -1
  48. package/src/admin/free-consultation/free-consultation-utils.admin.ts +148 -148
  49. package/src/admin/free-consultation/index.ts +1 -1
  50. package/src/admin/index.ts +81 -81
  51. package/src/admin/logger/index.ts +78 -78
  52. package/src/admin/mailing/README.md +95 -95
  53. package/src/admin/mailing/appointment/appointment.mailing.service.ts +732 -732
  54. package/src/admin/mailing/appointment/index.ts +1 -1
  55. package/src/admin/mailing/appointment/templates/patient/appointment-confirmed.html +40 -40
  56. package/src/admin/mailing/base.mailing.service.ts +208 -208
  57. package/src/admin/mailing/index.ts +3 -3
  58. package/src/admin/mailing/practitionerInvite/existing-practitioner-invite.mailing.ts +611 -611
  59. package/src/admin/mailing/practitionerInvite/index.ts +2 -2
  60. package/src/admin/mailing/practitionerInvite/practitionerInvite.mailing.ts +395 -395
  61. package/src/admin/mailing/practitionerInvite/templates/existing-practitioner-invitation.template.ts +155 -155
  62. package/src/admin/mailing/practitionerInvite/templates/invitation.template.ts +101 -101
  63. package/src/admin/mailing/practitionerInvite/templates/invite-accepted-notification.template.ts +228 -228
  64. package/src/admin/mailing/practitionerInvite/templates/invite-rejected-notification.template.ts +242 -242
  65. package/src/admin/notifications/index.ts +1 -1
  66. package/src/admin/notifications/notifications.admin.ts +710 -710
  67. package/src/admin/requirements/README.md +128 -128
  68. package/src/admin/requirements/index.ts +1 -1
  69. package/src/admin/requirements/patient-requirements.admin.service.ts +475 -475
  70. package/src/admin/users/index.ts +1 -1
  71. package/src/admin/users/user-profile.admin.ts +405 -405
  72. package/src/backoffice/constants/certification.constants.ts +13 -13
  73. package/src/backoffice/constants/index.ts +1 -1
  74. package/src/backoffice/errors/backoffice.errors.ts +181 -181
  75. package/src/backoffice/errors/index.ts +1 -1
  76. package/src/backoffice/expo-safe/README.md +26 -26
  77. package/src/backoffice/expo-safe/index.ts +41 -41
  78. package/src/backoffice/index.ts +5 -5
  79. package/src/backoffice/services/FIXES_README.md +102 -102
  80. package/src/backoffice/services/README.md +57 -57
  81. package/src/backoffice/services/analytics.service.proposal.md +863 -863
  82. package/src/backoffice/services/analytics.service.summary.md +143 -143
  83. package/src/backoffice/services/brand.service.ts +256 -256
  84. package/src/backoffice/services/category.service.ts +384 -384
  85. package/src/backoffice/services/constants.service.ts +385 -385
  86. package/src/backoffice/services/documentation-template.service.ts +202 -202
  87. package/src/backoffice/services/index.ts +10 -10
  88. package/src/backoffice/services/migrate-products.ts +116 -116
  89. package/src/backoffice/services/product.service.ts +553 -553
  90. package/src/backoffice/services/requirement.service.ts +235 -235
  91. package/src/backoffice/services/subcategory.service.ts +461 -461
  92. package/src/backoffice/services/technology.service.ts +1151 -1151
  93. package/src/backoffice/types/README.md +12 -12
  94. package/src/backoffice/types/admin-constants.types.ts +69 -69
  95. package/src/backoffice/types/brand.types.ts +29 -29
  96. package/src/backoffice/types/category.types.ts +67 -67
  97. package/src/backoffice/types/documentation-templates.types.ts +28 -28
  98. package/src/backoffice/types/index.ts +10 -10
  99. package/src/backoffice/types/procedure-product.types.ts +38 -38
  100. package/src/backoffice/types/product.types.ts +240 -240
  101. package/src/backoffice/types/requirement.types.ts +63 -63
  102. package/src/backoffice/types/static/README.md +18 -18
  103. package/src/backoffice/types/static/blocking-condition.types.ts +21 -21
  104. package/src/backoffice/types/static/certification.types.ts +37 -37
  105. package/src/backoffice/types/static/contraindication.types.ts +19 -19
  106. package/src/backoffice/types/static/index.ts +6 -6
  107. package/src/backoffice/types/static/pricing.types.ts +16 -16
  108. package/src/backoffice/types/static/procedure-family.types.ts +14 -14
  109. package/src/backoffice/types/static/treatment-benefit.types.ts +22 -22
  110. package/src/backoffice/types/subcategory.types.ts +34 -34
  111. package/src/backoffice/types/technology.types.ts +168 -168
  112. package/src/backoffice/validations/index.ts +1 -1
  113. package/src/backoffice/validations/schemas.ts +164 -164
  114. package/src/config/__mocks__/firebase.ts +99 -99
  115. package/src/config/firebase.ts +78 -78
  116. package/src/config/index.ts +9 -9
  117. package/src/errors/auth.error.ts +6 -6
  118. package/src/errors/auth.errors.ts +211 -200
  119. package/src/errors/clinic.errors.ts +32 -32
  120. package/src/errors/firebase.errors.ts +47 -47
  121. package/src/errors/user.errors.ts +99 -99
  122. package/src/index.backup.ts +407 -407
  123. package/src/index.ts +6 -6
  124. package/src/locales/en.ts +31 -31
  125. package/src/recommender/admin/index.ts +1 -1
  126. package/src/recommender/admin/services/recommender.service.admin.ts +5 -5
  127. package/src/recommender/front/index.ts +1 -1
  128. package/src/recommender/front/services/onboarding.service.ts +5 -5
  129. package/src/recommender/front/services/recommender.service.ts +3 -3
  130. package/src/recommender/index.ts +1 -1
  131. package/src/services/PATIENTAUTH.MD +197 -197
  132. package/src/services/README.md +106 -106
  133. package/src/services/__tests__/auth/auth.mock.test.ts +17 -17
  134. package/src/services/__tests__/auth/auth.setup.ts +293 -293
  135. package/src/services/__tests__/auth.service.test.ts +346 -346
  136. package/src/services/__tests__/base.service.test.ts +77 -77
  137. package/src/services/__tests__/user.service.test.ts +528 -528
  138. package/src/services/analytics/ARCHITECTURE.md +199 -199
  139. package/src/services/analytics/CLOUD_FUNCTIONS.md +225 -225
  140. package/src/services/analytics/GROUPED_ANALYTICS.md +501 -501
  141. package/src/services/analytics/QUICK_START.md +393 -393
  142. package/src/services/analytics/README.md +304 -304
  143. package/src/services/analytics/SUMMARY.md +141 -141
  144. package/src/services/analytics/TRENDS.md +380 -380
  145. package/src/services/analytics/USAGE_GUIDE.md +518 -518
  146. package/src/services/analytics/analytics-cloud.service.ts +222 -222
  147. package/src/services/analytics/analytics.service.ts +2142 -2142
  148. package/src/services/analytics/index.ts +4 -4
  149. package/src/services/analytics/review-analytics.service.ts +941 -941
  150. package/src/services/analytics/utils/appointment-filtering.utils.ts +138 -138
  151. package/src/services/analytics/utils/cost-calculation.utils.ts +182 -182
  152. package/src/services/analytics/utils/grouping.utils.ts +434 -434
  153. package/src/services/analytics/utils/stored-analytics.utils.ts +347 -347
  154. package/src/services/analytics/utils/time-calculation.utils.ts +186 -186
  155. package/src/services/analytics/utils/trend-calculation.utils.ts +200 -200
  156. package/src/services/appointment/README.md +17 -17
  157. package/src/services/appointment/appointment.service.ts +2558 -2558
  158. package/src/services/appointment/index.ts +1 -1
  159. package/src/services/appointment/utils/appointment.utils.ts +552 -552
  160. package/src/services/appointment/utils/extended-procedure.utils.ts +314 -314
  161. package/src/services/appointment/utils/form-initialization.utils.ts +225 -225
  162. package/src/services/appointment/utils/recommended-procedure.utils.ts +195 -195
  163. package/src/services/appointment/utils/zone-management.utils.ts +353 -353
  164. package/src/services/appointment/utils/zone-photo.utils.ts +152 -152
  165. package/src/services/auth/auth.service.ts +1043 -989
  166. package/src/services/auth/auth.v2.service.ts +961 -961
  167. package/src/services/auth/index.ts +7 -7
  168. package/src/services/auth/utils/error.utils.ts +90 -90
  169. package/src/services/auth/utils/firebase.utils.ts +49 -49
  170. package/src/services/auth/utils/index.ts +21 -21
  171. package/src/services/auth/utils/practitioner.utils.ts +125 -125
  172. package/src/services/base.service.ts +41 -41
  173. package/src/services/calendar/calendar.service.ts +1077 -1077
  174. package/src/services/calendar/calendar.v2.service.ts +1683 -1683
  175. package/src/services/calendar/calendar.v3.service.ts +313 -313
  176. package/src/services/calendar/externalCalendar.service.ts +178 -178
  177. package/src/services/calendar/index.ts +5 -5
  178. package/src/services/calendar/synced-calendars.service.ts +743 -743
  179. package/src/services/calendar/utils/appointment.utils.ts +265 -265
  180. package/src/services/calendar/utils/calendar-event.utils.ts +646 -646
  181. package/src/services/calendar/utils/clinic.utils.ts +237 -237
  182. package/src/services/calendar/utils/docs.utils.ts +157 -157
  183. package/src/services/calendar/utils/google-calendar.utils.ts +697 -697
  184. package/src/services/calendar/utils/index.ts +8 -8
  185. package/src/services/calendar/utils/patient.utils.ts +198 -198
  186. package/src/services/calendar/utils/practitioner.utils.ts +221 -221
  187. package/src/services/calendar/utils/synced-calendar.utils.ts +472 -472
  188. package/src/services/clinic/README.md +204 -204
  189. package/src/services/clinic/__tests__/clinic-admin.service.test.ts +287 -287
  190. package/src/services/clinic/__tests__/clinic-group.service.test.ts +352 -352
  191. package/src/services/clinic/__tests__/clinic.service.test.ts +354 -354
  192. package/src/services/clinic/billing-transactions.service.ts +217 -217
  193. package/src/services/clinic/clinic-admin.service.ts +202 -202
  194. package/src/services/clinic/clinic-group.service.ts +310 -310
  195. package/src/services/clinic/clinic.service.ts +708 -708
  196. package/src/services/clinic/index.ts +5 -5
  197. package/src/services/clinic/practitioner-invite.service.ts +519 -519
  198. package/src/services/clinic/utils/admin.utils.ts +551 -551
  199. package/src/services/clinic/utils/clinic-group.utils.ts +646 -646
  200. package/src/services/clinic/utils/clinic.utils.ts +949 -949
  201. package/src/services/clinic/utils/filter.utils.d.ts +23 -23
  202. package/src/services/clinic/utils/filter.utils.ts +446 -446
  203. package/src/services/clinic/utils/index.ts +11 -11
  204. package/src/services/clinic/utils/photos.utils.ts +188 -188
  205. package/src/services/clinic/utils/search.utils.ts +84 -84
  206. package/src/services/clinic/utils/tag.utils.ts +124 -124
  207. package/src/services/documentation-templates/documentation-template.service.ts +537 -537
  208. package/src/services/documentation-templates/filled-document.service.ts +587 -587
  209. package/src/services/documentation-templates/index.ts +2 -2
  210. package/src/services/index.ts +14 -14
  211. package/src/services/media/index.ts +1 -1
  212. package/src/services/media/media.service.ts +418 -418
  213. package/src/services/notifications/__tests__/notification.service.test.ts +242 -242
  214. package/src/services/notifications/index.ts +1 -1
  215. package/src/services/notifications/notification.service.ts +215 -215
  216. package/src/services/patient/README.md +48 -48
  217. package/src/services/patient/To-Do.md +43 -43
  218. package/src/services/patient/__tests__/patient.service.test.ts +294 -294
  219. package/src/services/patient/index.ts +2 -2
  220. package/src/services/patient/patient.service.ts +883 -883
  221. package/src/services/patient/patientRequirements.service.ts +285 -285
  222. package/src/services/patient/utils/aesthetic-analysis.utils.ts +176 -176
  223. package/src/services/patient/utils/clinic.utils.ts +80 -80
  224. package/src/services/patient/utils/docs.utils.ts +142 -142
  225. package/src/services/patient/utils/index.ts +9 -9
  226. package/src/services/patient/utils/location.utils.ts +126 -126
  227. package/src/services/patient/utils/medical-stuff.utils.ts +143 -143
  228. package/src/services/patient/utils/medical.utils.ts +458 -458
  229. package/src/services/patient/utils/practitioner.utils.ts +260 -260
  230. package/src/services/patient/utils/profile.utils.ts +510 -510
  231. package/src/services/patient/utils/sensitive.utils.ts +260 -260
  232. package/src/services/patient/utils/token.utils.ts +211 -211
  233. package/src/services/practitioner/README.md +145 -145
  234. package/src/services/practitioner/index.ts +1 -1
  235. package/src/services/practitioner/practitioner.service.ts +1799 -1742
  236. package/src/services/procedure/README.md +163 -163
  237. package/src/services/procedure/index.ts +1 -1
  238. package/src/services/procedure/procedure.service.ts +2307 -2200
  239. package/src/services/reviews/index.ts +1 -1
  240. package/src/services/reviews/reviews.service.ts +734 -734
  241. package/src/services/user/index.ts +1 -1
  242. package/src/services/user/user.service.ts +489 -489
  243. package/src/services/user/user.v2.service.ts +466 -466
  244. package/src/types/analytics/analytics.types.ts +597 -597
  245. package/src/types/analytics/grouped-analytics.types.ts +173 -173
  246. package/src/types/analytics/index.ts +4 -4
  247. package/src/types/analytics/stored-analytics.types.ts +137 -137
  248. package/src/types/appointment/index.ts +480 -480
  249. package/src/types/calendar/index.ts +258 -258
  250. package/src/types/calendar/synced-calendar.types.ts +66 -66
  251. package/src/types/clinic/index.ts +498 -498
  252. package/src/types/clinic/practitioner-invite.types.ts +91 -91
  253. package/src/types/clinic/preferences.types.ts +159 -159
  254. package/src/types/clinic/to-do +3 -3
  255. package/src/types/documentation-templates/index.ts +308 -308
  256. package/src/types/index.ts +47 -47
  257. package/src/types/notifications/README.md +77 -77
  258. package/src/types/notifications/index.ts +286 -286
  259. package/src/types/patient/aesthetic-analysis.types.ts +66 -66
  260. package/src/types/patient/allergies.ts +58 -58
  261. package/src/types/patient/index.ts +275 -275
  262. package/src/types/patient/medical-info.types.ts +152 -152
  263. package/src/types/patient/patient-requirements.ts +92 -92
  264. package/src/types/patient/token.types.ts +61 -61
  265. package/src/types/practitioner/index.ts +206 -206
  266. package/src/types/procedure/index.ts +181 -181
  267. package/src/types/profile/index.ts +39 -39
  268. package/src/types/reviews/index.ts +132 -132
  269. package/src/types/tz-lookup.d.ts +4 -4
  270. package/src/types/user/index.ts +38 -38
  271. package/src/utils/TIMESTAMPS.md +176 -176
  272. package/src/utils/TimestampUtils.ts +241 -241
  273. package/src/utils/index.ts +1 -1
  274. package/src/validations/appointment.schema.ts +574 -574
  275. package/src/validations/calendar.schema.ts +225 -225
  276. package/src/validations/clinic.schema.ts +494 -494
  277. package/src/validations/common.schema.ts +25 -25
  278. package/src/validations/documentation-templates/index.ts +1 -1
  279. package/src/validations/documentation-templates/template.schema.ts +220 -220
  280. package/src/validations/documentation-templates.schema.ts +10 -10
  281. package/src/validations/index.ts +20 -20
  282. package/src/validations/media.schema.ts +10 -10
  283. package/src/validations/notification.schema.ts +90 -90
  284. package/src/validations/patient/aesthetic-analysis.schema.ts +55 -55
  285. package/src/validations/patient/medical-info.schema.ts +125 -125
  286. package/src/validations/patient/patient-requirements.schema.ts +84 -84
  287. package/src/validations/patient/token.schema.ts +29 -29
  288. package/src/validations/patient.schema.ts +217 -217
  289. package/src/validations/practitioner.schema.ts +222 -222
  290. package/src/validations/procedure-product.schema.ts +41 -41
  291. package/src/validations/procedure.schema.ts +124 -124
  292. package/src/validations/profile-info.schema.ts +41 -41
  293. package/src/validations/reviews.schema.ts +195 -195
  294. package/src/validations/schemas.ts +104 -104
  295. package/src/validations/shared.schema.ts +78 -78
@@ -1,2558 +1,2558 @@
1
- import {
2
- Firestore,
3
- Timestamp,
4
- DocumentSnapshot,
5
- serverTimestamp,
6
- arrayUnion,
7
- arrayRemove,
8
- QueryConstraint,
9
- where,
10
- orderBy,
11
- collection,
12
- query,
13
- limit,
14
- startAfter,
15
- getDocs,
16
- getCountFromServer,
17
- doc,
18
- getDoc,
19
- } from 'firebase/firestore';
20
- import { Auth } from 'firebase/auth';
21
- import { FirebaseApp } from 'firebase/app';
22
- import { Functions, getFunctions, httpsCallable } from 'firebase/functions';
23
- import { BaseService } from '../base.service';
24
- import {
25
- Appointment,
26
- AppointmentStatus,
27
- UpdateAppointmentData,
28
- SearchAppointmentsParams,
29
- PaymentStatus,
30
- AppointmentMediaItem,
31
- PatientReviewInfo,
32
- type CreateAppointmentHttpData,
33
- type ZonePhotoUploadData,
34
- BeforeAfterPerZone,
35
- ZoneItemData,
36
- ExtendedProcedureInfo,
37
- AppointmentProductMetadata,
38
- RecommendedProcedure,
39
- NextStepsRecommendation,
40
- APPOINTMENTS_COLLECTION,
41
- } from '../../types/appointment';
42
- import { PROCEDURES_COLLECTION } from '../../types/procedure';
43
- import {
44
- updateAppointmentSchema,
45
- searchAppointmentsSchema,
46
- rescheduleAppointmentSchema,
47
- zonePhotoUploadSchema,
48
- } from '../../validations/appointment.schema';
49
-
50
- // Import other services needed (dependency injection pattern)
51
- import { CalendarServiceV2 } from '../calendar/calendar.v2.service';
52
- import { PatientService } from '../patient/patient.service';
53
- import { PractitionerService } from '../practitioner/practitioner.service';
54
- import { ClinicService } from '../clinic/clinic.service';
55
- import { FilledDocumentService } from '../documentation-templates/filled-document.service';
56
- import {
57
- MediaService,
58
- MediaAccessLevel,
59
- MediaMetadata,
60
- MediaResource,
61
- } from '../media/media.service';
62
-
63
- // Import utility functions
64
- import {
65
- updateAppointmentUtil,
66
- getAppointmentByIdUtil,
67
- searchAppointmentsUtil,
68
- } from './utils/appointment.utils';
69
- import {
70
- addItemToZoneUtil,
71
- removeItemFromZoneUtil,
72
- updateZoneItemUtil,
73
- overridePriceForZoneItemUtil,
74
- updateSubzonesUtil,
75
- calculateFinalBilling,
76
- } from './utils/zone-management.utils';
77
- import {
78
- addExtendedProcedureUtil,
79
- removeExtendedProcedureUtil,
80
- getExtendedProceduresUtil,
81
- getAppointmentProductsUtil,
82
- } from './utils/extended-procedure.utils';
83
- import {
84
- addRecommendedProcedureUtil,
85
- removeRecommendedProcedureUtil,
86
- updateRecommendedProcedureUtil,
87
- getRecommendedProceduresUtil,
88
- } from './utils/recommended-procedure.utils';
89
- import {
90
- updateZonePhotoEntryUtil,
91
- addAfterPhotoToEntryUtil,
92
- updateZonePhotoNotesUtil,
93
- getZonePhotoEntryUtil,
94
- } from './utils/zone-photo.utils';
95
-
96
- /**
97
- * Interface for available booking slot
98
- */
99
- interface AvailableSlot {
100
- start: Date;
101
- }
102
-
103
- /**
104
- * AppointmentService is responsible for managing appointments,
105
- * including creating, updating, retrieving, and searching appointments.
106
- * It serves as the main entry point for working with appointment data.
107
- */
108
- export class AppointmentService extends BaseService {
109
- private calendarService: CalendarServiceV2;
110
- private patientService: PatientService;
111
- private practitionerService: PractitionerService;
112
- private clinicService: ClinicService;
113
- private filledDocumentService: FilledDocumentService;
114
- private mediaService: MediaService;
115
- private functions: Functions;
116
-
117
- /**
118
- * Creates a new AppointmentService instance.
119
- *
120
- * @param db Firestore instance
121
- * @param auth Firebase Auth instance
122
- * @param app Firebase App instance
123
- * @param calendarService Calendar service instance
124
- * @param patientService Patient service instance
125
- * @param practitionerService Practitioner service instance
126
- * @param clinicService Clinic service instance
127
- * @param filledDocumentService Filled document service instance
128
- */
129
- constructor(
130
- db: Firestore,
131
- auth: Auth,
132
- app: FirebaseApp,
133
- calendarService: CalendarServiceV2,
134
- patientService: PatientService,
135
- practitionerService: PractitionerService,
136
- clinicService: ClinicService,
137
- filledDocumentService: FilledDocumentService,
138
- ) {
139
- super(db, auth, app);
140
- this.calendarService = calendarService;
141
- this.patientService = patientService;
142
- this.practitionerService = practitionerService;
143
- this.clinicService = clinicService;
144
- this.filledDocumentService = filledDocumentService;
145
- this.mediaService = new MediaService(db, auth, app);
146
- this.functions = getFunctions(app, 'europe-west6'); // Initialize Firebase Functions with the correct region
147
- }
148
-
149
- /**
150
- * Gets available booking slots for a specific clinic, practitioner, and procedure using HTTP request.
151
- * This is an alternative implementation using direct HTTP request instead of callable function.
152
- *
153
- * @param clinicId ID of the clinic
154
- * @param practitionerId ID of the practitioner
155
- * @param procedureId ID of the procedure
156
- * @param startDate Start date of the time range to check
157
- * @param endDate End date of the time range to check
158
- * @returns Array of available booking slots
159
- */
160
- async getAvailableBookingSlotsHttp(
161
- clinicId: string,
162
- practitionerId: string,
163
- procedureId: string,
164
- startDate: Date,
165
- endDate: Date,
166
- ): Promise<AvailableSlot[]> {
167
- try {
168
- console.log(
169
- `[APPOINTMENT_SERVICE] Getting available booking slots via HTTP for clinic: ${clinicId}, practitioner: ${practitionerId}, procedure: ${procedureId}`,
170
- );
171
-
172
- // Validate input parameters
173
- if (!clinicId || !practitionerId || !procedureId || !startDate || !endDate) {
174
- throw new Error('Missing required parameters for booking slots calculation');
175
- }
176
-
177
- if (endDate <= startDate) {
178
- throw new Error('End date must be after start date');
179
- }
180
-
181
- // Check if user is authenticated
182
- const currentUser = this.auth.currentUser;
183
- if (!currentUser) {
184
- throw new Error('User must be authenticated to get available booking slots');
185
- }
186
-
187
- // Construct the function URL for the Express app endpoint
188
- const functionUrl = `https://europe-west6-metaestetics.cloudfunctions.net/bookingApi/getAvailableBookingSlots`;
189
-
190
- // Get the authenticated user's ID token
191
- // By default, getIdToken doesn't allow setting audience for web/mobile clients
192
- // So we need to treat this token specially on the server side
193
- const idToken = await currentUser.getIdToken();
194
-
195
- // Log that we're getting a token
196
- console.log(`[APPOINTMENT_SERVICE] Got user token, user ID: ${currentUser.uid}`);
197
-
198
- // Alternate direct URL (if needed):
199
- // const functionUrl = `https://getavailablebookingslotshttp-grqala5m6a-oa.a.run.app`;
200
-
201
- // Request data
202
- const requestData = {
203
- clinicId,
204
- practitionerId,
205
- procedureId,
206
- timeframe: {
207
- start: startDate.getTime(), // Convert to timestamp
208
- end: endDate.getTime(),
209
- },
210
- };
211
-
212
- console.log(`[APPOINTMENT_SERVICE] Making fetch request to ${functionUrl}`);
213
-
214
- // Make the HTTP request with expanded CORS options for browser
215
- const response = await fetch(functionUrl, {
216
- method: 'POST',
217
- mode: 'cors', // Important for cross-origin requests
218
- cache: 'no-cache', // Don't cache this request
219
- credentials: 'omit', // Don't send cookies since we're using token auth
220
- headers: {
221
- 'Content-Type': 'application/json',
222
- Authorization: `Bearer ${idToken}`,
223
- },
224
- redirect: 'follow',
225
- referrerPolicy: 'no-referrer',
226
- body: JSON.stringify(requestData),
227
- });
228
-
229
- console.log(
230
- `[APPOINTMENT_SERVICE] Received response ${response.status}: ${response.statusText}`,
231
- );
232
-
233
- // Check if the request was successful
234
- if (!response.ok) {
235
- const errorText = await response.text();
236
- console.error(`[APPOINTMENT_SERVICE] Error response details: ${errorText}`);
237
- throw new Error(
238
- `Failed to get available booking slots: ${response.status} ${response.statusText} - ${errorText}`,
239
- );
240
- }
241
-
242
- // Parse the response
243
- const result = await response.json();
244
- console.log(`[APPOINTMENT_SERVICE] Response parsed successfully`, result);
245
-
246
- if (!result.success) {
247
- throw new Error(result.error || 'Failed to get available booking slots');
248
- }
249
-
250
- // Convert timestamp numbers to Date objects
251
- const slots: AvailableSlot[] = result.availableSlots.map((slot: { start: number }) => ({
252
- start: new Date(slot.start),
253
- }));
254
-
255
- console.log(`[APPOINTMENT_SERVICE] Found ${slots.length} available booking slots via HTTP`);
256
-
257
- return slots;
258
- } catch (error) {
259
- console.error('[APPOINTMENT_SERVICE] Error getting available booking slots via HTTP:', error);
260
- throw error;
261
- }
262
- }
263
-
264
- /**
265
- * Creates an appointment via the Cloud Function orchestrateAppointmentCreation
266
- *
267
- * @param data - CreateAppointmentData object
268
- * @returns The created appointment
269
- */
270
- async createAppointmentHttp(data: CreateAppointmentHttpData): Promise<Appointment> {
271
- try {
272
- console.log('[APPOINTMENT_SERVICE] Creating appointment via cloud function');
273
-
274
- // Get the authenticated user's ID token
275
- const currentUser = this.auth.currentUser;
276
- if (!currentUser) {
277
- throw new Error('User must be authenticated to create an appointment');
278
- }
279
- const idToken = await currentUser.getIdToken();
280
-
281
- // Construct the function URL for the Express app endpoint
282
- const functionUrl = `https://europe-west6-metaestetics.cloudfunctions.net/bookingApi/orchestrateAppointmentCreation`;
283
-
284
- // Prepare request data for the Cloud Function
285
- // Map CreateAppointmentData to OrchestrateAppointmentCreationData format
286
- const requestData = {
287
- patientId: data.patientId,
288
- procedureId: data.procedureId,
289
- appointmentStartTime: data.appointmentStartTime.toMillis
290
- ? data.appointmentStartTime.toMillis()
291
- : new Date(data.appointmentStartTime as any).getTime(),
292
- appointmentEndTime: data.appointmentEndTime.toMillis
293
- ? data.appointmentEndTime.toMillis()
294
- : new Date(data.appointmentEndTime as any).getTime(),
295
- patientNotes: data?.patientNotes || null,
296
- };
297
-
298
- console.log(`[APPOINTMENT_SERVICE] Making fetch request to ${functionUrl}`);
299
-
300
- // Make the HTTP request with expanded CORS options for browser
301
- const response = await fetch(functionUrl, {
302
- method: 'POST',
303
- mode: 'cors',
304
- cache: 'no-cache',
305
- credentials: 'omit',
306
- headers: {
307
- 'Content-Type': 'application/json',
308
- Authorization: `Bearer ${idToken}`,
309
- },
310
- redirect: 'follow',
311
- referrerPolicy: 'no-referrer',
312
- body: JSON.stringify(requestData),
313
- });
314
-
315
- console.log(
316
- `[APPOINTMENT_SERVICE] Received response ${response.status}: ${response.statusText}`,
317
- );
318
-
319
- // Check if the request was successful
320
- if (!response.ok) {
321
- const errorText = await response.text();
322
- console.error(`[APPOINTMENT_SERVICE] Error response details: ${errorText}`);
323
- throw new Error(
324
- `Failed to create appointment: ${response.status} ${response.statusText} - ${errorText}`,
325
- );
326
- }
327
-
328
- // Parse the response
329
- const result = await response.json();
330
-
331
- if (!result.success) {
332
- throw new Error(result.error || 'Failed to create appointment');
333
- }
334
-
335
- // If the backend returns the full appointment data
336
- if (result.appointmentData) {
337
- console.log(`[APPOINTMENT_SERVICE] Appointment created with ID: ${result.appointmentId}`);
338
- return result.appointmentData;
339
- }
340
-
341
- // If only the ID is returned, fetch the complete appointment
342
- const createdAppointment = await this.getAppointmentById(result.appointmentId);
343
- if (!createdAppointment) {
344
- throw new Error(`Failed to retrieve created appointment with ID: ${result.appointmentId}`);
345
- }
346
-
347
- return createdAppointment;
348
- } catch (error) {
349
- console.error('[APPOINTMENT_SERVICE] Error creating appointment via cloud function:', error);
350
- throw error;
351
- }
352
- }
353
-
354
- /**
355
- * Gets an appointment by ID.
356
- *
357
- * @param appointmentId ID of the appointment to retrieve
358
- * @returns The appointment or null if not found
359
- */
360
- async getAppointmentById(appointmentId: string): Promise<Appointment | null> {
361
- try {
362
- console.log(`[APPOINTMENT_SERVICE] Getting appointment with ID: ${appointmentId}`);
363
-
364
- const appointment = await getAppointmentByIdUtil(this.db, appointmentId);
365
-
366
- console.log(
367
- `[APPOINTMENT_SERVICE] Appointment ${appointmentId} ${appointment ? 'found' : 'not found'}`,
368
- );
369
-
370
- return appointment;
371
- } catch (error) {
372
- console.error(`[APPOINTMENT_SERVICE] Error getting appointment ${appointmentId}:`, error);
373
- throw error;
374
- }
375
- }
376
-
377
- /**
378
- * Updates an existing appointment.
379
- *
380
- * @param appointmentId ID of the appointment to update
381
- * @param data Update data for the appointment
382
- * @returns The updated appointment
383
- */
384
- async updateAppointment(
385
- appointmentId: string,
386
- data: UpdateAppointmentData,
387
- ): Promise<Appointment> {
388
- try {
389
- console.log(`[APPOINTMENT_SERVICE] Updating appointment with ID: ${appointmentId}`);
390
-
391
- // AUTO-MIGRATION: Convert old zonePhotos format to new array format BEFORE validation
392
- if (data.metadata?.zonePhotos) {
393
- const migratedZonePhotos: Record<string, BeforeAfterPerZone[]> = {};
394
-
395
- for (const [key, value] of Object.entries(data.metadata.zonePhotos)) {
396
- if (Array.isArray(value)) {
397
- // Already in new format
398
- migratedZonePhotos[key] = value as BeforeAfterPerZone[];
399
- } else {
400
- // Old format - convert to array
401
- console.log(`[APPOINTMENT_SERVICE] Auto-migrating ${key} from object to array format`);
402
- const oldData = value as any;
403
- migratedZonePhotos[key] = [
404
- {
405
- before: oldData.before || null,
406
- after: oldData.after || null,
407
- beforeNote: null,
408
- afterNote: null,
409
- },
410
- ];
411
- }
412
- }
413
-
414
- // Replace with migrated data
415
- data.metadata.zonePhotos = migratedZonePhotos;
416
- }
417
-
418
- // AUTO-CLEANUP: Remove invalid recommendedProcedures with empty notes BEFORE validation
419
- console.log(
420
- '[APPOINTMENT_SERVICE] 🔍 BEFORE CLEANUP - recommendedProcedures:',
421
- JSON.stringify(data.metadata?.recommendedProcedures, null, 2),
422
- );
423
-
424
- if (
425
- data.metadata?.recommendedProcedures &&
426
- Array.isArray(data.metadata.recommendedProcedures)
427
- ) {
428
- const validRecommendations = data.metadata.recommendedProcedures.filter((rec: any) => {
429
- const isValid = rec.note && typeof rec.note === 'string' && rec.note.trim().length > 0;
430
- if (!isValid) {
431
- console.log('[APPOINTMENT_SERVICE] ❌ INVALID recommendation found:', rec);
432
- }
433
- return isValid;
434
- });
435
-
436
- if (validRecommendations.length !== data.metadata.recommendedProcedures.length) {
437
- console.log(
438
- `[APPOINTMENT_SERVICE] 🧹 Removing ${
439
- data.metadata.recommendedProcedures.length - validRecommendations.length
440
- } invalid recommended procedures with empty notes`,
441
- );
442
- data.metadata.recommendedProcedures = validRecommendations;
443
- } else {
444
- console.log('[APPOINTMENT_SERVICE] ✅ All recommendedProcedures are valid');
445
- }
446
- }
447
-
448
- console.log(
449
- '[APPOINTMENT_SERVICE] 🔍 AFTER CLEANUP - recommendedProcedures:',
450
- JSON.stringify(data.metadata?.recommendedProcedures, null, 2),
451
- );
452
-
453
- // Validate input data
454
- console.log('[APPOINTMENT_SERVICE] 🔍 Starting Zod validation...');
455
- const validatedData = await updateAppointmentSchema.parseAsync(data);
456
- console.log('[APPOINTMENT_SERVICE] ✅ Zod validation passed!');
457
-
458
- // Update the appointment using the utility function
459
- const updatedAppointment = await updateAppointmentUtil(this.db, appointmentId, validatedData);
460
-
461
- console.log(`[APPOINTMENT_SERVICE] Appointment ${appointmentId} updated successfully`);
462
-
463
- return updatedAppointment;
464
- } catch (error) {
465
- console.error(`[APPOINTMENT_SERVICE] Error updating appointment ${appointmentId}:`, error);
466
- throw error;
467
- }
468
- }
469
-
470
- /**
471
- * Searches for appointments based on various criteria.
472
- *
473
- * @param params Search parameters
474
- * @returns Found appointments and the last document for pagination
475
- */
476
- async searchAppointments(params: SearchAppointmentsParams): Promise<{
477
- appointments: Appointment[];
478
- lastDoc: DocumentSnapshot | null;
479
- }> {
480
- try {
481
- console.log('[APPOINTMENT_SERVICE] Searching appointments with params:', params);
482
-
483
- // Validate search parameters
484
- await searchAppointmentsSchema.parseAsync(params);
485
-
486
- // Search for appointments using the utility function
487
- const result = await searchAppointmentsUtil(this.db, params);
488
-
489
- console.log(`[APPOINTMENT_SERVICE] Found ${result.appointments.length} appointments`);
490
-
491
- return result;
492
- } catch (error) {
493
- console.error('[APPOINTMENT_SERVICE] Error searching appointments:', error);
494
- throw error;
495
- }
496
- }
497
-
498
- /**
499
- * Gets appointments for a specific patient.
500
- *
501
- * @param patientId ID of the patient
502
- * @param options Optional parameters for filtering and pagination
503
- * @returns Found appointments and the last document for pagination
504
- */
505
- async getPatientAppointments(
506
- patientId: string,
507
- options?: {
508
- startDate?: Date;
509
- endDate?: Date;
510
- status?: AppointmentStatus | AppointmentStatus[];
511
- limit?: number;
512
- startAfter?: DocumentSnapshot;
513
- },
514
- ): Promise<{
515
- appointments: Appointment[];
516
- lastDoc: DocumentSnapshot | null;
517
- }> {
518
- console.log(`[APPOINTMENT_SERVICE] Getting appointments for patient: ${patientId}`);
519
-
520
- const searchParams: SearchAppointmentsParams = {
521
- patientId,
522
- startDate: options?.startDate,
523
- endDate: options?.endDate,
524
- status: options?.status,
525
- limit: options?.limit,
526
- startAfter: options?.startAfter,
527
- };
528
-
529
- return this.searchAppointments(searchParams);
530
- }
531
-
532
- /**
533
- * Gets appointments for a specific practitioner.
534
- *
535
- * @param practitionerId ID of the practitioner
536
- * @param options Optional parameters for filtering and pagination
537
- * @returns Found appointments and the last document for pagination
538
- */
539
- async getPractitionerAppointments(
540
- practitionerId: string,
541
- options?: {
542
- startDate?: Date;
543
- endDate?: Date;
544
- status?: AppointmentStatus | AppointmentStatus[];
545
- limit?: number;
546
- startAfter?: DocumentSnapshot;
547
- },
548
- ): Promise<{
549
- appointments: Appointment[];
550
- lastDoc: DocumentSnapshot | null;
551
- }> {
552
- console.log(`[APPOINTMENT_SERVICE] Getting appointments for practitioner: ${practitionerId}`);
553
-
554
- const searchParams: SearchAppointmentsParams = {
555
- practitionerId,
556
- startDate: options?.startDate,
557
- endDate: options?.endDate,
558
- status: options?.status,
559
- limit: options?.limit,
560
- startAfter: options?.startAfter,
561
- };
562
-
563
- return this.searchAppointments(searchParams);
564
- }
565
-
566
- /**
567
- * Gets appointments for a specific clinic.
568
- *
569
- * @param clinicBranchId ID of the clinic branch
570
- * @param options Optional parameters for filtering and pagination
571
- * @returns Found appointments and the last document for pagination
572
- */
573
- async getClinicAppointments(
574
- clinicBranchId: string,
575
- options?: {
576
- practitionerId?: string;
577
- startDate?: Date;
578
- endDate?: Date;
579
- status?: AppointmentStatus | AppointmentStatus[];
580
- limit?: number;
581
- startAfter?: DocumentSnapshot;
582
- },
583
- ): Promise<{
584
- appointments: Appointment[];
585
- lastDoc: DocumentSnapshot | null;
586
- }> {
587
- console.log(`[APPOINTMENT_SERVICE] Getting appointments for clinic: ${clinicBranchId}`);
588
-
589
- const searchParams: SearchAppointmentsParams = {
590
- clinicBranchId,
591
- practitionerId: options?.practitionerId,
592
- startDate: options?.startDate,
593
- endDate: options?.endDate,
594
- status: options?.status,
595
- limit: options?.limit,
596
- startAfter: options?.startAfter,
597
- };
598
-
599
- return this.searchAppointments(searchParams);
600
- }
601
-
602
- /**
603
- * Updates the status of an appointment.
604
- *
605
- * @param appointmentId ID of the appointment
606
- * @param newStatus New status to set
607
- * @param details Optional details for the status change
608
- * @returns The updated appointment
609
- */
610
- async updateAppointmentStatus(
611
- appointmentId: string,
612
- newStatus: AppointmentStatus,
613
- details?: {
614
- cancellationReason?: string;
615
- canceledBy?: 'patient' | 'clinic' | 'practitioner' | 'system';
616
- },
617
- ): Promise<Appointment> {
618
- console.log(
619
- `[APPOINTMENT_SERVICE] Updating status of appointment ${appointmentId} to ${newStatus}`,
620
- );
621
- const updateData: UpdateAppointmentData = {
622
- status: newStatus,
623
- updatedAt: serverTimestamp(),
624
- };
625
-
626
- if (
627
- newStatus === AppointmentStatus.CANCELED_CLINIC ||
628
- newStatus === AppointmentStatus.CANCELED_PATIENT ||
629
- newStatus === AppointmentStatus.CANCELED_PATIENT_RESCHEDULED
630
- ) {
631
- if (!details?.cancellationReason) {
632
- throw new Error('Cancellation reason is required when canceling.');
633
- }
634
- if (!details?.canceledBy) {
635
- throw new Error('Canceled by is required when canceling.');
636
- }
637
- updateData.cancellationReason = details.cancellationReason;
638
- updateData.canceledBy = details.canceledBy;
639
- updateData.cancellationTime = Timestamp.now();
640
- }
641
-
642
- if (newStatus === AppointmentStatus.CONFIRMED) {
643
- updateData.confirmationTime = Timestamp.now();
644
- }
645
-
646
- if (newStatus === AppointmentStatus.RESCHEDULED_BY_CLINIC) {
647
- updateData.rescheduleTime = Timestamp.now();
648
- }
649
-
650
- return this.updateAppointment(appointmentId, updateData);
651
- }
652
-
653
- /**
654
- * Confirms a PENDING appointment by an Admin/Clinic.
655
- */
656
- async confirmAppointmentAdmin(appointmentId: string): Promise<Appointment> {
657
- console.log(`[APPOINTMENT_SERVICE] Admin confirming appointment: ${appointmentId}`);
658
- const appointment = await this.getAppointmentById(appointmentId);
659
- if (!appointment) throw new Error(`Appointment ${appointmentId} not found.`);
660
- if (appointment.status !== AppointmentStatus.PENDING) {
661
- throw new Error(`Appointment ${appointmentId} is not in PENDING state to be confirmed.`);
662
- }
663
- return this.updateAppointmentStatus(appointmentId, AppointmentStatus.CONFIRMED);
664
- }
665
-
666
- /**
667
- * Cancels an appointment by the User (Patient).
668
- */
669
- async cancelAppointmentUser(appointmentId: string, reason: string): Promise<Appointment> {
670
- console.log(`[APPOINTMENT_SERVICE] User canceling appointment: ${appointmentId}`);
671
- return this.updateAppointmentStatus(appointmentId, AppointmentStatus.CANCELED_PATIENT, {
672
- cancellationReason: reason,
673
- canceledBy: 'patient',
674
- });
675
- }
676
-
677
- /**
678
- * Cancels an appointment by an Admin/Clinic.
679
- */
680
- async cancelAppointmentAdmin(appointmentId: string, reason: string): Promise<Appointment> {
681
- console.log(`[APPOINTMENT_SERVICE] Admin canceling appointment: ${appointmentId}`);
682
- return this.updateAppointmentStatus(appointmentId, AppointmentStatus.CANCELED_CLINIC, {
683
- cancellationReason: reason,
684
- canceledBy: 'clinic',
685
- });
686
- }
687
-
688
- /**
689
- * Admin proposes to reschedule an appointment.
690
- * Sets status to RESCHEDULED_BY_CLINIC and updates times.
691
- */
692
- async rescheduleAppointmentAdmin(params: {
693
- appointmentId: string;
694
- newStartTime: any; // Accept any type (number, string, Timestamp, etc.)
695
- newEndTime: any; // Accept any type (number, string, Timestamp, etc.)
696
- }): Promise<Appointment> {
697
- console.log(`[APPOINTMENT_SERVICE] Admin rescheduling appointment: ${params.appointmentId}`);
698
-
699
- // Validate input data
700
- const validatedParams = await rescheduleAppointmentSchema.parseAsync(params);
701
-
702
- // Convert input to Timestamp objects
703
- const startTimestamp = this.convertToTimestamp(validatedParams.newStartTime);
704
- const endTimestamp = this.convertToTimestamp(validatedParams.newEndTime);
705
-
706
- if (endTimestamp.toMillis() <= startTimestamp.toMillis()) {
707
- throw new Error('New end time must be after new start time.');
708
- }
709
-
710
- const updateData: UpdateAppointmentData = {
711
- status: AppointmentStatus.RESCHEDULED_BY_CLINIC,
712
- appointmentStartTime: startTimestamp,
713
- appointmentEndTime: endTimestamp,
714
- rescheduleTime: Timestamp.now(),
715
- confirmationTime: null,
716
- updatedAt: serverTimestamp(),
717
- };
718
- return this.updateAppointment(validatedParams.appointmentId, updateData);
719
- }
720
-
721
- /**
722
- * Helper method to convert various timestamp formats to Firestore Timestamp
723
- * @param value - Any timestamp format (Timestamp, number, string, Date, serialized Timestamp)
724
- * @returns Firestore Timestamp object
725
- */
726
- private convertToTimestamp(value: any): Timestamp {
727
- console.log(`[APPOINTMENT_SERVICE] Converting timestamp:`, {
728
- value,
729
- type: typeof value,
730
- });
731
-
732
- // If it's already a Timestamp object with methods
733
- if (value && typeof value.toMillis === 'function') {
734
- return value;
735
- }
736
-
737
- // If it's a number (milliseconds since epoch)
738
- if (typeof value === 'number') {
739
- return Timestamp.fromMillis(value);
740
- }
741
-
742
- // If it's a string (ISO date string)
743
- if (typeof value === 'string') {
744
- return Timestamp.fromDate(new Date(value));
745
- }
746
-
747
- // If it's a Date object
748
- if (value instanceof Date) {
749
- return Timestamp.fromDate(value);
750
- }
751
-
752
- // If it has _seconds property (serialized Timestamp) - THIS IS WHAT FRONTEND SENDS
753
- if (value && typeof value._seconds === 'number') {
754
- return new Timestamp(value._seconds, value._nanoseconds || 0);
755
- }
756
-
757
- // If it has seconds property (serialized Timestamp)
758
- if (value && typeof value.seconds === 'number') {
759
- return new Timestamp(value.seconds, value.nanoseconds || 0);
760
- }
761
-
762
- throw new Error(`Invalid timestamp format: ${typeof value}, value: ${JSON.stringify(value)}`);
763
- }
764
-
765
- /**
766
- * User confirms a reschedule proposed by the clinic.
767
- * Status changes from RESCHEDULED_BY_CLINIC to CONFIRMED.
768
- */
769
- async rescheduleAppointmentConfirmUser(appointmentId: string): Promise<Appointment> {
770
- console.log(`[APPOINTMENT_SERVICE] User confirming reschedule for: ${appointmentId}`);
771
- const appointment = await this.getAppointmentById(appointmentId);
772
- if (!appointment) throw new Error(`Appointment ${appointmentId} not found.`);
773
- if (appointment.status !== AppointmentStatus.RESCHEDULED_BY_CLINIC) {
774
- throw new Error(`Appointment ${appointmentId} is not in RESCHEDULED_BY_CLINIC state.`);
775
- }
776
- return this.updateAppointmentStatus(appointmentId, AppointmentStatus.CONFIRMED);
777
- }
778
-
779
- /**
780
- * User rejects a reschedule proposed by the clinic.
781
- * Status changes from RESCHEDULED_BY_CLINIC to CANCELED_PATIENT_RESCHEDULED.
782
- */
783
- async rescheduleAppointmentRejectUser(
784
- appointmentId: string,
785
- reason: string,
786
- ): Promise<Appointment> {
787
- console.log(`[APPOINTMENT_SERVICE] User rejecting reschedule for: ${appointmentId}`);
788
- const appointment = await this.getAppointmentById(appointmentId);
789
- if (!appointment) throw new Error(`Appointment ${appointmentId} not found.`);
790
- if (appointment.status !== AppointmentStatus.RESCHEDULED_BY_CLINIC) {
791
- throw new Error(`Appointment ${appointmentId} is not in RESCHEDULED_BY_CLINIC state.`);
792
- }
793
- return this.updateAppointmentStatus(
794
- appointmentId,
795
- AppointmentStatus.CANCELED_PATIENT_RESCHEDULED,
796
- {
797
- cancellationReason: reason,
798
- canceledBy: 'patient',
799
- },
800
- );
801
- }
802
-
803
- /**
804
- * Admin checks in a patient for their appointment.
805
- * Requires all pending user forms to be completed.
806
- */
807
- async checkInPatientAdmin(appointmentId: string): Promise<Appointment> {
808
- console.log(`[APPOINTMENT_SERVICE] Admin checking in patient for: ${appointmentId}`);
809
- const appointment = await this.getAppointmentById(appointmentId);
810
- if (!appointment) throw new Error(`Appointment ${appointmentId} not found.`);
811
-
812
- if (appointment.pendingUserFormsIds && appointment.pendingUserFormsIds.length > 0) {
813
- throw new Error(
814
- `Cannot check in: Patient has ${
815
- appointment.pendingUserFormsIds.length
816
- } pending required form(s). IDs: ${appointment.pendingUserFormsIds.join(', ')}`,
817
- );
818
- }
819
- if (
820
- appointment.status !== AppointmentStatus.CONFIRMED &&
821
- appointment.status !== AppointmentStatus.RESCHEDULED_BY_CLINIC
822
- ) {
823
- console.warn(
824
- `Checking in appointment ${appointmentId} with status ${appointment.status}. Ensure this is intended.`,
825
- );
826
- }
827
-
828
- return this.updateAppointmentStatus(appointmentId, AppointmentStatus.CHECKED_IN);
829
- }
830
-
831
- /**
832
- * Doctor starts the appointment procedure.
833
- */
834
- async startAppointmentDoctor(appointmentId: string): Promise<Appointment> {
835
- console.log(`[APPOINTMENT_SERVICE] Doctor starting appointment: ${appointmentId}`);
836
- const appointment = await this.getAppointmentById(appointmentId);
837
- if (!appointment) throw new Error(`Appointment ${appointmentId} not found.`);
838
- if (appointment.status !== AppointmentStatus.CHECKED_IN) {
839
- throw new Error(`Appointment ${appointmentId} must be CHECKED_IN to start.`);
840
- }
841
- // Update status and set procedureActualStartTime
842
- const updateData: UpdateAppointmentData = {
843
- status: AppointmentStatus.IN_PROGRESS,
844
- procedureActualStartTime: Timestamp.now(), // Set actual start time
845
- updatedAt: serverTimestamp(),
846
- };
847
- return this.updateAppointment(appointmentId, updateData);
848
- }
849
-
850
- /**
851
- * Doctor completes and finalizes the appointment.
852
- */
853
- async completeAppointmentDoctor(
854
- appointmentId: string,
855
- finalizationNotes: string,
856
- actualDurationMinutesInput?: number, // Renamed to avoid conflict if we calculate
857
- ): Promise<Appointment> {
858
- console.log(`[APPOINTMENT_SERVICE] Doctor completing appointment: ${appointmentId}`);
859
- const currentUser = this.auth.currentUser;
860
- if (!currentUser) throw new Error('Authentication required to complete appointment.');
861
-
862
- const appointment = await this.getAppointmentById(appointmentId);
863
- if (!appointment) throw new Error(`Appointment ${appointmentId} not found.`);
864
-
865
- let calculatedDurationMinutes = actualDurationMinutesInput;
866
- const procedureCompletionTime = Timestamp.now();
867
-
868
- // Calculate duration if not provided and actual start time is available
869
- if (calculatedDurationMinutes === undefined && appointment.procedureActualStartTime) {
870
- const startTimeMillis = appointment.procedureActualStartTime.toMillis();
871
- const endTimeMillis = procedureCompletionTime.toMillis();
872
- if (endTimeMillis > startTimeMillis) {
873
- calculatedDurationMinutes = Math.round((endTimeMillis - startTimeMillis) / 60000);
874
- }
875
- }
876
-
877
- const updateData: UpdateAppointmentData = {
878
- status: AppointmentStatus.COMPLETED,
879
- actualDurationMinutes: calculatedDurationMinutes, // Use calculated or provided duration
880
- finalizedDetails: {
881
- by: currentUser.uid, // This is used ID, not practitioner's profile ID (just so we know who completed the appointment)
882
- at: procedureCompletionTime, // Use consistent completion timestamp
883
- notes: finalizationNotes,
884
- },
885
- // Optionally update appointmentEndTime to the actual completion time
886
- // appointmentEndTime: procedureCompletionTime,
887
- updatedAt: serverTimestamp(),
888
- };
889
- return this.updateAppointment(appointmentId, updateData);
890
- }
891
-
892
- /**
893
- * Admin marks an appointment as No-Show.
894
- */
895
- async markNoShowAdmin(appointmentId: string): Promise<Appointment> {
896
- console.log(`[APPOINTMENT_SERVICE] Admin marking no-show for: ${appointmentId}`);
897
- const appointment = await this.getAppointmentById(appointmentId);
898
- if (!appointment) throw new Error(`Appointment ${appointmentId} not found.`);
899
- if (Timestamp.now().toMillis() < appointment.appointmentStartTime.toMillis()) {
900
- throw new Error('Cannot mark no-show before appointment start time.');
901
- }
902
- return this.updateAppointmentStatus(appointmentId, AppointmentStatus.NO_SHOW, {
903
- cancellationReason: 'Patient did not show up for the appointment.',
904
- canceledBy: 'clinic',
905
- });
906
- }
907
-
908
- /**
909
- * Adds a media item to an appointment.
910
- */
911
- async addMediaToAppointment(
912
- appointmentId: string,
913
- mediaItemData: Omit<AppointmentMediaItem, 'id' | 'uploadedAt'>,
914
- ): Promise<Appointment> {
915
- console.log(`[APPOINTMENT_SERVICE] Adding media to appointment ${appointmentId}`);
916
- const currentUser = this.auth.currentUser;
917
- if (!currentUser) throw new Error('Authentication required.');
918
-
919
- const newMediaItem: AppointmentMediaItem = {
920
- ...mediaItemData,
921
- id: this.generateId(),
922
- uploadedAt: Timestamp.now(),
923
- uploadedBy: currentUser.uid,
924
- };
925
-
926
- const updateData: UpdateAppointmentData = {
927
- media: arrayUnion(newMediaItem) as any,
928
- updatedAt: serverTimestamp(),
929
- };
930
- return this.updateAppointment(appointmentId, updateData);
931
- }
932
-
933
- /**
934
- * Removes a media item from an appointment.
935
- */
936
- async removeMediaFromAppointment(
937
- appointmentId: string,
938
- mediaItemId: string,
939
- ): Promise<Appointment> {
940
- console.log(
941
- `[APPOINTMENT_SERVICE] Removing media ${mediaItemId} from appointment ${appointmentId}`,
942
- );
943
- const appointment = await this.getAppointmentById(appointmentId);
944
- if (!appointment || !appointment.media) {
945
- throw new Error('Appointment or media list not found.');
946
- }
947
- const mediaToRemove = appointment.media.find(m => m.id === mediaItemId);
948
- if (!mediaToRemove) {
949
- throw new Error(`Media item ${mediaItemId} not found in appointment.`);
950
- }
951
-
952
- const updateData: UpdateAppointmentData = {
953
- media: arrayRemove(mediaToRemove) as any,
954
- updatedAt: serverTimestamp(),
955
- };
956
- return this.updateAppointment(appointmentId, updateData);
957
- }
958
-
959
- /**
960
- * Adds or updates review information for an appointment.
961
- */
962
- async addReviewToAppointment(
963
- appointmentId: string,
964
- reviewData: Omit<PatientReviewInfo, 'reviewedAt' | 'reviewId'>,
965
- ): Promise<Appointment> {
966
- console.log(`[APPOINTMENT_SERVICE] Adding review to appointment ${appointmentId}`);
967
- const newReviewInfo: PatientReviewInfo = {
968
- ...reviewData,
969
- reviewId: this.generateId(),
970
- reviewedAt: Timestamp.now(),
971
- };
972
- const updateData: UpdateAppointmentData = {
973
- reviewInfo: newReviewInfo,
974
- updatedAt: serverTimestamp(),
975
- };
976
- return this.updateAppointment(appointmentId, updateData);
977
- }
978
-
979
- /**
980
- * Updates the payment status of an appointment.
981
- */
982
- async updatePaymentStatus(
983
- appointmentId: string,
984
- paymentStatus: PaymentStatus,
985
- paymentTransactionId?: string,
986
- ): Promise<Appointment> {
987
- console.log(
988
- `[APPOINTMENT_SERVICE] Updating payment status of appointment ${appointmentId} to ${paymentStatus}`,
989
- );
990
- const updateData: UpdateAppointmentData = {
991
- paymentStatus,
992
- paymentTransactionId: paymentTransactionId || null,
993
- updatedAt: serverTimestamp(),
994
- };
995
- return this.updateAppointment(appointmentId, updateData);
996
- }
997
-
998
- /**
999
- * Updates the internal notes of an appointment.
1000
- *
1001
- * @param appointmentId ID of the appointment
1002
- * @param notes Updated internal notes
1003
- * @returns The updated appointment
1004
- */
1005
- async updateInternalNotes(appointmentId: string, notes: string | null): Promise<Appointment> {
1006
- console.log(`[APPOINTMENT_SERVICE] Updating internal notes for appointment: ${appointmentId}`);
1007
-
1008
- const updateData: UpdateAppointmentData = {
1009
- internalNotes: notes,
1010
- };
1011
-
1012
- return this.updateAppointment(appointmentId, updateData);
1013
- }
1014
-
1015
- /**
1016
- * Gets upcoming appointments for a specific patient.
1017
- * These include appointments with statuses: PENDING, CONFIRMED, CHECKED_IN, IN_PROGRESS
1018
- *
1019
- * @param patientId ID of the patient
1020
- * @param options Optional parameters for filtering and pagination
1021
- * @returns Found appointments and the last document for pagination
1022
- */
1023
- async getUpcomingPatientAppointments(
1024
- patientId: string,
1025
- options?: {
1026
- startDate?: Date; // Optional starting date (defaults to now)
1027
- endDate?: Date;
1028
- limit?: number;
1029
- startAfter?: DocumentSnapshot;
1030
- },
1031
- ): Promise<{
1032
- appointments: Appointment[];
1033
- lastDoc: DocumentSnapshot | null;
1034
- }> {
1035
- try {
1036
- console.log(`[APPOINTMENT_SERVICE] Getting upcoming appointments for patient: ${patientId}`);
1037
-
1038
- // Default to current date/time if no startDate provided
1039
- const effectiveStartDate = options?.startDate || new Date();
1040
-
1041
- // Define the statuses considered as "upcoming"
1042
- const upcomingStatuses = [
1043
- AppointmentStatus.PENDING,
1044
- AppointmentStatus.CONFIRMED,
1045
- AppointmentStatus.CHECKED_IN,
1046
- AppointmentStatus.IN_PROGRESS,
1047
- AppointmentStatus.RESCHEDULED_BY_CLINIC,
1048
- ];
1049
-
1050
- // Build query constraints
1051
- const constraints: QueryConstraint[] = [];
1052
-
1053
- // Patient ID filter
1054
- constraints.push(where('patientId', '==', patientId));
1055
-
1056
- // Status filter - multiple statuses
1057
- constraints.push(where('status', 'in', upcomingStatuses));
1058
-
1059
- // Date range filters
1060
- constraints.push(where('appointmentStartTime', '>=', Timestamp.fromDate(effectiveStartDate)));
1061
-
1062
- if (options?.endDate) {
1063
- constraints.push(where('appointmentStartTime', '<=', Timestamp.fromDate(options.endDate)));
1064
- }
1065
-
1066
- // Order by appointment start time (ascending for upcoming - closest first)
1067
- constraints.push(orderBy('appointmentStartTime', 'asc'));
1068
-
1069
- // Add pagination if specified
1070
- if (options?.limit) {
1071
- constraints.push(limit(options.limit));
1072
- }
1073
-
1074
- if (options?.startAfter) {
1075
- constraints.push(startAfter(options.startAfter));
1076
- }
1077
-
1078
- // Execute query
1079
- const q = query(collection(this.db, APPOINTMENTS_COLLECTION), ...constraints);
1080
- const querySnapshot = await getDocs(q);
1081
-
1082
- // Extract results
1083
- const appointments = querySnapshot.docs.map(doc => doc.data() as Appointment);
1084
-
1085
- // Get last document for pagination
1086
- const lastDoc =
1087
- querySnapshot.docs.length > 0 ? querySnapshot.docs[querySnapshot.docs.length - 1] : null;
1088
-
1089
- console.log(
1090
- `[APPOINTMENT_SERVICE] Found ${appointments.length} upcoming appointments for patient ${patientId}`,
1091
- );
1092
-
1093
- return { appointments, lastDoc };
1094
- } catch (error) {
1095
- console.error(
1096
- `[APPOINTMENT_SERVICE] Error getting upcoming appointments for patient ${patientId}:`,
1097
- error,
1098
- );
1099
- throw error;
1100
- }
1101
- }
1102
-
1103
- /**
1104
- * Gets past appointments for a specific patient.
1105
- * These include appointments with statuses: COMPLETED, CANCELED_PATIENT,
1106
- * CANCELED_PATIENT_RESCHEDULED, CANCELED_CLINIC, NO_SHOW
1107
- *
1108
- * @param patientId ID of the patient
1109
- * @param options Optional parameters for filtering and pagination
1110
- * @returns Found appointments and the last document for pagination
1111
- */
1112
- async getPastPatientAppointments(
1113
- patientId: string,
1114
- options?: {
1115
- startDate?: Date;
1116
- endDate?: Date; // Optional end date (defaults to now)
1117
- showCanceled?: boolean; // Whether to include canceled appointments
1118
- showNoShow?: boolean; // Whether to include no-show appointments
1119
- limit?: number;
1120
- startAfter?: DocumentSnapshot;
1121
- },
1122
- ): Promise<{
1123
- appointments: Appointment[];
1124
- lastDoc: DocumentSnapshot | null;
1125
- }> {
1126
- try {
1127
- console.log(`[APPOINTMENT_SERVICE] Getting past appointments for patient: ${patientId}`);
1128
-
1129
- // Default to current date/time if no endDate provided
1130
- const effectiveEndDate = options?.endDate || new Date();
1131
-
1132
- // Define the base status for past appointments
1133
- const pastStatuses: AppointmentStatus[] = [AppointmentStatus.COMPLETED];
1134
-
1135
- // Add canceled statuses if requested
1136
- if (options?.showCanceled) {
1137
- pastStatuses.push(
1138
- AppointmentStatus.CANCELED_PATIENT,
1139
- AppointmentStatus.CANCELED_PATIENT_RESCHEDULED,
1140
- AppointmentStatus.CANCELED_CLINIC,
1141
- );
1142
- }
1143
-
1144
- // Add no-show status if requested
1145
- if (options?.showNoShow) {
1146
- pastStatuses.push(AppointmentStatus.NO_SHOW);
1147
- }
1148
-
1149
- // Build query constraints
1150
- const constraints: QueryConstraint[] = [];
1151
-
1152
- // Patient ID filter
1153
- constraints.push(where('patientId', '==', patientId));
1154
-
1155
- // Status filter - multiple statuses
1156
- constraints.push(where('status', 'in', pastStatuses));
1157
-
1158
- // Date range filters
1159
- if (options?.startDate) {
1160
- constraints.push(
1161
- where('appointmentStartTime', '>=', Timestamp.fromDate(options.startDate)),
1162
- );
1163
- }
1164
-
1165
- constraints.push(where('appointmentStartTime', '<=', Timestamp.fromDate(effectiveEndDate)));
1166
-
1167
- // Order by appointment start time (descending for past - most recent first)
1168
- constraints.push(orderBy('appointmentStartTime', 'desc'));
1169
-
1170
- // Add pagination if specified
1171
- if (options?.limit) {
1172
- constraints.push(limit(options.limit));
1173
- }
1174
-
1175
- if (options?.startAfter) {
1176
- constraints.push(startAfter(options.startAfter));
1177
- }
1178
-
1179
- // Execute query
1180
- const q = query(collection(this.db, APPOINTMENTS_COLLECTION), ...constraints);
1181
- const querySnapshot = await getDocs(q);
1182
-
1183
- // Extract results
1184
- const appointments = querySnapshot.docs.map(doc => doc.data() as Appointment);
1185
-
1186
- // Get last document for pagination
1187
- const lastDoc =
1188
- querySnapshot.docs.length > 0 ? querySnapshot.docs[querySnapshot.docs.length - 1] : null;
1189
-
1190
- console.log(
1191
- `[APPOINTMENT_SERVICE] Found ${appointments.length} past appointments for patient ${patientId}`,
1192
- );
1193
-
1194
- return { appointments, lastDoc };
1195
- } catch (error) {
1196
- console.error(
1197
- `[APPOINTMENT_SERVICE] Error getting past appointments for patient ${patientId}:`,
1198
- error,
1199
- );
1200
- throw error;
1201
- }
1202
- }
1203
-
1204
- /**
1205
- * Counts completed appointments for a patient with optional clinic filtering.
1206
- *
1207
- * @param patientId ID of the patient.
1208
- * @param clinicBranchId Optional ID of the clinic branch to either include or exclude.
1209
- * @param excludeClinic Optional boolean. If true (default), excludes the specified clinic. If false, includes only that clinic.
1210
- * @returns The count of completed appointments.
1211
- */
1212
- async countCompletedAppointments(
1213
- patientId: string,
1214
- clinicBranchId?: string,
1215
- excludeClinic = true,
1216
- ): Promise<number> {
1217
- try {
1218
- console.log(
1219
- `[APPOINTMENT_SERVICE] Counting completed appointments for patient: ${patientId}`,
1220
- { clinicBranchId, excludeClinic },
1221
- );
1222
-
1223
- // Build query constraints
1224
- const constraints: QueryConstraint[] = [
1225
- where('patientId', '==', patientId),
1226
- where('status', '==', AppointmentStatus.COMPLETED),
1227
- ];
1228
-
1229
- if (clinicBranchId) {
1230
- if (excludeClinic) {
1231
- // Exclude appointments from the specified clinic
1232
- constraints.push(where('clinicBranchId', '!=', clinicBranchId));
1233
- } else {
1234
- // Include only appointments from the specified clinic
1235
- constraints.push(where('clinicBranchId', '==', clinicBranchId));
1236
- }
1237
- }
1238
-
1239
- // Execute query to get only the count
1240
- const q = query(collection(this.db, APPOINTMENTS_COLLECTION), ...constraints);
1241
- const snapshot = await getCountFromServer(q);
1242
- const count = snapshot.data().count;
1243
-
1244
- console.log(
1245
- `[APPOINTMENT_SERVICE] Found ${count} completed appointments for patient ${patientId}`,
1246
- );
1247
-
1248
- return count;
1249
- } catch (error) {
1250
- console.error(
1251
- `[APPOINTMENT_SERVICE] Error counting completed appointments for patient ${patientId}:`,
1252
- error,
1253
- );
1254
- throw error;
1255
- }
1256
- }
1257
-
1258
- /**
1259
- * Uploads a zone photo and updates appointment metadata
1260
- *
1261
- * @param uploadData Zone photo upload data containing appointment ID, zone ID, photo type, file, and optional notes
1262
- * @returns The uploaded media metadata
1263
- */
1264
- async uploadZonePhoto(uploadData: ZonePhotoUploadData): Promise<MediaMetadata> {
1265
- try {
1266
- console.log(
1267
- `[APPOINTMENT_SERVICE] Uploading ${uploadData.photoType} photo for zone ${uploadData.zoneId} in appointment ${uploadData.appointmentId}`,
1268
- );
1269
-
1270
- // Validate input data
1271
- const validatedData = await zonePhotoUploadSchema.parseAsync(uploadData);
1272
-
1273
- // Check if user is authenticated
1274
- const currentUser = this.auth.currentUser;
1275
- if (!currentUser) {
1276
- throw new Error('User must be authenticated to upload zone photos');
1277
- }
1278
-
1279
- // Get the appointment to verify it exists and user has access
1280
- const appointment = await this.getAppointmentById(validatedData.appointmentId);
1281
- if (!appointment) {
1282
- throw new Error(`Appointment with ID ${validatedData.appointmentId} not found`);
1283
- }
1284
-
1285
- // Generate collection name for the media
1286
- const collectionName = `appointment_${validatedData.appointmentId}_zone_photos`;
1287
-
1288
- // Generate filename with zone and photo type info
1289
- const timestamp = Date.now();
1290
- const fileExtension = validatedData.file.type?.split('/')[1] || 'jpg';
1291
- const fileName = `${validatedData.photoType}_${validatedData.zoneId}_${timestamp}.${fileExtension}`;
1292
-
1293
- console.log(
1294
- `[APPOINTMENT_SERVICE] Uploading file: ${fileName} to collection: ${collectionName}`,
1295
- );
1296
-
1297
- // Upload the media file using MediaService
1298
- const uploadedMedia = await this.mediaService.uploadMedia(
1299
- validatedData.file,
1300
- validatedData.appointmentId, // ownerId is the appointment ID
1301
- MediaAccessLevel.PRIVATE, // Zone photos are private
1302
- collectionName,
1303
- fileName,
1304
- );
1305
-
1306
- console.log(`[APPOINTMENT_SERVICE] Media uploaded successfully with ID: ${uploadedMedia.id}`);
1307
-
1308
- // Update appointment metadata with the new photo
1309
- await this.updateAppointmentZonePhoto(
1310
- validatedData.appointmentId,
1311
- validatedData.zoneId,
1312
- validatedData.photoType,
1313
- uploadedMedia,
1314
- validatedData.notes,
1315
- );
1316
-
1317
- console.log(
1318
- `[APPOINTMENT_SERVICE] Successfully uploaded and linked ${validatedData.photoType} photo for zone ${validatedData.zoneId}`,
1319
- );
1320
-
1321
- return uploadedMedia;
1322
- } catch (error) {
1323
- console.error('[APPOINTMENT_SERVICE] Error uploading zone photo:', error);
1324
- throw error;
1325
- }
1326
- }
1327
-
1328
- /**
1329
- * Updates appointment metadata with zone photo information
1330
- *
1331
- * @param appointmentId ID of the appointment
1332
- * @param zoneId ID of the zone
1333
- * @param photoType Type of photo ('before' or 'after')
1334
- * @param mediaMetadata Uploaded media metadata
1335
- * @param notes Optional notes for the photo
1336
- * @returns The updated appointment
1337
- */
1338
- private async updateAppointmentZonePhoto(
1339
- appointmentId: string,
1340
- zoneId: string,
1341
- photoType: 'before' | 'after',
1342
- mediaMetadata: MediaMetadata,
1343
- notes?: string,
1344
- ): Promise<Appointment> {
1345
- try {
1346
- console.log(
1347
- `[APPOINTMENT_SERVICE] Updating appointment metadata for ${photoType} photo in zone ${zoneId}`,
1348
- );
1349
-
1350
- // Get current appointment
1351
- const appointment = await this.getAppointmentById(appointmentId);
1352
- if (!appointment) {
1353
- throw new Error(`Appointment with ID ${appointmentId} not found`);
1354
- }
1355
-
1356
- // Initialize metadata if it doesn't exist
1357
- const currentMetadata = appointment.metadata || {
1358
- selectedZones: null,
1359
- zonePhotos: null,
1360
- zonesData: null,
1361
- appointmentProducts: [],
1362
- extendedProcedures: [],
1363
- recommendedProcedures: [],
1364
- zoneBilling: null,
1365
- finalbilling: null,
1366
- finalizationNotes: null,
1367
- };
1368
-
1369
- // Initialize zonePhotos if it doesn't exist (array model per zone)
1370
- let currentZonePhotos: Record<string, BeforeAfterPerZone[]> = {};
1371
-
1372
- // AUTO-MIGRATION: Convert old object format to new array format
1373
- if (currentMetadata.zonePhotos) {
1374
- for (const [key, value] of Object.entries(currentMetadata.zonePhotos)) {
1375
- if (Array.isArray(value)) {
1376
- // Already in new format
1377
- currentZonePhotos[key] = value as BeforeAfterPerZone[];
1378
- } else {
1379
- // Old format - convert to array
1380
- console.log(`[APPOINTMENT_SERVICE] Auto-migrating ${key} from object to array format`);
1381
- const oldData = value as any;
1382
- currentZonePhotos[key] = [
1383
- {
1384
- before: oldData.before || null,
1385
- after: oldData.after || null,
1386
- beforeNote: null,
1387
- afterNote: null,
1388
- },
1389
- ];
1390
- }
1391
- }
1392
- }
1393
-
1394
- // Initialize the zone array if it doesn't exist
1395
- if (!currentZonePhotos[zoneId]) {
1396
- currentZonePhotos[zoneId] = [];
1397
- }
1398
-
1399
- // Create a new entry for this uploaded photo with per-photo notes
1400
- const newEntry: BeforeAfterPerZone = {
1401
- before: photoType === 'before' ? mediaMetadata.url : null,
1402
- after: photoType === 'after' ? mediaMetadata.url : null,
1403
- beforeNote: photoType === 'before' ? notes || null : null,
1404
- afterNote: photoType === 'after' ? notes || null : null,
1405
- };
1406
-
1407
- // Append to the zone's photo list
1408
- currentZonePhotos[zoneId] = [...currentZonePhotos[zoneId], newEntry];
1409
- // Enforce max 10 photos per zone by keeping the most recent 10
1410
- if (currentZonePhotos[zoneId].length > 10) {
1411
- currentZonePhotos[zoneId] = currentZonePhotos[zoneId].slice(-10);
1412
- }
1413
-
1414
- // Update the appointment with new metadata
1415
- const updateData: UpdateAppointmentData = {
1416
- metadata: {
1417
- selectedZones: currentMetadata.selectedZones,
1418
- zonePhotos: currentZonePhotos,
1419
- zonesData: currentMetadata.zonesData || null,
1420
- appointmentProducts: currentMetadata.appointmentProducts || [],
1421
- extendedProcedures: currentMetadata.extendedProcedures || [],
1422
- recommendedProcedures: currentMetadata.recommendedProcedures || [],
1423
- // Only include zoneBilling if it exists (avoid undefined values in Firestore)
1424
- ...(currentMetadata.zoneBilling !== undefined && {
1425
- zoneBilling: currentMetadata.zoneBilling,
1426
- }),
1427
- finalbilling: currentMetadata.finalbilling,
1428
- finalizationNotes: currentMetadata.finalizationNotes,
1429
- },
1430
- updatedAt: serverTimestamp(),
1431
- };
1432
-
1433
- const updatedAppointment = await this.updateAppointment(appointmentId, updateData);
1434
-
1435
- console.log(
1436
- `[APPOINTMENT_SERVICE] Successfully updated appointment metadata for ${photoType} photo in zone ${zoneId}`,
1437
- );
1438
-
1439
- return updatedAppointment;
1440
- } catch (error) {
1441
- console.error(
1442
- `[APPOINTMENT_SERVICE] Error updating appointment metadata for zone photo:`,
1443
- error,
1444
- );
1445
- throw error;
1446
- }
1447
- }
1448
-
1449
- /**
1450
- * Gets zone photos for a specific appointment and zone
1451
- *
1452
- * @param appointmentId ID of the appointment
1453
- * @param zoneId ID of the zone (optional - if not provided, returns all zones)
1454
- * @returns Zone photos data
1455
- */
1456
- async getZonePhotos(
1457
- appointmentId: string,
1458
- zoneId?: string,
1459
- ): Promise<Record<string, BeforeAfterPerZone[]> | BeforeAfterPerZone[] | null> {
1460
- try {
1461
- console.log(`[APPOINTMENT_SERVICE] Getting zone photos for appointment ${appointmentId}`);
1462
-
1463
- const appointment = await this.getAppointmentById(appointmentId);
1464
- if (!appointment) {
1465
- throw new Error(`Appointment with ID ${appointmentId} not found`);
1466
- }
1467
-
1468
- const zonePhotos = appointment.metadata?.zonePhotos as
1469
- | Record<string, BeforeAfterPerZone[]>
1470
- | undefined
1471
- | null;
1472
- if (!zonePhotos) {
1473
- return null;
1474
- }
1475
-
1476
- // If specific zone requested, return only that zone's photos
1477
- if (zoneId) {
1478
- return zonePhotos[zoneId] || null;
1479
- }
1480
-
1481
- // Return all zone photos
1482
- return zonePhotos;
1483
- } catch (error) {
1484
- console.error(`[APPOINTMENT_SERVICE] Error getting zone photos:`, error);
1485
- throw error;
1486
- }
1487
- }
1488
-
1489
- /**
1490
- * Deletes a zone photo entry (by index) and updates appointment metadata
1491
- *
1492
- * @param appointmentId ID of the appointment
1493
- * @param zoneId ID of the zone
1494
- * @param photoIndex Index of the photo entry to delete in the zone array
1495
- * @returns The updated appointment
1496
- */
1497
- async deleteZonePhoto(
1498
- appointmentId: string,
1499
- zoneId: string,
1500
- photoIndex: number,
1501
- ): Promise<Appointment> {
1502
- try {
1503
- console.log(
1504
- `[APPOINTMENT_SERVICE] Deleting zone photo index ${photoIndex} for zone ${zoneId} in appointment ${appointmentId}`,
1505
- );
1506
-
1507
- // Get current appointment
1508
- const appointment = await this.getAppointmentById(appointmentId);
1509
- if (!appointment) {
1510
- throw new Error(`Appointment with ID ${appointmentId} not found`);
1511
- }
1512
-
1513
- const zonePhotos = appointment.metadata?.zonePhotos as
1514
- | Record<string, BeforeAfterPerZone[]>
1515
- | undefined
1516
- | null;
1517
- if (!zonePhotos || !zonePhotos[zoneId] || !Array.isArray(zonePhotos[zoneId])) {
1518
- throw new Error(`No photos found for zone ${zoneId} in appointment ${appointmentId}`);
1519
- }
1520
-
1521
- const zoneArray = [...zonePhotos[zoneId]];
1522
- if (photoIndex < 0 || photoIndex >= zoneArray.length) {
1523
- throw new Error(`Invalid photo index ${photoIndex} for zone ${zoneId}`);
1524
- }
1525
-
1526
- const entry = zoneArray[photoIndex];
1527
- const photoUrl = (entry.before || entry.after) as MediaResource | null;
1528
- if (!photoUrl) {
1529
- throw new Error(`No photo URL found for index ${photoIndex} in zone ${zoneId}`);
1530
- }
1531
-
1532
- // Try to find and delete the media from storage
1533
- try {
1534
- // Only try to delete if photoUrl is a string (URL)
1535
- if (typeof photoUrl === 'string') {
1536
- const mediaMetadata = await this.mediaService.getMediaMetadataByUrl(photoUrl);
1537
- if (mediaMetadata) {
1538
- await this.mediaService.deleteMedia(mediaMetadata.id);
1539
- console.log(`[APPOINTMENT_SERVICE] Deleted media file with ID: ${mediaMetadata.id}`);
1540
- }
1541
- }
1542
- } catch (mediaError) {
1543
- console.warn(
1544
- `[APPOINTMENT_SERVICE] Could not delete media file for URL ${photoUrl}:`,
1545
- mediaError,
1546
- );
1547
- // Continue with metadata update even if media deletion fails
1548
- }
1549
-
1550
- // Update appointment metadata to remove the photo entry at the specified index
1551
- const updatedZonePhotos: Record<string, BeforeAfterPerZone[]> = { ...zonePhotos } as any;
1552
- const updatedZoneArray = [...zoneArray];
1553
- updatedZoneArray.splice(photoIndex, 1);
1554
- if (updatedZoneArray.length === 0) {
1555
- delete updatedZonePhotos[zoneId];
1556
- } else {
1557
- updatedZonePhotos[zoneId] = updatedZoneArray;
1558
- }
1559
-
1560
- const updateData: UpdateAppointmentData = {
1561
- metadata: {
1562
- selectedZones: appointment.metadata?.selectedZones || null,
1563
- zonePhotos: updatedZonePhotos,
1564
- zonesData: appointment.metadata?.zonesData || null,
1565
- appointmentProducts: appointment.metadata?.appointmentProducts || [],
1566
- extendedProcedures: appointment.metadata?.extendedProcedures || [],
1567
- recommendedProcedures: appointment.metadata?.recommendedProcedures || [],
1568
- // Only include zoneBilling if it exists (avoid undefined values in Firestore)
1569
- ...(appointment.metadata?.zoneBilling !== undefined && {
1570
- zoneBilling: appointment.metadata.zoneBilling,
1571
- }),
1572
- finalbilling: appointment.metadata?.finalbilling || null,
1573
- finalizationNotes: appointment.metadata?.finalizationNotes || null,
1574
- },
1575
- updatedAt: serverTimestamp(),
1576
- };
1577
-
1578
- const updatedAppointment = await this.updateAppointment(appointmentId, updateData);
1579
-
1580
- console.log(
1581
- `[APPOINTMENT_SERVICE] Successfully deleted photo index ${photoIndex} for zone ${zoneId}`,
1582
- );
1583
-
1584
- return updatedAppointment;
1585
- } catch (error) {
1586
- console.error(`[APPOINTMENT_SERVICE] Error deleting zone photo:`, error);
1587
- throw error;
1588
- }
1589
- }
1590
-
1591
- /**
1592
- * Adds an item (product or note) to a specific zone
1593
- *
1594
- * @param appointmentId ID of the appointment
1595
- * @param zoneId Zone ID (must be category.zone format, e.g., "face.forehead")
1596
- * @param item Zone item data to add (without parentZone - it's inferred from zoneId)
1597
- * @returns The updated appointment
1598
- */
1599
- async addItemToZone(
1600
- appointmentId: string,
1601
- zoneId: string,
1602
- item: Omit<ZoneItemData, 'subtotal' | 'parentZone'>,
1603
- ): Promise<Appointment> {
1604
- try {
1605
- console.log(
1606
- `[APPOINTMENT_SERVICE] Adding item to zone ${zoneId} in appointment ${appointmentId}`,
1607
- );
1608
- return await addItemToZoneUtil(this.db, appointmentId, zoneId, item);
1609
- } catch (error) {
1610
- console.error(`[APPOINTMENT_SERVICE] Error adding item to zone:`, error);
1611
- throw error;
1612
- }
1613
- }
1614
-
1615
- /**
1616
- * Removes an item from a specific zone
1617
- *
1618
- * @param appointmentId ID of the appointment
1619
- * @param zoneId Zone ID
1620
- * @param itemIndex Index of the item to remove in the zone's items array
1621
- * @returns The updated appointment
1622
- */
1623
- async removeItemFromZone(
1624
- appointmentId: string,
1625
- zoneId: string,
1626
- itemIndex: number,
1627
- ): Promise<Appointment> {
1628
- try {
1629
- console.log(
1630
- `[APPOINTMENT_SERVICE] Removing item ${itemIndex} from zone ${zoneId} in appointment ${appointmentId}`,
1631
- );
1632
- return await removeItemFromZoneUtil(this.db, appointmentId, zoneId, itemIndex);
1633
- } catch (error) {
1634
- console.error(`[APPOINTMENT_SERVICE] Error removing item from zone:`, error);
1635
- throw error;
1636
- }
1637
- }
1638
-
1639
- /**
1640
- * Updates a specific item in a zone
1641
- *
1642
- * @param appointmentId ID of the appointment
1643
- * @param zoneId Zone ID
1644
- * @param itemIndex Index of the item to update
1645
- * @param updates Partial updates to apply to the item
1646
- * @returns The updated appointment
1647
- */
1648
- async updateZoneItem(
1649
- appointmentId: string,
1650
- zoneId: string,
1651
- itemIndex: number,
1652
- updates: Partial<ZoneItemData>,
1653
- ): Promise<Appointment> {
1654
- try {
1655
- console.log(
1656
- `[APPOINTMENT_SERVICE] Updating item ${itemIndex} in zone ${zoneId} in appointment ${appointmentId}`,
1657
- );
1658
- return await updateZoneItemUtil(this.db, appointmentId, zoneId, itemIndex, updates);
1659
- } catch (error) {
1660
- console.error(`[APPOINTMENT_SERVICE] Error updating zone item:`, error);
1661
- throw error;
1662
- }
1663
- }
1664
-
1665
- /**
1666
- * Overrides the price for a specific zone item
1667
- *
1668
- * @param appointmentId ID of the appointment
1669
- * @param zoneId Zone ID
1670
- * @param itemIndex Index of the item
1671
- * @param newPrice New price amount to set
1672
- * @returns The updated appointment
1673
- */
1674
- async overridePriceForZoneItem(
1675
- appointmentId: string,
1676
- zoneId: string,
1677
- itemIndex: number,
1678
- newPrice: number,
1679
- ): Promise<Appointment> {
1680
- try {
1681
- console.log(
1682
- `[APPOINTMENT_SERVICE] Overriding price for item ${itemIndex} in zone ${zoneId} to ${newPrice}`,
1683
- );
1684
- return await overridePriceForZoneItemUtil(
1685
- this.db,
1686
- appointmentId,
1687
- zoneId,
1688
- itemIndex,
1689
- newPrice,
1690
- );
1691
- } catch (error) {
1692
- console.error(`[APPOINTMENT_SERVICE] Error overriding price:`, error);
1693
- throw error;
1694
- }
1695
- }
1696
-
1697
- /**
1698
- * Updates subzones for a specific zone item
1699
- *
1700
- * @param appointmentId ID of the appointment
1701
- * @param zoneId Zone ID
1702
- * @param itemIndex Index of the item
1703
- * @param subzones Array of subzone keys (category.zone.subzone format)
1704
- * @returns The updated appointment
1705
- */
1706
- async updateSubzones(
1707
- appointmentId: string,
1708
- zoneId: string,
1709
- itemIndex: number,
1710
- subzones: string[],
1711
- ): Promise<Appointment> {
1712
- try {
1713
- console.log(
1714
- `[APPOINTMENT_SERVICE] Updating subzones for item ${itemIndex} in zone ${zoneId}`,
1715
- );
1716
- return await updateSubzonesUtil(this.db, appointmentId, zoneId, itemIndex, subzones);
1717
- } catch (error) {
1718
- console.error(`[APPOINTMENT_SERVICE] Error updating subzones:`, error);
1719
- throw error;
1720
- }
1721
- }
1722
-
1723
- /**
1724
- * Adds an extended procedure to an appointment
1725
- * Automatically aggregates products into appointmentProducts
1726
- *
1727
- * @param appointmentId ID of the appointment
1728
- * @param procedureId ID of the procedure to add
1729
- * @returns The updated appointment
1730
- */
1731
- async addExtendedProcedure(appointmentId: string, procedureId: string): Promise<Appointment> {
1732
- try {
1733
- console.log(
1734
- `[APPOINTMENT_SERVICE] Adding extended procedure ${procedureId} to appointment ${appointmentId}`,
1735
- );
1736
- return await addExtendedProcedureUtil(this.db, appointmentId, procedureId);
1737
- } catch (error) {
1738
- console.error(`[APPOINTMENT_SERVICE] Error adding extended procedure:`, error);
1739
- throw error;
1740
- }
1741
- }
1742
-
1743
- /**
1744
- * Removes an extended procedure from an appointment
1745
- * Also removes associated products from appointmentProducts
1746
- *
1747
- * @param appointmentId ID of the appointment
1748
- * @param procedureId ID of the procedure to remove
1749
- * @returns The updated appointment
1750
- */
1751
- async removeExtendedProcedure(appointmentId: string, procedureId: string): Promise<Appointment> {
1752
- try {
1753
- console.log(
1754
- `[APPOINTMENT_SERVICE] Removing extended procedure ${procedureId} from appointment ${appointmentId}`,
1755
- );
1756
- return await removeExtendedProcedureUtil(this.db, appointmentId, procedureId);
1757
- } catch (error) {
1758
- console.error(`[APPOINTMENT_SERVICE] Error removing extended procedure:`, error);
1759
- throw error;
1760
- }
1761
- }
1762
-
1763
- /**
1764
- * Gets all extended procedures for an appointment
1765
- *
1766
- * @param appointmentId ID of the appointment
1767
- * @returns Array of extended procedures
1768
- */
1769
- async getExtendedProcedures(appointmentId: string): Promise<ExtendedProcedureInfo[]> {
1770
- try {
1771
- console.log(
1772
- `[APPOINTMENT_SERVICE] Getting extended procedures for appointment ${appointmentId}`,
1773
- );
1774
- return await getExtendedProceduresUtil(this.db, appointmentId);
1775
- } catch (error) {
1776
- console.error(`[APPOINTMENT_SERVICE] Error getting extended procedures:`, error);
1777
- throw error;
1778
- }
1779
- }
1780
-
1781
- /**
1782
- * Gets all aggregated products for an appointment
1783
- * Includes products from main procedure and extended procedures
1784
- *
1785
- * @param appointmentId ID of the appointment
1786
- * @returns Array of appointment products
1787
- */
1788
- async getAppointmentProducts(appointmentId: string): Promise<AppointmentProductMetadata[]> {
1789
- try {
1790
- console.log(
1791
- `[APPOINTMENT_SERVICE] Getting appointment products for appointment ${appointmentId}`,
1792
- );
1793
- return await getAppointmentProductsUtil(this.db, appointmentId);
1794
- } catch (error) {
1795
- console.error(`[APPOINTMENT_SERVICE] Error getting appointment products:`, error);
1796
- throw error;
1797
- }
1798
- }
1799
-
1800
- /**
1801
- * Recalculates final billing for an appointment based on zone items
1802
- *
1803
- * @param appointmentId ID of the appointment
1804
- * @param taxRate Tax rate (e.g., 0.20 for 20%)
1805
- * @returns The updated appointment with recalculated billing
1806
- */
1807
- async recalculateFinalBilling(appointmentId: string, taxRate?: number): Promise<Appointment> {
1808
- try {
1809
- console.log(
1810
- `[APPOINTMENT_SERVICE] Recalculating final billing for appointment ${appointmentId}`,
1811
- );
1812
-
1813
- const appointment = await this.getAppointmentById(appointmentId);
1814
- if (!appointment) {
1815
- throw new Error(`Appointment with ID ${appointmentId} not found`);
1816
- }
1817
-
1818
- const zonesData = appointment.metadata?.zonesData;
1819
- if (!zonesData || Object.keys(zonesData).length === 0) {
1820
- throw new Error('No zone data available for billing calculation');
1821
- }
1822
-
1823
- const finalbilling = calculateFinalBilling(zonesData, taxRate);
1824
-
1825
- const currentMetadata = appointment.metadata || {
1826
- selectedZones: null,
1827
- zonePhotos: null,
1828
- zonesData: null,
1829
- appointmentProducts: [],
1830
- extendedProcedures: [],
1831
- recommendedProcedures: [],
1832
- finalbilling: null,
1833
- finalizationNotes: null,
1834
- };
1835
-
1836
- // Update payment status if billing data exists but status is NOT_APPLICABLE
1837
- // This handles cases where appointment was created with price 0 but billing was added later
1838
- const shouldUpdatePaymentStatus =
1839
- finalbilling.finalPrice > 0 &&
1840
- appointment.paymentStatus === PaymentStatus.NOT_APPLICABLE;
1841
-
1842
- const updateData: UpdateAppointmentData = {
1843
- metadata: {
1844
- selectedZones: currentMetadata.selectedZones,
1845
- zonePhotos: currentMetadata.zonePhotos,
1846
- zonesData: currentMetadata.zonesData,
1847
- appointmentProducts: currentMetadata.appointmentProducts || [],
1848
- extendedProcedures: currentMetadata.extendedProcedures || [],
1849
- recommendedProcedures: currentMetadata.recommendedProcedures || [],
1850
- // Only include zoneBilling if it exists (avoid undefined values in Firestore)
1851
- ...(currentMetadata.zoneBilling !== undefined && {
1852
- zoneBilling: currentMetadata.zoneBilling,
1853
- }),
1854
- finalbilling,
1855
- finalizationNotes: currentMetadata.finalizationNotes,
1856
- },
1857
- ...(shouldUpdatePaymentStatus && {
1858
- paymentStatus: PaymentStatus.UNPAID,
1859
- }),
1860
- updatedAt: serverTimestamp(),
1861
- };
1862
-
1863
- return await this.updateAppointment(appointmentId, updateData);
1864
- } catch (error) {
1865
- console.error(`[APPOINTMENT_SERVICE] Error recalculating final billing:`, error);
1866
- throw error;
1867
- }
1868
- }
1869
-
1870
- /**
1871
- * Adds a recommended procedure to an appointment
1872
- * Multiple recommendations of the same procedure are allowed (e.g., touch-up in 2 weeks, full treatment in 3 months)
1873
- *
1874
- * @param appointmentId ID of the appointment
1875
- * @param procedureId ID of the procedure to recommend
1876
- * @param note Note explaining the recommendation
1877
- * @param timeframe Suggested timeframe for the procedure
1878
- * @returns The updated appointment
1879
- */
1880
- async addRecommendedProcedure(
1881
- appointmentId: string,
1882
- procedureId: string,
1883
- note: string,
1884
- timeframe: { value: number; unit: 'day' | 'week' | 'month' | 'year' },
1885
- ): Promise<Appointment> {
1886
- try {
1887
- console.log(
1888
- `[APPOINTMENT_SERVICE] Adding recommended procedure ${procedureId} to appointment ${appointmentId}`,
1889
- );
1890
- return await addRecommendedProcedureUtil(
1891
- this.db,
1892
- appointmentId,
1893
- procedureId,
1894
- note,
1895
- timeframe,
1896
- );
1897
- } catch (error) {
1898
- console.error(`[APPOINTMENT_SERVICE] Error adding recommended procedure:`, error);
1899
- throw error;
1900
- }
1901
- }
1902
-
1903
- /**
1904
- * Removes a recommended procedure from an appointment by index
1905
- *
1906
- * @param appointmentId ID of the appointment
1907
- * @param recommendationIndex Index of the recommendation to remove
1908
- * @returns The updated appointment
1909
- */
1910
- async removeRecommendedProcedure(
1911
- appointmentId: string,
1912
- recommendationIndex: number,
1913
- ): Promise<Appointment> {
1914
- try {
1915
- console.log(
1916
- `[APPOINTMENT_SERVICE] Removing recommended procedure at index ${recommendationIndex} from appointment ${appointmentId}`,
1917
- );
1918
- return await removeRecommendedProcedureUtil(this.db, appointmentId, recommendationIndex);
1919
- } catch (error) {
1920
- console.error(`[APPOINTMENT_SERVICE] Error removing recommended procedure:`, error);
1921
- throw error;
1922
- }
1923
- }
1924
-
1925
- /**
1926
- * Updates a recommended procedure in an appointment by index
1927
- *
1928
- * @param appointmentId ID of the appointment
1929
- * @param recommendationIndex Index of the recommendation to update
1930
- * @param updates Partial updates (note and/or timeframe)
1931
- * @returns The updated appointment
1932
- */
1933
- async updateRecommendedProcedure(
1934
- appointmentId: string,
1935
- recommendationIndex: number,
1936
- updates: {
1937
- note?: string;
1938
- timeframe?: { value: number; unit: 'day' | 'week' | 'month' | 'year' };
1939
- },
1940
- ): Promise<Appointment> {
1941
- try {
1942
- console.log(
1943
- `[APPOINTMENT_SERVICE] Updating recommended procedure at index ${recommendationIndex} in appointment ${appointmentId}`,
1944
- );
1945
- return await updateRecommendedProcedureUtil(
1946
- this.db,
1947
- appointmentId,
1948
- recommendationIndex,
1949
- updates,
1950
- );
1951
- } catch (error) {
1952
- console.error(`[APPOINTMENT_SERVICE] Error updating recommended procedure:`, error);
1953
- throw error;
1954
- }
1955
- }
1956
-
1957
- /**
1958
- * Gets all recommended procedures for an appointment
1959
- *
1960
- * @param appointmentId ID of the appointment
1961
- * @returns Array of recommended procedures
1962
- */
1963
- async getRecommendedProcedures(appointmentId: string): Promise<RecommendedProcedure[]> {
1964
- try {
1965
- console.log(
1966
- `[APPOINTMENT_SERVICE] Getting recommended procedures for appointment ${appointmentId}`,
1967
- );
1968
- return await getRecommendedProceduresUtil(this.db, appointmentId);
1969
- } catch (error) {
1970
- console.error(`[APPOINTMENT_SERVICE] Error getting recommended procedures:`, error);
1971
- throw error;
1972
- }
1973
- }
1974
-
1975
- /**
1976
- * Updates a specific photo entry in a zone by index
1977
- * Can update before/after photos and their notes
1978
- *
1979
- * @param appointmentId ID of the appointment
1980
- * @param zoneId Zone ID
1981
- * @param photoIndex Index of the photo entry to update
1982
- * @param updates Partial updates to apply (before, after, beforeNote, afterNote)
1983
- * @returns The updated appointment
1984
- */
1985
- async updateZonePhotoEntry(
1986
- appointmentId: string,
1987
- zoneId: string,
1988
- photoIndex: number,
1989
- updates: Partial<BeforeAfterPerZone>,
1990
- ): Promise<Appointment> {
1991
- try {
1992
- console.log(
1993
- `[APPOINTMENT_SERVICE] Updating photo entry at index ${photoIndex} for zone ${zoneId} in appointment ${appointmentId}`,
1994
- );
1995
- return await updateZonePhotoEntryUtil(this.db, appointmentId, zoneId, photoIndex, updates);
1996
- } catch (error) {
1997
- console.error(`[APPOINTMENT_SERVICE] Error updating zone photo entry:`, error);
1998
- throw error;
1999
- }
2000
- }
2001
-
2002
- /**
2003
- * Adds an after photo to an existing before photo entry
2004
- *
2005
- * @param appointmentId ID of the appointment
2006
- * @param zoneId Zone ID
2007
- * @param photoIndex Index of the entry to add after photo to
2008
- * @param afterPhotoUrl URL of the after photo
2009
- * @param afterNote Optional note for the after photo
2010
- * @returns The updated appointment
2011
- */
2012
- async addAfterPhotoToEntry(
2013
- appointmentId: string,
2014
- zoneId: string,
2015
- photoIndex: number,
2016
- afterPhotoUrl: MediaResource,
2017
- afterNote?: string,
2018
- ): Promise<Appointment> {
2019
- try {
2020
- console.log(
2021
- `[APPOINTMENT_SERVICE] Adding after photo to entry at index ${photoIndex} for zone ${zoneId}`,
2022
- );
2023
- return await addAfterPhotoToEntryUtil(
2024
- this.db,
2025
- appointmentId,
2026
- zoneId,
2027
- photoIndex,
2028
- afterPhotoUrl,
2029
- afterNote,
2030
- );
2031
- } catch (error) {
2032
- console.error(`[APPOINTMENT_SERVICE] Error adding after photo to entry:`, error);
2033
- throw error;
2034
- }
2035
- }
2036
-
2037
- /**
2038
- * Updates notes for a photo entry
2039
- *
2040
- * @param appointmentId ID of the appointment
2041
- * @param zoneId Zone ID
2042
- * @param photoIndex Index of the entry
2043
- * @param beforeNote Optional note for before photo
2044
- * @param afterNote Optional note for after photo
2045
- * @returns The updated appointment
2046
- */
2047
- async updateZonePhotoNotes(
2048
- appointmentId: string,
2049
- zoneId: string,
2050
- photoIndex: number,
2051
- beforeNote?: string,
2052
- afterNote?: string,
2053
- ): Promise<Appointment> {
2054
- try {
2055
- console.log(
2056
- `[APPOINTMENT_SERVICE] Updating notes for photo entry at index ${photoIndex} for zone ${zoneId}`,
2057
- );
2058
- return await updateZonePhotoNotesUtil(
2059
- this.db,
2060
- appointmentId,
2061
- zoneId,
2062
- photoIndex,
2063
- beforeNote,
2064
- afterNote,
2065
- );
2066
- } catch (error) {
2067
- console.error(`[APPOINTMENT_SERVICE] Error updating zone photo notes:`, error);
2068
- throw error;
2069
- }
2070
- }
2071
-
2072
- /**
2073
- * Gets a specific photo entry from a zone
2074
- *
2075
- * @param appointmentId ID of the appointment
2076
- * @param zoneId Zone ID
2077
- * @param photoIndex Index of the entry
2078
- * @returns Photo entry
2079
- */
2080
- async getZonePhotoEntry(
2081
- appointmentId: string,
2082
- zoneId: string,
2083
- photoIndex: number,
2084
- ): Promise<BeforeAfterPerZone> {
2085
- try {
2086
- console.log(
2087
- `[APPOINTMENT_SERVICE] Getting photo entry at index ${photoIndex} for zone ${zoneId}`,
2088
- );
2089
- return await getZonePhotoEntryUtil(this.db, appointmentId, zoneId, photoIndex);
2090
- } catch (error) {
2091
- console.error(`[APPOINTMENT_SERVICE] Error getting zone photo entry:`, error);
2092
- throw error;
2093
- }
2094
- }
2095
-
2096
- /**
2097
- * Gets all next steps recommendations for a patient from their past appointments.
2098
- * Returns recommendations with context about which appointment, practitioner, and clinic suggested them.
2099
- *
2100
- * @param patientId ID of the patient
2101
- * @param options Optional parameters for filtering
2102
- * @returns Array of next steps recommendations with context
2103
- */
2104
- async getPatientNextStepsRecommendations(
2105
- patientId: string,
2106
- options?: {
2107
- /** Include dismissed recommendations (default: false) */
2108
- includeDismissed?: boolean;
2109
- /** Filter by clinic branch ID */
2110
- clinicBranchId?: string;
2111
- /** Filter by practitioner ID */
2112
- practitionerId?: string;
2113
- /** Limit the number of results */
2114
- limit?: number;
2115
- },
2116
- ): Promise<NextStepsRecommendation[]> {
2117
- try {
2118
- console.log(
2119
- `[APPOINTMENT_SERVICE] Getting next steps recommendations for patient: ${patientId}`,
2120
- options,
2121
- );
2122
-
2123
- // Get patient profile to check dismissed recommendations
2124
- const patientProfile = await this.patientService.getPatientProfile(patientId);
2125
- const dismissedIds = new Set(
2126
- patientProfile?.dismissedNextStepsRecommendations || [],
2127
- );
2128
-
2129
- // Get past appointments (completed appointments)
2130
- const pastAppointments = await this.getPastPatientAppointments(patientId, {
2131
- showCanceled: false,
2132
- showNoShow: false,
2133
- });
2134
-
2135
- // Also get appointments that have recommendations but might not be COMPLETED yet
2136
- // (e.g., doctor finalized but appointment status is still CONFIRMED/CHECKED_IN)
2137
- // Get all patient appointments that are past their end time
2138
- const now = new Date();
2139
- const allPastAppointments = await this.getPatientAppointments(patientId, {
2140
- endDate: now,
2141
- status: [
2142
- AppointmentStatus.COMPLETED,
2143
- AppointmentStatus.CONFIRMED,
2144
- AppointmentStatus.CHECKED_IN,
2145
- AppointmentStatus.IN_PROGRESS,
2146
- ],
2147
- });
2148
-
2149
- // Filter to only include appointments that are past their end time AND have recommendations
2150
- const appointmentsWithRecommendations = allPastAppointments.appointments.filter(
2151
- appointment => {
2152
- const endTime = appointment.appointmentEndTime?.toMillis
2153
- ? appointment.appointmentEndTime.toMillis()
2154
- : appointment.appointmentEndTime?.seconds
2155
- ? appointment.appointmentEndTime.seconds * 1000
2156
- : null;
2157
-
2158
- if (!endTime) return false;
2159
-
2160
- const isPastEndTime = endTime < now.getTime();
2161
- const hasRecommendations =
2162
- (appointment.metadata?.recommendedProcedures?.length || 0) > 0;
2163
-
2164
- return isPastEndTime && hasRecommendations;
2165
- },
2166
- );
2167
-
2168
- // Combine and deduplicate by appointment ID
2169
- const allAppointmentsMap = new Map<string, Appointment>();
2170
-
2171
- // Add completed appointments
2172
- pastAppointments.appointments.forEach(apt => {
2173
- allAppointmentsMap.set(apt.id, apt);
2174
- });
2175
-
2176
- // Add appointments with recommendations (will overwrite if duplicate)
2177
- appointmentsWithRecommendations.forEach(apt => {
2178
- allAppointmentsMap.set(apt.id, apt);
2179
- });
2180
-
2181
- const allAppointments = Array.from(allAppointmentsMap.values());
2182
-
2183
- const recommendations: NextStepsRecommendation[] = [];
2184
-
2185
- // Iterate through all appointments and extract recommendations
2186
- for (const appointment of allAppointments) {
2187
- // Filter by clinic if specified
2188
- if (options?.clinicBranchId && appointment.clinicBranchId !== options.clinicBranchId) {
2189
- continue;
2190
- }
2191
-
2192
- // Filter by practitioner if specified
2193
- if (options?.practitionerId && appointment.practitionerId !== options.practitionerId) {
2194
- continue;
2195
- }
2196
-
2197
- // Get recommended procedures from appointment metadata
2198
- const recommendedProcedures =
2199
- appointment.metadata?.recommendedProcedures || [];
2200
-
2201
- // Create NextStepsRecommendation for each recommended procedure
2202
- for (let index = 0; index < recommendedProcedures.length; index++) {
2203
- const recommendedProcedure = recommendedProcedures[index];
2204
- const recommendationId = `${appointment.id}:${index}`;
2205
-
2206
- // Skip if dismissed and not including dismissed
2207
- if (!options?.includeDismissed && dismissedIds.has(recommendationId)) {
2208
- continue;
2209
- }
2210
-
2211
- const nextStepsRecommendation: NextStepsRecommendation = {
2212
- id: recommendationId,
2213
- recommendedProcedure,
2214
- appointmentId: appointment.id,
2215
- appointmentDate: appointment.appointmentStartTime,
2216
- practitionerId: appointment.practitionerId,
2217
- practitionerName: appointment.practitionerInfo?.name || 'Unknown Practitioner',
2218
- clinicBranchId: appointment.clinicBranchId,
2219
- clinicName: appointment.clinicInfo?.name || 'Unknown Clinic',
2220
- appointmentStatus: appointment.status,
2221
- isDismissed: dismissedIds.has(recommendationId),
2222
- dismissedAt: null, // We don't track when it was dismissed, just that it was
2223
- };
2224
-
2225
- recommendations.push(nextStepsRecommendation);
2226
- }
2227
- }
2228
-
2229
- // Sort by appointment date (most recent first)
2230
- recommendations.sort((a, b) => {
2231
- const dateA = a.appointmentDate.toMillis();
2232
- const dateB = b.appointmentDate.toMillis();
2233
- return dateB - dateA;
2234
- });
2235
-
2236
- // Apply limit if specified
2237
- const limitedRecommendations = options?.limit
2238
- ? recommendations.slice(0, options.limit)
2239
- : recommendations;
2240
-
2241
- return limitedRecommendations;
2242
- } catch (error) {
2243
- console.error(
2244
- `[APPOINTMENT_SERVICE] Error getting next steps recommendations for patient ${patientId}:`,
2245
- error,
2246
- );
2247
- throw error;
2248
- }
2249
- }
2250
-
2251
- /**
2252
- * Dismisses a next steps recommendation for a patient.
2253
- * This prevents the recommendation from showing up in the default view.
2254
- *
2255
- * @param patientId ID of the patient
2256
- * @param recommendationId ID of the recommendation to dismiss (format: appointmentId:recommendationIndex)
2257
- * @returns Updated patient profile
2258
- */
2259
- async dismissNextStepsRecommendation(
2260
- patientId: string,
2261
- recommendationId: string,
2262
- ): Promise<void> {
2263
- try {
2264
- console.log(
2265
- `[APPOINTMENT_SERVICE] Dismissing recommendation ${recommendationId} for patient ${patientId}`,
2266
- );
2267
-
2268
- // Get patient profile
2269
- const patientProfile = await this.patientService.getPatientProfile(patientId);
2270
- if (!patientProfile) {
2271
- throw new Error(`Patient profile not found for patient ${patientId}`);
2272
- }
2273
-
2274
- // Get current dismissed recommendations
2275
- const dismissedRecommendations =
2276
- patientProfile.dismissedNextStepsRecommendations || [];
2277
-
2278
- // Check if already dismissed
2279
- if (dismissedRecommendations.includes(recommendationId)) {
2280
- console.log(
2281
- `[APPOINTMENT_SERVICE] Recommendation ${recommendationId} already dismissed`,
2282
- );
2283
- return;
2284
- }
2285
-
2286
- // Add to dismissed list
2287
- const updatedDismissed = [...dismissedRecommendations, recommendationId];
2288
-
2289
- // Update patient profile
2290
- await this.patientService.updatePatientProfile(patientId, {
2291
- dismissedNextStepsRecommendations: updatedDismissed,
2292
- });
2293
-
2294
- console.log(
2295
- `[APPOINTMENT_SERVICE] Successfully dismissed recommendation ${recommendationId} for patient ${patientId}`,
2296
- );
2297
- } catch (error) {
2298
- console.error(
2299
- `[APPOINTMENT_SERVICE] Error dismissing recommendation for patient ${patientId}:`,
2300
- error,
2301
- );
2302
- throw error;
2303
- }
2304
- }
2305
-
2306
- /**
2307
- * Undismisses a next steps recommendation for a patient.
2308
- * This makes the recommendation visible again in the default view.
2309
- *
2310
- * @param patientId ID of the patient
2311
- * @param recommendationId ID of the recommendation to undismiss (format: appointmentId:recommendationIndex)
2312
- * @returns Updated patient profile
2313
- */
2314
- async undismissNextStepsRecommendation(
2315
- patientId: string,
2316
- recommendationId: string,
2317
- ): Promise<void> {
2318
- try {
2319
- console.log(
2320
- `[APPOINTMENT_SERVICE] Undismissing recommendation ${recommendationId} for patient ${patientId}`,
2321
- );
2322
-
2323
- // Get patient profile
2324
- const patientProfile = await this.patientService.getPatientProfile(patientId);
2325
- if (!patientProfile) {
2326
- throw new Error(`Patient profile not found for patient ${patientId}`);
2327
- }
2328
-
2329
- // Get current dismissed recommendations
2330
- const dismissedRecommendations =
2331
- patientProfile.dismissedNextStepsRecommendations || [];
2332
-
2333
- // Check if not dismissed
2334
- if (!dismissedRecommendations.includes(recommendationId)) {
2335
- console.log(
2336
- `[APPOINTMENT_SERVICE] Recommendation ${recommendationId} is not dismissed`,
2337
- );
2338
- return;
2339
- }
2340
-
2341
- // Remove from dismissed list
2342
- const updatedDismissed = dismissedRecommendations.filter(
2343
- id => id !== recommendationId,
2344
- );
2345
-
2346
- // Update patient profile
2347
- await this.patientService.updatePatientProfile(patientId, {
2348
- dismissedNextStepsRecommendations: updatedDismissed,
2349
- });
2350
-
2351
- console.log(
2352
- `[APPOINTMENT_SERVICE] Successfully undismissed recommendation ${recommendationId} for patient ${patientId}`,
2353
- );
2354
- } catch (error) {
2355
- console.error(
2356
- `[APPOINTMENT_SERVICE] Error undismissing recommendation for patient ${patientId}:`,
2357
- error,
2358
- );
2359
- throw error;
2360
- }
2361
- }
2362
-
2363
- /**
2364
- * Gets next steps recommendations for a clinic.
2365
- * Returns all recommendations from appointments at the specified clinic.
2366
- * This is useful for clinic admins to see what treatments have been recommended to their patients.
2367
- *
2368
- * @param clinicBranchId ID of the clinic branch
2369
- * @param options Optional parameters for filtering
2370
- * @returns Array of next steps recommendations with context
2371
- */
2372
- async getClinicNextStepsRecommendations(
2373
- clinicBranchId: string,
2374
- options?: {
2375
- /** Filter by patient ID */
2376
- patientId?: string;
2377
- /** Filter by practitioner ID */
2378
- practitionerId?: string;
2379
- /** Limit the number of results */
2380
- limit?: number;
2381
- },
2382
- ): Promise<NextStepsRecommendation[]> {
2383
- try {
2384
- console.log(
2385
- `[APPOINTMENT_SERVICE] Getting next steps recommendations for clinic: ${clinicBranchId}`,
2386
- options,
2387
- );
2388
-
2389
- // Get past appointments for the clinic
2390
- const searchParams: SearchAppointmentsParams = {
2391
- clinicBranchId,
2392
- patientId: options?.patientId,
2393
- practitionerId: options?.practitionerId,
2394
- status: AppointmentStatus.COMPLETED,
2395
- };
2396
-
2397
- const { appointments } = await this.searchAppointments(searchParams);
2398
-
2399
- const recommendations: NextStepsRecommendation[] = [];
2400
-
2401
- // Iterate through appointments and extract recommendations
2402
- for (const appointment of appointments) {
2403
- // Get recommended procedures from appointment metadata
2404
- const recommendedProcedures =
2405
- appointment.metadata?.recommendedProcedures || [];
2406
-
2407
- // Create NextStepsRecommendation for each recommended procedure
2408
- for (let index = 0; index < recommendedProcedures.length; index++) {
2409
- const recommendedProcedure = recommendedProcedures[index];
2410
- const recommendationId = `${appointment.id}:${index}`;
2411
-
2412
- const nextStepsRecommendation: NextStepsRecommendation = {
2413
- id: recommendationId,
2414
- recommendedProcedure,
2415
- appointmentId: appointment.id,
2416
- appointmentDate: appointment.appointmentStartTime,
2417
- practitionerId: appointment.practitionerId,
2418
- practitionerName: appointment.practitionerInfo?.name || 'Unknown Practitioner',
2419
- clinicBranchId: appointment.clinicBranchId,
2420
- clinicName: appointment.clinicInfo?.name || 'Unknown Clinic',
2421
- appointmentStatus: appointment.status,
2422
- isDismissed: false, // Clinic view doesn't track dismissals
2423
- dismissedAt: null,
2424
- };
2425
-
2426
- recommendations.push(nextStepsRecommendation);
2427
- }
2428
- }
2429
-
2430
- // Sort by appointment date (most recent first)
2431
- recommendations.sort((a, b) => {
2432
- const dateA = a.appointmentDate.toMillis();
2433
- const dateB = b.appointmentDate.toMillis();
2434
- return dateB - dateA;
2435
- });
2436
-
2437
- // Apply limit if specified
2438
- const limitedRecommendations = options?.limit
2439
- ? recommendations.slice(0, options.limit)
2440
- : recommendations;
2441
-
2442
- console.log(
2443
- `[APPOINTMENT_SERVICE] Found ${limitedRecommendations.length} next steps recommendations for clinic ${clinicBranchId}`,
2444
- );
2445
-
2446
- return limitedRecommendations;
2447
- } catch (error) {
2448
- console.error(
2449
- `[APPOINTMENT_SERVICE] Error getting next steps recommendations for clinic ${clinicBranchId}:`,
2450
- error,
2451
- );
2452
- throw error;
2453
- }
2454
- }
2455
-
2456
- /**
2457
- * Gets next steps recommendations from a specific appointment.
2458
- * This is useful when viewing an appointment detail page in the clinic app
2459
- * to see what procedures were recommended during that appointment.
2460
- *
2461
- * @param appointmentId ID of the appointment
2462
- * @param options Optional parameters for filtering
2463
- * @returns Array of next steps recommendations from that appointment
2464
- */
2465
- async getAppointmentNextStepsRecommendations(
2466
- appointmentId: string,
2467
- options?: {
2468
- /** Filter by clinic branch ID - only show recommendations for procedures available at this clinic */
2469
- clinicBranchId?: string;
2470
- },
2471
- ): Promise<NextStepsRecommendation[]> {
2472
- try {
2473
- console.log(
2474
- `[APPOINTMENT_SERVICE] Getting next steps recommendations for appointment: ${appointmentId}`,
2475
- options,
2476
- );
2477
-
2478
- // Get the appointment
2479
- const appointment = await this.getAppointmentById(appointmentId);
2480
- if (!appointment) {
2481
- throw new Error(`Appointment with ID ${appointmentId} not found`);
2482
- }
2483
-
2484
- // Get recommended procedures from appointment metadata
2485
- const recommendedProcedures =
2486
- appointment.metadata?.recommendedProcedures || [];
2487
-
2488
- const recommendations: NextStepsRecommendation[] = [];
2489
-
2490
- // If clinicBranchId is provided, we need to check which procedures are available at that clinic
2491
- let availableProcedureIds: Set<string> | null = null;
2492
- if (options?.clinicBranchId) {
2493
- // Query procedures collection to get all procedure IDs available at this clinic
2494
- const proceduresQuery = query(
2495
- collection(this.db, PROCEDURES_COLLECTION),
2496
- where('clinicBranchId', '==', options.clinicBranchId),
2497
- where('isActive', '==', true),
2498
- );
2499
- const proceduresSnapshot = await getDocs(proceduresQuery);
2500
- availableProcedureIds = new Set(
2501
- proceduresSnapshot.docs.map(doc => doc.id),
2502
- );
2503
- console.log(
2504
- `[APPOINTMENT_SERVICE] Found ${availableProcedureIds.size} procedures available at clinic ${options.clinicBranchId}`,
2505
- );
2506
- }
2507
-
2508
- // Create NextStepsRecommendation for each recommended procedure
2509
- for (let index = 0; index < recommendedProcedures.length; index++) {
2510
- const recommendedProcedure = recommendedProcedures[index];
2511
- const procedureId = recommendedProcedure.procedure.procedureId;
2512
-
2513
- // If clinicBranchId is provided, filter to only include procedures available at that clinic
2514
- if (options?.clinicBranchId && availableProcedureIds) {
2515
- if (!availableProcedureIds.has(procedureId)) {
2516
- console.log(
2517
- `[APPOINTMENT_SERVICE] Skipping recommendation for procedure ${procedureId} - not available at clinic ${options.clinicBranchId}`,
2518
- );
2519
- continue;
2520
- }
2521
- }
2522
-
2523
- const recommendationId = `${appointment.id}:${index}`;
2524
-
2525
- const nextStepsRecommendation: NextStepsRecommendation = {
2526
- id: recommendationId,
2527
- recommendedProcedure,
2528
- appointmentId: appointment.id,
2529
- appointmentDate: appointment.appointmentStartTime,
2530
- practitionerId: appointment.practitionerId,
2531
- practitionerName: appointment.practitionerInfo?.name || 'Unknown Practitioner',
2532
- clinicBranchId: appointment.clinicBranchId,
2533
- clinicName: appointment.clinicInfo?.name || 'Unknown Clinic',
2534
- appointmentStatus: appointment.status,
2535
- isDismissed: false, // Clinic view doesn't track dismissals
2536
- dismissedAt: null,
2537
- };
2538
-
2539
- recommendations.push(nextStepsRecommendation);
2540
- }
2541
-
2542
- console.log(
2543
- `[APPOINTMENT_SERVICE] Found ${recommendations.length} next steps recommendations for appointment ${appointmentId}`,
2544
- options?.clinicBranchId
2545
- ? `(filtered to procedures available at clinic ${options.clinicBranchId})`
2546
- : '',
2547
- );
2548
-
2549
- return recommendations;
2550
- } catch (error) {
2551
- console.error(
2552
- `[APPOINTMENT_SERVICE] Error getting next steps recommendations for appointment ${appointmentId}:`,
2553
- error,
2554
- );
2555
- throw error;
2556
- }
2557
- }
2558
- }
1
+ import {
2
+ Firestore,
3
+ Timestamp,
4
+ DocumentSnapshot,
5
+ serverTimestamp,
6
+ arrayUnion,
7
+ arrayRemove,
8
+ QueryConstraint,
9
+ where,
10
+ orderBy,
11
+ collection,
12
+ query,
13
+ limit,
14
+ startAfter,
15
+ getDocs,
16
+ getCountFromServer,
17
+ doc,
18
+ getDoc,
19
+ } from 'firebase/firestore';
20
+ import { Auth } from 'firebase/auth';
21
+ import { FirebaseApp } from 'firebase/app';
22
+ import { Functions, getFunctions, httpsCallable } from 'firebase/functions';
23
+ import { BaseService } from '../base.service';
24
+ import {
25
+ Appointment,
26
+ AppointmentStatus,
27
+ UpdateAppointmentData,
28
+ SearchAppointmentsParams,
29
+ PaymentStatus,
30
+ AppointmentMediaItem,
31
+ PatientReviewInfo,
32
+ type CreateAppointmentHttpData,
33
+ type ZonePhotoUploadData,
34
+ BeforeAfterPerZone,
35
+ ZoneItemData,
36
+ ExtendedProcedureInfo,
37
+ AppointmentProductMetadata,
38
+ RecommendedProcedure,
39
+ NextStepsRecommendation,
40
+ APPOINTMENTS_COLLECTION,
41
+ } from '../../types/appointment';
42
+ import { PROCEDURES_COLLECTION } from '../../types/procedure';
43
+ import {
44
+ updateAppointmentSchema,
45
+ searchAppointmentsSchema,
46
+ rescheduleAppointmentSchema,
47
+ zonePhotoUploadSchema,
48
+ } from '../../validations/appointment.schema';
49
+
50
+ // Import other services needed (dependency injection pattern)
51
+ import { CalendarServiceV2 } from '../calendar/calendar.v2.service';
52
+ import { PatientService } from '../patient/patient.service';
53
+ import { PractitionerService } from '../practitioner/practitioner.service';
54
+ import { ClinicService } from '../clinic/clinic.service';
55
+ import { FilledDocumentService } from '../documentation-templates/filled-document.service';
56
+ import {
57
+ MediaService,
58
+ MediaAccessLevel,
59
+ MediaMetadata,
60
+ MediaResource,
61
+ } from '../media/media.service';
62
+
63
+ // Import utility functions
64
+ import {
65
+ updateAppointmentUtil,
66
+ getAppointmentByIdUtil,
67
+ searchAppointmentsUtil,
68
+ } from './utils/appointment.utils';
69
+ import {
70
+ addItemToZoneUtil,
71
+ removeItemFromZoneUtil,
72
+ updateZoneItemUtil,
73
+ overridePriceForZoneItemUtil,
74
+ updateSubzonesUtil,
75
+ calculateFinalBilling,
76
+ } from './utils/zone-management.utils';
77
+ import {
78
+ addExtendedProcedureUtil,
79
+ removeExtendedProcedureUtil,
80
+ getExtendedProceduresUtil,
81
+ getAppointmentProductsUtil,
82
+ } from './utils/extended-procedure.utils';
83
+ import {
84
+ addRecommendedProcedureUtil,
85
+ removeRecommendedProcedureUtil,
86
+ updateRecommendedProcedureUtil,
87
+ getRecommendedProceduresUtil,
88
+ } from './utils/recommended-procedure.utils';
89
+ import {
90
+ updateZonePhotoEntryUtil,
91
+ addAfterPhotoToEntryUtil,
92
+ updateZonePhotoNotesUtil,
93
+ getZonePhotoEntryUtil,
94
+ } from './utils/zone-photo.utils';
95
+
96
+ /**
97
+ * Interface for available booking slot
98
+ */
99
+ interface AvailableSlot {
100
+ start: Date;
101
+ }
102
+
103
+ /**
104
+ * AppointmentService is responsible for managing appointments,
105
+ * including creating, updating, retrieving, and searching appointments.
106
+ * It serves as the main entry point for working with appointment data.
107
+ */
108
+ export class AppointmentService extends BaseService {
109
+ private calendarService: CalendarServiceV2;
110
+ private patientService: PatientService;
111
+ private practitionerService: PractitionerService;
112
+ private clinicService: ClinicService;
113
+ private filledDocumentService: FilledDocumentService;
114
+ private mediaService: MediaService;
115
+ private functions: Functions;
116
+
117
+ /**
118
+ * Creates a new AppointmentService instance.
119
+ *
120
+ * @param db Firestore instance
121
+ * @param auth Firebase Auth instance
122
+ * @param app Firebase App instance
123
+ * @param calendarService Calendar service instance
124
+ * @param patientService Patient service instance
125
+ * @param practitionerService Practitioner service instance
126
+ * @param clinicService Clinic service instance
127
+ * @param filledDocumentService Filled document service instance
128
+ */
129
+ constructor(
130
+ db: Firestore,
131
+ auth: Auth,
132
+ app: FirebaseApp,
133
+ calendarService: CalendarServiceV2,
134
+ patientService: PatientService,
135
+ practitionerService: PractitionerService,
136
+ clinicService: ClinicService,
137
+ filledDocumentService: FilledDocumentService,
138
+ ) {
139
+ super(db, auth, app);
140
+ this.calendarService = calendarService;
141
+ this.patientService = patientService;
142
+ this.practitionerService = practitionerService;
143
+ this.clinicService = clinicService;
144
+ this.filledDocumentService = filledDocumentService;
145
+ this.mediaService = new MediaService(db, auth, app);
146
+ this.functions = getFunctions(app, 'europe-west6'); // Initialize Firebase Functions with the correct region
147
+ }
148
+
149
+ /**
150
+ * Gets available booking slots for a specific clinic, practitioner, and procedure using HTTP request.
151
+ * This is an alternative implementation using direct HTTP request instead of callable function.
152
+ *
153
+ * @param clinicId ID of the clinic
154
+ * @param practitionerId ID of the practitioner
155
+ * @param procedureId ID of the procedure
156
+ * @param startDate Start date of the time range to check
157
+ * @param endDate End date of the time range to check
158
+ * @returns Array of available booking slots
159
+ */
160
+ async getAvailableBookingSlotsHttp(
161
+ clinicId: string,
162
+ practitionerId: string,
163
+ procedureId: string,
164
+ startDate: Date,
165
+ endDate: Date,
166
+ ): Promise<AvailableSlot[]> {
167
+ try {
168
+ console.log(
169
+ `[APPOINTMENT_SERVICE] Getting available booking slots via HTTP for clinic: ${clinicId}, practitioner: ${practitionerId}, procedure: ${procedureId}`,
170
+ );
171
+
172
+ // Validate input parameters
173
+ if (!clinicId || !practitionerId || !procedureId || !startDate || !endDate) {
174
+ throw new Error('Missing required parameters for booking slots calculation');
175
+ }
176
+
177
+ if (endDate <= startDate) {
178
+ throw new Error('End date must be after start date');
179
+ }
180
+
181
+ // Check if user is authenticated
182
+ const currentUser = this.auth.currentUser;
183
+ if (!currentUser) {
184
+ throw new Error('User must be authenticated to get available booking slots');
185
+ }
186
+
187
+ // Construct the function URL for the Express app endpoint
188
+ const functionUrl = `https://europe-west6-metaestetics.cloudfunctions.net/bookingApi/getAvailableBookingSlots`;
189
+
190
+ // Get the authenticated user's ID token
191
+ // By default, getIdToken doesn't allow setting audience for web/mobile clients
192
+ // So we need to treat this token specially on the server side
193
+ const idToken = await currentUser.getIdToken();
194
+
195
+ // Log that we're getting a token
196
+ console.log(`[APPOINTMENT_SERVICE] Got user token, user ID: ${currentUser.uid}`);
197
+
198
+ // Alternate direct URL (if needed):
199
+ // const functionUrl = `https://getavailablebookingslotshttp-grqala5m6a-oa.a.run.app`;
200
+
201
+ // Request data
202
+ const requestData = {
203
+ clinicId,
204
+ practitionerId,
205
+ procedureId,
206
+ timeframe: {
207
+ start: startDate.getTime(), // Convert to timestamp
208
+ end: endDate.getTime(),
209
+ },
210
+ };
211
+
212
+ console.log(`[APPOINTMENT_SERVICE] Making fetch request to ${functionUrl}`);
213
+
214
+ // Make the HTTP request with expanded CORS options for browser
215
+ const response = await fetch(functionUrl, {
216
+ method: 'POST',
217
+ mode: 'cors', // Important for cross-origin requests
218
+ cache: 'no-cache', // Don't cache this request
219
+ credentials: 'omit', // Don't send cookies since we're using token auth
220
+ headers: {
221
+ 'Content-Type': 'application/json',
222
+ Authorization: `Bearer ${idToken}`,
223
+ },
224
+ redirect: 'follow',
225
+ referrerPolicy: 'no-referrer',
226
+ body: JSON.stringify(requestData),
227
+ });
228
+
229
+ console.log(
230
+ `[APPOINTMENT_SERVICE] Received response ${response.status}: ${response.statusText}`,
231
+ );
232
+
233
+ // Check if the request was successful
234
+ if (!response.ok) {
235
+ const errorText = await response.text();
236
+ console.error(`[APPOINTMENT_SERVICE] Error response details: ${errorText}`);
237
+ throw new Error(
238
+ `Failed to get available booking slots: ${response.status} ${response.statusText} - ${errorText}`,
239
+ );
240
+ }
241
+
242
+ // Parse the response
243
+ const result = await response.json();
244
+ console.log(`[APPOINTMENT_SERVICE] Response parsed successfully`, result);
245
+
246
+ if (!result.success) {
247
+ throw new Error(result.error || 'Failed to get available booking slots');
248
+ }
249
+
250
+ // Convert timestamp numbers to Date objects
251
+ const slots: AvailableSlot[] = result.availableSlots.map((slot: { start: number }) => ({
252
+ start: new Date(slot.start),
253
+ }));
254
+
255
+ console.log(`[APPOINTMENT_SERVICE] Found ${slots.length} available booking slots via HTTP`);
256
+
257
+ return slots;
258
+ } catch (error) {
259
+ console.error('[APPOINTMENT_SERVICE] Error getting available booking slots via HTTP:', error);
260
+ throw error;
261
+ }
262
+ }
263
+
264
+ /**
265
+ * Creates an appointment via the Cloud Function orchestrateAppointmentCreation
266
+ *
267
+ * @param data - CreateAppointmentData object
268
+ * @returns The created appointment
269
+ */
270
+ async createAppointmentHttp(data: CreateAppointmentHttpData): Promise<Appointment> {
271
+ try {
272
+ console.log('[APPOINTMENT_SERVICE] Creating appointment via cloud function');
273
+
274
+ // Get the authenticated user's ID token
275
+ const currentUser = this.auth.currentUser;
276
+ if (!currentUser) {
277
+ throw new Error('User must be authenticated to create an appointment');
278
+ }
279
+ const idToken = await currentUser.getIdToken();
280
+
281
+ // Construct the function URL for the Express app endpoint
282
+ const functionUrl = `https://europe-west6-metaestetics.cloudfunctions.net/bookingApi/orchestrateAppointmentCreation`;
283
+
284
+ // Prepare request data for the Cloud Function
285
+ // Map CreateAppointmentData to OrchestrateAppointmentCreationData format
286
+ const requestData = {
287
+ patientId: data.patientId,
288
+ procedureId: data.procedureId,
289
+ appointmentStartTime: data.appointmentStartTime.toMillis
290
+ ? data.appointmentStartTime.toMillis()
291
+ : new Date(data.appointmentStartTime as any).getTime(),
292
+ appointmentEndTime: data.appointmentEndTime.toMillis
293
+ ? data.appointmentEndTime.toMillis()
294
+ : new Date(data.appointmentEndTime as any).getTime(),
295
+ patientNotes: data?.patientNotes || null,
296
+ };
297
+
298
+ console.log(`[APPOINTMENT_SERVICE] Making fetch request to ${functionUrl}`);
299
+
300
+ // Make the HTTP request with expanded CORS options for browser
301
+ const response = await fetch(functionUrl, {
302
+ method: 'POST',
303
+ mode: 'cors',
304
+ cache: 'no-cache',
305
+ credentials: 'omit',
306
+ headers: {
307
+ 'Content-Type': 'application/json',
308
+ Authorization: `Bearer ${idToken}`,
309
+ },
310
+ redirect: 'follow',
311
+ referrerPolicy: 'no-referrer',
312
+ body: JSON.stringify(requestData),
313
+ });
314
+
315
+ console.log(
316
+ `[APPOINTMENT_SERVICE] Received response ${response.status}: ${response.statusText}`,
317
+ );
318
+
319
+ // Check if the request was successful
320
+ if (!response.ok) {
321
+ const errorText = await response.text();
322
+ console.error(`[APPOINTMENT_SERVICE] Error response details: ${errorText}`);
323
+ throw new Error(
324
+ `Failed to create appointment: ${response.status} ${response.statusText} - ${errorText}`,
325
+ );
326
+ }
327
+
328
+ // Parse the response
329
+ const result = await response.json();
330
+
331
+ if (!result.success) {
332
+ throw new Error(result.error || 'Failed to create appointment');
333
+ }
334
+
335
+ // If the backend returns the full appointment data
336
+ if (result.appointmentData) {
337
+ console.log(`[APPOINTMENT_SERVICE] Appointment created with ID: ${result.appointmentId}`);
338
+ return result.appointmentData;
339
+ }
340
+
341
+ // If only the ID is returned, fetch the complete appointment
342
+ const createdAppointment = await this.getAppointmentById(result.appointmentId);
343
+ if (!createdAppointment) {
344
+ throw new Error(`Failed to retrieve created appointment with ID: ${result.appointmentId}`);
345
+ }
346
+
347
+ return createdAppointment;
348
+ } catch (error) {
349
+ console.error('[APPOINTMENT_SERVICE] Error creating appointment via cloud function:', error);
350
+ throw error;
351
+ }
352
+ }
353
+
354
+ /**
355
+ * Gets an appointment by ID.
356
+ *
357
+ * @param appointmentId ID of the appointment to retrieve
358
+ * @returns The appointment or null if not found
359
+ */
360
+ async getAppointmentById(appointmentId: string): Promise<Appointment | null> {
361
+ try {
362
+ console.log(`[APPOINTMENT_SERVICE] Getting appointment with ID: ${appointmentId}`);
363
+
364
+ const appointment = await getAppointmentByIdUtil(this.db, appointmentId);
365
+
366
+ console.log(
367
+ `[APPOINTMENT_SERVICE] Appointment ${appointmentId} ${appointment ? 'found' : 'not found'}`,
368
+ );
369
+
370
+ return appointment;
371
+ } catch (error) {
372
+ console.error(`[APPOINTMENT_SERVICE] Error getting appointment ${appointmentId}:`, error);
373
+ throw error;
374
+ }
375
+ }
376
+
377
+ /**
378
+ * Updates an existing appointment.
379
+ *
380
+ * @param appointmentId ID of the appointment to update
381
+ * @param data Update data for the appointment
382
+ * @returns The updated appointment
383
+ */
384
+ async updateAppointment(
385
+ appointmentId: string,
386
+ data: UpdateAppointmentData,
387
+ ): Promise<Appointment> {
388
+ try {
389
+ console.log(`[APPOINTMENT_SERVICE] Updating appointment with ID: ${appointmentId}`);
390
+
391
+ // AUTO-MIGRATION: Convert old zonePhotos format to new array format BEFORE validation
392
+ if (data.metadata?.zonePhotos) {
393
+ const migratedZonePhotos: Record<string, BeforeAfterPerZone[]> = {};
394
+
395
+ for (const [key, value] of Object.entries(data.metadata.zonePhotos)) {
396
+ if (Array.isArray(value)) {
397
+ // Already in new format
398
+ migratedZonePhotos[key] = value as BeforeAfterPerZone[];
399
+ } else {
400
+ // Old format - convert to array
401
+ console.log(`[APPOINTMENT_SERVICE] Auto-migrating ${key} from object to array format`);
402
+ const oldData = value as any;
403
+ migratedZonePhotos[key] = [
404
+ {
405
+ before: oldData.before || null,
406
+ after: oldData.after || null,
407
+ beforeNote: null,
408
+ afterNote: null,
409
+ },
410
+ ];
411
+ }
412
+ }
413
+
414
+ // Replace with migrated data
415
+ data.metadata.zonePhotos = migratedZonePhotos;
416
+ }
417
+
418
+ // AUTO-CLEANUP: Remove invalid recommendedProcedures with empty notes BEFORE validation
419
+ console.log(
420
+ '[APPOINTMENT_SERVICE] 🔍 BEFORE CLEANUP - recommendedProcedures:',
421
+ JSON.stringify(data.metadata?.recommendedProcedures, null, 2),
422
+ );
423
+
424
+ if (
425
+ data.metadata?.recommendedProcedures &&
426
+ Array.isArray(data.metadata.recommendedProcedures)
427
+ ) {
428
+ const validRecommendations = data.metadata.recommendedProcedures.filter((rec: any) => {
429
+ const isValid = rec.note && typeof rec.note === 'string' && rec.note.trim().length > 0;
430
+ if (!isValid) {
431
+ console.log('[APPOINTMENT_SERVICE] ❌ INVALID recommendation found:', rec);
432
+ }
433
+ return isValid;
434
+ });
435
+
436
+ if (validRecommendations.length !== data.metadata.recommendedProcedures.length) {
437
+ console.log(
438
+ `[APPOINTMENT_SERVICE] 🧹 Removing ${
439
+ data.metadata.recommendedProcedures.length - validRecommendations.length
440
+ } invalid recommended procedures with empty notes`,
441
+ );
442
+ data.metadata.recommendedProcedures = validRecommendations;
443
+ } else {
444
+ console.log('[APPOINTMENT_SERVICE] ✅ All recommendedProcedures are valid');
445
+ }
446
+ }
447
+
448
+ console.log(
449
+ '[APPOINTMENT_SERVICE] 🔍 AFTER CLEANUP - recommendedProcedures:',
450
+ JSON.stringify(data.metadata?.recommendedProcedures, null, 2),
451
+ );
452
+
453
+ // Validate input data
454
+ console.log('[APPOINTMENT_SERVICE] 🔍 Starting Zod validation...');
455
+ const validatedData = await updateAppointmentSchema.parseAsync(data);
456
+ console.log('[APPOINTMENT_SERVICE] ✅ Zod validation passed!');
457
+
458
+ // Update the appointment using the utility function
459
+ const updatedAppointment = await updateAppointmentUtil(this.db, appointmentId, validatedData);
460
+
461
+ console.log(`[APPOINTMENT_SERVICE] Appointment ${appointmentId} updated successfully`);
462
+
463
+ return updatedAppointment;
464
+ } catch (error) {
465
+ console.error(`[APPOINTMENT_SERVICE] Error updating appointment ${appointmentId}:`, error);
466
+ throw error;
467
+ }
468
+ }
469
+
470
+ /**
471
+ * Searches for appointments based on various criteria.
472
+ *
473
+ * @param params Search parameters
474
+ * @returns Found appointments and the last document for pagination
475
+ */
476
+ async searchAppointments(params: SearchAppointmentsParams): Promise<{
477
+ appointments: Appointment[];
478
+ lastDoc: DocumentSnapshot | null;
479
+ }> {
480
+ try {
481
+ console.log('[APPOINTMENT_SERVICE] Searching appointments with params:', params);
482
+
483
+ // Validate search parameters
484
+ await searchAppointmentsSchema.parseAsync(params);
485
+
486
+ // Search for appointments using the utility function
487
+ const result = await searchAppointmentsUtil(this.db, params);
488
+
489
+ console.log(`[APPOINTMENT_SERVICE] Found ${result.appointments.length} appointments`);
490
+
491
+ return result;
492
+ } catch (error) {
493
+ console.error('[APPOINTMENT_SERVICE] Error searching appointments:', error);
494
+ throw error;
495
+ }
496
+ }
497
+
498
+ /**
499
+ * Gets appointments for a specific patient.
500
+ *
501
+ * @param patientId ID of the patient
502
+ * @param options Optional parameters for filtering and pagination
503
+ * @returns Found appointments and the last document for pagination
504
+ */
505
+ async getPatientAppointments(
506
+ patientId: string,
507
+ options?: {
508
+ startDate?: Date;
509
+ endDate?: Date;
510
+ status?: AppointmentStatus | AppointmentStatus[];
511
+ limit?: number;
512
+ startAfter?: DocumentSnapshot;
513
+ },
514
+ ): Promise<{
515
+ appointments: Appointment[];
516
+ lastDoc: DocumentSnapshot | null;
517
+ }> {
518
+ console.log(`[APPOINTMENT_SERVICE] Getting appointments for patient: ${patientId}`);
519
+
520
+ const searchParams: SearchAppointmentsParams = {
521
+ patientId,
522
+ startDate: options?.startDate,
523
+ endDate: options?.endDate,
524
+ status: options?.status,
525
+ limit: options?.limit,
526
+ startAfter: options?.startAfter,
527
+ };
528
+
529
+ return this.searchAppointments(searchParams);
530
+ }
531
+
532
+ /**
533
+ * Gets appointments for a specific practitioner.
534
+ *
535
+ * @param practitionerId ID of the practitioner
536
+ * @param options Optional parameters for filtering and pagination
537
+ * @returns Found appointments and the last document for pagination
538
+ */
539
+ async getPractitionerAppointments(
540
+ practitionerId: string,
541
+ options?: {
542
+ startDate?: Date;
543
+ endDate?: Date;
544
+ status?: AppointmentStatus | AppointmentStatus[];
545
+ limit?: number;
546
+ startAfter?: DocumentSnapshot;
547
+ },
548
+ ): Promise<{
549
+ appointments: Appointment[];
550
+ lastDoc: DocumentSnapshot | null;
551
+ }> {
552
+ console.log(`[APPOINTMENT_SERVICE] Getting appointments for practitioner: ${practitionerId}`);
553
+
554
+ const searchParams: SearchAppointmentsParams = {
555
+ practitionerId,
556
+ startDate: options?.startDate,
557
+ endDate: options?.endDate,
558
+ status: options?.status,
559
+ limit: options?.limit,
560
+ startAfter: options?.startAfter,
561
+ };
562
+
563
+ return this.searchAppointments(searchParams);
564
+ }
565
+
566
+ /**
567
+ * Gets appointments for a specific clinic.
568
+ *
569
+ * @param clinicBranchId ID of the clinic branch
570
+ * @param options Optional parameters for filtering and pagination
571
+ * @returns Found appointments and the last document for pagination
572
+ */
573
+ async getClinicAppointments(
574
+ clinicBranchId: string,
575
+ options?: {
576
+ practitionerId?: string;
577
+ startDate?: Date;
578
+ endDate?: Date;
579
+ status?: AppointmentStatus | AppointmentStatus[];
580
+ limit?: number;
581
+ startAfter?: DocumentSnapshot;
582
+ },
583
+ ): Promise<{
584
+ appointments: Appointment[];
585
+ lastDoc: DocumentSnapshot | null;
586
+ }> {
587
+ console.log(`[APPOINTMENT_SERVICE] Getting appointments for clinic: ${clinicBranchId}`);
588
+
589
+ const searchParams: SearchAppointmentsParams = {
590
+ clinicBranchId,
591
+ practitionerId: options?.practitionerId,
592
+ startDate: options?.startDate,
593
+ endDate: options?.endDate,
594
+ status: options?.status,
595
+ limit: options?.limit,
596
+ startAfter: options?.startAfter,
597
+ };
598
+
599
+ return this.searchAppointments(searchParams);
600
+ }
601
+
602
+ /**
603
+ * Updates the status of an appointment.
604
+ *
605
+ * @param appointmentId ID of the appointment
606
+ * @param newStatus New status to set
607
+ * @param details Optional details for the status change
608
+ * @returns The updated appointment
609
+ */
610
+ async updateAppointmentStatus(
611
+ appointmentId: string,
612
+ newStatus: AppointmentStatus,
613
+ details?: {
614
+ cancellationReason?: string;
615
+ canceledBy?: 'patient' | 'clinic' | 'practitioner' | 'system';
616
+ },
617
+ ): Promise<Appointment> {
618
+ console.log(
619
+ `[APPOINTMENT_SERVICE] Updating status of appointment ${appointmentId} to ${newStatus}`,
620
+ );
621
+ const updateData: UpdateAppointmentData = {
622
+ status: newStatus,
623
+ updatedAt: serverTimestamp(),
624
+ };
625
+
626
+ if (
627
+ newStatus === AppointmentStatus.CANCELED_CLINIC ||
628
+ newStatus === AppointmentStatus.CANCELED_PATIENT ||
629
+ newStatus === AppointmentStatus.CANCELED_PATIENT_RESCHEDULED
630
+ ) {
631
+ if (!details?.cancellationReason) {
632
+ throw new Error('Cancellation reason is required when canceling.');
633
+ }
634
+ if (!details?.canceledBy) {
635
+ throw new Error('Canceled by is required when canceling.');
636
+ }
637
+ updateData.cancellationReason = details.cancellationReason;
638
+ updateData.canceledBy = details.canceledBy;
639
+ updateData.cancellationTime = Timestamp.now();
640
+ }
641
+
642
+ if (newStatus === AppointmentStatus.CONFIRMED) {
643
+ updateData.confirmationTime = Timestamp.now();
644
+ }
645
+
646
+ if (newStatus === AppointmentStatus.RESCHEDULED_BY_CLINIC) {
647
+ updateData.rescheduleTime = Timestamp.now();
648
+ }
649
+
650
+ return this.updateAppointment(appointmentId, updateData);
651
+ }
652
+
653
+ /**
654
+ * Confirms a PENDING appointment by an Admin/Clinic.
655
+ */
656
+ async confirmAppointmentAdmin(appointmentId: string): Promise<Appointment> {
657
+ console.log(`[APPOINTMENT_SERVICE] Admin confirming appointment: ${appointmentId}`);
658
+ const appointment = await this.getAppointmentById(appointmentId);
659
+ if (!appointment) throw new Error(`Appointment ${appointmentId} not found.`);
660
+ if (appointment.status !== AppointmentStatus.PENDING) {
661
+ throw new Error(`Appointment ${appointmentId} is not in PENDING state to be confirmed.`);
662
+ }
663
+ return this.updateAppointmentStatus(appointmentId, AppointmentStatus.CONFIRMED);
664
+ }
665
+
666
+ /**
667
+ * Cancels an appointment by the User (Patient).
668
+ */
669
+ async cancelAppointmentUser(appointmentId: string, reason: string): Promise<Appointment> {
670
+ console.log(`[APPOINTMENT_SERVICE] User canceling appointment: ${appointmentId}`);
671
+ return this.updateAppointmentStatus(appointmentId, AppointmentStatus.CANCELED_PATIENT, {
672
+ cancellationReason: reason,
673
+ canceledBy: 'patient',
674
+ });
675
+ }
676
+
677
+ /**
678
+ * Cancels an appointment by an Admin/Clinic.
679
+ */
680
+ async cancelAppointmentAdmin(appointmentId: string, reason: string): Promise<Appointment> {
681
+ console.log(`[APPOINTMENT_SERVICE] Admin canceling appointment: ${appointmentId}`);
682
+ return this.updateAppointmentStatus(appointmentId, AppointmentStatus.CANCELED_CLINIC, {
683
+ cancellationReason: reason,
684
+ canceledBy: 'clinic',
685
+ });
686
+ }
687
+
688
+ /**
689
+ * Admin proposes to reschedule an appointment.
690
+ * Sets status to RESCHEDULED_BY_CLINIC and updates times.
691
+ */
692
+ async rescheduleAppointmentAdmin(params: {
693
+ appointmentId: string;
694
+ newStartTime: any; // Accept any type (number, string, Timestamp, etc.)
695
+ newEndTime: any; // Accept any type (number, string, Timestamp, etc.)
696
+ }): Promise<Appointment> {
697
+ console.log(`[APPOINTMENT_SERVICE] Admin rescheduling appointment: ${params.appointmentId}`);
698
+
699
+ // Validate input data
700
+ const validatedParams = await rescheduleAppointmentSchema.parseAsync(params);
701
+
702
+ // Convert input to Timestamp objects
703
+ const startTimestamp = this.convertToTimestamp(validatedParams.newStartTime);
704
+ const endTimestamp = this.convertToTimestamp(validatedParams.newEndTime);
705
+
706
+ if (endTimestamp.toMillis() <= startTimestamp.toMillis()) {
707
+ throw new Error('New end time must be after new start time.');
708
+ }
709
+
710
+ const updateData: UpdateAppointmentData = {
711
+ status: AppointmentStatus.RESCHEDULED_BY_CLINIC,
712
+ appointmentStartTime: startTimestamp,
713
+ appointmentEndTime: endTimestamp,
714
+ rescheduleTime: Timestamp.now(),
715
+ confirmationTime: null,
716
+ updatedAt: serverTimestamp(),
717
+ };
718
+ return this.updateAppointment(validatedParams.appointmentId, updateData);
719
+ }
720
+
721
+ /**
722
+ * Helper method to convert various timestamp formats to Firestore Timestamp
723
+ * @param value - Any timestamp format (Timestamp, number, string, Date, serialized Timestamp)
724
+ * @returns Firestore Timestamp object
725
+ */
726
+ private convertToTimestamp(value: any): Timestamp {
727
+ console.log(`[APPOINTMENT_SERVICE] Converting timestamp:`, {
728
+ value,
729
+ type: typeof value,
730
+ });
731
+
732
+ // If it's already a Timestamp object with methods
733
+ if (value && typeof value.toMillis === 'function') {
734
+ return value;
735
+ }
736
+
737
+ // If it's a number (milliseconds since epoch)
738
+ if (typeof value === 'number') {
739
+ return Timestamp.fromMillis(value);
740
+ }
741
+
742
+ // If it's a string (ISO date string)
743
+ if (typeof value === 'string') {
744
+ return Timestamp.fromDate(new Date(value));
745
+ }
746
+
747
+ // If it's a Date object
748
+ if (value instanceof Date) {
749
+ return Timestamp.fromDate(value);
750
+ }
751
+
752
+ // If it has _seconds property (serialized Timestamp) - THIS IS WHAT FRONTEND SENDS
753
+ if (value && typeof value._seconds === 'number') {
754
+ return new Timestamp(value._seconds, value._nanoseconds || 0);
755
+ }
756
+
757
+ // If it has seconds property (serialized Timestamp)
758
+ if (value && typeof value.seconds === 'number') {
759
+ return new Timestamp(value.seconds, value.nanoseconds || 0);
760
+ }
761
+
762
+ throw new Error(`Invalid timestamp format: ${typeof value}, value: ${JSON.stringify(value)}`);
763
+ }
764
+
765
+ /**
766
+ * User confirms a reschedule proposed by the clinic.
767
+ * Status changes from RESCHEDULED_BY_CLINIC to CONFIRMED.
768
+ */
769
+ async rescheduleAppointmentConfirmUser(appointmentId: string): Promise<Appointment> {
770
+ console.log(`[APPOINTMENT_SERVICE] User confirming reschedule for: ${appointmentId}`);
771
+ const appointment = await this.getAppointmentById(appointmentId);
772
+ if (!appointment) throw new Error(`Appointment ${appointmentId} not found.`);
773
+ if (appointment.status !== AppointmentStatus.RESCHEDULED_BY_CLINIC) {
774
+ throw new Error(`Appointment ${appointmentId} is not in RESCHEDULED_BY_CLINIC state.`);
775
+ }
776
+ return this.updateAppointmentStatus(appointmentId, AppointmentStatus.CONFIRMED);
777
+ }
778
+
779
+ /**
780
+ * User rejects a reschedule proposed by the clinic.
781
+ * Status changes from RESCHEDULED_BY_CLINIC to CANCELED_PATIENT_RESCHEDULED.
782
+ */
783
+ async rescheduleAppointmentRejectUser(
784
+ appointmentId: string,
785
+ reason: string,
786
+ ): Promise<Appointment> {
787
+ console.log(`[APPOINTMENT_SERVICE] User rejecting reschedule for: ${appointmentId}`);
788
+ const appointment = await this.getAppointmentById(appointmentId);
789
+ if (!appointment) throw new Error(`Appointment ${appointmentId} not found.`);
790
+ if (appointment.status !== AppointmentStatus.RESCHEDULED_BY_CLINIC) {
791
+ throw new Error(`Appointment ${appointmentId} is not in RESCHEDULED_BY_CLINIC state.`);
792
+ }
793
+ return this.updateAppointmentStatus(
794
+ appointmentId,
795
+ AppointmentStatus.CANCELED_PATIENT_RESCHEDULED,
796
+ {
797
+ cancellationReason: reason,
798
+ canceledBy: 'patient',
799
+ },
800
+ );
801
+ }
802
+
803
+ /**
804
+ * Admin checks in a patient for their appointment.
805
+ * Requires all pending user forms to be completed.
806
+ */
807
+ async checkInPatientAdmin(appointmentId: string): Promise<Appointment> {
808
+ console.log(`[APPOINTMENT_SERVICE] Admin checking in patient for: ${appointmentId}`);
809
+ const appointment = await this.getAppointmentById(appointmentId);
810
+ if (!appointment) throw new Error(`Appointment ${appointmentId} not found.`);
811
+
812
+ if (appointment.pendingUserFormsIds && appointment.pendingUserFormsIds.length > 0) {
813
+ throw new Error(
814
+ `Cannot check in: Patient has ${
815
+ appointment.pendingUserFormsIds.length
816
+ } pending required form(s). IDs: ${appointment.pendingUserFormsIds.join(', ')}`,
817
+ );
818
+ }
819
+ if (
820
+ appointment.status !== AppointmentStatus.CONFIRMED &&
821
+ appointment.status !== AppointmentStatus.RESCHEDULED_BY_CLINIC
822
+ ) {
823
+ console.warn(
824
+ `Checking in appointment ${appointmentId} with status ${appointment.status}. Ensure this is intended.`,
825
+ );
826
+ }
827
+
828
+ return this.updateAppointmentStatus(appointmentId, AppointmentStatus.CHECKED_IN);
829
+ }
830
+
831
+ /**
832
+ * Doctor starts the appointment procedure.
833
+ */
834
+ async startAppointmentDoctor(appointmentId: string): Promise<Appointment> {
835
+ console.log(`[APPOINTMENT_SERVICE] Doctor starting appointment: ${appointmentId}`);
836
+ const appointment = await this.getAppointmentById(appointmentId);
837
+ if (!appointment) throw new Error(`Appointment ${appointmentId} not found.`);
838
+ if (appointment.status !== AppointmentStatus.CHECKED_IN) {
839
+ throw new Error(`Appointment ${appointmentId} must be CHECKED_IN to start.`);
840
+ }
841
+ // Update status and set procedureActualStartTime
842
+ const updateData: UpdateAppointmentData = {
843
+ status: AppointmentStatus.IN_PROGRESS,
844
+ procedureActualStartTime: Timestamp.now(), // Set actual start time
845
+ updatedAt: serverTimestamp(),
846
+ };
847
+ return this.updateAppointment(appointmentId, updateData);
848
+ }
849
+
850
+ /**
851
+ * Doctor completes and finalizes the appointment.
852
+ */
853
+ async completeAppointmentDoctor(
854
+ appointmentId: string,
855
+ finalizationNotes: string,
856
+ actualDurationMinutesInput?: number, // Renamed to avoid conflict if we calculate
857
+ ): Promise<Appointment> {
858
+ console.log(`[APPOINTMENT_SERVICE] Doctor completing appointment: ${appointmentId}`);
859
+ const currentUser = this.auth.currentUser;
860
+ if (!currentUser) throw new Error('Authentication required to complete appointment.');
861
+
862
+ const appointment = await this.getAppointmentById(appointmentId);
863
+ if (!appointment) throw new Error(`Appointment ${appointmentId} not found.`);
864
+
865
+ let calculatedDurationMinutes = actualDurationMinutesInput;
866
+ const procedureCompletionTime = Timestamp.now();
867
+
868
+ // Calculate duration if not provided and actual start time is available
869
+ if (calculatedDurationMinutes === undefined && appointment.procedureActualStartTime) {
870
+ const startTimeMillis = appointment.procedureActualStartTime.toMillis();
871
+ const endTimeMillis = procedureCompletionTime.toMillis();
872
+ if (endTimeMillis > startTimeMillis) {
873
+ calculatedDurationMinutes = Math.round((endTimeMillis - startTimeMillis) / 60000);
874
+ }
875
+ }
876
+
877
+ const updateData: UpdateAppointmentData = {
878
+ status: AppointmentStatus.COMPLETED,
879
+ actualDurationMinutes: calculatedDurationMinutes, // Use calculated or provided duration
880
+ finalizedDetails: {
881
+ by: currentUser.uid, // This is used ID, not practitioner's profile ID (just so we know who completed the appointment)
882
+ at: procedureCompletionTime, // Use consistent completion timestamp
883
+ notes: finalizationNotes,
884
+ },
885
+ // Optionally update appointmentEndTime to the actual completion time
886
+ // appointmentEndTime: procedureCompletionTime,
887
+ updatedAt: serverTimestamp(),
888
+ };
889
+ return this.updateAppointment(appointmentId, updateData);
890
+ }
891
+
892
+ /**
893
+ * Admin marks an appointment as No-Show.
894
+ */
895
+ async markNoShowAdmin(appointmentId: string): Promise<Appointment> {
896
+ console.log(`[APPOINTMENT_SERVICE] Admin marking no-show for: ${appointmentId}`);
897
+ const appointment = await this.getAppointmentById(appointmentId);
898
+ if (!appointment) throw new Error(`Appointment ${appointmentId} not found.`);
899
+ if (Timestamp.now().toMillis() < appointment.appointmentStartTime.toMillis()) {
900
+ throw new Error('Cannot mark no-show before appointment start time.');
901
+ }
902
+ return this.updateAppointmentStatus(appointmentId, AppointmentStatus.NO_SHOW, {
903
+ cancellationReason: 'Patient did not show up for the appointment.',
904
+ canceledBy: 'clinic',
905
+ });
906
+ }
907
+
908
+ /**
909
+ * Adds a media item to an appointment.
910
+ */
911
+ async addMediaToAppointment(
912
+ appointmentId: string,
913
+ mediaItemData: Omit<AppointmentMediaItem, 'id' | 'uploadedAt'>,
914
+ ): Promise<Appointment> {
915
+ console.log(`[APPOINTMENT_SERVICE] Adding media to appointment ${appointmentId}`);
916
+ const currentUser = this.auth.currentUser;
917
+ if (!currentUser) throw new Error('Authentication required.');
918
+
919
+ const newMediaItem: AppointmentMediaItem = {
920
+ ...mediaItemData,
921
+ id: this.generateId(),
922
+ uploadedAt: Timestamp.now(),
923
+ uploadedBy: currentUser.uid,
924
+ };
925
+
926
+ const updateData: UpdateAppointmentData = {
927
+ media: arrayUnion(newMediaItem) as any,
928
+ updatedAt: serverTimestamp(),
929
+ };
930
+ return this.updateAppointment(appointmentId, updateData);
931
+ }
932
+
933
+ /**
934
+ * Removes a media item from an appointment.
935
+ */
936
+ async removeMediaFromAppointment(
937
+ appointmentId: string,
938
+ mediaItemId: string,
939
+ ): Promise<Appointment> {
940
+ console.log(
941
+ `[APPOINTMENT_SERVICE] Removing media ${mediaItemId} from appointment ${appointmentId}`,
942
+ );
943
+ const appointment = await this.getAppointmentById(appointmentId);
944
+ if (!appointment || !appointment.media) {
945
+ throw new Error('Appointment or media list not found.');
946
+ }
947
+ const mediaToRemove = appointment.media.find(m => m.id === mediaItemId);
948
+ if (!mediaToRemove) {
949
+ throw new Error(`Media item ${mediaItemId} not found in appointment.`);
950
+ }
951
+
952
+ const updateData: UpdateAppointmentData = {
953
+ media: arrayRemove(mediaToRemove) as any,
954
+ updatedAt: serverTimestamp(),
955
+ };
956
+ return this.updateAppointment(appointmentId, updateData);
957
+ }
958
+
959
+ /**
960
+ * Adds or updates review information for an appointment.
961
+ */
962
+ async addReviewToAppointment(
963
+ appointmentId: string,
964
+ reviewData: Omit<PatientReviewInfo, 'reviewedAt' | 'reviewId'>,
965
+ ): Promise<Appointment> {
966
+ console.log(`[APPOINTMENT_SERVICE] Adding review to appointment ${appointmentId}`);
967
+ const newReviewInfo: PatientReviewInfo = {
968
+ ...reviewData,
969
+ reviewId: this.generateId(),
970
+ reviewedAt: Timestamp.now(),
971
+ };
972
+ const updateData: UpdateAppointmentData = {
973
+ reviewInfo: newReviewInfo,
974
+ updatedAt: serverTimestamp(),
975
+ };
976
+ return this.updateAppointment(appointmentId, updateData);
977
+ }
978
+
979
+ /**
980
+ * Updates the payment status of an appointment.
981
+ */
982
+ async updatePaymentStatus(
983
+ appointmentId: string,
984
+ paymentStatus: PaymentStatus,
985
+ paymentTransactionId?: string,
986
+ ): Promise<Appointment> {
987
+ console.log(
988
+ `[APPOINTMENT_SERVICE] Updating payment status of appointment ${appointmentId} to ${paymentStatus}`,
989
+ );
990
+ const updateData: UpdateAppointmentData = {
991
+ paymentStatus,
992
+ paymentTransactionId: paymentTransactionId || null,
993
+ updatedAt: serverTimestamp(),
994
+ };
995
+ return this.updateAppointment(appointmentId, updateData);
996
+ }
997
+
998
+ /**
999
+ * Updates the internal notes of an appointment.
1000
+ *
1001
+ * @param appointmentId ID of the appointment
1002
+ * @param notes Updated internal notes
1003
+ * @returns The updated appointment
1004
+ */
1005
+ async updateInternalNotes(appointmentId: string, notes: string | null): Promise<Appointment> {
1006
+ console.log(`[APPOINTMENT_SERVICE] Updating internal notes for appointment: ${appointmentId}`);
1007
+
1008
+ const updateData: UpdateAppointmentData = {
1009
+ internalNotes: notes,
1010
+ };
1011
+
1012
+ return this.updateAppointment(appointmentId, updateData);
1013
+ }
1014
+
1015
+ /**
1016
+ * Gets upcoming appointments for a specific patient.
1017
+ * These include appointments with statuses: PENDING, CONFIRMED, CHECKED_IN, IN_PROGRESS
1018
+ *
1019
+ * @param patientId ID of the patient
1020
+ * @param options Optional parameters for filtering and pagination
1021
+ * @returns Found appointments and the last document for pagination
1022
+ */
1023
+ async getUpcomingPatientAppointments(
1024
+ patientId: string,
1025
+ options?: {
1026
+ startDate?: Date; // Optional starting date (defaults to now)
1027
+ endDate?: Date;
1028
+ limit?: number;
1029
+ startAfter?: DocumentSnapshot;
1030
+ },
1031
+ ): Promise<{
1032
+ appointments: Appointment[];
1033
+ lastDoc: DocumentSnapshot | null;
1034
+ }> {
1035
+ try {
1036
+ console.log(`[APPOINTMENT_SERVICE] Getting upcoming appointments for patient: ${patientId}`);
1037
+
1038
+ // Default to current date/time if no startDate provided
1039
+ const effectiveStartDate = options?.startDate || new Date();
1040
+
1041
+ // Define the statuses considered as "upcoming"
1042
+ const upcomingStatuses = [
1043
+ AppointmentStatus.PENDING,
1044
+ AppointmentStatus.CONFIRMED,
1045
+ AppointmentStatus.CHECKED_IN,
1046
+ AppointmentStatus.IN_PROGRESS,
1047
+ AppointmentStatus.RESCHEDULED_BY_CLINIC,
1048
+ ];
1049
+
1050
+ // Build query constraints
1051
+ const constraints: QueryConstraint[] = [];
1052
+
1053
+ // Patient ID filter
1054
+ constraints.push(where('patientId', '==', patientId));
1055
+
1056
+ // Status filter - multiple statuses
1057
+ constraints.push(where('status', 'in', upcomingStatuses));
1058
+
1059
+ // Date range filters
1060
+ constraints.push(where('appointmentStartTime', '>=', Timestamp.fromDate(effectiveStartDate)));
1061
+
1062
+ if (options?.endDate) {
1063
+ constraints.push(where('appointmentStartTime', '<=', Timestamp.fromDate(options.endDate)));
1064
+ }
1065
+
1066
+ // Order by appointment start time (ascending for upcoming - closest first)
1067
+ constraints.push(orderBy('appointmentStartTime', 'asc'));
1068
+
1069
+ // Add pagination if specified
1070
+ if (options?.limit) {
1071
+ constraints.push(limit(options.limit));
1072
+ }
1073
+
1074
+ if (options?.startAfter) {
1075
+ constraints.push(startAfter(options.startAfter));
1076
+ }
1077
+
1078
+ // Execute query
1079
+ const q = query(collection(this.db, APPOINTMENTS_COLLECTION), ...constraints);
1080
+ const querySnapshot = await getDocs(q);
1081
+
1082
+ // Extract results
1083
+ const appointments = querySnapshot.docs.map(doc => doc.data() as Appointment);
1084
+
1085
+ // Get last document for pagination
1086
+ const lastDoc =
1087
+ querySnapshot.docs.length > 0 ? querySnapshot.docs[querySnapshot.docs.length - 1] : null;
1088
+
1089
+ console.log(
1090
+ `[APPOINTMENT_SERVICE] Found ${appointments.length} upcoming appointments for patient ${patientId}`,
1091
+ );
1092
+
1093
+ return { appointments, lastDoc };
1094
+ } catch (error) {
1095
+ console.error(
1096
+ `[APPOINTMENT_SERVICE] Error getting upcoming appointments for patient ${patientId}:`,
1097
+ error,
1098
+ );
1099
+ throw error;
1100
+ }
1101
+ }
1102
+
1103
+ /**
1104
+ * Gets past appointments for a specific patient.
1105
+ * These include appointments with statuses: COMPLETED, CANCELED_PATIENT,
1106
+ * CANCELED_PATIENT_RESCHEDULED, CANCELED_CLINIC, NO_SHOW
1107
+ *
1108
+ * @param patientId ID of the patient
1109
+ * @param options Optional parameters for filtering and pagination
1110
+ * @returns Found appointments and the last document for pagination
1111
+ */
1112
+ async getPastPatientAppointments(
1113
+ patientId: string,
1114
+ options?: {
1115
+ startDate?: Date;
1116
+ endDate?: Date; // Optional end date (defaults to now)
1117
+ showCanceled?: boolean; // Whether to include canceled appointments
1118
+ showNoShow?: boolean; // Whether to include no-show appointments
1119
+ limit?: number;
1120
+ startAfter?: DocumentSnapshot;
1121
+ },
1122
+ ): Promise<{
1123
+ appointments: Appointment[];
1124
+ lastDoc: DocumentSnapshot | null;
1125
+ }> {
1126
+ try {
1127
+ console.log(`[APPOINTMENT_SERVICE] Getting past appointments for patient: ${patientId}`);
1128
+
1129
+ // Default to current date/time if no endDate provided
1130
+ const effectiveEndDate = options?.endDate || new Date();
1131
+
1132
+ // Define the base status for past appointments
1133
+ const pastStatuses: AppointmentStatus[] = [AppointmentStatus.COMPLETED];
1134
+
1135
+ // Add canceled statuses if requested
1136
+ if (options?.showCanceled) {
1137
+ pastStatuses.push(
1138
+ AppointmentStatus.CANCELED_PATIENT,
1139
+ AppointmentStatus.CANCELED_PATIENT_RESCHEDULED,
1140
+ AppointmentStatus.CANCELED_CLINIC,
1141
+ );
1142
+ }
1143
+
1144
+ // Add no-show status if requested
1145
+ if (options?.showNoShow) {
1146
+ pastStatuses.push(AppointmentStatus.NO_SHOW);
1147
+ }
1148
+
1149
+ // Build query constraints
1150
+ const constraints: QueryConstraint[] = [];
1151
+
1152
+ // Patient ID filter
1153
+ constraints.push(where('patientId', '==', patientId));
1154
+
1155
+ // Status filter - multiple statuses
1156
+ constraints.push(where('status', 'in', pastStatuses));
1157
+
1158
+ // Date range filters
1159
+ if (options?.startDate) {
1160
+ constraints.push(
1161
+ where('appointmentStartTime', '>=', Timestamp.fromDate(options.startDate)),
1162
+ );
1163
+ }
1164
+
1165
+ constraints.push(where('appointmentStartTime', '<=', Timestamp.fromDate(effectiveEndDate)));
1166
+
1167
+ // Order by appointment start time (descending for past - most recent first)
1168
+ constraints.push(orderBy('appointmentStartTime', 'desc'));
1169
+
1170
+ // Add pagination if specified
1171
+ if (options?.limit) {
1172
+ constraints.push(limit(options.limit));
1173
+ }
1174
+
1175
+ if (options?.startAfter) {
1176
+ constraints.push(startAfter(options.startAfter));
1177
+ }
1178
+
1179
+ // Execute query
1180
+ const q = query(collection(this.db, APPOINTMENTS_COLLECTION), ...constraints);
1181
+ const querySnapshot = await getDocs(q);
1182
+
1183
+ // Extract results
1184
+ const appointments = querySnapshot.docs.map(doc => doc.data() as Appointment);
1185
+
1186
+ // Get last document for pagination
1187
+ const lastDoc =
1188
+ querySnapshot.docs.length > 0 ? querySnapshot.docs[querySnapshot.docs.length - 1] : null;
1189
+
1190
+ console.log(
1191
+ `[APPOINTMENT_SERVICE] Found ${appointments.length} past appointments for patient ${patientId}`,
1192
+ );
1193
+
1194
+ return { appointments, lastDoc };
1195
+ } catch (error) {
1196
+ console.error(
1197
+ `[APPOINTMENT_SERVICE] Error getting past appointments for patient ${patientId}:`,
1198
+ error,
1199
+ );
1200
+ throw error;
1201
+ }
1202
+ }
1203
+
1204
+ /**
1205
+ * Counts completed appointments for a patient with optional clinic filtering.
1206
+ *
1207
+ * @param patientId ID of the patient.
1208
+ * @param clinicBranchId Optional ID of the clinic branch to either include or exclude.
1209
+ * @param excludeClinic Optional boolean. If true (default), excludes the specified clinic. If false, includes only that clinic.
1210
+ * @returns The count of completed appointments.
1211
+ */
1212
+ async countCompletedAppointments(
1213
+ patientId: string,
1214
+ clinicBranchId?: string,
1215
+ excludeClinic = true,
1216
+ ): Promise<number> {
1217
+ try {
1218
+ console.log(
1219
+ `[APPOINTMENT_SERVICE] Counting completed appointments for patient: ${patientId}`,
1220
+ { clinicBranchId, excludeClinic },
1221
+ );
1222
+
1223
+ // Build query constraints
1224
+ const constraints: QueryConstraint[] = [
1225
+ where('patientId', '==', patientId),
1226
+ where('status', '==', AppointmentStatus.COMPLETED),
1227
+ ];
1228
+
1229
+ if (clinicBranchId) {
1230
+ if (excludeClinic) {
1231
+ // Exclude appointments from the specified clinic
1232
+ constraints.push(where('clinicBranchId', '!=', clinicBranchId));
1233
+ } else {
1234
+ // Include only appointments from the specified clinic
1235
+ constraints.push(where('clinicBranchId', '==', clinicBranchId));
1236
+ }
1237
+ }
1238
+
1239
+ // Execute query to get only the count
1240
+ const q = query(collection(this.db, APPOINTMENTS_COLLECTION), ...constraints);
1241
+ const snapshot = await getCountFromServer(q);
1242
+ const count = snapshot.data().count;
1243
+
1244
+ console.log(
1245
+ `[APPOINTMENT_SERVICE] Found ${count} completed appointments for patient ${patientId}`,
1246
+ );
1247
+
1248
+ return count;
1249
+ } catch (error) {
1250
+ console.error(
1251
+ `[APPOINTMENT_SERVICE] Error counting completed appointments for patient ${patientId}:`,
1252
+ error,
1253
+ );
1254
+ throw error;
1255
+ }
1256
+ }
1257
+
1258
+ /**
1259
+ * Uploads a zone photo and updates appointment metadata
1260
+ *
1261
+ * @param uploadData Zone photo upload data containing appointment ID, zone ID, photo type, file, and optional notes
1262
+ * @returns The uploaded media metadata
1263
+ */
1264
+ async uploadZonePhoto(uploadData: ZonePhotoUploadData): Promise<MediaMetadata> {
1265
+ try {
1266
+ console.log(
1267
+ `[APPOINTMENT_SERVICE] Uploading ${uploadData.photoType} photo for zone ${uploadData.zoneId} in appointment ${uploadData.appointmentId}`,
1268
+ );
1269
+
1270
+ // Validate input data
1271
+ const validatedData = await zonePhotoUploadSchema.parseAsync(uploadData);
1272
+
1273
+ // Check if user is authenticated
1274
+ const currentUser = this.auth.currentUser;
1275
+ if (!currentUser) {
1276
+ throw new Error('User must be authenticated to upload zone photos');
1277
+ }
1278
+
1279
+ // Get the appointment to verify it exists and user has access
1280
+ const appointment = await this.getAppointmentById(validatedData.appointmentId);
1281
+ if (!appointment) {
1282
+ throw new Error(`Appointment with ID ${validatedData.appointmentId} not found`);
1283
+ }
1284
+
1285
+ // Generate collection name for the media
1286
+ const collectionName = `appointment_${validatedData.appointmentId}_zone_photos`;
1287
+
1288
+ // Generate filename with zone and photo type info
1289
+ const timestamp = Date.now();
1290
+ const fileExtension = validatedData.file.type?.split('/')[1] || 'jpg';
1291
+ const fileName = `${validatedData.photoType}_${validatedData.zoneId}_${timestamp}.${fileExtension}`;
1292
+
1293
+ console.log(
1294
+ `[APPOINTMENT_SERVICE] Uploading file: ${fileName} to collection: ${collectionName}`,
1295
+ );
1296
+
1297
+ // Upload the media file using MediaService
1298
+ const uploadedMedia = await this.mediaService.uploadMedia(
1299
+ validatedData.file,
1300
+ validatedData.appointmentId, // ownerId is the appointment ID
1301
+ MediaAccessLevel.PRIVATE, // Zone photos are private
1302
+ collectionName,
1303
+ fileName,
1304
+ );
1305
+
1306
+ console.log(`[APPOINTMENT_SERVICE] Media uploaded successfully with ID: ${uploadedMedia.id}`);
1307
+
1308
+ // Update appointment metadata with the new photo
1309
+ await this.updateAppointmentZonePhoto(
1310
+ validatedData.appointmentId,
1311
+ validatedData.zoneId,
1312
+ validatedData.photoType,
1313
+ uploadedMedia,
1314
+ validatedData.notes,
1315
+ );
1316
+
1317
+ console.log(
1318
+ `[APPOINTMENT_SERVICE] Successfully uploaded and linked ${validatedData.photoType} photo for zone ${validatedData.zoneId}`,
1319
+ );
1320
+
1321
+ return uploadedMedia;
1322
+ } catch (error) {
1323
+ console.error('[APPOINTMENT_SERVICE] Error uploading zone photo:', error);
1324
+ throw error;
1325
+ }
1326
+ }
1327
+
1328
+ /**
1329
+ * Updates appointment metadata with zone photo information
1330
+ *
1331
+ * @param appointmentId ID of the appointment
1332
+ * @param zoneId ID of the zone
1333
+ * @param photoType Type of photo ('before' or 'after')
1334
+ * @param mediaMetadata Uploaded media metadata
1335
+ * @param notes Optional notes for the photo
1336
+ * @returns The updated appointment
1337
+ */
1338
+ private async updateAppointmentZonePhoto(
1339
+ appointmentId: string,
1340
+ zoneId: string,
1341
+ photoType: 'before' | 'after',
1342
+ mediaMetadata: MediaMetadata,
1343
+ notes?: string,
1344
+ ): Promise<Appointment> {
1345
+ try {
1346
+ console.log(
1347
+ `[APPOINTMENT_SERVICE] Updating appointment metadata for ${photoType} photo in zone ${zoneId}`,
1348
+ );
1349
+
1350
+ // Get current appointment
1351
+ const appointment = await this.getAppointmentById(appointmentId);
1352
+ if (!appointment) {
1353
+ throw new Error(`Appointment with ID ${appointmentId} not found`);
1354
+ }
1355
+
1356
+ // Initialize metadata if it doesn't exist
1357
+ const currentMetadata = appointment.metadata || {
1358
+ selectedZones: null,
1359
+ zonePhotos: null,
1360
+ zonesData: null,
1361
+ appointmentProducts: [],
1362
+ extendedProcedures: [],
1363
+ recommendedProcedures: [],
1364
+ zoneBilling: null,
1365
+ finalbilling: null,
1366
+ finalizationNotes: null,
1367
+ };
1368
+
1369
+ // Initialize zonePhotos if it doesn't exist (array model per zone)
1370
+ let currentZonePhotos: Record<string, BeforeAfterPerZone[]> = {};
1371
+
1372
+ // AUTO-MIGRATION: Convert old object format to new array format
1373
+ if (currentMetadata.zonePhotos) {
1374
+ for (const [key, value] of Object.entries(currentMetadata.zonePhotos)) {
1375
+ if (Array.isArray(value)) {
1376
+ // Already in new format
1377
+ currentZonePhotos[key] = value as BeforeAfterPerZone[];
1378
+ } else {
1379
+ // Old format - convert to array
1380
+ console.log(`[APPOINTMENT_SERVICE] Auto-migrating ${key} from object to array format`);
1381
+ const oldData = value as any;
1382
+ currentZonePhotos[key] = [
1383
+ {
1384
+ before: oldData.before || null,
1385
+ after: oldData.after || null,
1386
+ beforeNote: null,
1387
+ afterNote: null,
1388
+ },
1389
+ ];
1390
+ }
1391
+ }
1392
+ }
1393
+
1394
+ // Initialize the zone array if it doesn't exist
1395
+ if (!currentZonePhotos[zoneId]) {
1396
+ currentZonePhotos[zoneId] = [];
1397
+ }
1398
+
1399
+ // Create a new entry for this uploaded photo with per-photo notes
1400
+ const newEntry: BeforeAfterPerZone = {
1401
+ before: photoType === 'before' ? mediaMetadata.url : null,
1402
+ after: photoType === 'after' ? mediaMetadata.url : null,
1403
+ beforeNote: photoType === 'before' ? notes || null : null,
1404
+ afterNote: photoType === 'after' ? notes || null : null,
1405
+ };
1406
+
1407
+ // Append to the zone's photo list
1408
+ currentZonePhotos[zoneId] = [...currentZonePhotos[zoneId], newEntry];
1409
+ // Enforce max 10 photos per zone by keeping the most recent 10
1410
+ if (currentZonePhotos[zoneId].length > 10) {
1411
+ currentZonePhotos[zoneId] = currentZonePhotos[zoneId].slice(-10);
1412
+ }
1413
+
1414
+ // Update the appointment with new metadata
1415
+ const updateData: UpdateAppointmentData = {
1416
+ metadata: {
1417
+ selectedZones: currentMetadata.selectedZones,
1418
+ zonePhotos: currentZonePhotos,
1419
+ zonesData: currentMetadata.zonesData || null,
1420
+ appointmentProducts: currentMetadata.appointmentProducts || [],
1421
+ extendedProcedures: currentMetadata.extendedProcedures || [],
1422
+ recommendedProcedures: currentMetadata.recommendedProcedures || [],
1423
+ // Only include zoneBilling if it exists (avoid undefined values in Firestore)
1424
+ ...(currentMetadata.zoneBilling !== undefined && {
1425
+ zoneBilling: currentMetadata.zoneBilling,
1426
+ }),
1427
+ finalbilling: currentMetadata.finalbilling,
1428
+ finalizationNotes: currentMetadata.finalizationNotes,
1429
+ },
1430
+ updatedAt: serverTimestamp(),
1431
+ };
1432
+
1433
+ const updatedAppointment = await this.updateAppointment(appointmentId, updateData);
1434
+
1435
+ console.log(
1436
+ `[APPOINTMENT_SERVICE] Successfully updated appointment metadata for ${photoType} photo in zone ${zoneId}`,
1437
+ );
1438
+
1439
+ return updatedAppointment;
1440
+ } catch (error) {
1441
+ console.error(
1442
+ `[APPOINTMENT_SERVICE] Error updating appointment metadata for zone photo:`,
1443
+ error,
1444
+ );
1445
+ throw error;
1446
+ }
1447
+ }
1448
+
1449
+ /**
1450
+ * Gets zone photos for a specific appointment and zone
1451
+ *
1452
+ * @param appointmentId ID of the appointment
1453
+ * @param zoneId ID of the zone (optional - if not provided, returns all zones)
1454
+ * @returns Zone photos data
1455
+ */
1456
+ async getZonePhotos(
1457
+ appointmentId: string,
1458
+ zoneId?: string,
1459
+ ): Promise<Record<string, BeforeAfterPerZone[]> | BeforeAfterPerZone[] | null> {
1460
+ try {
1461
+ console.log(`[APPOINTMENT_SERVICE] Getting zone photos for appointment ${appointmentId}`);
1462
+
1463
+ const appointment = await this.getAppointmentById(appointmentId);
1464
+ if (!appointment) {
1465
+ throw new Error(`Appointment with ID ${appointmentId} not found`);
1466
+ }
1467
+
1468
+ const zonePhotos = appointment.metadata?.zonePhotos as
1469
+ | Record<string, BeforeAfterPerZone[]>
1470
+ | undefined
1471
+ | null;
1472
+ if (!zonePhotos) {
1473
+ return null;
1474
+ }
1475
+
1476
+ // If specific zone requested, return only that zone's photos
1477
+ if (zoneId) {
1478
+ return zonePhotos[zoneId] || null;
1479
+ }
1480
+
1481
+ // Return all zone photos
1482
+ return zonePhotos;
1483
+ } catch (error) {
1484
+ console.error(`[APPOINTMENT_SERVICE] Error getting zone photos:`, error);
1485
+ throw error;
1486
+ }
1487
+ }
1488
+
1489
+ /**
1490
+ * Deletes a zone photo entry (by index) and updates appointment metadata
1491
+ *
1492
+ * @param appointmentId ID of the appointment
1493
+ * @param zoneId ID of the zone
1494
+ * @param photoIndex Index of the photo entry to delete in the zone array
1495
+ * @returns The updated appointment
1496
+ */
1497
+ async deleteZonePhoto(
1498
+ appointmentId: string,
1499
+ zoneId: string,
1500
+ photoIndex: number,
1501
+ ): Promise<Appointment> {
1502
+ try {
1503
+ console.log(
1504
+ `[APPOINTMENT_SERVICE] Deleting zone photo index ${photoIndex} for zone ${zoneId} in appointment ${appointmentId}`,
1505
+ );
1506
+
1507
+ // Get current appointment
1508
+ const appointment = await this.getAppointmentById(appointmentId);
1509
+ if (!appointment) {
1510
+ throw new Error(`Appointment with ID ${appointmentId} not found`);
1511
+ }
1512
+
1513
+ const zonePhotos = appointment.metadata?.zonePhotos as
1514
+ | Record<string, BeforeAfterPerZone[]>
1515
+ | undefined
1516
+ | null;
1517
+ if (!zonePhotos || !zonePhotos[zoneId] || !Array.isArray(zonePhotos[zoneId])) {
1518
+ throw new Error(`No photos found for zone ${zoneId} in appointment ${appointmentId}`);
1519
+ }
1520
+
1521
+ const zoneArray = [...zonePhotos[zoneId]];
1522
+ if (photoIndex < 0 || photoIndex >= zoneArray.length) {
1523
+ throw new Error(`Invalid photo index ${photoIndex} for zone ${zoneId}`);
1524
+ }
1525
+
1526
+ const entry = zoneArray[photoIndex];
1527
+ const photoUrl = (entry.before || entry.after) as MediaResource | null;
1528
+ if (!photoUrl) {
1529
+ throw new Error(`No photo URL found for index ${photoIndex} in zone ${zoneId}`);
1530
+ }
1531
+
1532
+ // Try to find and delete the media from storage
1533
+ try {
1534
+ // Only try to delete if photoUrl is a string (URL)
1535
+ if (typeof photoUrl === 'string') {
1536
+ const mediaMetadata = await this.mediaService.getMediaMetadataByUrl(photoUrl);
1537
+ if (mediaMetadata) {
1538
+ await this.mediaService.deleteMedia(mediaMetadata.id);
1539
+ console.log(`[APPOINTMENT_SERVICE] Deleted media file with ID: ${mediaMetadata.id}`);
1540
+ }
1541
+ }
1542
+ } catch (mediaError) {
1543
+ console.warn(
1544
+ `[APPOINTMENT_SERVICE] Could not delete media file for URL ${photoUrl}:`,
1545
+ mediaError,
1546
+ );
1547
+ // Continue with metadata update even if media deletion fails
1548
+ }
1549
+
1550
+ // Update appointment metadata to remove the photo entry at the specified index
1551
+ const updatedZonePhotos: Record<string, BeforeAfterPerZone[]> = { ...zonePhotos } as any;
1552
+ const updatedZoneArray = [...zoneArray];
1553
+ updatedZoneArray.splice(photoIndex, 1);
1554
+ if (updatedZoneArray.length === 0) {
1555
+ delete updatedZonePhotos[zoneId];
1556
+ } else {
1557
+ updatedZonePhotos[zoneId] = updatedZoneArray;
1558
+ }
1559
+
1560
+ const updateData: UpdateAppointmentData = {
1561
+ metadata: {
1562
+ selectedZones: appointment.metadata?.selectedZones || null,
1563
+ zonePhotos: updatedZonePhotos,
1564
+ zonesData: appointment.metadata?.zonesData || null,
1565
+ appointmentProducts: appointment.metadata?.appointmentProducts || [],
1566
+ extendedProcedures: appointment.metadata?.extendedProcedures || [],
1567
+ recommendedProcedures: appointment.metadata?.recommendedProcedures || [],
1568
+ // Only include zoneBilling if it exists (avoid undefined values in Firestore)
1569
+ ...(appointment.metadata?.zoneBilling !== undefined && {
1570
+ zoneBilling: appointment.metadata.zoneBilling,
1571
+ }),
1572
+ finalbilling: appointment.metadata?.finalbilling || null,
1573
+ finalizationNotes: appointment.metadata?.finalizationNotes || null,
1574
+ },
1575
+ updatedAt: serverTimestamp(),
1576
+ };
1577
+
1578
+ const updatedAppointment = await this.updateAppointment(appointmentId, updateData);
1579
+
1580
+ console.log(
1581
+ `[APPOINTMENT_SERVICE] Successfully deleted photo index ${photoIndex} for zone ${zoneId}`,
1582
+ );
1583
+
1584
+ return updatedAppointment;
1585
+ } catch (error) {
1586
+ console.error(`[APPOINTMENT_SERVICE] Error deleting zone photo:`, error);
1587
+ throw error;
1588
+ }
1589
+ }
1590
+
1591
+ /**
1592
+ * Adds an item (product or note) to a specific zone
1593
+ *
1594
+ * @param appointmentId ID of the appointment
1595
+ * @param zoneId Zone ID (must be category.zone format, e.g., "face.forehead")
1596
+ * @param item Zone item data to add (without parentZone - it's inferred from zoneId)
1597
+ * @returns The updated appointment
1598
+ */
1599
+ async addItemToZone(
1600
+ appointmentId: string,
1601
+ zoneId: string,
1602
+ item: Omit<ZoneItemData, 'subtotal' | 'parentZone'>,
1603
+ ): Promise<Appointment> {
1604
+ try {
1605
+ console.log(
1606
+ `[APPOINTMENT_SERVICE] Adding item to zone ${zoneId} in appointment ${appointmentId}`,
1607
+ );
1608
+ return await addItemToZoneUtil(this.db, appointmentId, zoneId, item);
1609
+ } catch (error) {
1610
+ console.error(`[APPOINTMENT_SERVICE] Error adding item to zone:`, error);
1611
+ throw error;
1612
+ }
1613
+ }
1614
+
1615
+ /**
1616
+ * Removes an item from a specific zone
1617
+ *
1618
+ * @param appointmentId ID of the appointment
1619
+ * @param zoneId Zone ID
1620
+ * @param itemIndex Index of the item to remove in the zone's items array
1621
+ * @returns The updated appointment
1622
+ */
1623
+ async removeItemFromZone(
1624
+ appointmentId: string,
1625
+ zoneId: string,
1626
+ itemIndex: number,
1627
+ ): Promise<Appointment> {
1628
+ try {
1629
+ console.log(
1630
+ `[APPOINTMENT_SERVICE] Removing item ${itemIndex} from zone ${zoneId} in appointment ${appointmentId}`,
1631
+ );
1632
+ return await removeItemFromZoneUtil(this.db, appointmentId, zoneId, itemIndex);
1633
+ } catch (error) {
1634
+ console.error(`[APPOINTMENT_SERVICE] Error removing item from zone:`, error);
1635
+ throw error;
1636
+ }
1637
+ }
1638
+
1639
+ /**
1640
+ * Updates a specific item in a zone
1641
+ *
1642
+ * @param appointmentId ID of the appointment
1643
+ * @param zoneId Zone ID
1644
+ * @param itemIndex Index of the item to update
1645
+ * @param updates Partial updates to apply to the item
1646
+ * @returns The updated appointment
1647
+ */
1648
+ async updateZoneItem(
1649
+ appointmentId: string,
1650
+ zoneId: string,
1651
+ itemIndex: number,
1652
+ updates: Partial<ZoneItemData>,
1653
+ ): Promise<Appointment> {
1654
+ try {
1655
+ console.log(
1656
+ `[APPOINTMENT_SERVICE] Updating item ${itemIndex} in zone ${zoneId} in appointment ${appointmentId}`,
1657
+ );
1658
+ return await updateZoneItemUtil(this.db, appointmentId, zoneId, itemIndex, updates);
1659
+ } catch (error) {
1660
+ console.error(`[APPOINTMENT_SERVICE] Error updating zone item:`, error);
1661
+ throw error;
1662
+ }
1663
+ }
1664
+
1665
+ /**
1666
+ * Overrides the price for a specific zone item
1667
+ *
1668
+ * @param appointmentId ID of the appointment
1669
+ * @param zoneId Zone ID
1670
+ * @param itemIndex Index of the item
1671
+ * @param newPrice New price amount to set
1672
+ * @returns The updated appointment
1673
+ */
1674
+ async overridePriceForZoneItem(
1675
+ appointmentId: string,
1676
+ zoneId: string,
1677
+ itemIndex: number,
1678
+ newPrice: number,
1679
+ ): Promise<Appointment> {
1680
+ try {
1681
+ console.log(
1682
+ `[APPOINTMENT_SERVICE] Overriding price for item ${itemIndex} in zone ${zoneId} to ${newPrice}`,
1683
+ );
1684
+ return await overridePriceForZoneItemUtil(
1685
+ this.db,
1686
+ appointmentId,
1687
+ zoneId,
1688
+ itemIndex,
1689
+ newPrice,
1690
+ );
1691
+ } catch (error) {
1692
+ console.error(`[APPOINTMENT_SERVICE] Error overriding price:`, error);
1693
+ throw error;
1694
+ }
1695
+ }
1696
+
1697
+ /**
1698
+ * Updates subzones for a specific zone item
1699
+ *
1700
+ * @param appointmentId ID of the appointment
1701
+ * @param zoneId Zone ID
1702
+ * @param itemIndex Index of the item
1703
+ * @param subzones Array of subzone keys (category.zone.subzone format)
1704
+ * @returns The updated appointment
1705
+ */
1706
+ async updateSubzones(
1707
+ appointmentId: string,
1708
+ zoneId: string,
1709
+ itemIndex: number,
1710
+ subzones: string[],
1711
+ ): Promise<Appointment> {
1712
+ try {
1713
+ console.log(
1714
+ `[APPOINTMENT_SERVICE] Updating subzones for item ${itemIndex} in zone ${zoneId}`,
1715
+ );
1716
+ return await updateSubzonesUtil(this.db, appointmentId, zoneId, itemIndex, subzones);
1717
+ } catch (error) {
1718
+ console.error(`[APPOINTMENT_SERVICE] Error updating subzones:`, error);
1719
+ throw error;
1720
+ }
1721
+ }
1722
+
1723
+ /**
1724
+ * Adds an extended procedure to an appointment
1725
+ * Automatically aggregates products into appointmentProducts
1726
+ *
1727
+ * @param appointmentId ID of the appointment
1728
+ * @param procedureId ID of the procedure to add
1729
+ * @returns The updated appointment
1730
+ */
1731
+ async addExtendedProcedure(appointmentId: string, procedureId: string): Promise<Appointment> {
1732
+ try {
1733
+ console.log(
1734
+ `[APPOINTMENT_SERVICE] Adding extended procedure ${procedureId} to appointment ${appointmentId}`,
1735
+ );
1736
+ return await addExtendedProcedureUtil(this.db, appointmentId, procedureId);
1737
+ } catch (error) {
1738
+ console.error(`[APPOINTMENT_SERVICE] Error adding extended procedure:`, error);
1739
+ throw error;
1740
+ }
1741
+ }
1742
+
1743
+ /**
1744
+ * Removes an extended procedure from an appointment
1745
+ * Also removes associated products from appointmentProducts
1746
+ *
1747
+ * @param appointmentId ID of the appointment
1748
+ * @param procedureId ID of the procedure to remove
1749
+ * @returns The updated appointment
1750
+ */
1751
+ async removeExtendedProcedure(appointmentId: string, procedureId: string): Promise<Appointment> {
1752
+ try {
1753
+ console.log(
1754
+ `[APPOINTMENT_SERVICE] Removing extended procedure ${procedureId} from appointment ${appointmentId}`,
1755
+ );
1756
+ return await removeExtendedProcedureUtil(this.db, appointmentId, procedureId);
1757
+ } catch (error) {
1758
+ console.error(`[APPOINTMENT_SERVICE] Error removing extended procedure:`, error);
1759
+ throw error;
1760
+ }
1761
+ }
1762
+
1763
+ /**
1764
+ * Gets all extended procedures for an appointment
1765
+ *
1766
+ * @param appointmentId ID of the appointment
1767
+ * @returns Array of extended procedures
1768
+ */
1769
+ async getExtendedProcedures(appointmentId: string): Promise<ExtendedProcedureInfo[]> {
1770
+ try {
1771
+ console.log(
1772
+ `[APPOINTMENT_SERVICE] Getting extended procedures for appointment ${appointmentId}`,
1773
+ );
1774
+ return await getExtendedProceduresUtil(this.db, appointmentId);
1775
+ } catch (error) {
1776
+ console.error(`[APPOINTMENT_SERVICE] Error getting extended procedures:`, error);
1777
+ throw error;
1778
+ }
1779
+ }
1780
+
1781
+ /**
1782
+ * Gets all aggregated products for an appointment
1783
+ * Includes products from main procedure and extended procedures
1784
+ *
1785
+ * @param appointmentId ID of the appointment
1786
+ * @returns Array of appointment products
1787
+ */
1788
+ async getAppointmentProducts(appointmentId: string): Promise<AppointmentProductMetadata[]> {
1789
+ try {
1790
+ console.log(
1791
+ `[APPOINTMENT_SERVICE] Getting appointment products for appointment ${appointmentId}`,
1792
+ );
1793
+ return await getAppointmentProductsUtil(this.db, appointmentId);
1794
+ } catch (error) {
1795
+ console.error(`[APPOINTMENT_SERVICE] Error getting appointment products:`, error);
1796
+ throw error;
1797
+ }
1798
+ }
1799
+
1800
+ /**
1801
+ * Recalculates final billing for an appointment based on zone items
1802
+ *
1803
+ * @param appointmentId ID of the appointment
1804
+ * @param taxRate Tax rate (e.g., 0.20 for 20%)
1805
+ * @returns The updated appointment with recalculated billing
1806
+ */
1807
+ async recalculateFinalBilling(appointmentId: string, taxRate?: number): Promise<Appointment> {
1808
+ try {
1809
+ console.log(
1810
+ `[APPOINTMENT_SERVICE] Recalculating final billing for appointment ${appointmentId}`,
1811
+ );
1812
+
1813
+ const appointment = await this.getAppointmentById(appointmentId);
1814
+ if (!appointment) {
1815
+ throw new Error(`Appointment with ID ${appointmentId} not found`);
1816
+ }
1817
+
1818
+ const zonesData = appointment.metadata?.zonesData;
1819
+ if (!zonesData || Object.keys(zonesData).length === 0) {
1820
+ throw new Error('No zone data available for billing calculation');
1821
+ }
1822
+
1823
+ const finalbilling = calculateFinalBilling(zonesData, taxRate);
1824
+
1825
+ const currentMetadata = appointment.metadata || {
1826
+ selectedZones: null,
1827
+ zonePhotos: null,
1828
+ zonesData: null,
1829
+ appointmentProducts: [],
1830
+ extendedProcedures: [],
1831
+ recommendedProcedures: [],
1832
+ finalbilling: null,
1833
+ finalizationNotes: null,
1834
+ };
1835
+
1836
+ // Update payment status if billing data exists but status is NOT_APPLICABLE
1837
+ // This handles cases where appointment was created with price 0 but billing was added later
1838
+ const shouldUpdatePaymentStatus =
1839
+ finalbilling.finalPrice > 0 &&
1840
+ appointment.paymentStatus === PaymentStatus.NOT_APPLICABLE;
1841
+
1842
+ const updateData: UpdateAppointmentData = {
1843
+ metadata: {
1844
+ selectedZones: currentMetadata.selectedZones,
1845
+ zonePhotos: currentMetadata.zonePhotos,
1846
+ zonesData: currentMetadata.zonesData,
1847
+ appointmentProducts: currentMetadata.appointmentProducts || [],
1848
+ extendedProcedures: currentMetadata.extendedProcedures || [],
1849
+ recommendedProcedures: currentMetadata.recommendedProcedures || [],
1850
+ // Only include zoneBilling if it exists (avoid undefined values in Firestore)
1851
+ ...(currentMetadata.zoneBilling !== undefined && {
1852
+ zoneBilling: currentMetadata.zoneBilling,
1853
+ }),
1854
+ finalbilling,
1855
+ finalizationNotes: currentMetadata.finalizationNotes,
1856
+ },
1857
+ ...(shouldUpdatePaymentStatus && {
1858
+ paymentStatus: PaymentStatus.UNPAID,
1859
+ }),
1860
+ updatedAt: serverTimestamp(),
1861
+ };
1862
+
1863
+ return await this.updateAppointment(appointmentId, updateData);
1864
+ } catch (error) {
1865
+ console.error(`[APPOINTMENT_SERVICE] Error recalculating final billing:`, error);
1866
+ throw error;
1867
+ }
1868
+ }
1869
+
1870
+ /**
1871
+ * Adds a recommended procedure to an appointment
1872
+ * Multiple recommendations of the same procedure are allowed (e.g., touch-up in 2 weeks, full treatment in 3 months)
1873
+ *
1874
+ * @param appointmentId ID of the appointment
1875
+ * @param procedureId ID of the procedure to recommend
1876
+ * @param note Note explaining the recommendation
1877
+ * @param timeframe Suggested timeframe for the procedure
1878
+ * @returns The updated appointment
1879
+ */
1880
+ async addRecommendedProcedure(
1881
+ appointmentId: string,
1882
+ procedureId: string,
1883
+ note: string,
1884
+ timeframe: { value: number; unit: 'day' | 'week' | 'month' | 'year' },
1885
+ ): Promise<Appointment> {
1886
+ try {
1887
+ console.log(
1888
+ `[APPOINTMENT_SERVICE] Adding recommended procedure ${procedureId} to appointment ${appointmentId}`,
1889
+ );
1890
+ return await addRecommendedProcedureUtil(
1891
+ this.db,
1892
+ appointmentId,
1893
+ procedureId,
1894
+ note,
1895
+ timeframe,
1896
+ );
1897
+ } catch (error) {
1898
+ console.error(`[APPOINTMENT_SERVICE] Error adding recommended procedure:`, error);
1899
+ throw error;
1900
+ }
1901
+ }
1902
+
1903
+ /**
1904
+ * Removes a recommended procedure from an appointment by index
1905
+ *
1906
+ * @param appointmentId ID of the appointment
1907
+ * @param recommendationIndex Index of the recommendation to remove
1908
+ * @returns The updated appointment
1909
+ */
1910
+ async removeRecommendedProcedure(
1911
+ appointmentId: string,
1912
+ recommendationIndex: number,
1913
+ ): Promise<Appointment> {
1914
+ try {
1915
+ console.log(
1916
+ `[APPOINTMENT_SERVICE] Removing recommended procedure at index ${recommendationIndex} from appointment ${appointmentId}`,
1917
+ );
1918
+ return await removeRecommendedProcedureUtil(this.db, appointmentId, recommendationIndex);
1919
+ } catch (error) {
1920
+ console.error(`[APPOINTMENT_SERVICE] Error removing recommended procedure:`, error);
1921
+ throw error;
1922
+ }
1923
+ }
1924
+
1925
+ /**
1926
+ * Updates a recommended procedure in an appointment by index
1927
+ *
1928
+ * @param appointmentId ID of the appointment
1929
+ * @param recommendationIndex Index of the recommendation to update
1930
+ * @param updates Partial updates (note and/or timeframe)
1931
+ * @returns The updated appointment
1932
+ */
1933
+ async updateRecommendedProcedure(
1934
+ appointmentId: string,
1935
+ recommendationIndex: number,
1936
+ updates: {
1937
+ note?: string;
1938
+ timeframe?: { value: number; unit: 'day' | 'week' | 'month' | 'year' };
1939
+ },
1940
+ ): Promise<Appointment> {
1941
+ try {
1942
+ console.log(
1943
+ `[APPOINTMENT_SERVICE] Updating recommended procedure at index ${recommendationIndex} in appointment ${appointmentId}`,
1944
+ );
1945
+ return await updateRecommendedProcedureUtil(
1946
+ this.db,
1947
+ appointmentId,
1948
+ recommendationIndex,
1949
+ updates,
1950
+ );
1951
+ } catch (error) {
1952
+ console.error(`[APPOINTMENT_SERVICE] Error updating recommended procedure:`, error);
1953
+ throw error;
1954
+ }
1955
+ }
1956
+
1957
+ /**
1958
+ * Gets all recommended procedures for an appointment
1959
+ *
1960
+ * @param appointmentId ID of the appointment
1961
+ * @returns Array of recommended procedures
1962
+ */
1963
+ async getRecommendedProcedures(appointmentId: string): Promise<RecommendedProcedure[]> {
1964
+ try {
1965
+ console.log(
1966
+ `[APPOINTMENT_SERVICE] Getting recommended procedures for appointment ${appointmentId}`,
1967
+ );
1968
+ return await getRecommendedProceduresUtil(this.db, appointmentId);
1969
+ } catch (error) {
1970
+ console.error(`[APPOINTMENT_SERVICE] Error getting recommended procedures:`, error);
1971
+ throw error;
1972
+ }
1973
+ }
1974
+
1975
+ /**
1976
+ * Updates a specific photo entry in a zone by index
1977
+ * Can update before/after photos and their notes
1978
+ *
1979
+ * @param appointmentId ID of the appointment
1980
+ * @param zoneId Zone ID
1981
+ * @param photoIndex Index of the photo entry to update
1982
+ * @param updates Partial updates to apply (before, after, beforeNote, afterNote)
1983
+ * @returns The updated appointment
1984
+ */
1985
+ async updateZonePhotoEntry(
1986
+ appointmentId: string,
1987
+ zoneId: string,
1988
+ photoIndex: number,
1989
+ updates: Partial<BeforeAfterPerZone>,
1990
+ ): Promise<Appointment> {
1991
+ try {
1992
+ console.log(
1993
+ `[APPOINTMENT_SERVICE] Updating photo entry at index ${photoIndex} for zone ${zoneId} in appointment ${appointmentId}`,
1994
+ );
1995
+ return await updateZonePhotoEntryUtil(this.db, appointmentId, zoneId, photoIndex, updates);
1996
+ } catch (error) {
1997
+ console.error(`[APPOINTMENT_SERVICE] Error updating zone photo entry:`, error);
1998
+ throw error;
1999
+ }
2000
+ }
2001
+
2002
+ /**
2003
+ * Adds an after photo to an existing before photo entry
2004
+ *
2005
+ * @param appointmentId ID of the appointment
2006
+ * @param zoneId Zone ID
2007
+ * @param photoIndex Index of the entry to add after photo to
2008
+ * @param afterPhotoUrl URL of the after photo
2009
+ * @param afterNote Optional note for the after photo
2010
+ * @returns The updated appointment
2011
+ */
2012
+ async addAfterPhotoToEntry(
2013
+ appointmentId: string,
2014
+ zoneId: string,
2015
+ photoIndex: number,
2016
+ afterPhotoUrl: MediaResource,
2017
+ afterNote?: string,
2018
+ ): Promise<Appointment> {
2019
+ try {
2020
+ console.log(
2021
+ `[APPOINTMENT_SERVICE] Adding after photo to entry at index ${photoIndex} for zone ${zoneId}`,
2022
+ );
2023
+ return await addAfterPhotoToEntryUtil(
2024
+ this.db,
2025
+ appointmentId,
2026
+ zoneId,
2027
+ photoIndex,
2028
+ afterPhotoUrl,
2029
+ afterNote,
2030
+ );
2031
+ } catch (error) {
2032
+ console.error(`[APPOINTMENT_SERVICE] Error adding after photo to entry:`, error);
2033
+ throw error;
2034
+ }
2035
+ }
2036
+
2037
+ /**
2038
+ * Updates notes for a photo entry
2039
+ *
2040
+ * @param appointmentId ID of the appointment
2041
+ * @param zoneId Zone ID
2042
+ * @param photoIndex Index of the entry
2043
+ * @param beforeNote Optional note for before photo
2044
+ * @param afterNote Optional note for after photo
2045
+ * @returns The updated appointment
2046
+ */
2047
+ async updateZonePhotoNotes(
2048
+ appointmentId: string,
2049
+ zoneId: string,
2050
+ photoIndex: number,
2051
+ beforeNote?: string,
2052
+ afterNote?: string,
2053
+ ): Promise<Appointment> {
2054
+ try {
2055
+ console.log(
2056
+ `[APPOINTMENT_SERVICE] Updating notes for photo entry at index ${photoIndex} for zone ${zoneId}`,
2057
+ );
2058
+ return await updateZonePhotoNotesUtil(
2059
+ this.db,
2060
+ appointmentId,
2061
+ zoneId,
2062
+ photoIndex,
2063
+ beforeNote,
2064
+ afterNote,
2065
+ );
2066
+ } catch (error) {
2067
+ console.error(`[APPOINTMENT_SERVICE] Error updating zone photo notes:`, error);
2068
+ throw error;
2069
+ }
2070
+ }
2071
+
2072
+ /**
2073
+ * Gets a specific photo entry from a zone
2074
+ *
2075
+ * @param appointmentId ID of the appointment
2076
+ * @param zoneId Zone ID
2077
+ * @param photoIndex Index of the entry
2078
+ * @returns Photo entry
2079
+ */
2080
+ async getZonePhotoEntry(
2081
+ appointmentId: string,
2082
+ zoneId: string,
2083
+ photoIndex: number,
2084
+ ): Promise<BeforeAfterPerZone> {
2085
+ try {
2086
+ console.log(
2087
+ `[APPOINTMENT_SERVICE] Getting photo entry at index ${photoIndex} for zone ${zoneId}`,
2088
+ );
2089
+ return await getZonePhotoEntryUtil(this.db, appointmentId, zoneId, photoIndex);
2090
+ } catch (error) {
2091
+ console.error(`[APPOINTMENT_SERVICE] Error getting zone photo entry:`, error);
2092
+ throw error;
2093
+ }
2094
+ }
2095
+
2096
+ /**
2097
+ * Gets all next steps recommendations for a patient from their past appointments.
2098
+ * Returns recommendations with context about which appointment, practitioner, and clinic suggested them.
2099
+ *
2100
+ * @param patientId ID of the patient
2101
+ * @param options Optional parameters for filtering
2102
+ * @returns Array of next steps recommendations with context
2103
+ */
2104
+ async getPatientNextStepsRecommendations(
2105
+ patientId: string,
2106
+ options?: {
2107
+ /** Include dismissed recommendations (default: false) */
2108
+ includeDismissed?: boolean;
2109
+ /** Filter by clinic branch ID */
2110
+ clinicBranchId?: string;
2111
+ /** Filter by practitioner ID */
2112
+ practitionerId?: string;
2113
+ /** Limit the number of results */
2114
+ limit?: number;
2115
+ },
2116
+ ): Promise<NextStepsRecommendation[]> {
2117
+ try {
2118
+ console.log(
2119
+ `[APPOINTMENT_SERVICE] Getting next steps recommendations for patient: ${patientId}`,
2120
+ options,
2121
+ );
2122
+
2123
+ // Get patient profile to check dismissed recommendations
2124
+ const patientProfile = await this.patientService.getPatientProfile(patientId);
2125
+ const dismissedIds = new Set(
2126
+ patientProfile?.dismissedNextStepsRecommendations || [],
2127
+ );
2128
+
2129
+ // Get past appointments (completed appointments)
2130
+ const pastAppointments = await this.getPastPatientAppointments(patientId, {
2131
+ showCanceled: false,
2132
+ showNoShow: false,
2133
+ });
2134
+
2135
+ // Also get appointments that have recommendations but might not be COMPLETED yet
2136
+ // (e.g., doctor finalized but appointment status is still CONFIRMED/CHECKED_IN)
2137
+ // Get all patient appointments that are past their end time
2138
+ const now = new Date();
2139
+ const allPastAppointments = await this.getPatientAppointments(patientId, {
2140
+ endDate: now,
2141
+ status: [
2142
+ AppointmentStatus.COMPLETED,
2143
+ AppointmentStatus.CONFIRMED,
2144
+ AppointmentStatus.CHECKED_IN,
2145
+ AppointmentStatus.IN_PROGRESS,
2146
+ ],
2147
+ });
2148
+
2149
+ // Filter to only include appointments that are past their end time AND have recommendations
2150
+ const appointmentsWithRecommendations = allPastAppointments.appointments.filter(
2151
+ appointment => {
2152
+ const endTime = appointment.appointmentEndTime?.toMillis
2153
+ ? appointment.appointmentEndTime.toMillis()
2154
+ : appointment.appointmentEndTime?.seconds
2155
+ ? appointment.appointmentEndTime.seconds * 1000
2156
+ : null;
2157
+
2158
+ if (!endTime) return false;
2159
+
2160
+ const isPastEndTime = endTime < now.getTime();
2161
+ const hasRecommendations =
2162
+ (appointment.metadata?.recommendedProcedures?.length || 0) > 0;
2163
+
2164
+ return isPastEndTime && hasRecommendations;
2165
+ },
2166
+ );
2167
+
2168
+ // Combine and deduplicate by appointment ID
2169
+ const allAppointmentsMap = new Map<string, Appointment>();
2170
+
2171
+ // Add completed appointments
2172
+ pastAppointments.appointments.forEach(apt => {
2173
+ allAppointmentsMap.set(apt.id, apt);
2174
+ });
2175
+
2176
+ // Add appointments with recommendations (will overwrite if duplicate)
2177
+ appointmentsWithRecommendations.forEach(apt => {
2178
+ allAppointmentsMap.set(apt.id, apt);
2179
+ });
2180
+
2181
+ const allAppointments = Array.from(allAppointmentsMap.values());
2182
+
2183
+ const recommendations: NextStepsRecommendation[] = [];
2184
+
2185
+ // Iterate through all appointments and extract recommendations
2186
+ for (const appointment of allAppointments) {
2187
+ // Filter by clinic if specified
2188
+ if (options?.clinicBranchId && appointment.clinicBranchId !== options.clinicBranchId) {
2189
+ continue;
2190
+ }
2191
+
2192
+ // Filter by practitioner if specified
2193
+ if (options?.practitionerId && appointment.practitionerId !== options.practitionerId) {
2194
+ continue;
2195
+ }
2196
+
2197
+ // Get recommended procedures from appointment metadata
2198
+ const recommendedProcedures =
2199
+ appointment.metadata?.recommendedProcedures || [];
2200
+
2201
+ // Create NextStepsRecommendation for each recommended procedure
2202
+ for (let index = 0; index < recommendedProcedures.length; index++) {
2203
+ const recommendedProcedure = recommendedProcedures[index];
2204
+ const recommendationId = `${appointment.id}:${index}`;
2205
+
2206
+ // Skip if dismissed and not including dismissed
2207
+ if (!options?.includeDismissed && dismissedIds.has(recommendationId)) {
2208
+ continue;
2209
+ }
2210
+
2211
+ const nextStepsRecommendation: NextStepsRecommendation = {
2212
+ id: recommendationId,
2213
+ recommendedProcedure,
2214
+ appointmentId: appointment.id,
2215
+ appointmentDate: appointment.appointmentStartTime,
2216
+ practitionerId: appointment.practitionerId,
2217
+ practitionerName: appointment.practitionerInfo?.name || 'Unknown Practitioner',
2218
+ clinicBranchId: appointment.clinicBranchId,
2219
+ clinicName: appointment.clinicInfo?.name || 'Unknown Clinic',
2220
+ appointmentStatus: appointment.status,
2221
+ isDismissed: dismissedIds.has(recommendationId),
2222
+ dismissedAt: null, // We don't track when it was dismissed, just that it was
2223
+ };
2224
+
2225
+ recommendations.push(nextStepsRecommendation);
2226
+ }
2227
+ }
2228
+
2229
+ // Sort by appointment date (most recent first)
2230
+ recommendations.sort((a, b) => {
2231
+ const dateA = a.appointmentDate.toMillis();
2232
+ const dateB = b.appointmentDate.toMillis();
2233
+ return dateB - dateA;
2234
+ });
2235
+
2236
+ // Apply limit if specified
2237
+ const limitedRecommendations = options?.limit
2238
+ ? recommendations.slice(0, options.limit)
2239
+ : recommendations;
2240
+
2241
+ return limitedRecommendations;
2242
+ } catch (error) {
2243
+ console.error(
2244
+ `[APPOINTMENT_SERVICE] Error getting next steps recommendations for patient ${patientId}:`,
2245
+ error,
2246
+ );
2247
+ throw error;
2248
+ }
2249
+ }
2250
+
2251
+ /**
2252
+ * Dismisses a next steps recommendation for a patient.
2253
+ * This prevents the recommendation from showing up in the default view.
2254
+ *
2255
+ * @param patientId ID of the patient
2256
+ * @param recommendationId ID of the recommendation to dismiss (format: appointmentId:recommendationIndex)
2257
+ * @returns Updated patient profile
2258
+ */
2259
+ async dismissNextStepsRecommendation(
2260
+ patientId: string,
2261
+ recommendationId: string,
2262
+ ): Promise<void> {
2263
+ try {
2264
+ console.log(
2265
+ `[APPOINTMENT_SERVICE] Dismissing recommendation ${recommendationId} for patient ${patientId}`,
2266
+ );
2267
+
2268
+ // Get patient profile
2269
+ const patientProfile = await this.patientService.getPatientProfile(patientId);
2270
+ if (!patientProfile) {
2271
+ throw new Error(`Patient profile not found for patient ${patientId}`);
2272
+ }
2273
+
2274
+ // Get current dismissed recommendations
2275
+ const dismissedRecommendations =
2276
+ patientProfile.dismissedNextStepsRecommendations || [];
2277
+
2278
+ // Check if already dismissed
2279
+ if (dismissedRecommendations.includes(recommendationId)) {
2280
+ console.log(
2281
+ `[APPOINTMENT_SERVICE] Recommendation ${recommendationId} already dismissed`,
2282
+ );
2283
+ return;
2284
+ }
2285
+
2286
+ // Add to dismissed list
2287
+ const updatedDismissed = [...dismissedRecommendations, recommendationId];
2288
+
2289
+ // Update patient profile
2290
+ await this.patientService.updatePatientProfile(patientId, {
2291
+ dismissedNextStepsRecommendations: updatedDismissed,
2292
+ });
2293
+
2294
+ console.log(
2295
+ `[APPOINTMENT_SERVICE] Successfully dismissed recommendation ${recommendationId} for patient ${patientId}`,
2296
+ );
2297
+ } catch (error) {
2298
+ console.error(
2299
+ `[APPOINTMENT_SERVICE] Error dismissing recommendation for patient ${patientId}:`,
2300
+ error,
2301
+ );
2302
+ throw error;
2303
+ }
2304
+ }
2305
+
2306
+ /**
2307
+ * Undismisses a next steps recommendation for a patient.
2308
+ * This makes the recommendation visible again in the default view.
2309
+ *
2310
+ * @param patientId ID of the patient
2311
+ * @param recommendationId ID of the recommendation to undismiss (format: appointmentId:recommendationIndex)
2312
+ * @returns Updated patient profile
2313
+ */
2314
+ async undismissNextStepsRecommendation(
2315
+ patientId: string,
2316
+ recommendationId: string,
2317
+ ): Promise<void> {
2318
+ try {
2319
+ console.log(
2320
+ `[APPOINTMENT_SERVICE] Undismissing recommendation ${recommendationId} for patient ${patientId}`,
2321
+ );
2322
+
2323
+ // Get patient profile
2324
+ const patientProfile = await this.patientService.getPatientProfile(patientId);
2325
+ if (!patientProfile) {
2326
+ throw new Error(`Patient profile not found for patient ${patientId}`);
2327
+ }
2328
+
2329
+ // Get current dismissed recommendations
2330
+ const dismissedRecommendations =
2331
+ patientProfile.dismissedNextStepsRecommendations || [];
2332
+
2333
+ // Check if not dismissed
2334
+ if (!dismissedRecommendations.includes(recommendationId)) {
2335
+ console.log(
2336
+ `[APPOINTMENT_SERVICE] Recommendation ${recommendationId} is not dismissed`,
2337
+ );
2338
+ return;
2339
+ }
2340
+
2341
+ // Remove from dismissed list
2342
+ const updatedDismissed = dismissedRecommendations.filter(
2343
+ id => id !== recommendationId,
2344
+ );
2345
+
2346
+ // Update patient profile
2347
+ await this.patientService.updatePatientProfile(patientId, {
2348
+ dismissedNextStepsRecommendations: updatedDismissed,
2349
+ });
2350
+
2351
+ console.log(
2352
+ `[APPOINTMENT_SERVICE] Successfully undismissed recommendation ${recommendationId} for patient ${patientId}`,
2353
+ );
2354
+ } catch (error) {
2355
+ console.error(
2356
+ `[APPOINTMENT_SERVICE] Error undismissing recommendation for patient ${patientId}:`,
2357
+ error,
2358
+ );
2359
+ throw error;
2360
+ }
2361
+ }
2362
+
2363
+ /**
2364
+ * Gets next steps recommendations for a clinic.
2365
+ * Returns all recommendations from appointments at the specified clinic.
2366
+ * This is useful for clinic admins to see what treatments have been recommended to their patients.
2367
+ *
2368
+ * @param clinicBranchId ID of the clinic branch
2369
+ * @param options Optional parameters for filtering
2370
+ * @returns Array of next steps recommendations with context
2371
+ */
2372
+ async getClinicNextStepsRecommendations(
2373
+ clinicBranchId: string,
2374
+ options?: {
2375
+ /** Filter by patient ID */
2376
+ patientId?: string;
2377
+ /** Filter by practitioner ID */
2378
+ practitionerId?: string;
2379
+ /** Limit the number of results */
2380
+ limit?: number;
2381
+ },
2382
+ ): Promise<NextStepsRecommendation[]> {
2383
+ try {
2384
+ console.log(
2385
+ `[APPOINTMENT_SERVICE] Getting next steps recommendations for clinic: ${clinicBranchId}`,
2386
+ options,
2387
+ );
2388
+
2389
+ // Get past appointments for the clinic
2390
+ const searchParams: SearchAppointmentsParams = {
2391
+ clinicBranchId,
2392
+ patientId: options?.patientId,
2393
+ practitionerId: options?.practitionerId,
2394
+ status: AppointmentStatus.COMPLETED,
2395
+ };
2396
+
2397
+ const { appointments } = await this.searchAppointments(searchParams);
2398
+
2399
+ const recommendations: NextStepsRecommendation[] = [];
2400
+
2401
+ // Iterate through appointments and extract recommendations
2402
+ for (const appointment of appointments) {
2403
+ // Get recommended procedures from appointment metadata
2404
+ const recommendedProcedures =
2405
+ appointment.metadata?.recommendedProcedures || [];
2406
+
2407
+ // Create NextStepsRecommendation for each recommended procedure
2408
+ for (let index = 0; index < recommendedProcedures.length; index++) {
2409
+ const recommendedProcedure = recommendedProcedures[index];
2410
+ const recommendationId = `${appointment.id}:${index}`;
2411
+
2412
+ const nextStepsRecommendation: NextStepsRecommendation = {
2413
+ id: recommendationId,
2414
+ recommendedProcedure,
2415
+ appointmentId: appointment.id,
2416
+ appointmentDate: appointment.appointmentStartTime,
2417
+ practitionerId: appointment.practitionerId,
2418
+ practitionerName: appointment.practitionerInfo?.name || 'Unknown Practitioner',
2419
+ clinicBranchId: appointment.clinicBranchId,
2420
+ clinicName: appointment.clinicInfo?.name || 'Unknown Clinic',
2421
+ appointmentStatus: appointment.status,
2422
+ isDismissed: false, // Clinic view doesn't track dismissals
2423
+ dismissedAt: null,
2424
+ };
2425
+
2426
+ recommendations.push(nextStepsRecommendation);
2427
+ }
2428
+ }
2429
+
2430
+ // Sort by appointment date (most recent first)
2431
+ recommendations.sort((a, b) => {
2432
+ const dateA = a.appointmentDate.toMillis();
2433
+ const dateB = b.appointmentDate.toMillis();
2434
+ return dateB - dateA;
2435
+ });
2436
+
2437
+ // Apply limit if specified
2438
+ const limitedRecommendations = options?.limit
2439
+ ? recommendations.slice(0, options.limit)
2440
+ : recommendations;
2441
+
2442
+ console.log(
2443
+ `[APPOINTMENT_SERVICE] Found ${limitedRecommendations.length} next steps recommendations for clinic ${clinicBranchId}`,
2444
+ );
2445
+
2446
+ return limitedRecommendations;
2447
+ } catch (error) {
2448
+ console.error(
2449
+ `[APPOINTMENT_SERVICE] Error getting next steps recommendations for clinic ${clinicBranchId}:`,
2450
+ error,
2451
+ );
2452
+ throw error;
2453
+ }
2454
+ }
2455
+
2456
+ /**
2457
+ * Gets next steps recommendations from a specific appointment.
2458
+ * This is useful when viewing an appointment detail page in the clinic app
2459
+ * to see what procedures were recommended during that appointment.
2460
+ *
2461
+ * @param appointmentId ID of the appointment
2462
+ * @param options Optional parameters for filtering
2463
+ * @returns Array of next steps recommendations from that appointment
2464
+ */
2465
+ async getAppointmentNextStepsRecommendations(
2466
+ appointmentId: string,
2467
+ options?: {
2468
+ /** Filter by clinic branch ID - only show recommendations for procedures available at this clinic */
2469
+ clinicBranchId?: string;
2470
+ },
2471
+ ): Promise<NextStepsRecommendation[]> {
2472
+ try {
2473
+ console.log(
2474
+ `[APPOINTMENT_SERVICE] Getting next steps recommendations for appointment: ${appointmentId}`,
2475
+ options,
2476
+ );
2477
+
2478
+ // Get the appointment
2479
+ const appointment = await this.getAppointmentById(appointmentId);
2480
+ if (!appointment) {
2481
+ throw new Error(`Appointment with ID ${appointmentId} not found`);
2482
+ }
2483
+
2484
+ // Get recommended procedures from appointment metadata
2485
+ const recommendedProcedures =
2486
+ appointment.metadata?.recommendedProcedures || [];
2487
+
2488
+ const recommendations: NextStepsRecommendation[] = [];
2489
+
2490
+ // If clinicBranchId is provided, we need to check which procedures are available at that clinic
2491
+ let availableProcedureIds: Set<string> | null = null;
2492
+ if (options?.clinicBranchId) {
2493
+ // Query procedures collection to get all procedure IDs available at this clinic
2494
+ const proceduresQuery = query(
2495
+ collection(this.db, PROCEDURES_COLLECTION),
2496
+ where('clinicBranchId', '==', options.clinicBranchId),
2497
+ where('isActive', '==', true),
2498
+ );
2499
+ const proceduresSnapshot = await getDocs(proceduresQuery);
2500
+ availableProcedureIds = new Set(
2501
+ proceduresSnapshot.docs.map(doc => doc.id),
2502
+ );
2503
+ console.log(
2504
+ `[APPOINTMENT_SERVICE] Found ${availableProcedureIds.size} procedures available at clinic ${options.clinicBranchId}`,
2505
+ );
2506
+ }
2507
+
2508
+ // Create NextStepsRecommendation for each recommended procedure
2509
+ for (let index = 0; index < recommendedProcedures.length; index++) {
2510
+ const recommendedProcedure = recommendedProcedures[index];
2511
+ const procedureId = recommendedProcedure.procedure.procedureId;
2512
+
2513
+ // If clinicBranchId is provided, filter to only include procedures available at that clinic
2514
+ if (options?.clinicBranchId && availableProcedureIds) {
2515
+ if (!availableProcedureIds.has(procedureId)) {
2516
+ console.log(
2517
+ `[APPOINTMENT_SERVICE] Skipping recommendation for procedure ${procedureId} - not available at clinic ${options.clinicBranchId}`,
2518
+ );
2519
+ continue;
2520
+ }
2521
+ }
2522
+
2523
+ const recommendationId = `${appointment.id}:${index}`;
2524
+
2525
+ const nextStepsRecommendation: NextStepsRecommendation = {
2526
+ id: recommendationId,
2527
+ recommendedProcedure,
2528
+ appointmentId: appointment.id,
2529
+ appointmentDate: appointment.appointmentStartTime,
2530
+ practitionerId: appointment.practitionerId,
2531
+ practitionerName: appointment.practitionerInfo?.name || 'Unknown Practitioner',
2532
+ clinicBranchId: appointment.clinicBranchId,
2533
+ clinicName: appointment.clinicInfo?.name || 'Unknown Clinic',
2534
+ appointmentStatus: appointment.status,
2535
+ isDismissed: false, // Clinic view doesn't track dismissals
2536
+ dismissedAt: null,
2537
+ };
2538
+
2539
+ recommendations.push(nextStepsRecommendation);
2540
+ }
2541
+
2542
+ console.log(
2543
+ `[APPOINTMENT_SERVICE] Found ${recommendations.length} next steps recommendations for appointment ${appointmentId}`,
2544
+ options?.clinicBranchId
2545
+ ? `(filtered to procedures available at clinic ${options.clinicBranchId})`
2546
+ : '',
2547
+ );
2548
+
2549
+ return recommendations;
2550
+ } catch (error) {
2551
+ console.error(
2552
+ `[APPOINTMENT_SERVICE] Error getting next steps recommendations for appointment ${appointmentId}:`,
2553
+ error,
2554
+ );
2555
+ throw error;
2556
+ }
2557
+ }
2558
+ }