@blackcode_sa/metaestetics-api 1.12.65 → 1.12.67

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 (273) hide show
  1. package/dist/admin/index.d.mts +2 -0
  2. package/dist/admin/index.d.ts +2 -0
  3. package/dist/admin/index.js +45 -4
  4. package/dist/admin/index.mjs +45 -4
  5. package/dist/backoffice/index.d.mts +33 -0
  6. package/dist/backoffice/index.d.ts +33 -0
  7. package/dist/backoffice/index.js +63 -0
  8. package/dist/backoffice/index.mjs +63 -0
  9. package/dist/index.d.mts +35 -0
  10. package/dist/index.d.ts +35 -0
  11. package/dist/index.js +116 -11
  12. package/dist/index.mjs +116 -11
  13. package/package.json +119 -119
  14. package/src/__mocks__/firstore.ts +10 -10
  15. package/src/admin/aggregation/README.md +79 -79
  16. package/src/admin/aggregation/appointment/README.md +128 -128
  17. package/src/admin/aggregation/appointment/appointment.aggregation.service.ts +1844 -1844
  18. package/src/admin/aggregation/appointment/index.ts +1 -1
  19. package/src/admin/aggregation/clinic/README.md +52 -52
  20. package/src/admin/aggregation/clinic/clinic.aggregation.service.ts +703 -703
  21. package/src/admin/aggregation/clinic/index.ts +1 -1
  22. package/src/admin/aggregation/forms/README.md +13 -13
  23. package/src/admin/aggregation/forms/filled-forms.aggregation.service.ts +322 -322
  24. package/src/admin/aggregation/forms/index.ts +1 -1
  25. package/src/admin/aggregation/index.ts +8 -8
  26. package/src/admin/aggregation/patient/README.md +27 -27
  27. package/src/admin/aggregation/patient/index.ts +1 -1
  28. package/src/admin/aggregation/patient/patient.aggregation.service.ts +141 -141
  29. package/src/admin/aggregation/practitioner/README.md +42 -42
  30. package/src/admin/aggregation/practitioner/index.ts +1 -1
  31. package/src/admin/aggregation/practitioner/practitioner.aggregation.service.ts +433 -433
  32. package/src/admin/aggregation/practitioner-invite/index.ts +1 -1
  33. package/src/admin/aggregation/practitioner-invite/practitioner-invite.aggregation.service.ts +961 -961
  34. package/src/admin/aggregation/procedure/README.md +43 -43
  35. package/src/admin/aggregation/procedure/index.ts +1 -1
  36. package/src/admin/aggregation/procedure/procedure.aggregation.service.ts +702 -702
  37. package/src/admin/aggregation/reviews/index.ts +1 -1
  38. package/src/admin/aggregation/reviews/reviews.aggregation.service.ts +689 -641
  39. package/src/admin/booking/README.md +125 -125
  40. package/src/admin/booking/booking.admin.ts +1037 -1037
  41. package/src/admin/booking/booking.calculator.ts +712 -712
  42. package/src/admin/booking/booking.types.ts +59 -59
  43. package/src/admin/booking/index.ts +3 -3
  44. package/src/admin/booking/timezones-problem.md +185 -185
  45. package/src/admin/calendar/README.md +7 -7
  46. package/src/admin/calendar/calendar.admin.service.ts +345 -345
  47. package/src/admin/calendar/index.ts +1 -1
  48. package/src/admin/documentation-templates/document-manager.admin.ts +260 -260
  49. package/src/admin/documentation-templates/index.ts +1 -1
  50. package/src/admin/free-consultation/free-consultation-utils.admin.ts +148 -148
  51. package/src/admin/free-consultation/index.ts +1 -1
  52. package/src/admin/index.ts +75 -75
  53. package/src/admin/logger/index.ts +78 -78
  54. package/src/admin/mailing/README.md +95 -95
  55. package/src/admin/mailing/appointment/appointment.mailing.service.ts +732 -732
  56. package/src/admin/mailing/appointment/index.ts +1 -1
  57. package/src/admin/mailing/appointment/templates/patient/appointment-confirmed.html +40 -40
  58. package/src/admin/mailing/base.mailing.service.ts +208 -208
  59. package/src/admin/mailing/index.ts +3 -3
  60. package/src/admin/mailing/practitionerInvite/existing-practitioner-invite.mailing.ts +611 -611
  61. package/src/admin/mailing/practitionerInvite/index.ts +2 -2
  62. package/src/admin/mailing/practitionerInvite/practitionerInvite.mailing.ts +395 -395
  63. package/src/admin/mailing/practitionerInvite/templates/existing-practitioner-invitation.template.ts +155 -155
  64. package/src/admin/mailing/practitionerInvite/templates/invitation.template.ts +101 -101
  65. package/src/admin/mailing/practitionerInvite/templates/invite-accepted-notification.template.ts +228 -228
  66. package/src/admin/mailing/practitionerInvite/templates/invite-rejected-notification.template.ts +242 -242
  67. package/src/admin/notifications/index.ts +1 -1
  68. package/src/admin/notifications/notifications.admin.ts +710 -710
  69. package/src/admin/requirements/README.md +128 -128
  70. package/src/admin/requirements/index.ts +1 -1
  71. package/src/admin/requirements/patient-requirements.admin.service.ts +475 -475
  72. package/src/admin/users/index.ts +1 -1
  73. package/src/admin/users/user-profile.admin.ts +405 -405
  74. package/src/backoffice/constants/certification.constants.ts +13 -13
  75. package/src/backoffice/constants/index.ts +1 -1
  76. package/src/backoffice/errors/backoffice.errors.ts +181 -181
  77. package/src/backoffice/errors/index.ts +1 -1
  78. package/src/backoffice/expo-safe/README.md +26 -26
  79. package/src/backoffice/expo-safe/index.ts +41 -41
  80. package/src/backoffice/index.ts +5 -5
  81. package/src/backoffice/services/FIXES_README.md +102 -102
  82. package/src/backoffice/services/README.md +40 -40
  83. package/src/backoffice/services/brand.service.ts +256 -256
  84. package/src/backoffice/services/category.service.ts +341 -318
  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 +417 -395
  92. package/src/backoffice/services/technology.service.ts +1104 -1083
  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 -62
  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 -163
  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 +200 -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/appointment/README.md +17 -17
  139. package/src/services/appointment/appointment.service.ts +2505 -2505
  140. package/src/services/appointment/index.ts +1 -1
  141. package/src/services/appointment/utils/appointment.utils.ts +552 -552
  142. package/src/services/appointment/utils/extended-procedure.utils.ts +314 -314
  143. package/src/services/appointment/utils/form-initialization.utils.ts +225 -225
  144. package/src/services/appointment/utils/recommended-procedure.utils.ts +195 -195
  145. package/src/services/appointment/utils/zone-management.utils.ts +353 -353
  146. package/src/services/appointment/utils/zone-photo.utils.ts +152 -152
  147. package/src/services/auth/auth.service.ts +989 -989
  148. package/src/services/auth/auth.v2.service.ts +961 -961
  149. package/src/services/auth/index.ts +7 -7
  150. package/src/services/auth/utils/error.utils.ts +90 -90
  151. package/src/services/auth/utils/firebase.utils.ts +49 -49
  152. package/src/services/auth/utils/index.ts +21 -21
  153. package/src/services/auth/utils/practitioner.utils.ts +125 -125
  154. package/src/services/base.service.ts +41 -41
  155. package/src/services/calendar/calendar.service.ts +1077 -1077
  156. package/src/services/calendar/calendar.v2.service.ts +1683 -1683
  157. package/src/services/calendar/calendar.v3.service.ts +313 -313
  158. package/src/services/calendar/externalCalendar.service.ts +178 -178
  159. package/src/services/calendar/index.ts +5 -5
  160. package/src/services/calendar/synced-calendars.service.ts +743 -743
  161. package/src/services/calendar/utils/appointment.utils.ts +265 -265
  162. package/src/services/calendar/utils/calendar-event.utils.ts +646 -646
  163. package/src/services/calendar/utils/clinic.utils.ts +237 -237
  164. package/src/services/calendar/utils/docs.utils.ts +157 -157
  165. package/src/services/calendar/utils/google-calendar.utils.ts +697 -697
  166. package/src/services/calendar/utils/index.ts +8 -8
  167. package/src/services/calendar/utils/patient.utils.ts +198 -198
  168. package/src/services/calendar/utils/practitioner.utils.ts +221 -221
  169. package/src/services/calendar/utils/synced-calendar.utils.ts +472 -472
  170. package/src/services/clinic/README.md +204 -204
  171. package/src/services/clinic/__tests__/clinic-admin.service.test.ts +287 -287
  172. package/src/services/clinic/__tests__/clinic-group.service.test.ts +352 -352
  173. package/src/services/clinic/__tests__/clinic.service.test.ts +354 -354
  174. package/src/services/clinic/billing-transactions.service.ts +217 -217
  175. package/src/services/clinic/clinic-admin.service.ts +202 -202
  176. package/src/services/clinic/clinic-group.service.ts +310 -310
  177. package/src/services/clinic/clinic.service.ts +708 -708
  178. package/src/services/clinic/index.ts +5 -5
  179. package/src/services/clinic/practitioner-invite.service.ts +519 -519
  180. package/src/services/clinic/utils/admin.utils.ts +551 -551
  181. package/src/services/clinic/utils/clinic-group.utils.ts +646 -646
  182. package/src/services/clinic/utils/clinic.utils.ts +949 -949
  183. package/src/services/clinic/utils/filter.utils.d.ts +23 -23
  184. package/src/services/clinic/utils/filter.utils.ts +446 -446
  185. package/src/services/clinic/utils/index.ts +11 -11
  186. package/src/services/clinic/utils/photos.utils.ts +188 -188
  187. package/src/services/clinic/utils/search.utils.ts +84 -84
  188. package/src/services/clinic/utils/tag.utils.ts +124 -124
  189. package/src/services/documentation-templates/documentation-template.service.ts +537 -537
  190. package/src/services/documentation-templates/filled-document.service.ts +587 -587
  191. package/src/services/documentation-templates/index.ts +2 -2
  192. package/src/services/index.ts +13 -13
  193. package/src/services/media/index.ts +1 -1
  194. package/src/services/media/media.service.ts +418 -418
  195. package/src/services/notifications/__tests__/notification.service.test.ts +242 -242
  196. package/src/services/notifications/index.ts +1 -1
  197. package/src/services/notifications/notification.service.ts +215 -215
  198. package/src/services/patient/README.md +48 -48
  199. package/src/services/patient/To-Do.md +43 -43
  200. package/src/services/patient/__tests__/patient.service.test.ts +294 -294
  201. package/src/services/patient/index.ts +2 -2
  202. package/src/services/patient/patient.service.ts +883 -883
  203. package/src/services/patient/patientRequirements.service.ts +285 -285
  204. package/src/services/patient/utils/aesthetic-analysis.utils.ts +176 -176
  205. package/src/services/patient/utils/clinic.utils.ts +80 -80
  206. package/src/services/patient/utils/docs.utils.ts +142 -142
  207. package/src/services/patient/utils/index.ts +9 -9
  208. package/src/services/patient/utils/location.utils.ts +126 -126
  209. package/src/services/patient/utils/medical-stuff.utils.ts +143 -143
  210. package/src/services/patient/utils/medical.utils.ts +458 -458
  211. package/src/services/patient/utils/practitioner.utils.ts +260 -260
  212. package/src/services/patient/utils/profile.utils.ts +510 -510
  213. package/src/services/patient/utils/sensitive.utils.ts +260 -260
  214. package/src/services/patient/utils/token.utils.ts +211 -211
  215. package/src/services/practitioner/README.md +145 -145
  216. package/src/services/practitioner/index.ts +1 -1
  217. package/src/services/practitioner/practitioner.service.ts +1742 -1742
  218. package/src/services/procedure/README.md +163 -163
  219. package/src/services/procedure/index.ts +1 -1
  220. package/src/services/procedure/procedure.service.ts +1715 -1715
  221. package/src/services/reviews/index.ts +1 -1
  222. package/src/services/reviews/reviews.service.ts +683 -636
  223. package/src/services/user/index.ts +1 -1
  224. package/src/services/user/user.service.ts +489 -489
  225. package/src/services/user/user.v2.service.ts +466 -466
  226. package/src/types/appointment/index.ts +480 -480
  227. package/src/types/calendar/index.ts +258 -258
  228. package/src/types/calendar/synced-calendar.types.ts +66 -66
  229. package/src/types/clinic/index.ts +489 -489
  230. package/src/types/clinic/practitioner-invite.types.ts +91 -91
  231. package/src/types/clinic/preferences.types.ts +159 -159
  232. package/src/types/clinic/to-do +3 -3
  233. package/src/types/documentation-templates/index.ts +308 -308
  234. package/src/types/index.ts +44 -44
  235. package/src/types/notifications/README.md +77 -77
  236. package/src/types/notifications/index.ts +265 -265
  237. package/src/types/patient/aesthetic-analysis.types.ts +66 -66
  238. package/src/types/patient/allergies.ts +58 -58
  239. package/src/types/patient/index.ts +275 -275
  240. package/src/types/patient/medical-info.types.ts +152 -152
  241. package/src/types/patient/patient-requirements.ts +92 -92
  242. package/src/types/patient/token.types.ts +61 -61
  243. package/src/types/practitioner/index.ts +206 -206
  244. package/src/types/procedure/index.ts +181 -181
  245. package/src/types/profile/index.ts +39 -39
  246. package/src/types/reviews/index.ts +132 -130
  247. package/src/types/tz-lookup.d.ts +4 -4
  248. package/src/types/user/index.ts +38 -38
  249. package/src/utils/TIMESTAMPS.md +176 -176
  250. package/src/utils/TimestampUtils.ts +241 -241
  251. package/src/utils/index.ts +1 -1
  252. package/src/validations/appointment.schema.ts +574 -574
  253. package/src/validations/calendar.schema.ts +225 -225
  254. package/src/validations/clinic.schema.ts +493 -493
  255. package/src/validations/common.schema.ts +25 -25
  256. package/src/validations/documentation-templates/index.ts +1 -1
  257. package/src/validations/documentation-templates/template.schema.ts +220 -220
  258. package/src/validations/documentation-templates.schema.ts +10 -10
  259. package/src/validations/index.ts +20 -20
  260. package/src/validations/media.schema.ts +10 -10
  261. package/src/validations/notification.schema.ts +90 -90
  262. package/src/validations/patient/aesthetic-analysis.schema.ts +55 -55
  263. package/src/validations/patient/medical-info.schema.ts +125 -125
  264. package/src/validations/patient/patient-requirements.schema.ts +84 -84
  265. package/src/validations/patient/token.schema.ts +29 -29
  266. package/src/validations/patient.schema.ts +217 -217
  267. package/src/validations/practitioner.schema.ts +222 -222
  268. package/src/validations/procedure-product.schema.ts +41 -41
  269. package/src/validations/procedure.schema.ts +124 -124
  270. package/src/validations/profile-info.schema.ts +41 -41
  271. package/src/validations/reviews.schema.ts +195 -189
  272. package/src/validations/schemas.ts +104 -104
  273. package/src/validations/shared.schema.ts +78 -78
