@blackcode_sa/metaestetics-api 1.12.62 → 1.12.63
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/admin/index.d.mts +4 -2
- package/dist/admin/index.d.ts +4 -2
- package/dist/admin/index.js +4 -45
- package/dist/admin/index.mjs +4 -45
- package/dist/backoffice/index.d.mts +9 -0
- package/dist/backoffice/index.d.ts +9 -0
- package/dist/backoffice/index.js +11 -0
- package/dist/backoffice/index.mjs +11 -0
- package/dist/index.d.mts +99 -3
- package/dist/index.d.ts +99 -3
- package/dist/index.js +545 -281
- package/dist/index.mjs +867 -603
- package/package.json +119 -119
- package/src/__mocks__/firstore.ts +10 -10
- package/src/admin/aggregation/README.md +79 -79
- package/src/admin/aggregation/appointment/README.md +128 -128
- package/src/admin/aggregation/appointment/appointment.aggregation.service.ts +1844 -1844
- package/src/admin/aggregation/appointment/index.ts +1 -1
- package/src/admin/aggregation/clinic/README.md +52 -52
- package/src/admin/aggregation/clinic/clinic.aggregation.service.ts +703 -703
- package/src/admin/aggregation/clinic/index.ts +1 -1
- package/src/admin/aggregation/forms/README.md +13 -13
- package/src/admin/aggregation/forms/filled-forms.aggregation.service.ts +322 -322
- package/src/admin/aggregation/forms/index.ts +1 -1
- package/src/admin/aggregation/index.ts +8 -8
- package/src/admin/aggregation/patient/README.md +27 -27
- package/src/admin/aggregation/patient/index.ts +1 -1
- package/src/admin/aggregation/patient/patient.aggregation.service.ts +141 -141
- package/src/admin/aggregation/practitioner/README.md +42 -42
- package/src/admin/aggregation/practitioner/index.ts +1 -1
- package/src/admin/aggregation/practitioner/practitioner.aggregation.service.ts +433 -433
- package/src/admin/aggregation/practitioner-invite/index.ts +1 -1
- package/src/admin/aggregation/practitioner-invite/practitioner-invite.aggregation.service.ts +961 -961
- package/src/admin/aggregation/procedure/README.md +43 -43
- package/src/admin/aggregation/procedure/index.ts +1 -1
- package/src/admin/aggregation/procedure/procedure.aggregation.service.ts +702 -702
- package/src/admin/aggregation/reviews/index.ts +1 -1
- package/src/admin/aggregation/reviews/reviews.aggregation.service.ts +641 -689
- package/src/admin/booking/README.md +125 -125
- package/src/admin/booking/booking.admin.ts +1037 -1037
- package/src/admin/booking/booking.calculator.ts +712 -712
- package/src/admin/booking/booking.types.ts +59 -59
- package/src/admin/booking/index.ts +3 -3
- package/src/admin/booking/timezones-problem.md +185 -185
- package/src/admin/calendar/README.md +7 -7
- package/src/admin/calendar/calendar.admin.service.ts +345 -345
- package/src/admin/calendar/index.ts +1 -1
- package/src/admin/documentation-templates/document-manager.admin.ts +260 -260
- package/src/admin/documentation-templates/index.ts +1 -1
- package/src/admin/free-consultation/free-consultation-utils.admin.ts +148 -148
- package/src/admin/free-consultation/index.ts +1 -1
- package/src/admin/index.ts +75 -75
- package/src/admin/logger/index.ts +78 -78
- package/src/admin/mailing/README.md +95 -95
- package/src/admin/mailing/appointment/appointment.mailing.service.ts +732 -732
- package/src/admin/mailing/appointment/index.ts +1 -1
- package/src/admin/mailing/appointment/templates/patient/appointment-confirmed.html +40 -40
- package/src/admin/mailing/base.mailing.service.ts +208 -208
- package/src/admin/mailing/index.ts +3 -3
- package/src/admin/mailing/practitionerInvite/existing-practitioner-invite.mailing.ts +611 -611
- package/src/admin/mailing/practitionerInvite/index.ts +2 -2
- package/src/admin/mailing/practitionerInvite/practitionerInvite.mailing.ts +395 -395
- package/src/admin/mailing/practitionerInvite/templates/existing-practitioner-invitation.template.ts +155 -155
- package/src/admin/mailing/practitionerInvite/templates/invitation.template.ts +101 -101
- package/src/admin/mailing/practitionerInvite/templates/invite-accepted-notification.template.ts +228 -228
- package/src/admin/mailing/practitionerInvite/templates/invite-rejected-notification.template.ts +242 -242
- package/src/admin/notifications/index.ts +1 -1
- package/src/admin/notifications/notifications.admin.ts +710 -710
- package/src/admin/requirements/README.md +128 -128
- package/src/admin/requirements/index.ts +1 -1
- package/src/admin/requirements/patient-requirements.admin.service.ts +475 -475
- package/src/admin/users/index.ts +1 -1
- package/src/admin/users/user-profile.admin.ts +405 -405
- package/src/backoffice/constants/certification.constants.ts +13 -13
- package/src/backoffice/constants/index.ts +1 -1
- package/src/backoffice/errors/backoffice.errors.ts +181 -181
- package/src/backoffice/errors/index.ts +1 -1
- package/src/backoffice/expo-safe/README.md +26 -26
- package/src/backoffice/expo-safe/index.ts +41 -41
- package/src/backoffice/index.ts +5 -5
- package/src/backoffice/services/FIXES_README.md +102 -102
- package/src/backoffice/services/README.md +40 -40
- package/src/backoffice/services/brand.service.ts +256 -256
- package/src/backoffice/services/category.service.ts +318 -318
- package/src/backoffice/services/constants.service.ts +385 -385
- package/src/backoffice/services/documentation-template.service.ts +202 -202
- package/src/backoffice/services/index.ts +8 -8
- package/src/backoffice/services/migrate-products.ts +116 -116
- package/src/backoffice/services/product.service.ts +553 -553
- package/src/backoffice/services/requirement.service.ts +235 -235
- package/src/backoffice/services/subcategory.service.ts +395 -395
- package/src/backoffice/services/technology.service.ts +1083 -1070
- package/src/backoffice/types/README.md +12 -12
- package/src/backoffice/types/admin-constants.types.ts +69 -69
- package/src/backoffice/types/brand.types.ts +29 -29
- package/src/backoffice/types/category.types.ts +62 -62
- package/src/backoffice/types/documentation-templates.types.ts +28 -28
- package/src/backoffice/types/index.ts +10 -10
- package/src/backoffice/types/procedure-product.types.ts +38 -38
- package/src/backoffice/types/product.types.ts +240 -240
- package/src/backoffice/types/requirement.types.ts +63 -63
- package/src/backoffice/types/static/README.md +18 -18
- package/src/backoffice/types/static/blocking-condition.types.ts +21 -21
- package/src/backoffice/types/static/certification.types.ts +37 -37
- package/src/backoffice/types/static/contraindication.types.ts +19 -19
- package/src/backoffice/types/static/index.ts +6 -6
- package/src/backoffice/types/static/pricing.types.ts +16 -16
- package/src/backoffice/types/static/procedure-family.types.ts +14 -14
- package/src/backoffice/types/static/treatment-benefit.types.ts +22 -22
- package/src/backoffice/types/subcategory.types.ts +34 -34
- package/src/backoffice/types/technology.types.ts +163 -161
- package/src/backoffice/validations/index.ts +1 -1
- package/src/backoffice/validations/schemas.ts +164 -163
- package/src/config/__mocks__/firebase.ts +99 -99
- package/src/config/firebase.ts +78 -78
- package/src/config/index.ts +9 -9
- package/src/errors/auth.error.ts +6 -6
- package/src/errors/auth.errors.ts +200 -200
- package/src/errors/clinic.errors.ts +32 -32
- package/src/errors/firebase.errors.ts +47 -47
- package/src/errors/user.errors.ts +99 -99
- package/src/index.backup.ts +407 -407
- package/src/index.ts +6 -6
- package/src/locales/en.ts +31 -31
- package/src/recommender/admin/index.ts +1 -1
- package/src/recommender/admin/services/recommender.service.admin.ts +5 -5
- package/src/recommender/front/index.ts +1 -1
- package/src/recommender/front/services/onboarding.service.ts +5 -5
- package/src/recommender/front/services/recommender.service.ts +3 -3
- package/src/recommender/index.ts +1 -1
- package/src/services/PATIENTAUTH.MD +197 -197
- package/src/services/README.md +106 -106
- package/src/services/__tests__/auth/auth.mock.test.ts +17 -17
- package/src/services/__tests__/auth/auth.setup.ts +293 -293
- package/src/services/__tests__/auth.service.test.ts +346 -346
- package/src/services/__tests__/base.service.test.ts +77 -77
- package/src/services/__tests__/user.service.test.ts +528 -528
- package/src/services/appointment/README.md +17 -17
- package/src/services/appointment/appointment.service.ts +2505 -2082
- package/src/services/appointment/index.ts +1 -1
- package/src/services/appointment/utils/appointment.utils.ts +552 -552
- package/src/services/appointment/utils/extended-procedure.utils.ts +314 -314
- package/src/services/appointment/utils/form-initialization.utils.ts +225 -225
- package/src/services/appointment/utils/recommended-procedure.utils.ts +195 -195
- package/src/services/appointment/utils/zone-management.utils.ts +353 -353
- package/src/services/appointment/utils/zone-photo.utils.ts +152 -152
- package/src/services/auth/auth.service.ts +989 -989
- package/src/services/auth/auth.v2.service.ts +961 -961
- package/src/services/auth/index.ts +7 -7
- package/src/services/auth/utils/error.utils.ts +90 -90
- package/src/services/auth/utils/firebase.utils.ts +49 -49
- package/src/services/auth/utils/index.ts +21 -21
- package/src/services/auth/utils/practitioner.utils.ts +125 -125
- package/src/services/base.service.ts +41 -41
- package/src/services/calendar/calendar.service.ts +1077 -1077
- package/src/services/calendar/calendar.v2.service.ts +1683 -1683
- package/src/services/calendar/calendar.v3.service.ts +313 -313
- package/src/services/calendar/externalCalendar.service.ts +178 -178
- package/src/services/calendar/index.ts +5 -5
- package/src/services/calendar/synced-calendars.service.ts +743 -743
- package/src/services/calendar/utils/appointment.utils.ts +265 -265
- package/src/services/calendar/utils/calendar-event.utils.ts +646 -646
- package/src/services/calendar/utils/clinic.utils.ts +237 -237
- package/src/services/calendar/utils/docs.utils.ts +157 -157
- package/src/services/calendar/utils/google-calendar.utils.ts +697 -697
- package/src/services/calendar/utils/index.ts +8 -8
- package/src/services/calendar/utils/patient.utils.ts +198 -198
- package/src/services/calendar/utils/practitioner.utils.ts +221 -221
- package/src/services/calendar/utils/synced-calendar.utils.ts +472 -472
- package/src/services/clinic/README.md +204 -204
- package/src/services/clinic/__tests__/clinic-admin.service.test.ts +287 -287
- package/src/services/clinic/__tests__/clinic-group.service.test.ts +352 -352
- package/src/services/clinic/__tests__/clinic.service.test.ts +354 -354
- package/src/services/clinic/billing-transactions.service.ts +217 -217
- package/src/services/clinic/clinic-admin.service.ts +202 -202
- package/src/services/clinic/clinic-group.service.ts +310 -310
- package/src/services/clinic/clinic.service.ts +708 -708
- package/src/services/clinic/index.ts +5 -5
- package/src/services/clinic/practitioner-invite.service.ts +519 -519
- package/src/services/clinic/utils/admin.utils.ts +551 -551
- package/src/services/clinic/utils/clinic-group.utils.ts +646 -646
- package/src/services/clinic/utils/clinic.utils.ts +949 -949
- package/src/services/clinic/utils/filter.utils.d.ts +23 -23
- package/src/services/clinic/utils/filter.utils.ts +446 -446
- package/src/services/clinic/utils/index.ts +11 -11
- package/src/services/clinic/utils/photos.utils.ts +188 -188
- package/src/services/clinic/utils/search.utils.ts +84 -84
- package/src/services/clinic/utils/tag.utils.ts +124 -124
- package/src/services/documentation-templates/documentation-template.service.ts +537 -537
- package/src/services/documentation-templates/filled-document.service.ts +587 -587
- package/src/services/documentation-templates/index.ts +2 -2
- package/src/services/index.ts +13 -13
- package/src/services/media/index.ts +1 -1
- package/src/services/media/media.service.ts +418 -418
- package/src/services/notifications/__tests__/notification.service.test.ts +242 -242
- package/src/services/notifications/index.ts +1 -1
- package/src/services/notifications/notification.service.ts +215 -215
- package/src/services/patient/README.md +48 -48
- package/src/services/patient/To-Do.md +43 -43
- package/src/services/patient/__tests__/patient.service.test.ts +294 -294
- package/src/services/patient/index.ts +2 -2
- package/src/services/patient/patient.service.ts +883 -883
- package/src/services/patient/patientRequirements.service.ts +285 -285
- package/src/services/patient/utils/aesthetic-analysis.utils.ts +176 -176
- package/src/services/patient/utils/clinic.utils.ts +80 -80
- package/src/services/patient/utils/docs.utils.ts +142 -142
- package/src/services/patient/utils/index.ts +9 -9
- package/src/services/patient/utils/location.utils.ts +126 -126
- package/src/services/patient/utils/medical-stuff.utils.ts +143 -143
- package/src/services/patient/utils/medical.utils.ts +458 -458
- package/src/services/patient/utils/practitioner.utils.ts +260 -260
- package/src/services/patient/utils/profile.utils.ts +510 -510
- package/src/services/patient/utils/sensitive.utils.ts +260 -260
- package/src/services/patient/utils/token.utils.ts +211 -211
- package/src/services/practitioner/README.md +145 -145
- package/src/services/practitioner/index.ts +1 -1
- package/src/services/practitioner/practitioner.service.ts +1742 -1742
- package/src/services/procedure/README.md +163 -163
- package/src/services/procedure/index.ts +1 -1
- package/src/services/procedure/procedure.service.ts +1682 -1682
- package/src/services/reviews/index.ts +1 -1
- package/src/services/reviews/reviews.service.ts +636 -683
- package/src/services/user/index.ts +1 -1
- package/src/services/user/user.service.ts +489 -489
- package/src/services/user/user.v2.service.ts +466 -466
- package/src/types/appointment/index.ts +481 -453
- package/src/types/calendar/index.ts +258 -258
- package/src/types/calendar/synced-calendar.types.ts +66 -66
- package/src/types/clinic/index.ts +489 -489
- package/src/types/clinic/practitioner-invite.types.ts +91 -91
- package/src/types/clinic/preferences.types.ts +159 -159
- package/src/types/clinic/to-do +3 -3
- package/src/types/documentation-templates/index.ts +308 -308
- package/src/types/index.ts +44 -44
- package/src/types/notifications/README.md +77 -77
- package/src/types/notifications/index.ts +265 -265
- package/src/types/patient/aesthetic-analysis.types.ts +66 -66
- package/src/types/patient/allergies.ts +58 -58
- package/src/types/patient/index.ts +275 -273
- package/src/types/patient/medical-info.types.ts +152 -152
- package/src/types/patient/patient-requirements.ts +92 -92
- package/src/types/patient/token.types.ts +61 -61
- package/src/types/practitioner/index.ts +206 -206
- package/src/types/procedure/index.ts +181 -181
- package/src/types/profile/index.ts +39 -39
- package/src/types/reviews/index.ts +130 -132
- package/src/types/tz-lookup.d.ts +4 -4
- package/src/types/user/index.ts +38 -38
- package/src/utils/TIMESTAMPS.md +176 -176
- package/src/utils/TimestampUtils.ts +241 -241
- package/src/utils/index.ts +1 -1
- package/src/validations/appointment.schema.ts +574 -574
- package/src/validations/calendar.schema.ts +225 -225
- package/src/validations/clinic.schema.ts +493 -493
- package/src/validations/common.schema.ts +25 -25
- package/src/validations/documentation-templates/index.ts +1 -1
- package/src/validations/documentation-templates/template.schema.ts +220 -220
- package/src/validations/documentation-templates.schema.ts +10 -10
- package/src/validations/index.ts +20 -20
- package/src/validations/media.schema.ts +10 -10
- package/src/validations/notification.schema.ts +90 -90
- package/src/validations/patient/aesthetic-analysis.schema.ts +55 -55
- package/src/validations/patient/medical-info.schema.ts +125 -125
- package/src/validations/patient/patient-requirements.schema.ts +84 -84
- package/src/validations/patient/token.schema.ts +29 -29
- package/src/validations/patient.schema.ts +217 -216
- package/src/validations/practitioner.schema.ts +222 -222
- package/src/validations/procedure-product.schema.ts +41 -41
- package/src/validations/procedure.schema.ts +124 -124
- package/src/validations/profile-info.schema.ts +41 -41
- package/src/validations/reviews.schema.ts +189 -195
- package/src/validations/schemas.ts +104 -104
- package/src/validations/shared.schema.ts +78 -78
|
@@ -1,2082 +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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
import {
|
|
52
|
-
import {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
private
|
|
110
|
-
private
|
|
111
|
-
private
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
* @param
|
|
121
|
-
* @param
|
|
122
|
-
* @param
|
|
123
|
-
* @param
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
this.
|
|
141
|
-
this.
|
|
142
|
-
this.
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
* @param
|
|
154
|
-
* @
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
if (
|
|
174
|
-
throw new Error('
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
//
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
//
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
//
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
);
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
);
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
console.
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
const
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
//
|
|
282
|
-
const
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
);
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
);
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
console.log(
|
|
363
|
-
|
|
364
|
-
);
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
*
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
data.metadata?.recommendedProcedures
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
const
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
console.
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
console.
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
*
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
*
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
*
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
*
|
|
604
|
-
*
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
updateData.
|
|
640
|
-
}
|
|
641
|
-
|
|
642
|
-
if (newStatus === AppointmentStatus.
|
|
643
|
-
updateData.
|
|
644
|
-
}
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
});
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
});
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
//
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
const
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
const
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
}
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
const
|
|
944
|
-
if (!
|
|
945
|
-
throw new Error(
|
|
946
|
-
}
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
};
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
*
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
};
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
*
|
|
1017
|
-
*
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
AppointmentStatus.
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
if (options?.
|
|
1071
|
-
constraints.push(
|
|
1072
|
-
}
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
//
|
|
1079
|
-
const
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
*
|
|
1105
|
-
*
|
|
1106
|
-
*
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
if (options?.
|
|
1172
|
-
constraints.push(
|
|
1173
|
-
}
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
//
|
|
1180
|
-
const
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
*
|
|
1206
|
-
*
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
);
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
const
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
validatedData.
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
);
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
*
|
|
1330
|
-
*
|
|
1331
|
-
* @param
|
|
1332
|
-
* @
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
if (
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
);
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
*
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
*
|
|
1491
|
-
*
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
);
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
*
|
|
1593
|
-
*
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
*
|
|
1617
|
-
*
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
*
|
|
1641
|
-
*
|
|
1642
|
-
* @
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
*
|
|
1667
|
-
*
|
|
1668
|
-
* @
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
*
|
|
1699
|
-
*
|
|
1700
|
-
* @
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
*
|
|
1725
|
-
*
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
*
|
|
1745
|
-
*
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
*
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
*
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
*
|
|
1863
|
-
*
|
|
1864
|
-
*
|
|
1865
|
-
* @
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
appointmentId
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
*
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
*
|
|
1918
|
-
*
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
appointmentId
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
*
|
|
1968
|
-
*
|
|
1969
|
-
*
|
|
1970
|
-
* @
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
*
|
|
1995
|
-
*
|
|
1996
|
-
* @param
|
|
1997
|
-
* @
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
*
|
|
2030
|
-
*
|
|
2031
|
-
* @param
|
|
2032
|
-
* @
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
*
|
|
2065
|
-
*
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
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
|
+
}
|