@@ -1,2505 +1,2505 @@
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
- const updateData: UpdateAppointmentData = {
1837
- metadata: {
1838
- selectedZones: currentMetadata.selectedZones,
1839
- zonePhotos: currentMetadata.zonePhotos,
1840
- zonesData: currentMetadata.zonesData,
1841
- appointmentProducts: currentMetadata.appointmentProducts || [],
1842
- extendedProcedures: currentMetadata.extendedProcedures || [],
1843
- recommendedProcedures: currentMetadata.recommendedProcedures || [],
1844
- // Only include zoneBilling if it exists (avoid undefined values in Firestore)
1845
- ...(currentMetadata.zoneBilling !== undefined && {
1846
- zoneBilling: currentMetadata.zoneBilling,
1847
- }),
1848
- finalbilling,
1849
- finalizationNotes: currentMetadata.finalizationNotes,
1850
- },
1851
- updatedAt: serverTimestamp(),
1852
- };
1853
-
1854
- return await this.updateAppointment(appointmentId, updateData);
1855
- } catch (error) {
1856
- console.error(`[APPOINTMENT_SERVICE] Error recalculating final billing:`, error);
1857
- throw error;
1858
- }
1859
- }
1860
-
1861
- /**
1862
- * Adds a recommended procedure to an appointment
1863
- * Multiple recommendations of the same procedure are allowed (e.g., touch-up in 2 weeks, full treatment in 3 months)
1864
- *
1865
- * @param appointmentId ID of the appointment
1866
- * @param procedureId ID of the procedure to recommend
1867
- * @param note Note explaining the recommendation
1868
- * @param timeframe Suggested timeframe for the procedure
1869
- * @returns The updated appointment
1870
- */
1871
- async addRecommendedProcedure(
1872
- appointmentId: string,
1873
- procedureId: string,
1874
- note: string,
1875
- timeframe: { value: number; unit: 'day' | 'week' | 'month' | 'year' },
1876
- ): Promise<Appointment> {
1877
- try {
1878
- console.log(
1879
- `[APPOINTMENT_SERVICE] Adding recommended procedure ${procedureId} to appointment ${appointmentId}`,
1880
- );
1881
- return await addRecommendedProcedureUtil(
1882
- this.db,
1883
- appointmentId,
1884
- procedureId,
1885
- note,
1886
- timeframe,
1887
- );
1888
- } catch (error) {
1889
- console.error(`[APPOINTMENT_SERVICE] Error adding recommended procedure:`, error);
1890
- throw error;
1891
- }
1892
- }
1893
-
1894
- /**
1895
- * Removes a recommended procedure from an appointment by index
1896
- *
1897
- * @param appointmentId ID of the appointment
1898
- * @param recommendationIndex Index of the recommendation to remove
1899
- * @returns The updated appointment
1900
- */
1901
- async removeRecommendedProcedure(
1902
- appointmentId: string,
1903
- recommendationIndex: number,
1904
- ): Promise<Appointment> {
1905
- try {
1906
- console.log(
1907
- `[APPOINTMENT_SERVICE] Removing recommended procedure at index ${recommendationIndex} from appointment ${appointmentId}`,
1908
- );
1909
- return await removeRecommendedProcedureUtil(this.db, appointmentId, recommendationIndex);
1910
- } catch (error) {
1911
- console.error(`[APPOINTMENT_SERVICE] Error removing recommended procedure:`, error);
1912
- throw error;
1913
- }
1914
- }
1915
-
1916
- /**
1917
- * Updates a recommended procedure in an appointment by index
1918
- *
1919
- * @param appointmentId ID of the appointment
1920
- * @param recommendationIndex Index of the recommendation to update
1921
- * @param updates Partial updates (note and/or timeframe)
1922
- * @returns The updated appointment
1923
- */
1924
- async updateRecommendedProcedure(
1925
- appointmentId: string,
1926
- recommendationIndex: number,
1927
- updates: {
1928
- note?: string;
1929
- timeframe?: { value: number; unit: 'day' | 'week' | 'month' | 'year' };
1930
- },
1931
- ): Promise<Appointment> {
1932
- try {
1933
- console.log(
1934
- `[APPOINTMENT_SERVICE] Updating recommended procedure at index ${recommendationIndex} in appointment ${appointmentId}`,
1935
- );
1936
- return await updateRecommendedProcedureUtil(
1937
- this.db,
1938
- appointmentId,
1939
- recommendationIndex,
1940
- updates,
1941
- );
1942
- } catch (error) {
1943
- console.error(`[APPOINTMENT_SERVICE] Error updating recommended procedure:`, error);
1944
- throw error;
1945
- }
1946
- }
1947
-
1948
- /**
1949
- * Gets all recommended procedures for an appointment
1950
- *
1951
- * @param appointmentId ID of the appointment
1952
- * @returns Array of recommended procedures
1953
- */
1954
- async getRecommendedProcedures(appointmentId: string): Promise<RecommendedProcedure[]> {
1955
- try {
1956
- console.log(
1957
- `[APPOINTMENT_SERVICE] Getting recommended procedures for appointment ${appointmentId}`,
1958
- );
1959
- return await getRecommendedProceduresUtil(this.db, appointmentId);
1960
- } catch (error) {
1961
- console.error(`[APPOINTMENT_SERVICE] Error getting recommended procedures:`, error);
1962
- throw error;
1963
- }
1964
- }
1965
-
1966
- /**
1967
- * Updates a specific photo entry in a zone by index
1968
- * Can update before/after photos and their notes
1969
- *
1970
- * @param appointmentId ID of the appointment
1971
- * @param zoneId Zone ID
1972
- * @param photoIndex Index of the photo entry to update
1973
- * @param updates Partial updates to apply (before, after, beforeNote, afterNote)
1974
- * @returns The updated appointment
1975
- */
1976
- async updateZonePhotoEntry(
1977
- appointmentId: string,
1978
- zoneId: string,
1979
- photoIndex: number,
1980
- updates: Partial<BeforeAfterPerZone>,
1981
- ): Promise<Appointment> {
1982
- try {
1983
- console.log(
1984
- `[APPOINTMENT_SERVICE] Updating photo entry at index ${photoIndex} for zone ${zoneId} in appointment ${appointmentId}`,
1985
- );
1986
- return await updateZonePhotoEntryUtil(this.db, appointmentId, zoneId, photoIndex, updates);
1987
- } catch (error) {
1988
- console.error(`[APPOINTMENT_SERVICE] Error updating zone photo entry:`, error);
1989
- throw error;
1990
- }
1991
- }
1992
-
1993
- /**
1994
- * Adds an after photo to an existing before photo entry
1995
- *
1996
- * @param appointmentId ID of the appointment
1997
- * @param zoneId Zone ID
1998
- * @param photoIndex Index of the entry to add after photo to
1999
- * @param afterPhotoUrl URL of the after photo
2000
- * @param afterNote Optional note for the after photo
2001
- * @returns The updated appointment
2002
- */
2003
- async addAfterPhotoToEntry(
2004
- appointmentId: string,
2005
- zoneId: string,
2006
- photoIndex: number,
2007
- afterPhotoUrl: MediaResource,
2008
- afterNote?: string,
2009
- ): Promise<Appointment> {
2010
- try {
2011
- console.log(
2012
- `[APPOINTMENT_SERVICE] Adding after photo to entry at index ${photoIndex} for zone ${zoneId}`,
2013
- );
2014
- return await addAfterPhotoToEntryUtil(
2015
- this.db,
2016
- appointmentId,
2017
- zoneId,
2018
- photoIndex,
2019
- afterPhotoUrl,
2020
- afterNote,
2021
- );
2022
- } catch (error) {
2023
- console.error(`[APPOINTMENT_SERVICE] Error adding after photo to entry:`, error);
2024
- throw error;
2025
- }
2026
- }
2027
-
2028
- /**
2029
- * Updates notes for a photo entry
2030
- *
2031
- * @param appointmentId ID of the appointment
2032
- * @param zoneId Zone ID
2033
- * @param photoIndex Index of the entry
2034
- * @param beforeNote Optional note for before photo
2035
- * @param afterNote Optional note for after photo
2036
- * @returns The updated appointment
2037
- */
2038
- async updateZonePhotoNotes(
2039
- appointmentId: string,
2040
- zoneId: string,
2041
- photoIndex: number,
2042
- beforeNote?: string,
2043
- afterNote?: string,
2044
- ): Promise<Appointment> {
2045
- try {
2046
- console.log(
2047
- `[APPOINTMENT_SERVICE] Updating notes for photo entry at index ${photoIndex} for zone ${zoneId}`,
2048
- );
2049
- return await updateZonePhotoNotesUtil(
2050
- this.db,
2051
- appointmentId,
2052
- zoneId,
2053
- photoIndex,
2054
- beforeNote,
2055
- afterNote,
2056
- );
2057
- } catch (error) {
2058
- console.error(`[APPOINTMENT_SERVICE] Error updating zone photo notes:`, error);
2059
- throw error;
2060
- }
2061
- }
2062
-
2063
- /**
2064
- * Gets a specific photo entry from a zone
2065
- *
2066
- * @param appointmentId ID of the appointment
2067
- * @param zoneId Zone ID
2068
- * @param photoIndex Index of the entry
2069
- * @returns Photo entry
2070
- */
2071
- async getZonePhotoEntry(
2072
- appointmentId: string,
2073
- zoneId: string,
2074
- photoIndex: number,
2075
- ): Promise<BeforeAfterPerZone> {
2076
- try {
2077
- console.log(
2078
- `[APPOINTMENT_SERVICE] Getting photo entry at index ${photoIndex} for zone ${zoneId}`,
2079
- );
2080
- return await getZonePhotoEntryUtil(this.db, appointmentId, zoneId, photoIndex);
2081
- } catch (error) {
2082
- console.error(`[APPOINTMENT_SERVICE] Error getting zone photo entry:`, error);
2083
- throw error;
2084
- }
2085
- }
2086
-
2087
- /**
2088
- * Gets all next steps recommendations for a patient from their past appointments.
2089
- * Returns recommendations with context about which appointment, practitioner, and clinic suggested them.
2090
- *
2091
- * @param patientId ID of the patient
2092
- * @param options Optional parameters for filtering
2093
- * @returns Array of next steps recommendations with context
2094
- */
2095
- async getPatientNextStepsRecommendations(
2096
- patientId: string,
2097
- options?: {
2098
- /** Include dismissed recommendations (default: false) */
2099
- includeDismissed?: boolean;
2100
- /** Filter by clinic branch ID */
2101
- clinicBranchId?: string;
2102
- /** Filter by practitioner ID */
2103
- practitionerId?: string;
2104
- /** Limit the number of results */
2105
- limit?: number;
2106
- },
2107
- ): Promise<NextStepsRecommendation[]> {
2108
- try {
2109
- console.log(
2110
- `[APPOINTMENT_SERVICE] Getting next steps recommendations for patient: ${patientId}`,
2111
- options,
2112
- );
2113
-
2114
- // Get patient profile to check dismissed recommendations
2115
- const patientProfile = await this.patientService.getPatientProfile(patientId);
2116
- const dismissedIds = new Set(
2117
- patientProfile?.dismissedNextStepsRecommendations || [],
2118
- );
2119
-
2120
- // Get past appointments (completed appointments)
2121
- const pastAppointments = await this.getPastPatientAppointments(patientId, {
2122
- showCanceled: false,
2123
- showNoShow: false,
2124
- });
2125
-
2126
- const recommendations: NextStepsRecommendation[] = [];
2127
-
2128
- // Iterate through past appointments and extract recommendations
2129
- for (const appointment of pastAppointments.appointments) {
2130
- // Filter by clinic if specified
2131
- if (options?.clinicBranchId && appointment.clinicBranchId !== options.clinicBranchId) {
2132
- continue;
2133
- }
2134
-
2135
- // Filter by practitioner if specified
2136
- if (options?.practitionerId && appointment.practitionerId !== options.practitionerId) {
2137
- continue;
2138
- }
2139
-
2140
- // Get recommended procedures from appointment metadata
2141
- const recommendedProcedures =
2142
- appointment.metadata?.recommendedProcedures || [];
2143
-
2144
- // Create NextStepsRecommendation for each recommended procedure
2145
- for (let index = 0; index < recommendedProcedures.length; index++) {
2146
- const recommendedProcedure = recommendedProcedures[index];
2147
- const recommendationId = `${appointment.id}:${index}`;
2148
-
2149
- // Skip if dismissed and not including dismissed
2150
- if (!options?.includeDismissed && dismissedIds.has(recommendationId)) {
2151
- continue;
2152
- }
2153
-
2154
- const nextStepsRecommendation: NextStepsRecommendation = {
2155
- id: recommendationId,
2156
- recommendedProcedure,
2157
- appointmentId: appointment.id,
2158
- appointmentDate: appointment.appointmentStartTime,
2159
- practitionerId: appointment.practitionerId,
2160
- practitionerName: appointment.practitionerInfo?.name || 'Unknown Practitioner',
2161
- clinicBranchId: appointment.clinicBranchId,
2162
- clinicName: appointment.clinicInfo?.name || 'Unknown Clinic',
2163
- appointmentStatus: appointment.status,
2164
- isDismissed: dismissedIds.has(recommendationId),
2165
- dismissedAt: null, // We don't track when it was dismissed, just that it was
2166
- };
2167
-
2168
- recommendations.push(nextStepsRecommendation);
2169
- }
2170
- }
2171
-
2172
- // Sort by appointment date (most recent first)
2173
- recommendations.sort((a, b) => {
2174
- const dateA = a.appointmentDate.toMillis();
2175
- const dateB = b.appointmentDate.toMillis();
2176
- return dateB - dateA;
2177
- });
2178
-
2179
- // Apply limit if specified
2180
- const limitedRecommendations = options?.limit
2181
- ? recommendations.slice(0, options.limit)
2182
- : recommendations;
2183
-
2184
- console.log(
2185
- `[APPOINTMENT_SERVICE] Found ${limitedRecommendations.length} next steps recommendations for patient ${patientId}`,
2186
- );
2187
-
2188
- return limitedRecommendations;
2189
- } catch (error) {
2190
- console.error(
2191
- `[APPOINTMENT_SERVICE] Error getting next steps recommendations for patient ${patientId}:`,
2192
- error,
2193
- );
2194
- throw error;
2195
- }
2196
- }
2197
-
2198
- /**
2199
- * Dismisses a next steps recommendation for a patient.
2200
- * This prevents the recommendation from showing up in the default view.
2201
- *
2202
- * @param patientId ID of the patient
2203
- * @param recommendationId ID of the recommendation to dismiss (format: appointmentId:recommendationIndex)
2204
- * @returns Updated patient profile
2205
- */
2206
- async dismissNextStepsRecommendation(
2207
- patientId: string,
2208
- recommendationId: string,
2209
- ): Promise<void> {
2210
- try {
2211
- console.log(
2212
- `[APPOINTMENT_SERVICE] Dismissing recommendation ${recommendationId} for patient ${patientId}`,
2213
- );
2214
-
2215
- // Get patient profile
2216
- const patientProfile = await this.patientService.getPatientProfile(patientId);
2217
- if (!patientProfile) {
2218
- throw new Error(`Patient profile not found for patient ${patientId}`);
2219
- }
2220
-
2221
- // Get current dismissed recommendations
2222
- const dismissedRecommendations =
2223
- patientProfile.dismissedNextStepsRecommendations || [];
2224
-
2225
- // Check if already dismissed
2226
- if (dismissedRecommendations.includes(recommendationId)) {
2227
- console.log(
2228
- `[APPOINTMENT_SERVICE] Recommendation ${recommendationId} already dismissed`,
2229
- );
2230
- return;
2231
- }
2232
-
2233
- // Add to dismissed list
2234
- const updatedDismissed = [...dismissedRecommendations, recommendationId];
2235
-
2236
- // Update patient profile
2237
- await this.patientService.updatePatientProfile(patientId, {
2238
- dismissedNextStepsRecommendations: updatedDismissed,
2239
- });
2240
-
2241
- console.log(
2242
- `[APPOINTMENT_SERVICE] Successfully dismissed recommendation ${recommendationId} for patient ${patientId}`,
2243
- );
2244
- } catch (error) {
2245
- console.error(
2246
- `[APPOINTMENT_SERVICE] Error dismissing recommendation for patient ${patientId}:`,
2247
- error,
2248
- );
2249
- throw error;
2250
- }
2251
- }
2252
-
2253
- /**
2254
- * Undismisses a next steps recommendation for a patient.
2255
- * This makes the recommendation visible again in the default view.
2256
- *
2257
- * @param patientId ID of the patient
2258
- * @param recommendationId ID of the recommendation to undismiss (format: appointmentId:recommendationIndex)
2259
- * @returns Updated patient profile
2260
- */
2261
- async undismissNextStepsRecommendation(
2262
- patientId: string,
2263
- recommendationId: string,
2264
- ): Promise<void> {
2265
- try {
2266
- console.log(
2267
- `[APPOINTMENT_SERVICE] Undismissing recommendation ${recommendationId} for patient ${patientId}`,
2268
- );
2269
-
2270
- // Get patient profile
2271
- const patientProfile = await this.patientService.getPatientProfile(patientId);
2272
- if (!patientProfile) {
2273
- throw new Error(`Patient profile not found for patient ${patientId}`);
2274
- }
2275
-
2276
- // Get current dismissed recommendations
2277
- const dismissedRecommendations =
2278
- patientProfile.dismissedNextStepsRecommendations || [];
2279
-
2280
- // Check if not dismissed
2281
- if (!dismissedRecommendations.includes(recommendationId)) {
2282
- console.log(
2283
- `[APPOINTMENT_SERVICE] Recommendation ${recommendationId} is not dismissed`,
2284
- );
2285
- return;
2286
- }
2287
-
2288
- // Remove from dismissed list
2289
- const updatedDismissed = dismissedRecommendations.filter(
2290
- id => id !== recommendationId,
2291
- );
2292
-
2293
- // Update patient profile
2294
- await this.patientService.updatePatientProfile(patientId, {
2295
- dismissedNextStepsRecommendations: updatedDismissed,
2296
- });
2297
-
2298
- console.log(
2299
- `[APPOINTMENT_SERVICE] Successfully undismissed recommendation ${recommendationId} for patient ${patientId}`,
2300
- );
2301
- } catch (error) {
2302
- console.error(
2303
- `[APPOINTMENT_SERVICE] Error undismissing recommendation for patient ${patientId}:`,
2304
- error,
2305
- );
2306
- throw error;
2307
- }
2308
- }
2309
-
2310
- /**
2311
- * Gets next steps recommendations for a clinic.
2312
- * Returns all recommendations from appointments at the specified clinic.
2313
- * This is useful for clinic admins to see what treatments have been recommended to their patients.
2314
- *
2315
- * @param clinicBranchId ID of the clinic branch
2316
- * @param options Optional parameters for filtering
2317
- * @returns Array of next steps recommendations with context
2318
- */
2319
- async getClinicNextStepsRecommendations(
2320
- clinicBranchId: string,
2321
- options?: {
2322
- /** Filter by patient ID */
2323
- patientId?: string;
2324
- /** Filter by practitioner ID */
2325
- practitionerId?: string;
2326
- /** Limit the number of results */
2327
- limit?: number;
2328
- },
2329
- ): Promise<NextStepsRecommendation[]> {
2330
- try {
2331
- console.log(
2332
- `[APPOINTMENT_SERVICE] Getting next steps recommendations for clinic: ${clinicBranchId}`,
2333
- options,
2334
- );
2335
-
2336
- // Get past appointments for the clinic
2337
- const searchParams: SearchAppointmentsParams = {
2338
- clinicBranchId,
2339
- patientId: options?.patientId,
2340
- practitionerId: options?.practitionerId,
2341
- status: AppointmentStatus.COMPLETED,
2342
- };
2343
-
2344
- const { appointments } = await this.searchAppointments(searchParams);
2345
-
2346
- const recommendations: NextStepsRecommendation[] = [];
2347
-
2348
- // Iterate through appointments and extract recommendations
2349
- for (const appointment of appointments) {
2350
- // Get recommended procedures from appointment metadata
2351
- const recommendedProcedures =
2352
- appointment.metadata?.recommendedProcedures || [];
2353
-
2354
- // Create NextStepsRecommendation for each recommended procedure
2355
- for (let index = 0; index < recommendedProcedures.length; index++) {
2356
- const recommendedProcedure = recommendedProcedures[index];
2357
- const recommendationId = `${appointment.id}:${index}`;
2358
-
2359
- const nextStepsRecommendation: NextStepsRecommendation = {
2360
- id: recommendationId,
2361
- recommendedProcedure,
2362
- appointmentId: appointment.id,
2363
- appointmentDate: appointment.appointmentStartTime,
2364
- practitionerId: appointment.practitionerId,
2365
- practitionerName: appointment.practitionerInfo?.name || 'Unknown Practitioner',
2366
- clinicBranchId: appointment.clinicBranchId,
2367
- clinicName: appointment.clinicInfo?.name || 'Unknown Clinic',
2368
- appointmentStatus: appointment.status,
2369
- isDismissed: false, // Clinic view doesn't track dismissals
2370
- dismissedAt: null,
2371
- };
2372
-
2373
- recommendations.push(nextStepsRecommendation);
2374
- }
2375
- }
2376
-
2377
- // Sort by appointment date (most recent first)
2378
- recommendations.sort((a, b) => {
2379
- const dateA = a.appointmentDate.toMillis();
2380
- const dateB = b.appointmentDate.toMillis();
2381
- return dateB - dateA;
2382
- });
2383
-
2384
- // Apply limit if specified
2385
- const limitedRecommendations = options?.limit
2386
- ? recommendations.slice(0, options.limit)
2387
- : recommendations;
2388
-
2389
- console.log(
2390
- `[APPOINTMENT_SERVICE] Found ${limitedRecommendations.length} next steps recommendations for clinic ${clinicBranchId}`,
2391
- );
2392
-
2393
- return limitedRecommendations;
2394
- } catch (error) {
2395
- console.error(
2396
- `[APPOINTMENT_SERVICE] Error getting next steps recommendations for clinic ${clinicBranchId}:`,
2397
- error,
2398
- );
2399
- throw error;
2400
- }
2401
- }
2402
-
2403
- /**
2404
- * Gets next steps recommendations from a specific appointment.
2405
- * This is useful when viewing an appointment detail page in the clinic app
2406
- * to see what procedures were recommended during that appointment.
2407
- *
2408
- * @param appointmentId ID of the appointment
2409
- * @param options Optional parameters for filtering
2410
- * @returns Array of next steps recommendations from that appointment
2411
- */
2412
- async getAppointmentNextStepsRecommendations(
2413
- appointmentId: string,
2414
- options?: {
2415
- /** Filter by clinic branch ID - only show recommendations for procedures available at this clinic */
2416
- clinicBranchId?: string;
2417
- },
2418
- ): Promise<NextStepsRecommendation[]> {
2419
- try {
2420
- console.log(
2421
- `[APPOINTMENT_SERVICE] Getting next steps recommendations for appointment: ${appointmentId}`,
2422
- options,
2423
- );
2424
-
2425
- // Get the appointment
2426
- const appointment = await this.getAppointmentById(appointmentId);
2427
- if (!appointment) {
2428
- throw new Error(`Appointment with ID ${appointmentId} not found`);
2429
- }
2430
-
2431
- // Get recommended procedures from appointment metadata
2432
- const recommendedProcedures =
2433
- appointment.metadata?.recommendedProcedures || [];
2434
-
2435
- const recommendations: NextStepsRecommendation[] = [];
2436
-
2437
- // If clinicBranchId is provided, we need to check which procedures are available at that clinic
2438
- let availableProcedureIds: Set<string> | null = null;
2439
- if (options?.clinicBranchId) {
2440
- // Query procedures collection to get all procedure IDs available at this clinic
2441
- const proceduresQuery = query(
2442
- collection(this.db, PROCEDURES_COLLECTION),
2443
- where('clinicBranchId', '==', options.clinicBranchId),
2444
- where('isActive', '==', true),
2445
- );
2446
- const proceduresSnapshot = await getDocs(proceduresQuery);
2447
- availableProcedureIds = new Set(
2448
- proceduresSnapshot.docs.map(doc => doc.id),
2449
- );
2450
- console.log(
2451
- `[APPOINTMENT_SERVICE] Found ${availableProcedureIds.size} procedures available at clinic ${options.clinicBranchId}`,
2452
- );
2453
- }
2454
-
2455
- // Create NextStepsRecommendation for each recommended procedure
2456
- for (let index = 0; index < recommendedProcedures.length; index++) {
2457
- const recommendedProcedure = recommendedProcedures[index];
2458
- const procedureId = recommendedProcedure.procedure.procedureId;
2459
-
2460
- // If clinicBranchId is provided, filter to only include procedures available at that clinic
2461
- if (options?.clinicBranchId && availableProcedureIds) {
2462
- if (!availableProcedureIds.has(procedureId)) {
2463
- console.log(
2464
- `[APPOINTMENT_SERVICE] Skipping recommendation for procedure ${procedureId} - not available at clinic ${options.clinicBranchId}`,
2465
- );
2466
- continue;
2467
- }
2468
- }
2469
-
2470
- const recommendationId = `${appointment.id}:${index}`;
2471
-
2472
- const nextStepsRecommendation: NextStepsRecommendation = {
2473
- id: recommendationId,
2474
- recommendedProcedure,
2475
- appointmentId: appointment.id,
2476
- appointmentDate: appointment.appointmentStartTime,
2477
- practitionerId: appointment.practitionerId,
2478
- practitionerName: appointment.practitionerInfo?.name || 'Unknown Practitioner',
2479
- clinicBranchId: appointment.clinicBranchId,
2480
- clinicName: appointment.clinicInfo?.name || 'Unknown Clinic',
2481
- appointmentStatus: appointment.status,
2482
- isDismissed: false, // Clinic view doesn't track dismissals
2483
- dismissedAt: null,
2484
- };
2485
-
2486
- recommendations.push(nextStepsRecommendation);
2487
- }
2488
-
2489
- console.log(
2490
- `[APPOINTMENT_SERVICE] Found ${recommendations.length} next steps recommendations for appointment ${appointmentId}`,
2491
- options?.clinicBranchId
2492
- ? `(filtered to procedures available at clinic ${options.clinicBranchId})`
2493
- : '',
2494
- );
2495
-
2496
- return recommendations;
2497
- } catch (error) {
2498
- console.error(
2499
- `[APPOINTMENT_SERVICE] Error getting next steps recommendations for appointment ${appointmentId}:`,
2500
- error,
2501
- );
2502
- throw error;
2503
- }
2504
- }
2505
- }
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
+ const updateData: UpdateAppointmentData = {
1837
+ metadata: {
1838
+ selectedZones: currentMetadata.selectedZones,
1839
+ zonePhotos: currentMetadata.zonePhotos,
1840
+ zonesData: currentMetadata.zonesData,
1841
+ appointmentProducts: currentMetadata.appointmentProducts || [],
1842
+ extendedProcedures: currentMetadata.extendedProcedures || [],
1843
+ recommendedProcedures: currentMetadata.recommendedProcedures || [],
1844
+ // Only include zoneBilling if it exists (avoid undefined values in Firestore)
1845
+ ...(currentMetadata.zoneBilling !== undefined && {
1846
+ zoneBilling: currentMetadata.zoneBilling,
1847
+ }),
1848
+ finalbilling,
1849
+ finalizationNotes: currentMetadata.finalizationNotes,
1850
+ },
1851
+ updatedAt: serverTimestamp(),
1852
+ };
1853
+
1854
+ return await this.updateAppointment(appointmentId, updateData);
1855
+ } catch (error) {
1856
+ console.error(`[APPOINTMENT_SERVICE] Error recalculating final billing:`, error);
1857
+ throw error;
1858
+ }
1859
+ }
1860
+
1861
+ /**
1862
+ * Adds a recommended procedure to an appointment
1863
+ * Multiple recommendations of the same procedure are allowed (e.g., touch-up in 2 weeks, full treatment in 3 months)
1864
+ *
1865
+ * @param appointmentId ID of the appointment
1866
+ * @param procedureId ID of the procedure to recommend
1867
+ * @param note Note explaining the recommendation
1868
+ * @param timeframe Suggested timeframe for the procedure
1869
+ * @returns The updated appointment
1870
+ */
1871
+ async addRecommendedProcedure(
1872
+ appointmentId: string,
1873
+ procedureId: string,
1874
+ note: string,
1875
+ timeframe: { value: number; unit: 'day' | 'week' | 'month' | 'year' },
1876
+ ): Promise<Appointment> {
1877
+ try {
1878
+ console.log(
1879
+ `[APPOINTMENT_SERVICE] Adding recommended procedure ${procedureId} to appointment ${appointmentId}`,
1880
+ );
1881
+ return await addRecommendedProcedureUtil(
1882
+ this.db,
1883
+ appointmentId,
1884
+ procedureId,
1885
+ note,
1886
+ timeframe,
1887
+ );
1888
+ } catch (error) {
1889
+ console.error(`[APPOINTMENT_SERVICE] Error adding recommended procedure:`, error);
1890
+ throw error;
1891
+ }
1892
+ }
1893
+
1894
+ /**
1895
+ * Removes a recommended procedure from an appointment by index
1896
+ *
1897
+ * @param appointmentId ID of the appointment
1898
+ * @param recommendationIndex Index of the recommendation to remove
1899
+ * @returns The updated appointment
1900
+ */
1901
+ async removeRecommendedProcedure(
1902
+ appointmentId: string,
1903
+ recommendationIndex: number,
1904
+ ): Promise<Appointment> {
1905
+ try {
1906
+ console.log(
1907
+ `[APPOINTMENT_SERVICE] Removing recommended procedure at index ${recommendationIndex} from appointment ${appointmentId}`,
1908
+ );
1909
+ return await removeRecommendedProcedureUtil(this.db, appointmentId, recommendationIndex);
1910
+ } catch (error) {
1911
+ console.error(`[APPOINTMENT_SERVICE] Error removing recommended procedure:`, error);
1912
+ throw error;
1913
+ }
1914
+ }
1915
+
1916
+ /**
1917
+ * Updates a recommended procedure in an appointment by index
1918
+ *
1919
+ * @param appointmentId ID of the appointment
1920
+ * @param recommendationIndex Index of the recommendation to update
1921
+ * @param updates Partial updates (note and/or timeframe)
1922
+ * @returns The updated appointment
1923
+ */
1924
+ async updateRecommendedProcedure(
1925
+ appointmentId: string,
1926
+ recommendationIndex: number,
1927
+ updates: {
1928
+ note?: string;
1929
+ timeframe?: { value: number; unit: 'day' | 'week' | 'month' | 'year' };
1930
+ },
1931
+ ): Promise<Appointment> {
1932
+ try {
1933
+ console.log(
1934
+ `[APPOINTMENT_SERVICE] Updating recommended procedure at index ${recommendationIndex} in appointment ${appointmentId}`,
1935
+ );
1936
+ return await updateRecommendedProcedureUtil(
1937
+ this.db,
1938
+ appointmentId,
1939
+ recommendationIndex,
1940
+ updates,
1941
+ );
1942
+ } catch (error) {
1943
+ console.error(`[APPOINTMENT_SERVICE] Error updating recommended procedure:`, error);
1944
+ throw error;
1945
+ }
1946
+ }
1947
+
1948
+ /**
1949
+ * Gets all recommended procedures for an appointment
1950
+ *
1951
+ * @param appointmentId ID of the appointment
1952
+ * @returns Array of recommended procedures
1953
+ */
1954
+ async getRecommendedProcedures(appointmentId: string): Promise<RecommendedProcedure[]> {
1955
+ try {
1956
+ console.log(
1957
+ `[APPOINTMENT_SERVICE] Getting recommended procedures for appointment ${appointmentId}`,
1958
+ );
1959
+ return await getRecommendedProceduresUtil(this.db, appointmentId);
1960
+ } catch (error) {
1961
+ console.error(`[APPOINTMENT_SERVICE] Error getting recommended procedures:`, error);
1962
+ throw error;
1963
+ }
1964
+ }
1965
+
1966
+ /**
1967
+ * Updates a specific photo entry in a zone by index
1968
+ * Can update before/after photos and their notes
1969
+ *
1970
+ * @param appointmentId ID of the appointment
1971
+ * @param zoneId Zone ID
1972
+ * @param photoIndex Index of the photo entry to update
1973
+ * @param updates Partial updates to apply (before, after, beforeNote, afterNote)
1974
+ * @returns The updated appointment
1975
+ */
1976
+ async updateZonePhotoEntry(
1977
+ appointmentId: string,
1978
+ zoneId: string,
1979
+ photoIndex: number,
1980
+ updates: Partial<BeforeAfterPerZone>,
1981
+ ): Promise<Appointment> {
1982
+ try {
1983
+ console.log(
1984
+ `[APPOINTMENT_SERVICE] Updating photo entry at index ${photoIndex} for zone ${zoneId} in appointment ${appointmentId}`,
1985
+ );
1986
+ return await updateZonePhotoEntryUtil(this.db, appointmentId, zoneId, photoIndex, updates);
1987
+ } catch (error) {
1988
+ console.error(`[APPOINTMENT_SERVICE] Error updating zone photo entry:`, error);
1989
+ throw error;
1990
+ }
1991
+ }
1992
+
1993
+ /**
1994
+ * Adds an after photo to an existing before photo entry
1995
+ *
1996
+ * @param appointmentId ID of the appointment
1997
+ * @param zoneId Zone ID
1998
+ * @param photoIndex Index of the entry to add after photo to
1999
+ * @param afterPhotoUrl URL of the after photo
2000
+ * @param afterNote Optional note for the after photo
2001
+ * @returns The updated appointment
2002
+ */
2003
+ async addAfterPhotoToEntry(
2004
+ appointmentId: string,
2005
+ zoneId: string,
2006
+ photoIndex: number,
2007
+ afterPhotoUrl: MediaResource,
2008
+ afterNote?: string,
2009
+ ): Promise<Appointment> {
2010
+ try {
2011
+ console.log(
2012
+ `[APPOINTMENT_SERVICE] Adding after photo to entry at index ${photoIndex} for zone ${zoneId}`,
2013
+ );
2014
+ return await addAfterPhotoToEntryUtil(
2015
+ this.db,
2016
+ appointmentId,
2017
+ zoneId,
2018
+ photoIndex,
2019
+ afterPhotoUrl,
2020
+ afterNote,
2021
+ );
2022
+ } catch (error) {
2023
+ console.error(`[APPOINTMENT_SERVICE] Error adding after photo to entry:`, error);
2024
+ throw error;
2025
+ }
2026
+ }
2027
+
2028
+ /**
2029
+ * Updates notes for a photo entry
2030
+ *
2031
+ * @param appointmentId ID of the appointment
2032
+ * @param zoneId Zone ID
2033
+ * @param photoIndex Index of the entry
2034
+ * @param beforeNote Optional note for before photo
2035
+ * @param afterNote Optional note for after photo
2036
+ * @returns The updated appointment
2037
+ */
2038
+ async updateZonePhotoNotes(
2039
+ appointmentId: string,
2040
+ zoneId: string,
2041
+ photoIndex: number,
2042
+ beforeNote?: string,
2043
+ afterNote?: string,
2044
+ ): Promise<Appointment> {
2045
+ try {
2046
+ console.log(
2047
+ `[APPOINTMENT_SERVICE] Updating notes for photo entry at index ${photoIndex} for zone ${zoneId}`,
2048
+ );
2049
+ return await updateZonePhotoNotesUtil(
2050
+ this.db,
2051
+ appointmentId,
2052
+ zoneId,
2053
+ photoIndex,
2054
+ beforeNote,
2055
+ afterNote,
2056
+ );
2057
+ } catch (error) {
2058
+ console.error(`[APPOINTMENT_SERVICE] Error updating zone photo notes:`, error);
2059
+ throw error;
2060
+ }
2061
+ }
2062
+
2063
+ /**
2064
+ * Gets a specific photo entry from a zone
2065
+ *
2066
+ * @param appointmentId ID of the appointment
2067
+ * @param zoneId Zone ID
2068
+ * @param photoIndex Index of the entry
2069
+ * @returns Photo entry
2070
+ */
2071
+ async getZonePhotoEntry(
2072
+ appointmentId: string,
2073
+ zoneId: string,
2074
+ photoIndex: number,
2075
+ ): Promise<BeforeAfterPerZone> {
2076
+ try {
2077
+ console.log(
2078
+ `[APPOINTMENT_SERVICE] Getting photo entry at index ${photoIndex} for zone ${zoneId}`,
2079
+ );
2080
+ return await getZonePhotoEntryUtil(this.db, appointmentId, zoneId, photoIndex);
2081
+ } catch (error) {
2082
+ console.error(`[APPOINTMENT_SERVICE] Error getting zone photo entry:`, error);
2083
+ throw error;
2084
+ }
2085
+ }
2086
+
2087
+ /**
2088
+ * Gets all next steps recommendations for a patient from their past appointments.
2089
+ * Returns recommendations with context about which appointment, practitioner, and clinic suggested them.
2090
+ *
2091
+ * @param patientId ID of the patient
2092
+ * @param options Optional parameters for filtering
2093
+ * @returns Array of next steps recommendations with context
2094
+ */
2095
+ async getPatientNextStepsRecommendations(
2096
+ patientId: string,
2097
+ options?: {
2098
+ /** Include dismissed recommendations (default: false) */
2099
+ includeDismissed?: boolean;
2100
+ /** Filter by clinic branch ID */
2101
+ clinicBranchId?: string;
2102
+ /** Filter by practitioner ID */
2103
+ practitionerId?: string;
2104
+ /** Limit the number of results */
2105
+ limit?: number;
2106
+ },
2107
+ ): Promise<NextStepsRecommendation[]> {
2108
+ try {
2109
+ console.log(
2110
+ `[APPOINTMENT_SERVICE] Getting next steps recommendations for patient: ${patientId}`,
2111
+ options,
2112
+ );
2113
+
2114
+ // Get patient profile to check dismissed recommendations
2115
+ const patientProfile = await this.patientService.getPatientProfile(patientId);
2116
+ const dismissedIds = new Set(
2117
+ patientProfile?.dismissedNextStepsRecommendations || [],
2118
+ );
2119
+
2120
+ // Get past appointments (completed appointments)
2121
+ const pastAppointments = await this.getPastPatientAppointments(patientId, {
2122
+ showCanceled: false,
2123
+ showNoShow: false,
2124
+ });
2125
+
2126
+ const recommendations: NextStepsRecommendation[] = [];
2127
+
2128
+ // Iterate through past appointments and extract recommendations
2129
+ for (const appointment of pastAppointments.appointments) {
2130
+ // Filter by clinic if specified
2131
+ if (options?.clinicBranchId && appointment.clinicBranchId !== options.clinicBranchId) {
2132
+ continue;
2133
+ }
2134
+
2135
+ // Filter by practitioner if specified
2136
+ if (options?.practitionerId && appointment.practitionerId !== options.practitionerId) {
2137
+ continue;
2138
+ }
2139
+
2140
+ // Get recommended procedures from appointment metadata
2141
+ const recommendedProcedures =
2142
+ appointment.metadata?.recommendedProcedures || [];
2143
+
2144
+ // Create NextStepsRecommendation for each recommended procedure
2145
+ for (let index = 0; index < recommendedProcedures.length; index++) {
2146
+ const recommendedProcedure = recommendedProcedures[index];
2147
+ const recommendationId = `${appointment.id}:${index}`;
2148
+
2149
+ // Skip if dismissed and not including dismissed
2150
+ if (!options?.includeDismissed && dismissedIds.has(recommendationId)) {
2151
+ continue;
2152
+ }
2153
+
2154
+ const nextStepsRecommendation: NextStepsRecommendation = {
2155
+ id: recommendationId,
2156
+ recommendedProcedure,
2157
+ appointmentId: appointment.id,
2158
+ appointmentDate: appointment.appointmentStartTime,
2159
+ practitionerId: appointment.practitionerId,
2160
+ practitionerName: appointment.practitionerInfo?.name || 'Unknown Practitioner',
2161
+ clinicBranchId: appointment.clinicBranchId,
2162
+ clinicName: appointment.clinicInfo?.name || 'Unknown Clinic',
2163
+ appointmentStatus: appointment.status,
2164
+ isDismissed: dismissedIds.has(recommendationId),
2165
+ dismissedAt: null, // We don't track when it was dismissed, just that it was
2166
+ };
2167
+
2168
+ recommendations.push(nextStepsRecommendation);
2169
+ }
2170
+ }
2171
+
2172
+ // Sort by appointment date (most recent first)
2173
+ recommendations.sort((a, b) => {
2174
+ const dateA = a.appointmentDate.toMillis();
2175
+ const dateB = b.appointmentDate.toMillis();
2176
+ return dateB - dateA;
2177
+ });
2178
+
2179
+ // Apply limit if specified
2180
+ const limitedRecommendations = options?.limit
2181
+ ? recommendations.slice(0, options.limit)
2182
+ : recommendations;
2183
+
2184
+ console.log(
2185
+ `[APPOINTMENT_SERVICE] Found ${limitedRecommendations.length} next steps recommendations for patient ${patientId}`,
2186
+ );
2187
+
2188
+ return limitedRecommendations;
2189
+ } catch (error) {
2190
+ console.error(
2191
+ `[APPOINTMENT_SERVICE] Error getting next steps recommendations for patient ${patientId}:`,
2192
+ error,
2193
+ );
2194
+ throw error;
2195
+ }
2196
+ }
2197
+
2198
+ /**
2199
+ * Dismisses a next steps recommendation for a patient.
2200
+ * This prevents the recommendation from showing up in the default view.
2201
+ *
2202
+ * @param patientId ID of the patient
2203
+ * @param recommendationId ID of the recommendation to dismiss (format: appointmentId:recommendationIndex)
2204
+ * @returns Updated patient profile
2205
+ */
2206
+ async dismissNextStepsRecommendation(
2207
+ patientId: string,
2208
+ recommendationId: string,
2209
+ ): Promise<void> {
2210
+ try {
2211
+ console.log(
2212
+ `[APPOINTMENT_SERVICE] Dismissing recommendation ${recommendationId} for patient ${patientId}`,
2213
+ );
2214
+
2215
+ // Get patient profile
2216
+ const patientProfile = await this.patientService.getPatientProfile(patientId);
2217
+ if (!patientProfile) {
2218
+ throw new Error(`Patient profile not found for patient ${patientId}`);
2219
+ }
2220
+
2221
+ // Get current dismissed recommendations
2222
+ const dismissedRecommendations =
2223
+ patientProfile.dismissedNextStepsRecommendations || [];
2224
+
2225
+ // Check if already dismissed
2226
+ if (dismissedRecommendations.includes(recommendationId)) {
2227
+ console.log(
2228
+ `[APPOINTMENT_SERVICE] Recommendation ${recommendationId} already dismissed`,
2229
+ );
2230
+ return;
2231
+ }
2232
+
2233
+ // Add to dismissed list
2234
+ const updatedDismissed = [...dismissedRecommendations, recommendationId];
2235
+
2236
+ // Update patient profile
2237
+ await this.patientService.updatePatientProfile(patientId, {
2238
+ dismissedNextStepsRecommendations: updatedDismissed,
2239
+ });
2240
+
2241
+ console.log(
2242
+ `[APPOINTMENT_SERVICE] Successfully dismissed recommendation ${recommendationId} for patient ${patientId}`,
2243
+ );
2244
+ } catch (error) {
2245
+ console.error(
2246
+ `[APPOINTMENT_SERVICE] Error dismissing recommendation for patient ${patientId}:`,
2247
+ error,
2248
+ );
2249
+ throw error;
2250
+ }
2251
+ }
2252
+
2253
+ /**
2254
+ * Undismisses a next steps recommendation for a patient.
2255
+ * This makes the recommendation visible again in the default view.
2256
+ *
2257
+ * @param patientId ID of the patient
2258
+ * @param recommendationId ID of the recommendation to undismiss (format: appointmentId:recommendationIndex)
2259
+ * @returns Updated patient profile
2260
+ */
2261
+ async undismissNextStepsRecommendation(
2262
+ patientId: string,
2263
+ recommendationId: string,
2264
+ ): Promise<void> {
2265
+ try {
2266
+ console.log(
2267
+ `[APPOINTMENT_SERVICE] Undismissing recommendation ${recommendationId} for patient ${patientId}`,
2268
+ );
2269
+
2270
+ // Get patient profile
2271
+ const patientProfile = await this.patientService.getPatientProfile(patientId);
2272
+ if (!patientProfile) {
2273
+ throw new Error(`Patient profile not found for patient ${patientId}`);
2274
+ }
2275
+
2276
+ // Get current dismissed recommendations
2277
+ const dismissedRecommendations =
2278
+ patientProfile.dismissedNextStepsRecommendations || [];
2279
+
2280
+ // Check if not dismissed
2281
+ if (!dismissedRecommendations.includes(recommendationId)) {
2282
+ console.log(
2283
+ `[APPOINTMENT_SERVICE] Recommendation ${recommendationId} is not dismissed`,
2284
+ );
2285
+ return;
2286
+ }
2287
+
2288
+ // Remove from dismissed list
2289
+ const updatedDismissed = dismissedRecommendations.filter(
2290
+ id => id !== recommendationId,
2291
+ );
2292
+
2293
+ // Update patient profile
2294
+ await this.patientService.updatePatientProfile(patientId, {
2295
+ dismissedNextStepsRecommendations: updatedDismissed,
2296
+ });
2297
+
2298
+ console.log(
2299
+ `[APPOINTMENT_SERVICE] Successfully undismissed recommendation ${recommendationId} for patient ${patientId}`,
2300
+ );
2301
+ } catch (error) {
2302
+ console.error(
2303
+ `[APPOINTMENT_SERVICE] Error undismissing recommendation for patient ${patientId}:`,
2304
+ error,
2305
+ );
2306
+ throw error;
2307
+ }
2308
+ }
2309
+
2310
+ /**
2311
+ * Gets next steps recommendations for a clinic.
2312
+ * Returns all recommendations from appointments at the specified clinic.
2313
+ * This is useful for clinic admins to see what treatments have been recommended to their patients.
2314
+ *
2315
+ * @param clinicBranchId ID of the clinic branch
2316
+ * @param options Optional parameters for filtering
2317
+ * @returns Array of next steps recommendations with context
2318
+ */
2319
+ async getClinicNextStepsRecommendations(
2320
+ clinicBranchId: string,
2321
+ options?: {
2322
+ /** Filter by patient ID */
2323
+ patientId?: string;
2324
+ /** Filter by practitioner ID */
2325
+ practitionerId?: string;
2326
+ /** Limit the number of results */
2327
+ limit?: number;
2328
+ },
2329
+ ): Promise<NextStepsRecommendation[]> {
2330
+ try {
2331
+ console.log(
2332
+ `[APPOINTMENT_SERVICE] Getting next steps recommendations for clinic: ${clinicBranchId}`,
2333
+ options,
2334
+ );
2335
+
2336
+ // Get past appointments for the clinic
2337
+ const searchParams: SearchAppointmentsParams = {
2338
+ clinicBranchId,
2339
+ patientId: options?.patientId,
2340
+ practitionerId: options?.practitionerId,
2341
+ status: AppointmentStatus.COMPLETED,
2342
+ };
2343
+
2344
+ const { appointments } = await this.searchAppointments(searchParams);
2345
+
2346
+ const recommendations: NextStepsRecommendation[] = [];
2347
+
2348
+ // Iterate through appointments and extract recommendations
2349
+ for (const appointment of appointments) {
2350
+ // Get recommended procedures from appointment metadata
2351
+ const recommendedProcedures =
2352
+ appointment.metadata?.recommendedProcedures || [];
2353
+
2354
+ // Create NextStepsRecommendation for each recommended procedure
2355
+ for (let index = 0; index < recommendedProcedures.length; index++) {
2356
+ const recommendedProcedure = recommendedProcedures[index];
2357
+ const recommendationId = `${appointment.id}:${index}`;
2358
+
2359
+ const nextStepsRecommendation: NextStepsRecommendation = {
2360
+ id: recommendationId,
2361
+ recommendedProcedure,
2362
+ appointmentId: appointment.id,
2363
+ appointmentDate: appointment.appointmentStartTime,
2364
+ practitionerId: appointment.practitionerId,
2365
+ practitionerName: appointment.practitionerInfo?.name || 'Unknown Practitioner',
2366
+ clinicBranchId: appointment.clinicBranchId,
2367
+ clinicName: appointment.clinicInfo?.name || 'Unknown Clinic',
2368
+ appointmentStatus: appointment.status,
2369
+ isDismissed: false, // Clinic view doesn't track dismissals
2370
+ dismissedAt: null,
2371
+ };
2372
+
2373
+ recommendations.push(nextStepsRecommendation);
2374
+ }
2375
+ }
2376
+
2377
+ // Sort by appointment date (most recent first)
2378
+ recommendations.sort((a, b) => {
2379
+ const dateA = a.appointmentDate.toMillis();
2380
+ const dateB = b.appointmentDate.toMillis();
2381
+ return dateB - dateA;
2382
+ });
2383
+
2384
+ // Apply limit if specified
2385
+ const limitedRecommendations = options?.limit
2386
+ ? recommendations.slice(0, options.limit)
2387
+ : recommendations;
2388
+
2389
+ console.log(
2390
+ `[APPOINTMENT_SERVICE] Found ${limitedRecommendations.length} next steps recommendations for clinic ${clinicBranchId}`,
2391
+ );
2392
+
2393
+ return limitedRecommendations;
2394
+ } catch (error) {
2395
+ console.error(
2396
+ `[APPOINTMENT_SERVICE] Error getting next steps recommendations for clinic ${clinicBranchId}:`,
2397
+ error,
2398
+ );
2399
+ throw error;
2400
+ }
2401
+ }
2402
+
2403
+ /**
2404
+ * Gets next steps recommendations from a specific appointment.
2405
+ * This is useful when viewing an appointment detail page in the clinic app
2406
+ * to see what procedures were recommended during that appointment.
2407
+ *
2408
+ * @param appointmentId ID of the appointment
2409
+ * @param options Optional parameters for filtering
2410
+ * @returns Array of next steps recommendations from that appointment
2411
+ */
2412
+ async getAppointmentNextStepsRecommendations(
2413
+ appointmentId: string,
2414
+ options?: {
2415
+ /** Filter by clinic branch ID - only show recommendations for procedures available at this clinic */
2416
+ clinicBranchId?: string;
2417
+ },
2418
+ ): Promise<NextStepsRecommendation[]> {
2419
+ try {
2420
+ console.log(
2421
+ `[APPOINTMENT_SERVICE] Getting next steps recommendations for appointment: ${appointmentId}`,
2422
+ options,
2423
+ );
2424
+
2425
+ // Get the appointment
2426
+ const appointment = await this.getAppointmentById(appointmentId);
2427
+ if (!appointment) {
2428
+ throw new Error(`Appointment with ID ${appointmentId} not found`);
2429
+ }
2430
+
2431
+ // Get recommended procedures from appointment metadata
2432
+ const recommendedProcedures =
2433
+ appointment.metadata?.recommendedProcedures || [];
2434
+
2435
+ const recommendations: NextStepsRecommendation[] = [];
2436
+
2437
+ // If clinicBranchId is provided, we need to check which procedures are available at that clinic
2438
+ let availableProcedureIds: Set<string> | null = null;
2439
+ if (options?.clinicBranchId) {
2440
+ // Query procedures collection to get all procedure IDs available at this clinic
2441
+ const proceduresQuery = query(
2442
+ collection(this.db, PROCEDURES_COLLECTION),
2443
+ where('clinicBranchId', '==', options.clinicBranchId),
2444
+ where('isActive', '==', true),
2445
+ );
2446
+ const proceduresSnapshot = await getDocs(proceduresQuery);
2447
+ availableProcedureIds = new Set(
2448
+ proceduresSnapshot.docs.map(doc => doc.id),
2449
+ );
2450
+ console.log(
2451
+ `[APPOINTMENT_SERVICE] Found ${availableProcedureIds.size} procedures available at clinic ${options.clinicBranchId}`,
2452
+ );
2453
+ }
2454
+
2455
+ // Create NextStepsRecommendation for each recommended procedure
2456
+ for (let index = 0; index < recommendedProcedures.length; index++) {
2457
+ const recommendedProcedure = recommendedProcedures[index];
2458
+ const procedureId = recommendedProcedure.procedure.procedureId;
2459
+
2460
+ // If clinicBranchId is provided, filter to only include procedures available at that clinic
2461
+ if (options?.clinicBranchId && availableProcedureIds) {
2462
+ if (!availableProcedureIds.has(procedureId)) {
2463
+ console.log(
2464
+ `[APPOINTMENT_SERVICE] Skipping recommendation for procedure ${procedureId} - not available at clinic ${options.clinicBranchId}`,
2465
+ );
2466
+ continue;
2467
+ }
2468
+ }
2469
+
2470
+ const recommendationId = `${appointment.id}:${index}`;
2471
+
2472
+ const nextStepsRecommendation: NextStepsRecommendation = {
2473
+ id: recommendationId,
2474
+ recommendedProcedure,
2475
+ appointmentId: appointment.id,
2476
+ appointmentDate: appointment.appointmentStartTime,
2477
+ practitionerId: appointment.practitionerId,
2478
+ practitionerName: appointment.practitionerInfo?.name || 'Unknown Practitioner',
2479
+ clinicBranchId: appointment.clinicBranchId,
2480
+ clinicName: appointment.clinicInfo?.name || 'Unknown Clinic',
2481
+ appointmentStatus: appointment.status,
2482
+ isDismissed: false, // Clinic view doesn't track dismissals
2483
+ dismissedAt: null,
2484
+ };
2485
+
2486
+ recommendations.push(nextStepsRecommendation);
2487
+ }
2488
+
2489
+ console.log(
2490
+ `[APPOINTMENT_SERVICE] Found ${recommendations.length} next steps recommendations for appointment ${appointmentId}`,
2491
+ options?.clinicBranchId
2492
+ ? `(filtered to procedures available at clinic ${options.clinicBranchId})`
2493
+ : '',
2494
+ );
2495
+
2496
+ return recommendations;
2497
+ } catch (error) {
2498
+ console.error(
2499
+ `[APPOINTMENT_SERVICE] Error getting next steps recommendations for appointment ${appointmentId}:`,
2500
+ error,
2501
+ );
2502
+ throw error;
2503
+ }
2504
+ }
2505
+ }