@blackcode_sa/metaestetics-api 1.12.62 → 1.12.64
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 +86 -1
- package/dist/backoffice/index.d.ts +86 -1
- package/dist/backoffice/index.js +308 -0
- package/dist/backoffice/index.mjs +306 -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 +11 -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,1683 +1,1683 @@
|
|
|
1
|
-
import { Auth } from "firebase/auth";
|
|
2
|
-
import { Firestore, Timestamp, serverTimestamp } from "firebase/firestore";
|
|
3
|
-
import { FirebaseApp } from "firebase/app";
|
|
4
|
-
import { BaseService } from "../base.service";
|
|
5
|
-
import {
|
|
6
|
-
CalendarEvent,
|
|
7
|
-
CalendarEventStatus,
|
|
8
|
-
CalendarEventTime,
|
|
9
|
-
CalendarEventType,
|
|
10
|
-
CalendarSyncStatus,
|
|
11
|
-
CreateCalendarEventData,
|
|
12
|
-
UpdateCalendarEventData,
|
|
13
|
-
CALENDAR_COLLECTION,
|
|
14
|
-
SyncedCalendarEvent,
|
|
15
|
-
ProcedureInfo,
|
|
16
|
-
TimeSlot,
|
|
17
|
-
CreateAppointmentParams,
|
|
18
|
-
UpdateAppointmentParams,
|
|
19
|
-
SearchCalendarEventsParams,
|
|
20
|
-
SearchLocationEnum,
|
|
21
|
-
DateRange,
|
|
22
|
-
} from "../../types/calendar";
|
|
23
|
-
import {
|
|
24
|
-
PRACTITIONERS_COLLECTION,
|
|
25
|
-
PractitionerClinicWorkingHours,
|
|
26
|
-
} from "../../types/practitioner";
|
|
27
|
-
import {
|
|
28
|
-
PATIENTS_COLLECTION,
|
|
29
|
-
Gender,
|
|
30
|
-
PATIENT_SENSITIVE_INFO_COLLECTION,
|
|
31
|
-
} from "../../types/patient";
|
|
32
|
-
import { CLINICS_COLLECTION } from "../../types/clinic";
|
|
33
|
-
import { SyncedCalendarProvider } from "../../types/calendar/synced-calendar.types";
|
|
34
|
-
import {
|
|
35
|
-
ClinicInfo,
|
|
36
|
-
PatientProfileInfo,
|
|
37
|
-
PractitionerProfileInfo,
|
|
38
|
-
} from "../../types/profile";
|
|
39
|
-
import {
|
|
40
|
-
doc,
|
|
41
|
-
getDoc,
|
|
42
|
-
collection,
|
|
43
|
-
query,
|
|
44
|
-
where,
|
|
45
|
-
getDocs,
|
|
46
|
-
setDoc,
|
|
47
|
-
updateDoc,
|
|
48
|
-
QueryConstraint,
|
|
49
|
-
CollectionReference,
|
|
50
|
-
DocumentData,
|
|
51
|
-
} from "firebase/firestore";
|
|
52
|
-
import {
|
|
53
|
-
createAppointmentSchema,
|
|
54
|
-
updateAppointmentSchema,
|
|
55
|
-
} from "../../validations/appointment.schema";
|
|
56
|
-
|
|
57
|
-
// Import utility functions
|
|
58
|
-
import {
|
|
59
|
-
createAppointmentUtil,
|
|
60
|
-
updateAppointmentUtil,
|
|
61
|
-
deleteAppointmentUtil,
|
|
62
|
-
} from "./utils/appointment.utils";
|
|
63
|
-
import { searchCalendarEventsUtil } from "./utils/calendar-event.utils";
|
|
64
|
-
import { SyncedCalendarsService } from "./synced-calendars.service";
|
|
65
|
-
import { Timestamp as FirestoreTimestamp } from "firebase-admin/firestore";
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* Minimum appointment duration in minutes
|
|
69
|
-
*/
|
|
70
|
-
const MIN_APPOINTMENT_DURATION = 15;
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* Refactored Calendar Service
|
|
74
|
-
* Provides streamlined calendar management with proper access control and scheduling rules
|
|
75
|
-
*/
|
|
76
|
-
export class CalendarServiceV2 extends BaseService {
|
|
77
|
-
private syncedCalendarsService: SyncedCalendarsService;
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* Creates a new CalendarService instance
|
|
81
|
-
* @param db - Firestore instance
|
|
82
|
-
* @param auth - Firebase Auth instance
|
|
83
|
-
* @param app - Firebase App instance
|
|
84
|
-
*/
|
|
85
|
-
constructor(db: Firestore, auth: Auth, app: FirebaseApp) {
|
|
86
|
-
super(db, auth, app);
|
|
87
|
-
this.syncedCalendarsService = new SyncedCalendarsService(db, auth, app);
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
// #region Public API Methods
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Creates a new appointment with proper validation and scheduling rules
|
|
94
|
-
* @param params - Appointment creation parameters
|
|
95
|
-
* @returns Created calendar event
|
|
96
|
-
*/
|
|
97
|
-
async createAppointment(
|
|
98
|
-
params: CreateAppointmentParams
|
|
99
|
-
): Promise<CalendarEvent> {
|
|
100
|
-
// Validate input parameters
|
|
101
|
-
await this.validateAppointmentParams(params);
|
|
102
|
-
|
|
103
|
-
// Check clinic working hours
|
|
104
|
-
await this.validateClinicWorkingHours(params.clinicId, params.eventTime);
|
|
105
|
-
|
|
106
|
-
// Check doctor availability
|
|
107
|
-
await this.validateDoctorAvailability(
|
|
108
|
-
params.doctorId,
|
|
109
|
-
params.eventTime,
|
|
110
|
-
params.clinicId
|
|
111
|
-
);
|
|
112
|
-
|
|
113
|
-
// Fetch profile info cards
|
|
114
|
-
const { clinicInfo, practitionerInfo, patientInfo } =
|
|
115
|
-
await this.fetchProfileInfoCards(
|
|
116
|
-
params.clinicId,
|
|
117
|
-
params.doctorId,
|
|
118
|
-
params.patientId
|
|
119
|
-
);
|
|
120
|
-
|
|
121
|
-
// Create the appointment
|
|
122
|
-
const appointmentData: Omit<
|
|
123
|
-
CreateCalendarEventData,
|
|
124
|
-
"id" | "createdAt" | "updatedAt"
|
|
125
|
-
> = {
|
|
126
|
-
clinicBranchId: params.clinicId,
|
|
127
|
-
clinicBranchInfo: clinicInfo,
|
|
128
|
-
practitionerProfileId: params.doctorId,
|
|
129
|
-
practitionerProfileInfo: practitionerInfo,
|
|
130
|
-
patientProfileId: params.patientId,
|
|
131
|
-
patientProfileInfo: patientInfo,
|
|
132
|
-
procedureId: params.procedureId,
|
|
133
|
-
eventLocation: params.eventLocation,
|
|
134
|
-
eventName: "Appointment", // TODO: Add procedure name when procedure model is available
|
|
135
|
-
eventTime: params.eventTime,
|
|
136
|
-
description: params.description || "",
|
|
137
|
-
status: CalendarEventStatus.PENDING,
|
|
138
|
-
syncStatus: CalendarSyncStatus.INTERNAL,
|
|
139
|
-
eventType: CalendarEventType.APPOINTMENT,
|
|
140
|
-
};
|
|
141
|
-
|
|
142
|
-
const appointment = await createAppointmentUtil(
|
|
143
|
-
this.db,
|
|
144
|
-
params.clinicId,
|
|
145
|
-
params.doctorId,
|
|
146
|
-
params.patientId,
|
|
147
|
-
appointmentData,
|
|
148
|
-
this.generateId.bind(this)
|
|
149
|
-
);
|
|
150
|
-
|
|
151
|
-
// Sync with external calendars if needed
|
|
152
|
-
await this.syncAppointmentWithExternalCalendars(appointment);
|
|
153
|
-
|
|
154
|
-
return appointment;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
/**
|
|
158
|
-
* Updates an existing appointment
|
|
159
|
-
* @param params - Appointment update parameters
|
|
160
|
-
* @returns Updated calendar event
|
|
161
|
-
*/
|
|
162
|
-
async updateAppointment(
|
|
163
|
-
params: UpdateAppointmentParams
|
|
164
|
-
): Promise<CalendarEvent> {
|
|
165
|
-
// Validate permissions
|
|
166
|
-
await this.validateUpdatePermissions(params);
|
|
167
|
-
|
|
168
|
-
const updateData: Omit<UpdateCalendarEventData, "updatedAt"> = {
|
|
169
|
-
eventTime: params.eventTime,
|
|
170
|
-
description: params.description,
|
|
171
|
-
status: params.status,
|
|
172
|
-
};
|
|
173
|
-
|
|
174
|
-
const appointment = await updateAppointmentUtil(
|
|
175
|
-
this.db,
|
|
176
|
-
params.clinicId,
|
|
177
|
-
params.doctorId,
|
|
178
|
-
params.patientId,
|
|
179
|
-
params.appointmentId,
|
|
180
|
-
updateData
|
|
181
|
-
);
|
|
182
|
-
|
|
183
|
-
// Sync with external calendars if needed
|
|
184
|
-
await this.syncAppointmentWithExternalCalendars(appointment);
|
|
185
|
-
|
|
186
|
-
return appointment;
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
/**
|
|
190
|
-
* Gets available appointment slots for a doctor at a clinic
|
|
191
|
-
* @param clinicId - ID of the clinic
|
|
192
|
-
* @param doctorId - ID of the doctor
|
|
193
|
-
* @param date - Date to check availability for
|
|
194
|
-
* @returns Array of available time slots
|
|
195
|
-
*/
|
|
196
|
-
async getAvailableSlots(
|
|
197
|
-
clinicId: string,
|
|
198
|
-
doctorId: string,
|
|
199
|
-
date: Date
|
|
200
|
-
): Promise<TimeSlot[]> {
|
|
201
|
-
// Get clinic working hours
|
|
202
|
-
const workingHours = await this.getClinicWorkingHours(clinicId, date);
|
|
203
|
-
|
|
204
|
-
// Get doctor's schedule
|
|
205
|
-
const doctorSchedule = await this.getDoctorSchedule(doctorId, date);
|
|
206
|
-
|
|
207
|
-
// Get existing appointments
|
|
208
|
-
const existingAppointments = await this.getDoctorAppointments(
|
|
209
|
-
doctorId,
|
|
210
|
-
date
|
|
211
|
-
);
|
|
212
|
-
|
|
213
|
-
// Calculate available slots
|
|
214
|
-
return this.calculateAvailableSlots(
|
|
215
|
-
workingHours,
|
|
216
|
-
doctorSchedule,
|
|
217
|
-
existingAppointments
|
|
218
|
-
);
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
/**
|
|
222
|
-
* Confirms an appointment
|
|
223
|
-
* @param appointmentId - ID of the appointment
|
|
224
|
-
* @param clinicId - ID of the clinic
|
|
225
|
-
* @returns Confirmed calendar event
|
|
226
|
-
*/
|
|
227
|
-
async confirmAppointment(
|
|
228
|
-
appointmentId: string,
|
|
229
|
-
clinicId: string
|
|
230
|
-
): Promise<CalendarEvent> {
|
|
231
|
-
return this.updateAppointmentStatus(
|
|
232
|
-
appointmentId,
|
|
233
|
-
clinicId,
|
|
234
|
-
CalendarEventStatus.CONFIRMED
|
|
235
|
-
);
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
/**
|
|
239
|
-
* Rejects an appointment
|
|
240
|
-
* @param appointmentId - ID of the appointment
|
|
241
|
-
* @param clinicId - ID of the clinic
|
|
242
|
-
* @returns Rejected calendar event
|
|
243
|
-
*/
|
|
244
|
-
async rejectAppointment(
|
|
245
|
-
appointmentId: string,
|
|
246
|
-
clinicId: string
|
|
247
|
-
): Promise<CalendarEvent> {
|
|
248
|
-
return this.updateAppointmentStatus(
|
|
249
|
-
appointmentId,
|
|
250
|
-
clinicId,
|
|
251
|
-
CalendarEventStatus.REJECTED
|
|
252
|
-
);
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
/**
|
|
256
|
-
* Cancels an appointment
|
|
257
|
-
* @param appointmentId - ID of the appointment
|
|
258
|
-
* @param clinicId - ID of the clinic
|
|
259
|
-
* @returns Canceled calendar event
|
|
260
|
-
*/
|
|
261
|
-
async cancelAppointment(
|
|
262
|
-
appointmentId: string,
|
|
263
|
-
clinicId: string
|
|
264
|
-
): Promise<CalendarEvent> {
|
|
265
|
-
return this.updateAppointmentStatus(
|
|
266
|
-
appointmentId,
|
|
267
|
-
clinicId,
|
|
268
|
-
CalendarEventStatus.CANCELED
|
|
269
|
-
);
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
/**
|
|
273
|
-
* Imports events from external calendars
|
|
274
|
-
* @param entityType - Type of entity (practitioner or patient)
|
|
275
|
-
* @param entityId - ID of the entity
|
|
276
|
-
* @param startDate - Start date for fetching events
|
|
277
|
-
* @param endDate - End date for fetching events
|
|
278
|
-
* @returns Number of events imported
|
|
279
|
-
*/
|
|
280
|
-
async importEventsFromExternalCalendars(
|
|
281
|
-
entityType: "doctor" | "patient",
|
|
282
|
-
entityId: string,
|
|
283
|
-
startDate: Date,
|
|
284
|
-
endDate: Date
|
|
285
|
-
): Promise<number> {
|
|
286
|
-
// Only practitioners (doctors) should sync two-way
|
|
287
|
-
// Patients only sync outwards (from our system to external calendars)
|
|
288
|
-
if (entityType === "patient") {
|
|
289
|
-
return 0;
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
// For doctors, get their synced calendars
|
|
293
|
-
const syncedCalendars =
|
|
294
|
-
await this.syncedCalendarsService.getPractitionerSyncedCalendars(
|
|
295
|
-
entityId
|
|
296
|
-
);
|
|
297
|
-
|
|
298
|
-
// Filter active calendars
|
|
299
|
-
const activeCalendars = syncedCalendars.filter((cal) => cal.isActive);
|
|
300
|
-
|
|
301
|
-
if (activeCalendars.length === 0) {
|
|
302
|
-
return 0;
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
let importedEventsCount = 0;
|
|
306
|
-
const currentTime = Timestamp.now();
|
|
307
|
-
|
|
308
|
-
// Import from each calendar
|
|
309
|
-
for (const calendar of activeCalendars) {
|
|
310
|
-
try {
|
|
311
|
-
let externalEvents: any[] = [];
|
|
312
|
-
|
|
313
|
-
// Fetch events based on provider and entity type
|
|
314
|
-
if (calendar.provider === SyncedCalendarProvider.GOOGLE) {
|
|
315
|
-
externalEvents =
|
|
316
|
-
await this.syncedCalendarsService.fetchEventsFromPractitionerGoogleCalendar(
|
|
317
|
-
entityId,
|
|
318
|
-
calendar.id,
|
|
319
|
-
startDate,
|
|
320
|
-
endDate
|
|
321
|
-
);
|
|
322
|
-
}
|
|
323
|
-
// Add other providers as needed
|
|
324
|
-
|
|
325
|
-
// Process and import each event
|
|
326
|
-
for (const externalEvent of externalEvents) {
|
|
327
|
-
try {
|
|
328
|
-
// Convert the external event to our format
|
|
329
|
-
const convertedEvent =
|
|
330
|
-
this.syncedCalendarsService.convertGoogleEventsToPractitionerEvents(
|
|
331
|
-
entityId,
|
|
332
|
-
[externalEvent]
|
|
333
|
-
)[0];
|
|
334
|
-
|
|
335
|
-
// Skip events without valid time data
|
|
336
|
-
if (!convertedEvent.eventTime) {
|
|
337
|
-
continue;
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
// Create event data from external event
|
|
341
|
-
const eventData: Omit<
|
|
342
|
-
CreateCalendarEventData,
|
|
343
|
-
"id" | "createdAt" | "updatedAt"
|
|
344
|
-
> = {
|
|
345
|
-
// Ensure all required fields are set
|
|
346
|
-
eventName: convertedEvent.eventName || "External Event",
|
|
347
|
-
eventTime: convertedEvent.eventTime,
|
|
348
|
-
description: convertedEvent.description || "",
|
|
349
|
-
status: CalendarEventStatus.CONFIRMED,
|
|
350
|
-
syncStatus: CalendarSyncStatus.EXTERNAL,
|
|
351
|
-
eventType: CalendarEventType.BLOCKING,
|
|
352
|
-
practitionerProfileId: entityId,
|
|
353
|
-
syncedCalendarEventId: [
|
|
354
|
-
{
|
|
355
|
-
eventId: externalEvent.id,
|
|
356
|
-
syncedCalendarProvider: calendar.provider,
|
|
357
|
-
syncedAt: currentTime,
|
|
358
|
-
},
|
|
359
|
-
],
|
|
360
|
-
};
|
|
361
|
-
|
|
362
|
-
// Create the event in the doctor's calendar
|
|
363
|
-
const doctorEvent = await this.createDoctorBlockingEvent(
|
|
364
|
-
entityId,
|
|
365
|
-
eventData
|
|
366
|
-
);
|
|
367
|
-
|
|
368
|
-
if (doctorEvent) {
|
|
369
|
-
importedEventsCount++;
|
|
370
|
-
}
|
|
371
|
-
} catch (eventError) {
|
|
372
|
-
console.error("Error importing event:", eventError);
|
|
373
|
-
// Continue with other events even if one fails
|
|
374
|
-
}
|
|
375
|
-
}
|
|
376
|
-
} catch (calendarError) {
|
|
377
|
-
console.error(
|
|
378
|
-
`Error fetching events from calendar ${calendar.id}:`,
|
|
379
|
-
calendarError
|
|
380
|
-
);
|
|
381
|
-
// Continue with other calendars even if one fails
|
|
382
|
-
}
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
return importedEventsCount;
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
/**
|
|
389
|
-
* Creates a blocking event in a doctor's calendar
|
|
390
|
-
* @param doctorId - ID of the doctor
|
|
391
|
-
* @param eventData - Calendar event data
|
|
392
|
-
* @returns Created calendar event
|
|
393
|
-
*/
|
|
394
|
-
private async createDoctorBlockingEvent(
|
|
395
|
-
doctorId: string,
|
|
396
|
-
eventData: Omit<CreateCalendarEventData, "id" | "createdAt" | "updatedAt">
|
|
397
|
-
): Promise<CalendarEvent | null> {
|
|
398
|
-
try {
|
|
399
|
-
// Generate a unique ID for the event
|
|
400
|
-
const eventId = this.generateId();
|
|
401
|
-
|
|
402
|
-
// Create the event document reference
|
|
403
|
-
const eventRef = doc(
|
|
404
|
-
this.db,
|
|
405
|
-
PRACTITIONERS_COLLECTION,
|
|
406
|
-
doctorId,
|
|
407
|
-
CALENDAR_COLLECTION,
|
|
408
|
-
eventId
|
|
409
|
-
);
|
|
410
|
-
|
|
411
|
-
// Prepare the event data
|
|
412
|
-
const newEvent: CreateCalendarEventData = {
|
|
413
|
-
id: eventId,
|
|
414
|
-
...eventData,
|
|
415
|
-
createdAt: serverTimestamp(),
|
|
416
|
-
updatedAt: serverTimestamp(),
|
|
417
|
-
};
|
|
418
|
-
|
|
419
|
-
// Set the document
|
|
420
|
-
await setDoc(eventRef, newEvent);
|
|
421
|
-
|
|
422
|
-
// Return the event
|
|
423
|
-
return {
|
|
424
|
-
...newEvent,
|
|
425
|
-
createdAt: Timestamp.now(),
|
|
426
|
-
updatedAt: Timestamp.now(),
|
|
427
|
-
} as CalendarEvent;
|
|
428
|
-
} catch (error) {
|
|
429
|
-
console.error(
|
|
430
|
-
`Error creating blocking event for doctor ${doctorId}:`,
|
|
431
|
-
error
|
|
432
|
-
);
|
|
433
|
-
return null;
|
|
434
|
-
}
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
/**
|
|
438
|
-
* Periodically syncs events from external calendars for doctors
|
|
439
|
-
* This would be called via a scheduled Cloud Function
|
|
440
|
-
* @param lookbackDays - Number of days to look back for events
|
|
441
|
-
* @param lookforwardDays - Number of days to look forward for events
|
|
442
|
-
*/
|
|
443
|
-
async synchronizeExternalCalendars(
|
|
444
|
-
lookbackDays: number = 7,
|
|
445
|
-
lookforwardDays: number = 30
|
|
446
|
-
): Promise<void> {
|
|
447
|
-
try {
|
|
448
|
-
// Get all doctors who have active synced calendars
|
|
449
|
-
const practitionersRef = collection(this.db, PRACTITIONERS_COLLECTION);
|
|
450
|
-
const practitionersSnapshot = await getDocs(practitionersRef);
|
|
451
|
-
|
|
452
|
-
// Prepare date range
|
|
453
|
-
const startDate = new Date();
|
|
454
|
-
startDate.setDate(startDate.getDate() - lookbackDays);
|
|
455
|
-
|
|
456
|
-
const endDate = new Date();
|
|
457
|
-
endDate.setDate(endDate.getDate() + lookforwardDays);
|
|
458
|
-
|
|
459
|
-
// For each doctor, check their synced calendars
|
|
460
|
-
const syncPromises = [];
|
|
461
|
-
for (const docSnapshot of practitionersSnapshot.docs) {
|
|
462
|
-
const practitionerId = docSnapshot.id;
|
|
463
|
-
|
|
464
|
-
// Import events from external calendars
|
|
465
|
-
syncPromises.push(
|
|
466
|
-
this.importEventsFromExternalCalendars(
|
|
467
|
-
"doctor",
|
|
468
|
-
practitionerId,
|
|
469
|
-
startDate,
|
|
470
|
-
endDate
|
|
471
|
-
)
|
|
472
|
-
.then((count) => {
|
|
473
|
-
console.log(
|
|
474
|
-
`Imported ${count} events for doctor ${practitionerId}`
|
|
475
|
-
);
|
|
476
|
-
})
|
|
477
|
-
.catch((error) => {
|
|
478
|
-
console.error(
|
|
479
|
-
`Error importing events for doctor ${practitionerId}:`,
|
|
480
|
-
error
|
|
481
|
-
);
|
|
482
|
-
})
|
|
483
|
-
);
|
|
484
|
-
|
|
485
|
-
// Also update existing events that might have changed
|
|
486
|
-
syncPromises.push(
|
|
487
|
-
this.updateExistingEventsFromExternalCalendars(
|
|
488
|
-
practitionerId,
|
|
489
|
-
startDate,
|
|
490
|
-
endDate
|
|
491
|
-
)
|
|
492
|
-
.then((count) => {
|
|
493
|
-
console.log(
|
|
494
|
-
`Updated ${count} events for doctor ${practitionerId}`
|
|
495
|
-
);
|
|
496
|
-
})
|
|
497
|
-
.catch((error) => {
|
|
498
|
-
console.error(
|
|
499
|
-
`Error updating events for doctor ${practitionerId}:`,
|
|
500
|
-
error
|
|
501
|
-
);
|
|
502
|
-
})
|
|
503
|
-
);
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
// Wait for all sync operations to complete
|
|
507
|
-
await Promise.all(syncPromises);
|
|
508
|
-
console.log("Completed external calendar synchronization");
|
|
509
|
-
} catch (error) {
|
|
510
|
-
console.error("Error synchronizing external calendars:", error);
|
|
511
|
-
}
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
/**
|
|
515
|
-
* Updates existing events that were synced from external calendars
|
|
516
|
-
* @param doctorId - ID of the doctor
|
|
517
|
-
* @param startDate - Start date for fetching events
|
|
518
|
-
* @param endDate - End date for fetching events
|
|
519
|
-
* @returns Number of events updated
|
|
520
|
-
*/
|
|
521
|
-
private async updateExistingEventsFromExternalCalendars(
|
|
522
|
-
doctorId: string,
|
|
523
|
-
startDate: Date,
|
|
524
|
-
endDate: Date
|
|
525
|
-
): Promise<number> {
|
|
526
|
-
try {
|
|
527
|
-
// Get all EXTERNAL events for this doctor within the date range
|
|
528
|
-
const eventsRef = collection(
|
|
529
|
-
this.db,
|
|
530
|
-
PRACTITIONERS_COLLECTION,
|
|
531
|
-
doctorId,
|
|
532
|
-
CALENDAR_COLLECTION
|
|
533
|
-
);
|
|
534
|
-
const q = query(
|
|
535
|
-
eventsRef,
|
|
536
|
-
where("syncStatus", "==", CalendarSyncStatus.EXTERNAL),
|
|
537
|
-
where("eventTime.start", ">=", Timestamp.fromDate(startDate)),
|
|
538
|
-
where("eventTime.start", "<=", Timestamp.fromDate(endDate))
|
|
539
|
-
);
|
|
540
|
-
|
|
541
|
-
const eventsSnapshot = await getDocs(q);
|
|
542
|
-
const events = eventsSnapshot.docs.map((doc) => ({
|
|
543
|
-
id: doc.id,
|
|
544
|
-
...doc.data(),
|
|
545
|
-
})) as CalendarEvent[];
|
|
546
|
-
|
|
547
|
-
// Get the doctor's synced calendars
|
|
548
|
-
const calendars =
|
|
549
|
-
await this.syncedCalendarsService.getPractitionerSyncedCalendars(
|
|
550
|
-
doctorId
|
|
551
|
-
);
|
|
552
|
-
const activeCalendars = calendars.filter((cal) => cal.isActive);
|
|
553
|
-
|
|
554
|
-
if (activeCalendars.length === 0 || events.length === 0) {
|
|
555
|
-
return 0;
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
let updatedCount = 0;
|
|
559
|
-
|
|
560
|
-
// For each external event, check if it needs updating
|
|
561
|
-
for (const event of events) {
|
|
562
|
-
// Skip events without sync IDs
|
|
563
|
-
if (!event.syncedCalendarEventId?.length) continue;
|
|
564
|
-
|
|
565
|
-
for (const syncId of event.syncedCalendarEventId) {
|
|
566
|
-
// Find the calendar for this sync ID
|
|
567
|
-
const calendar = activeCalendars.find(
|
|
568
|
-
(cal) => cal.provider === syncId.syncedCalendarProvider
|
|
569
|
-
);
|
|
570
|
-
if (!calendar) continue;
|
|
571
|
-
|
|
572
|
-
// Check if the event exists and needs updating
|
|
573
|
-
if (syncId.syncedCalendarProvider === SyncedCalendarProvider.GOOGLE) {
|
|
574
|
-
try {
|
|
575
|
-
// Fetch the external event
|
|
576
|
-
const externalEvent = await this.fetchExternalEvent(
|
|
577
|
-
doctorId,
|
|
578
|
-
calendar,
|
|
579
|
-
syncId.eventId
|
|
580
|
-
);
|
|
581
|
-
|
|
582
|
-
// If the event was found, check if it's different from our local copy
|
|
583
|
-
if (externalEvent) {
|
|
584
|
-
// Compare basic properties (time, title, description)
|
|
585
|
-
const externalStartTime = new Date(
|
|
586
|
-
externalEvent.start.dateTime || externalEvent.start.date
|
|
587
|
-
).getTime();
|
|
588
|
-
const externalEndTime = new Date(
|
|
589
|
-
externalEvent.end.dateTime || externalEvent.end.date
|
|
590
|
-
).getTime();
|
|
591
|
-
const localStartTime = event.eventTime.start.toDate().getTime();
|
|
592
|
-
const localEndTime = event.eventTime.end.toDate().getTime();
|
|
593
|
-
|
|
594
|
-
// If times or title/description have changed, update our local copy
|
|
595
|
-
if (
|
|
596
|
-
externalStartTime !== localStartTime ||
|
|
597
|
-
externalEndTime !== localEndTime ||
|
|
598
|
-
externalEvent.summary !== event.eventName ||
|
|
599
|
-
externalEvent.description !== event.description
|
|
600
|
-
) {
|
|
601
|
-
// Update our local copy
|
|
602
|
-
await this.updateLocalEventFromExternal(
|
|
603
|
-
doctorId,
|
|
604
|
-
event.id,
|
|
605
|
-
externalEvent
|
|
606
|
-
);
|
|
607
|
-
updatedCount++;
|
|
608
|
-
}
|
|
609
|
-
} else {
|
|
610
|
-
// The event was deleted in the external calendar, mark it as canceled
|
|
611
|
-
await this.updateEventStatus(
|
|
612
|
-
doctorId,
|
|
613
|
-
event.id,
|
|
614
|
-
CalendarEventStatus.CANCELED
|
|
615
|
-
);
|
|
616
|
-
updatedCount++;
|
|
617
|
-
}
|
|
618
|
-
} catch (error) {
|
|
619
|
-
console.error(
|
|
620
|
-
`Error updating external event ${event.id}:`,
|
|
621
|
-
error
|
|
622
|
-
);
|
|
623
|
-
}
|
|
624
|
-
}
|
|
625
|
-
}
|
|
626
|
-
}
|
|
627
|
-
|
|
628
|
-
return updatedCount;
|
|
629
|
-
} catch (error) {
|
|
630
|
-
console.error(
|
|
631
|
-
"Error updating existing events from external calendars:",
|
|
632
|
-
error
|
|
633
|
-
);
|
|
634
|
-
return 0;
|
|
635
|
-
}
|
|
636
|
-
}
|
|
637
|
-
|
|
638
|
-
/**
|
|
639
|
-
* Fetches a single external event from Google Calendar
|
|
640
|
-
* @param doctorId - ID of the doctor
|
|
641
|
-
* @param calendar - Calendar information
|
|
642
|
-
* @param externalEventId - ID of the external event
|
|
643
|
-
* @returns External event data or null if not found
|
|
644
|
-
*/
|
|
645
|
-
private async fetchExternalEvent(
|
|
646
|
-
doctorId: string,
|
|
647
|
-
calendar: any,
|
|
648
|
-
externalEventId: string
|
|
649
|
-
): Promise<any | null> {
|
|
650
|
-
try {
|
|
651
|
-
if (calendar.provider === SyncedCalendarProvider.GOOGLE) {
|
|
652
|
-
// Refresh token if needed
|
|
653
|
-
// We're using the syncPractitionerEventsToGoogleCalendar to get the calendar with a refreshed token
|
|
654
|
-
const result =
|
|
655
|
-
await this.syncedCalendarsService.fetchEventFromPractitionerGoogleCalendar(
|
|
656
|
-
doctorId,
|
|
657
|
-
calendar.id,
|
|
658
|
-
externalEventId
|
|
659
|
-
);
|
|
660
|
-
|
|
661
|
-
return result;
|
|
662
|
-
}
|
|
663
|
-
return null;
|
|
664
|
-
} catch (error) {
|
|
665
|
-
console.error(`Error fetching external event ${externalEventId}:`, error);
|
|
666
|
-
return null;
|
|
667
|
-
}
|
|
668
|
-
}
|
|
669
|
-
|
|
670
|
-
/**
|
|
671
|
-
* Updates a local event with data from an external event
|
|
672
|
-
* @param doctorId - ID of the doctor
|
|
673
|
-
* @param eventId - ID of the local event
|
|
674
|
-
* @param externalEvent - External event data
|
|
675
|
-
*/
|
|
676
|
-
private async updateLocalEventFromExternal(
|
|
677
|
-
doctorId: string,
|
|
678
|
-
eventId: string,
|
|
679
|
-
externalEvent: any
|
|
680
|
-
): Promise<void> {
|
|
681
|
-
try {
|
|
682
|
-
// Create event time from external event
|
|
683
|
-
const startTime = new Date(
|
|
684
|
-
externalEvent.start.dateTime || externalEvent.start.date
|
|
685
|
-
);
|
|
686
|
-
const endTime = new Date(
|
|
687
|
-
externalEvent.end.dateTime || externalEvent.end.date
|
|
688
|
-
);
|
|
689
|
-
|
|
690
|
-
// Update the local event
|
|
691
|
-
const eventRef = doc(
|
|
692
|
-
this.db,
|
|
693
|
-
PRACTITIONERS_COLLECTION,
|
|
694
|
-
doctorId,
|
|
695
|
-
CALENDAR_COLLECTION,
|
|
696
|
-
eventId
|
|
697
|
-
);
|
|
698
|
-
|
|
699
|
-
await updateDoc(eventRef, {
|
|
700
|
-
eventName: externalEvent.summary || "External Event",
|
|
701
|
-
eventTime: {
|
|
702
|
-
start: Timestamp.fromDate(startTime),
|
|
703
|
-
end: Timestamp.fromDate(endTime),
|
|
704
|
-
},
|
|
705
|
-
description: externalEvent.description || "",
|
|
706
|
-
updatedAt: serverTimestamp(),
|
|
707
|
-
});
|
|
708
|
-
|
|
709
|
-
console.log(`Updated local event ${eventId} from external event`);
|
|
710
|
-
} catch (error) {
|
|
711
|
-
console.error(
|
|
712
|
-
`Error updating local event ${eventId} from external:`,
|
|
713
|
-
error
|
|
714
|
-
);
|
|
715
|
-
}
|
|
716
|
-
}
|
|
717
|
-
|
|
718
|
-
/**
|
|
719
|
-
* Updates an event's status
|
|
720
|
-
* @param doctorId - ID of the doctor
|
|
721
|
-
* @param eventId - ID of the event
|
|
722
|
-
* @param status - New status
|
|
723
|
-
*/
|
|
724
|
-
private async updateEventStatus(
|
|
725
|
-
doctorId: string,
|
|
726
|
-
eventId: string,
|
|
727
|
-
status: CalendarEventStatus
|
|
728
|
-
): Promise<void> {
|
|
729
|
-
try {
|
|
730
|
-
const eventRef = doc(
|
|
731
|
-
this.db,
|
|
732
|
-
PRACTITIONERS_COLLECTION,
|
|
733
|
-
doctorId,
|
|
734
|
-
CALENDAR_COLLECTION,
|
|
735
|
-
eventId
|
|
736
|
-
);
|
|
737
|
-
|
|
738
|
-
await updateDoc(eventRef, {
|
|
739
|
-
status,
|
|
740
|
-
updatedAt: serverTimestamp(),
|
|
741
|
-
});
|
|
742
|
-
|
|
743
|
-
console.log(`Updated event ${eventId} status to ${status}`);
|
|
744
|
-
} catch (error) {
|
|
745
|
-
console.error(`Error updating event ${eventId} status:`, error);
|
|
746
|
-
}
|
|
747
|
-
}
|
|
748
|
-
|
|
749
|
-
/**
|
|
750
|
-
* Creates a scheduled job to periodically sync external calendars
|
|
751
|
-
* Note: This would be implemented using Cloud Functions in a real application
|
|
752
|
-
* This is a sample implementation to show how it could be set up
|
|
753
|
-
* @param interval - Interval in hours
|
|
754
|
-
*/
|
|
755
|
-
createScheduledSyncJob(interval: number = 3): void {
|
|
756
|
-
// This is a simplified implementation
|
|
757
|
-
// In a real application, you would use Cloud Functions with Pub/Sub
|
|
758
|
-
console.log(
|
|
759
|
-
`Setting up scheduled calendar sync job every ${interval} hours`
|
|
760
|
-
);
|
|
761
|
-
|
|
762
|
-
// Example cloud function implementation:
|
|
763
|
-
/*
|
|
764
|
-
// Using Firebase Cloud Functions (in index.ts)
|
|
765
|
-
export const syncExternalCalendars = functions.pubsub
|
|
766
|
-
.schedule('every 3 hours')
|
|
767
|
-
.onRun(async (context) => {
|
|
768
|
-
try {
|
|
769
|
-
const db = admin.firestore();
|
|
770
|
-
const auth = admin.auth();
|
|
771
|
-
const app = admin.app();
|
|
772
|
-
|
|
773
|
-
const calendarService = new CalendarServiceV2(db, auth, app);
|
|
774
|
-
await calendarService.synchronizeExternalCalendars();
|
|
775
|
-
|
|
776
|
-
console.log('External calendar sync completed successfully');
|
|
777
|
-
return null;
|
|
778
|
-
} catch (error) {
|
|
779
|
-
console.error('Error in calendar sync job:', error);
|
|
780
|
-
return null;
|
|
781
|
-
}
|
|
782
|
-
});
|
|
783
|
-
*/
|
|
784
|
-
}
|
|
785
|
-
|
|
786
|
-
/**
|
|
787
|
-
* Searches for calendar events based on specified criteria.
|
|
788
|
-
*
|
|
789
|
-
* @param {SearchCalendarEventsParams} params - The search parameters.
|
|
790
|
-
* @param {SearchLocationEnum} params.searchLocation - The primary location to search (practitioner, patient, or clinic).
|
|
791
|
-
* @param {string} params.entityId - The ID of the entity (practitioner, patient, or clinic) to search within/for.
|
|
792
|
-
* @param {string} [params.clinicId] - Optional clinic ID to filter by.
|
|
793
|
-
* @param {string} [params.practitionerId] - Optional practitioner ID to filter by.
|
|
794
|
-
* @param {string} [params.patientId] - Optional patient ID to filter by.
|
|
795
|
-
* @param {string} [params.procedureId] - Optional procedure ID to filter by.
|
|
796
|
-
* @param {DateRange} [params.dateRange] - Optional date range to filter by (event start time).
|
|
797
|
-
* @param {CalendarEventStatus} [params.eventStatus] - Optional event status to filter by.
|
|
798
|
-
* @param {CalendarEventType} [params.eventType] - Optional event type to filter by.
|
|
799
|
-
* @returns {Promise<CalendarEvent[]>} A promise that resolves to an array of matching calendar events.
|
|
800
|
-
* @throws {Error} If the search location requires an entity ID that is not provided.
|
|
801
|
-
*/
|
|
802
|
-
async searchCalendarEvents(
|
|
803
|
-
params: SearchCalendarEventsParams
|
|
804
|
-
): Promise<CalendarEvent[]> {
|
|
805
|
-
// Use the utility function to perform the search
|
|
806
|
-
return searchCalendarEventsUtil(this.db, params);
|
|
807
|
-
}
|
|
808
|
-
|
|
809
|
-
/**
|
|
810
|
-
* Gets a doctor's upcoming appointments for a specific date range
|
|
811
|
-
*
|
|
812
|
-
* @param {string} doctorId - ID of the practitioner
|
|
813
|
-
* @param {Date} startDate - Start date of the range
|
|
814
|
-
* @param {Date} endDate - End date of the range
|
|
815
|
-
* @param {CalendarEventStatus} [status] - Optional status filter (defaults to CONFIRMED)
|
|
816
|
-
* @returns {Promise<CalendarEvent[]>} A promise that resolves to an array of appointments
|
|
817
|
-
*/
|
|
818
|
-
async getPractitionerUpcomingAppointments(
|
|
819
|
-
doctorId: string,
|
|
820
|
-
startDate: Date,
|
|
821
|
-
endDate: Date,
|
|
822
|
-
status: CalendarEventStatus = CalendarEventStatus.CONFIRMED
|
|
823
|
-
): Promise<CalendarEvent[]> {
|
|
824
|
-
// Create a date range for the query
|
|
825
|
-
const dateRange: DateRange = {
|
|
826
|
-
start: Timestamp.fromDate(startDate),
|
|
827
|
-
end: Timestamp.fromDate(endDate),
|
|
828
|
-
};
|
|
829
|
-
|
|
830
|
-
// Create the search parameters
|
|
831
|
-
const searchParams: SearchCalendarEventsParams = {
|
|
832
|
-
searchLocation: SearchLocationEnum.PRACTITIONER,
|
|
833
|
-
entityId: doctorId,
|
|
834
|
-
dateRange,
|
|
835
|
-
eventStatus: status,
|
|
836
|
-
eventType: CalendarEventType.APPOINTMENT,
|
|
837
|
-
};
|
|
838
|
-
|
|
839
|
-
// Search for the appointments
|
|
840
|
-
return this.searchCalendarEvents(searchParams);
|
|
841
|
-
}
|
|
842
|
-
|
|
843
|
-
/**
|
|
844
|
-
* Gets a patient's appointments for a specific date range
|
|
845
|
-
*
|
|
846
|
-
* @param {string} patientId - ID of the patient
|
|
847
|
-
* @param {Date} startDate - Start date of the range
|
|
848
|
-
* @param {Date} endDate - End date of the range
|
|
849
|
-
* @param {CalendarEventStatus} [status] - Optional status filter (defaults to all non-canceled appointments)
|
|
850
|
-
* @returns {Promise<CalendarEvent[]>} A promise that resolves to an array of appointments
|
|
851
|
-
*/
|
|
852
|
-
async getPatientAppointments(
|
|
853
|
-
patientId: string,
|
|
854
|
-
startDate: Date,
|
|
855
|
-
endDate: Date,
|
|
856
|
-
status?: CalendarEventStatus
|
|
857
|
-
): Promise<CalendarEvent[]> {
|
|
858
|
-
// Create a date range for the query
|
|
859
|
-
const dateRange: DateRange = {
|
|
860
|
-
start: Timestamp.fromDate(startDate),
|
|
861
|
-
end: Timestamp.fromDate(endDate),
|
|
862
|
-
};
|
|
863
|
-
|
|
864
|
-
// Create the search parameters
|
|
865
|
-
const searchParams: SearchCalendarEventsParams = {
|
|
866
|
-
searchLocation: SearchLocationEnum.PATIENT,
|
|
867
|
-
entityId: patientId,
|
|
868
|
-
dateRange,
|
|
869
|
-
eventType: CalendarEventType.APPOINTMENT,
|
|
870
|
-
};
|
|
871
|
-
|
|
872
|
-
// Add status filter if provided
|
|
873
|
-
if (status) {
|
|
874
|
-
searchParams.eventStatus = status;
|
|
875
|
-
}
|
|
876
|
-
|
|
877
|
-
// Search for the appointments
|
|
878
|
-
return this.searchCalendarEvents(searchParams);
|
|
879
|
-
}
|
|
880
|
-
|
|
881
|
-
/**
|
|
882
|
-
* Gets all appointments for a clinic within a specific date range
|
|
883
|
-
*
|
|
884
|
-
* @param {string} clinicId - ID of the clinic
|
|
885
|
-
* @param {Date} startDate - Start date of the range
|
|
886
|
-
* @param {Date} endDate - End date of the range
|
|
887
|
-
* @param {string} [doctorId] - Optional doctor ID to filter by
|
|
888
|
-
* @param {CalendarEventStatus} [status] - Optional status filter
|
|
889
|
-
* @returns {Promise<CalendarEvent[]>} A promise that resolves to an array of appointments
|
|
890
|
-
*/
|
|
891
|
-
async getClinicAppointments(
|
|
892
|
-
clinicId: string,
|
|
893
|
-
startDate: Date,
|
|
894
|
-
endDate: Date,
|
|
895
|
-
doctorId?: string,
|
|
896
|
-
status?: CalendarEventStatus
|
|
897
|
-
): Promise<CalendarEvent[]> {
|
|
898
|
-
// Create a date range for the query
|
|
899
|
-
const dateRange: DateRange = {
|
|
900
|
-
start: Timestamp.fromDate(startDate),
|
|
901
|
-
end: Timestamp.fromDate(endDate),
|
|
902
|
-
};
|
|
903
|
-
|
|
904
|
-
// Create the search parameters
|
|
905
|
-
const searchParams: SearchCalendarEventsParams = {
|
|
906
|
-
searchLocation: SearchLocationEnum.CLINIC,
|
|
907
|
-
entityId: clinicId,
|
|
908
|
-
dateRange,
|
|
909
|
-
eventType: CalendarEventType.APPOINTMENT,
|
|
910
|
-
};
|
|
911
|
-
|
|
912
|
-
// Add doctor filter if provided
|
|
913
|
-
if (doctorId) {
|
|
914
|
-
searchParams.practitionerId = doctorId;
|
|
915
|
-
}
|
|
916
|
-
|
|
917
|
-
// Add status filter if provided
|
|
918
|
-
if (status) {
|
|
919
|
-
searchParams.eventStatus = status;
|
|
920
|
-
}
|
|
921
|
-
|
|
922
|
-
// Search for the appointments
|
|
923
|
-
return this.searchCalendarEvents(searchParams);
|
|
924
|
-
}
|
|
925
|
-
|
|
926
|
-
// #endregion
|
|
927
|
-
|
|
928
|
-
// #region Private Helper Methods
|
|
929
|
-
|
|
930
|
-
/**
|
|
931
|
-
* Validates appointment creation parameters
|
|
932
|
-
* @param params - Appointment parameters to validate
|
|
933
|
-
* @throws Error if validation fails
|
|
934
|
-
*/
|
|
935
|
-
private async validateAppointmentParams(
|
|
936
|
-
params: CreateAppointmentParams
|
|
937
|
-
): Promise<void> {
|
|
938
|
-
// TODO: Add custom validation logic after Zod schema validation
|
|
939
|
-
// - Check if doctor works at the clinic
|
|
940
|
-
// - Check if procedure is available at the clinic
|
|
941
|
-
// - Check if patient is eligible for the procedure
|
|
942
|
-
// - Validate time slot (15-minute increments)
|
|
943
|
-
// - Check clinic's subscription status
|
|
944
|
-
// - Check if auto-confirm is enabled
|
|
945
|
-
|
|
946
|
-
// Validate basic parameters using Zod schema
|
|
947
|
-
await createAppointmentSchema.parseAsync(params);
|
|
948
|
-
}
|
|
949
|
-
|
|
950
|
-
/**
|
|
951
|
-
* Validates if the event time falls within clinic working hours
|
|
952
|
-
* @param clinicId - ID of the clinic
|
|
953
|
-
* @param eventTime - Event time to validate
|
|
954
|
-
* @throws Error if validation fails
|
|
955
|
-
*/
|
|
956
|
-
private async validateClinicWorkingHours(
|
|
957
|
-
clinicId: string,
|
|
958
|
-
eventTime: CalendarEventTime
|
|
959
|
-
): Promise<void> {
|
|
960
|
-
// Get clinic working hours for the day
|
|
961
|
-
const startDate = eventTime.start.toDate();
|
|
962
|
-
const workingHours = await this.getClinicWorkingHours(clinicId, startDate);
|
|
963
|
-
|
|
964
|
-
if (workingHours.length === 0) {
|
|
965
|
-
throw new Error("Clinic is not open on this day");
|
|
966
|
-
}
|
|
967
|
-
|
|
968
|
-
// Find if the appointment time falls within any working hours slot
|
|
969
|
-
const startTime = startDate;
|
|
970
|
-
const endTime = eventTime.end.toDate();
|
|
971
|
-
const isWithinWorkingHours = workingHours.some((slot) => {
|
|
972
|
-
return slot.start <= startTime && slot.end >= endTime && slot.isAvailable;
|
|
973
|
-
});
|
|
974
|
-
|
|
975
|
-
if (!isWithinWorkingHours) {
|
|
976
|
-
throw new Error("Appointment time is outside clinic working hours");
|
|
977
|
-
}
|
|
978
|
-
}
|
|
979
|
-
|
|
980
|
-
/**
|
|
981
|
-
* Validates if the doctor is available during the event time
|
|
982
|
-
* @param doctorId - ID of the doctor
|
|
983
|
-
* @param eventTime - Event time to validate
|
|
984
|
-
* @param clinicId - ID of the clinic where the appointment is being booked
|
|
985
|
-
* @throws Error if validation fails
|
|
986
|
-
*/
|
|
987
|
-
private async validateDoctorAvailability(
|
|
988
|
-
doctorId: string,
|
|
989
|
-
eventTime: CalendarEventTime,
|
|
990
|
-
clinicId: string
|
|
991
|
-
): Promise<void> {
|
|
992
|
-
const startDate = eventTime.start.toDate();
|
|
993
|
-
const startTime = startDate;
|
|
994
|
-
const endTime = eventTime.end.toDate();
|
|
995
|
-
|
|
996
|
-
// Get doctor's document to check clinic-specific working hours
|
|
997
|
-
const practitionerRef = doc(this.db, PRACTITIONERS_COLLECTION, doctorId);
|
|
998
|
-
const practitionerDoc = await getDoc(practitionerRef);
|
|
999
|
-
|
|
1000
|
-
if (!practitionerDoc.exists()) {
|
|
1001
|
-
throw new Error(`Doctor with ID ${doctorId} not found`);
|
|
1002
|
-
}
|
|
1003
|
-
|
|
1004
|
-
const practitioner = practitionerDoc.data();
|
|
1005
|
-
|
|
1006
|
-
// Check if doctor works at the specified clinic
|
|
1007
|
-
if (!practitioner.clinics.includes(clinicId)) {
|
|
1008
|
-
throw new Error("Doctor does not work at this clinic");
|
|
1009
|
-
}
|
|
1010
|
-
|
|
1011
|
-
// Get doctor's clinic-specific working hours
|
|
1012
|
-
const clinicWorkingHours = practitioner.clinicWorkingHours?.find(
|
|
1013
|
-
(hours: PractitionerClinicWorkingHours) =>
|
|
1014
|
-
hours.clinicId === clinicId && hours.isActive
|
|
1015
|
-
);
|
|
1016
|
-
|
|
1017
|
-
if (!clinicWorkingHours) {
|
|
1018
|
-
throw new Error("Doctor does not have working hours set for this clinic");
|
|
1019
|
-
}
|
|
1020
|
-
|
|
1021
|
-
// Get the day of the week (0 = Sunday, 1 = Monday, etc.)
|
|
1022
|
-
const dayOfWeek = startDate.getDay();
|
|
1023
|
-
const dayKey = [
|
|
1024
|
-
"sunday",
|
|
1025
|
-
"monday",
|
|
1026
|
-
"tuesday",
|
|
1027
|
-
"wednesday",
|
|
1028
|
-
"thursday",
|
|
1029
|
-
"friday",
|
|
1030
|
-
"saturday",
|
|
1031
|
-
][dayOfWeek];
|
|
1032
|
-
const daySchedule = clinicWorkingHours.workingHours[dayKey];
|
|
1033
|
-
|
|
1034
|
-
if (!daySchedule) {
|
|
1035
|
-
throw new Error("Doctor is not working on this day at this clinic");
|
|
1036
|
-
}
|
|
1037
|
-
|
|
1038
|
-
// Convert working hours to Date objects for comparison
|
|
1039
|
-
const [startHour, startMinute] = daySchedule.start.split(":").map(Number);
|
|
1040
|
-
const [endHour, endMinute] = daySchedule.end.split(":").map(Number);
|
|
1041
|
-
|
|
1042
|
-
const scheduleStart = new Date(startDate);
|
|
1043
|
-
scheduleStart.setHours(startHour, startMinute, 0, 0);
|
|
1044
|
-
|
|
1045
|
-
const scheduleEnd = new Date(startDate);
|
|
1046
|
-
scheduleEnd.setHours(endHour, endMinute, 0, 0);
|
|
1047
|
-
|
|
1048
|
-
// Check if the appointment time is within doctor's working hours
|
|
1049
|
-
if (startTime < scheduleStart || endTime > scheduleEnd) {
|
|
1050
|
-
throw new Error(
|
|
1051
|
-
"Appointment time is outside doctor's working hours at this clinic"
|
|
1052
|
-
);
|
|
1053
|
-
}
|
|
1054
|
-
|
|
1055
|
-
// Get existing appointments
|
|
1056
|
-
const appointments = await this.getDoctorAppointments(doctorId, startDate);
|
|
1057
|
-
|
|
1058
|
-
// Check for overlapping appointments
|
|
1059
|
-
const hasOverlap = appointments.some((appointment) => {
|
|
1060
|
-
const appointmentStart = appointment.eventTime.start.toDate();
|
|
1061
|
-
const appointmentEnd = appointment.eventTime.end.toDate();
|
|
1062
|
-
return (
|
|
1063
|
-
(startTime >= appointmentStart && startTime < appointmentEnd) ||
|
|
1064
|
-
(endTime > appointmentStart && endTime <= appointmentEnd) ||
|
|
1065
|
-
(startTime <= appointmentStart && endTime >= appointmentEnd)
|
|
1066
|
-
);
|
|
1067
|
-
});
|
|
1068
|
-
|
|
1069
|
-
if (hasOverlap) {
|
|
1070
|
-
throw new Error("Doctor has another appointment during this time");
|
|
1071
|
-
}
|
|
1072
|
-
}
|
|
1073
|
-
|
|
1074
|
-
/**
|
|
1075
|
-
* Updates appointment status
|
|
1076
|
-
* @param appointmentId - ID of the appointment
|
|
1077
|
-
* @param clinicId - ID of the clinic
|
|
1078
|
-
* @param status - New status
|
|
1079
|
-
* @returns Updated calendar event
|
|
1080
|
-
*/
|
|
1081
|
-
private async updateAppointmentStatus(
|
|
1082
|
-
appointmentId: string,
|
|
1083
|
-
clinicId: string,
|
|
1084
|
-
status: CalendarEventStatus
|
|
1085
|
-
): Promise<CalendarEvent> {
|
|
1086
|
-
// Get the appointment
|
|
1087
|
-
const baseCollectionPath = `${CLINICS_COLLECTION}/${clinicId}/${CALENDAR_COLLECTION}`;
|
|
1088
|
-
const appointmentRef = doc(this.db, baseCollectionPath, appointmentId);
|
|
1089
|
-
const appointmentDoc = await getDoc(appointmentRef);
|
|
1090
|
-
|
|
1091
|
-
if (!appointmentDoc.exists()) {
|
|
1092
|
-
throw new Error(`Appointment with ID ${appointmentId} not found`);
|
|
1093
|
-
}
|
|
1094
|
-
|
|
1095
|
-
const appointment = appointmentDoc.data() as CalendarEvent;
|
|
1096
|
-
|
|
1097
|
-
// Validate that the appointment belongs to the specified clinic
|
|
1098
|
-
if (appointment.clinicBranchId !== clinicId) {
|
|
1099
|
-
throw new Error("Appointment does not belong to the specified clinic");
|
|
1100
|
-
}
|
|
1101
|
-
|
|
1102
|
-
// Validate the status transition
|
|
1103
|
-
this.validateStatusTransition(appointment.status, status);
|
|
1104
|
-
|
|
1105
|
-
// Update the appointment
|
|
1106
|
-
const updateParams: UpdateAppointmentParams = {
|
|
1107
|
-
appointmentId,
|
|
1108
|
-
clinicId,
|
|
1109
|
-
eventTime: appointment.eventTime,
|
|
1110
|
-
description: appointment.description || "",
|
|
1111
|
-
doctorId: appointment.practitionerProfileId || "",
|
|
1112
|
-
patientId: appointment.patientProfileId || "",
|
|
1113
|
-
status,
|
|
1114
|
-
};
|
|
1115
|
-
|
|
1116
|
-
// Validate update parameters
|
|
1117
|
-
await this.validateUpdatePermissions(updateParams);
|
|
1118
|
-
|
|
1119
|
-
// Update the appointment
|
|
1120
|
-
return this.updateAppointment(updateParams);
|
|
1121
|
-
}
|
|
1122
|
-
|
|
1123
|
-
/**
|
|
1124
|
-
* Validates status transition
|
|
1125
|
-
* @param currentStatus - Current status
|
|
1126
|
-
* @param newStatus - New status
|
|
1127
|
-
* @throws Error if transition is invalid
|
|
1128
|
-
*/
|
|
1129
|
-
private validateStatusTransition(
|
|
1130
|
-
currentStatus: CalendarEventStatus,
|
|
1131
|
-
newStatus: CalendarEventStatus
|
|
1132
|
-
): void {
|
|
1133
|
-
// Define valid status transitions
|
|
1134
|
-
const validTransitions: Record<CalendarEventStatus, CalendarEventStatus[]> =
|
|
1135
|
-
{
|
|
1136
|
-
[CalendarEventStatus.PENDING]: [
|
|
1137
|
-
CalendarEventStatus.CONFIRMED,
|
|
1138
|
-
CalendarEventStatus.REJECTED,
|
|
1139
|
-
CalendarEventStatus.CANCELED,
|
|
1140
|
-
],
|
|
1141
|
-
[CalendarEventStatus.CONFIRMED]: [
|
|
1142
|
-
CalendarEventStatus.CANCELED,
|
|
1143
|
-
CalendarEventStatus.COMPLETED,
|
|
1144
|
-
CalendarEventStatus.RESCHEDULED,
|
|
1145
|
-
CalendarEventStatus.NO_SHOW,
|
|
1146
|
-
],
|
|
1147
|
-
[CalendarEventStatus.REJECTED]: [],
|
|
1148
|
-
[CalendarEventStatus.CANCELED]: [],
|
|
1149
|
-
[CalendarEventStatus.RESCHEDULED]: [
|
|
1150
|
-
CalendarEventStatus.CONFIRMED,
|
|
1151
|
-
CalendarEventStatus.CANCELED,
|
|
1152
|
-
],
|
|
1153
|
-
[CalendarEventStatus.COMPLETED]: [],
|
|
1154
|
-
[CalendarEventStatus.NO_SHOW]: [],
|
|
1155
|
-
};
|
|
1156
|
-
|
|
1157
|
-
// Check if transition is valid
|
|
1158
|
-
if (!validTransitions[currentStatus].includes(newStatus)) {
|
|
1159
|
-
throw new Error(
|
|
1160
|
-
`Invalid status transition from ${currentStatus} to ${newStatus}`
|
|
1161
|
-
);
|
|
1162
|
-
}
|
|
1163
|
-
}
|
|
1164
|
-
|
|
1165
|
-
/**
|
|
1166
|
-
* Syncs appointment with external calendars based on entity type and status
|
|
1167
|
-
* @param appointment - Calendar event to sync
|
|
1168
|
-
*/
|
|
1169
|
-
private async syncAppointmentWithExternalCalendars(
|
|
1170
|
-
appointment: CalendarEvent
|
|
1171
|
-
): Promise<void> {
|
|
1172
|
-
if (!appointment.practitionerProfileId || !appointment.patientProfileId) {
|
|
1173
|
-
return;
|
|
1174
|
-
}
|
|
1175
|
-
|
|
1176
|
-
try {
|
|
1177
|
-
// Get synced calendars for doctor and patient (no longer sync with clinic)
|
|
1178
|
-
const [doctorCalendars, patientCalendars] = await Promise.all([
|
|
1179
|
-
this.syncedCalendarsService.getPractitionerSyncedCalendars(
|
|
1180
|
-
appointment.practitionerProfileId
|
|
1181
|
-
),
|
|
1182
|
-
this.syncedCalendarsService.getPatientSyncedCalendars(
|
|
1183
|
-
appointment.patientProfileId
|
|
1184
|
-
),
|
|
1185
|
-
]);
|
|
1186
|
-
|
|
1187
|
-
// Filter active calendars
|
|
1188
|
-
const activeDoctorCalendars = doctorCalendars.filter(
|
|
1189
|
-
(cal) => cal.isActive
|
|
1190
|
-
);
|
|
1191
|
-
const activePatientCalendars = patientCalendars.filter(
|
|
1192
|
-
(cal) => cal.isActive
|
|
1193
|
-
);
|
|
1194
|
-
|
|
1195
|
-
// Skip if there are no active calendars
|
|
1196
|
-
if (
|
|
1197
|
-
activeDoctorCalendars.length === 0 &&
|
|
1198
|
-
activePatientCalendars.length === 0
|
|
1199
|
-
) {
|
|
1200
|
-
return;
|
|
1201
|
-
}
|
|
1202
|
-
|
|
1203
|
-
// Only sync INTERNAL events (those created within our system)
|
|
1204
|
-
if (appointment.syncStatus !== CalendarSyncStatus.INTERNAL) {
|
|
1205
|
-
return;
|
|
1206
|
-
}
|
|
1207
|
-
|
|
1208
|
-
// For doctors: Only sync CONFIRMED status events
|
|
1209
|
-
if (
|
|
1210
|
-
appointment.status === CalendarEventStatus.CONFIRMED &&
|
|
1211
|
-
activeDoctorCalendars.length > 0
|
|
1212
|
-
) {
|
|
1213
|
-
await Promise.all(
|
|
1214
|
-
activeDoctorCalendars.map((calendar) =>
|
|
1215
|
-
this.syncEventToExternalCalendar(appointment, calendar, "doctor")
|
|
1216
|
-
)
|
|
1217
|
-
);
|
|
1218
|
-
}
|
|
1219
|
-
|
|
1220
|
-
// For patients: Sync all events EXCEPT CANCELED and REJECTED
|
|
1221
|
-
if (
|
|
1222
|
-
appointment.status !== CalendarEventStatus.CANCELED &&
|
|
1223
|
-
appointment.status !== CalendarEventStatus.REJECTED &&
|
|
1224
|
-
activePatientCalendars.length > 0
|
|
1225
|
-
) {
|
|
1226
|
-
await Promise.all(
|
|
1227
|
-
activePatientCalendars.map((calendar) =>
|
|
1228
|
-
this.syncEventToExternalCalendar(appointment, calendar, "patient")
|
|
1229
|
-
)
|
|
1230
|
-
);
|
|
1231
|
-
}
|
|
1232
|
-
} catch (error) {
|
|
1233
|
-
console.error("Error syncing with external calendars:", error);
|
|
1234
|
-
// Don't throw error as this is not critical for appointment creation
|
|
1235
|
-
}
|
|
1236
|
-
}
|
|
1237
|
-
|
|
1238
|
-
/**
|
|
1239
|
-
* Syncs a single event to an external calendar
|
|
1240
|
-
* @param appointment - Calendar event to sync
|
|
1241
|
-
* @param calendar - External calendar to sync with
|
|
1242
|
-
* @param entityType - Type of entity owning the calendar
|
|
1243
|
-
*/
|
|
1244
|
-
private async syncEventToExternalCalendar(
|
|
1245
|
-
appointment: CalendarEvent,
|
|
1246
|
-
calendar: any,
|
|
1247
|
-
entityType: "doctor" | "patient"
|
|
1248
|
-
): Promise<void> {
|
|
1249
|
-
try {
|
|
1250
|
-
// Create a copy of the appointment to modify for external syncing
|
|
1251
|
-
const eventToSync = { ...appointment };
|
|
1252
|
-
|
|
1253
|
-
// Prepare event title based on status and entity type
|
|
1254
|
-
let eventTitle = appointment.eventName;
|
|
1255
|
-
const clinicName = appointment.clinicBranchInfo?.name || "Clinic";
|
|
1256
|
-
|
|
1257
|
-
// Format title appropriately
|
|
1258
|
-
if (entityType === "patient") {
|
|
1259
|
-
eventTitle = `[${appointment.status}] ${eventTitle} @ ${clinicName}`;
|
|
1260
|
-
} else {
|
|
1261
|
-
eventTitle = `${eventTitle} - Patient: ${
|
|
1262
|
-
appointment.patientProfileInfo?.fullName || "Unknown"
|
|
1263
|
-
} @ ${clinicName}`;
|
|
1264
|
-
}
|
|
1265
|
-
|
|
1266
|
-
// Update the event name for external sync
|
|
1267
|
-
eventToSync.eventName = eventTitle;
|
|
1268
|
-
|
|
1269
|
-
// Check if this event was previously synced with this calendar
|
|
1270
|
-
const existingSyncId = appointment.syncedCalendarEventId?.find(
|
|
1271
|
-
(sync) => sync.syncedCalendarProvider === calendar.provider
|
|
1272
|
-
)?.eventId;
|
|
1273
|
-
|
|
1274
|
-
// If we have a synced event ID, we should update the existing event
|
|
1275
|
-
// If not, create a new event
|
|
1276
|
-
|
|
1277
|
-
if (calendar.provider === SyncedCalendarProvider.GOOGLE) {
|
|
1278
|
-
const result =
|
|
1279
|
-
await this.syncedCalendarsService.syncPractitionerEventsToGoogleCalendar(
|
|
1280
|
-
entityType === "doctor"
|
|
1281
|
-
? appointment.practitionerProfileId!
|
|
1282
|
-
: appointment.patientProfileId!,
|
|
1283
|
-
calendar.id,
|
|
1284
|
-
[eventToSync],
|
|
1285
|
-
existingSyncId // Pass existing sync ID if we have one
|
|
1286
|
-
);
|
|
1287
|
-
|
|
1288
|
-
// If sync was successful and we've created a new event (no existing sync),
|
|
1289
|
-
// we should update our local event with the new sync ID
|
|
1290
|
-
if (result.success && result.eventIds?.length && !existingSyncId) {
|
|
1291
|
-
// Update the appointment with the new sync ID
|
|
1292
|
-
const newSyncEvent: SyncedCalendarEvent = {
|
|
1293
|
-
eventId: result.eventIds[0],
|
|
1294
|
-
syncedCalendarProvider: calendar.provider,
|
|
1295
|
-
syncedAt: Timestamp.now(),
|
|
1296
|
-
};
|
|
1297
|
-
|
|
1298
|
-
// Update the event in the database with the new sync ID
|
|
1299
|
-
await this.updateEventWithSyncId(
|
|
1300
|
-
entityType === "doctor"
|
|
1301
|
-
? appointment.practitionerProfileId!
|
|
1302
|
-
: appointment.patientProfileId!,
|
|
1303
|
-
entityType,
|
|
1304
|
-
appointment.id,
|
|
1305
|
-
newSyncEvent
|
|
1306
|
-
);
|
|
1307
|
-
}
|
|
1308
|
-
}
|
|
1309
|
-
} catch (error) {
|
|
1310
|
-
console.error(`Error syncing with ${entityType}'s calendar:`, error);
|
|
1311
|
-
// Don't throw error as this is not critical
|
|
1312
|
-
}
|
|
1313
|
-
}
|
|
1314
|
-
|
|
1315
|
-
/**
|
|
1316
|
-
* Updates an event with a new sync ID
|
|
1317
|
-
* @param entityId - ID of the entity (doctor or patient)
|
|
1318
|
-
* @param entityType - Type of entity
|
|
1319
|
-
* @param eventId - ID of the event
|
|
1320
|
-
* @param syncEvent - Sync event information
|
|
1321
|
-
*/
|
|
1322
|
-
private async updateEventWithSyncId(
|
|
1323
|
-
entityId: string,
|
|
1324
|
-
entityType: "doctor" | "patient",
|
|
1325
|
-
eventId: string,
|
|
1326
|
-
syncEvent: SyncedCalendarEvent
|
|
1327
|
-
): Promise<void> {
|
|
1328
|
-
try {
|
|
1329
|
-
// Determine the collection path based on entity type
|
|
1330
|
-
const collectionPath =
|
|
1331
|
-
entityType === "doctor"
|
|
1332
|
-
? `${PRACTITIONERS_COLLECTION}/${entityId}/${CALENDAR_COLLECTION}`
|
|
1333
|
-
: `${PATIENTS_COLLECTION}/${entityId}/${CALENDAR_COLLECTION}`;
|
|
1334
|
-
|
|
1335
|
-
// Get the event reference
|
|
1336
|
-
const eventRef = doc(this.db, collectionPath, eventId);
|
|
1337
|
-
const eventDoc = await getDoc(eventRef);
|
|
1338
|
-
|
|
1339
|
-
if (eventDoc.exists()) {
|
|
1340
|
-
const event = eventDoc.data() as CalendarEvent;
|
|
1341
|
-
const syncIds = [...(event.syncedCalendarEventId || [])];
|
|
1342
|
-
|
|
1343
|
-
// Check if we already have this sync ID
|
|
1344
|
-
const existingSyncIndex = syncIds.findIndex(
|
|
1345
|
-
(sync) =>
|
|
1346
|
-
sync.syncedCalendarProvider === syncEvent.syncedCalendarProvider
|
|
1347
|
-
);
|
|
1348
|
-
|
|
1349
|
-
if (existingSyncIndex >= 0) {
|
|
1350
|
-
// Update the existing sync ID
|
|
1351
|
-
syncIds[existingSyncIndex] = syncEvent;
|
|
1352
|
-
} else {
|
|
1353
|
-
// Add the new sync ID
|
|
1354
|
-
syncIds.push(syncEvent);
|
|
1355
|
-
}
|
|
1356
|
-
|
|
1357
|
-
// Update the event
|
|
1358
|
-
await updateDoc(eventRef, {
|
|
1359
|
-
syncedCalendarEventId: syncIds,
|
|
1360
|
-
updatedAt: serverTimestamp(),
|
|
1361
|
-
});
|
|
1362
|
-
|
|
1363
|
-
console.log(
|
|
1364
|
-
`Updated event ${eventId} with sync ID ${syncEvent.eventId}`
|
|
1365
|
-
);
|
|
1366
|
-
}
|
|
1367
|
-
} catch (error) {
|
|
1368
|
-
console.error("Error updating event with sync ID:", error);
|
|
1369
|
-
}
|
|
1370
|
-
}
|
|
1371
|
-
|
|
1372
|
-
/**
|
|
1373
|
-
* Validates update permissions and parameters
|
|
1374
|
-
* @param params - Update parameters to validate
|
|
1375
|
-
*/
|
|
1376
|
-
private async validateUpdatePermissions(
|
|
1377
|
-
params: UpdateAppointmentParams
|
|
1378
|
-
): Promise<void> {
|
|
1379
|
-
// TODO: Add custom validation logic after Zod schema validation
|
|
1380
|
-
// - Check if user has permission to update the appointment
|
|
1381
|
-
// - Check if the appointment exists
|
|
1382
|
-
// - Check if the new status transition is valid
|
|
1383
|
-
// - Check if the new time slot is valid
|
|
1384
|
-
// - Validate against clinic's business rules
|
|
1385
|
-
|
|
1386
|
-
// Validate basic parameters using Zod schema
|
|
1387
|
-
await updateAppointmentSchema.parseAsync(params);
|
|
1388
|
-
}
|
|
1389
|
-
|
|
1390
|
-
/**
|
|
1391
|
-
* Gets clinic working hours for a specific date
|
|
1392
|
-
* @param clinicId - ID of the clinic
|
|
1393
|
-
* @param date - Date to get working hours for
|
|
1394
|
-
* @returns Working hours for the clinic
|
|
1395
|
-
*/
|
|
1396
|
-
private async getClinicWorkingHours(
|
|
1397
|
-
clinicId: string,
|
|
1398
|
-
date: Date
|
|
1399
|
-
): Promise<TimeSlot[]> {
|
|
1400
|
-
// Get clinic document
|
|
1401
|
-
const clinicRef = doc(this.db, CLINICS_COLLECTION, clinicId);
|
|
1402
|
-
const clinicDoc = await getDoc(clinicRef);
|
|
1403
|
-
|
|
1404
|
-
if (!clinicDoc.exists()) {
|
|
1405
|
-
throw new Error(`Clinic with ID ${clinicId} not found`);
|
|
1406
|
-
}
|
|
1407
|
-
|
|
1408
|
-
// TODO: Implement proper working hours retrieval from clinic data model
|
|
1409
|
-
// For now, return default working hours (9 AM - 5 PM)
|
|
1410
|
-
const workingHours: TimeSlot[] = [];
|
|
1411
|
-
const dayOfWeek = date.getDay();
|
|
1412
|
-
|
|
1413
|
-
// Skip weekends (0 = Sunday, 6 = Saturday)
|
|
1414
|
-
if (dayOfWeek === 0 || dayOfWeek === 6) {
|
|
1415
|
-
return workingHours;
|
|
1416
|
-
}
|
|
1417
|
-
|
|
1418
|
-
// Create working hours slot (9 AM - 5 PM)
|
|
1419
|
-
const workingDate = new Date(date);
|
|
1420
|
-
workingDate.setHours(9, 0, 0, 0);
|
|
1421
|
-
const startTime = new Date(workingDate);
|
|
1422
|
-
|
|
1423
|
-
workingDate.setHours(17, 0, 0, 0);
|
|
1424
|
-
const endTime = new Date(workingDate);
|
|
1425
|
-
|
|
1426
|
-
workingHours.push({
|
|
1427
|
-
start: startTime,
|
|
1428
|
-
end: endTime,
|
|
1429
|
-
isAvailable: true,
|
|
1430
|
-
});
|
|
1431
|
-
|
|
1432
|
-
return workingHours;
|
|
1433
|
-
}
|
|
1434
|
-
|
|
1435
|
-
/**
|
|
1436
|
-
* Gets doctor's schedule for a specific date
|
|
1437
|
-
* @param doctorId - ID of the doctor
|
|
1438
|
-
* @param date - Date to get schedule for
|
|
1439
|
-
* @returns Doctor's schedule
|
|
1440
|
-
*/
|
|
1441
|
-
private async getDoctorSchedule(
|
|
1442
|
-
doctorId: string,
|
|
1443
|
-
date: Date
|
|
1444
|
-
): Promise<TimeSlot[]> {
|
|
1445
|
-
// Get doctor document
|
|
1446
|
-
const practitionerRef = doc(this.db, PRACTITIONERS_COLLECTION, doctorId);
|
|
1447
|
-
const practitionerDoc = await getDoc(practitionerRef);
|
|
1448
|
-
|
|
1449
|
-
if (!practitionerDoc.exists()) {
|
|
1450
|
-
throw new Error(`Doctor with ID ${doctorId} not found`);
|
|
1451
|
-
}
|
|
1452
|
-
|
|
1453
|
-
// TODO: Implement proper schedule retrieval from practitioner data model
|
|
1454
|
-
// For now, return default schedule (9 AM - 5 PM)
|
|
1455
|
-
const schedule: TimeSlot[] = [];
|
|
1456
|
-
const dayOfWeek = date.getDay();
|
|
1457
|
-
|
|
1458
|
-
// Skip weekends (0 = Sunday, 6 = Saturday)
|
|
1459
|
-
if (dayOfWeek === 0 || dayOfWeek === 6) {
|
|
1460
|
-
return schedule;
|
|
1461
|
-
}
|
|
1462
|
-
|
|
1463
|
-
// Create schedule slot (9 AM - 5 PM)
|
|
1464
|
-
const scheduleDate = new Date(date);
|
|
1465
|
-
scheduleDate.setHours(9, 0, 0, 0);
|
|
1466
|
-
const startTime = new Date(scheduleDate);
|
|
1467
|
-
|
|
1468
|
-
scheduleDate.setHours(17, 0, 0, 0);
|
|
1469
|
-
const endTime = new Date(scheduleDate);
|
|
1470
|
-
|
|
1471
|
-
schedule.push({
|
|
1472
|
-
start: startTime,
|
|
1473
|
-
end: endTime,
|
|
1474
|
-
isAvailable: true,
|
|
1475
|
-
});
|
|
1476
|
-
|
|
1477
|
-
return schedule;
|
|
1478
|
-
}
|
|
1479
|
-
|
|
1480
|
-
/**
|
|
1481
|
-
* Gets doctor's appointments for a specific date
|
|
1482
|
-
* @param doctorId - ID of the doctor
|
|
1483
|
-
* @param date - Date to get appointments for
|
|
1484
|
-
* @returns Array of calendar events
|
|
1485
|
-
*/
|
|
1486
|
-
private async getDoctorAppointments(
|
|
1487
|
-
doctorId: string,
|
|
1488
|
-
date: Date
|
|
1489
|
-
): Promise<CalendarEvent[]> {
|
|
1490
|
-
// Create start and end timestamps for the day
|
|
1491
|
-
const startOfDay = new Date(date);
|
|
1492
|
-
startOfDay.setHours(0, 0, 0, 0);
|
|
1493
|
-
const endOfDay = new Date(date);
|
|
1494
|
-
endOfDay.setHours(23, 59, 59, 999);
|
|
1495
|
-
|
|
1496
|
-
// Query appointments for the doctor on the specified date
|
|
1497
|
-
const appointmentsRef = collection(this.db, CALENDAR_COLLECTION);
|
|
1498
|
-
const q = query(
|
|
1499
|
-
appointmentsRef,
|
|
1500
|
-
where("practitionerProfileId", "==", doctorId),
|
|
1501
|
-
where("eventTime.start", ">=", Timestamp.fromDate(startOfDay)),
|
|
1502
|
-
where("eventTime.start", "<=", Timestamp.fromDate(endOfDay)),
|
|
1503
|
-
where("status", "in", [
|
|
1504
|
-
CalendarEventStatus.CONFIRMED,
|
|
1505
|
-
CalendarEventStatus.PENDING,
|
|
1506
|
-
])
|
|
1507
|
-
);
|
|
1508
|
-
|
|
1509
|
-
const querySnapshot = await getDocs(q);
|
|
1510
|
-
return querySnapshot.docs.map((doc) => doc.data() as CalendarEvent);
|
|
1511
|
-
}
|
|
1512
|
-
|
|
1513
|
-
/**
|
|
1514
|
-
* Calculates available time slots based on working hours, schedule and existing appointments
|
|
1515
|
-
* @param workingHours - Clinic working hours
|
|
1516
|
-
* @param doctorSchedule - Doctor's schedule
|
|
1517
|
-
* @param existingAppointments - Existing appointments
|
|
1518
|
-
* @returns Array of available time slots
|
|
1519
|
-
*/
|
|
1520
|
-
private calculateAvailableSlots(
|
|
1521
|
-
workingHours: TimeSlot[],
|
|
1522
|
-
doctorSchedule: TimeSlot[],
|
|
1523
|
-
existingAppointments: CalendarEvent[]
|
|
1524
|
-
): TimeSlot[] {
|
|
1525
|
-
const availableSlots: TimeSlot[] = [];
|
|
1526
|
-
|
|
1527
|
-
// First, find overlapping time slots between clinic hours and doctor schedule
|
|
1528
|
-
for (const workingHour of workingHours) {
|
|
1529
|
-
for (const scheduleSlot of doctorSchedule) {
|
|
1530
|
-
// Find overlap between working hours and doctor schedule
|
|
1531
|
-
const overlapStart = new Date(
|
|
1532
|
-
Math.max(workingHour.start.getTime(), scheduleSlot.start.getTime())
|
|
1533
|
-
);
|
|
1534
|
-
const overlapEnd = new Date(
|
|
1535
|
-
Math.min(workingHour.end.getTime(), scheduleSlot.end.getTime())
|
|
1536
|
-
);
|
|
1537
|
-
|
|
1538
|
-
// If there is an overlap and both slots are available
|
|
1539
|
-
if (
|
|
1540
|
-
overlapStart < overlapEnd &&
|
|
1541
|
-
workingHour.isAvailable &&
|
|
1542
|
-
scheduleSlot.isAvailable
|
|
1543
|
-
) {
|
|
1544
|
-
// Create 15-minute slots within the overlap period
|
|
1545
|
-
let slotStart = new Date(overlapStart);
|
|
1546
|
-
while (slotStart < overlapEnd) {
|
|
1547
|
-
const slotEnd = new Date(
|
|
1548
|
-
slotStart.getTime() + MIN_APPOINTMENT_DURATION * 60 * 1000
|
|
1549
|
-
);
|
|
1550
|
-
|
|
1551
|
-
// Check if this slot overlaps with any existing appointments
|
|
1552
|
-
const hasOverlap = existingAppointments.some((appointment) => {
|
|
1553
|
-
const appointmentStart = appointment.eventTime.start.toDate();
|
|
1554
|
-
const appointmentEnd = appointment.eventTime.end.toDate();
|
|
1555
|
-
return (
|
|
1556
|
-
(slotStart >= appointmentStart && slotStart < appointmentEnd) ||
|
|
1557
|
-
(slotEnd > appointmentStart && slotEnd <= appointmentEnd)
|
|
1558
|
-
);
|
|
1559
|
-
});
|
|
1560
|
-
|
|
1561
|
-
if (!hasOverlap && slotEnd <= overlapEnd) {
|
|
1562
|
-
availableSlots.push({
|
|
1563
|
-
start: new Date(slotStart),
|
|
1564
|
-
end: new Date(slotEnd),
|
|
1565
|
-
isAvailable: true,
|
|
1566
|
-
});
|
|
1567
|
-
}
|
|
1568
|
-
|
|
1569
|
-
// Move to next slot
|
|
1570
|
-
slotStart = new Date(
|
|
1571
|
-
slotStart.getTime() + MIN_APPOINTMENT_DURATION * 60 * 1000
|
|
1572
|
-
);
|
|
1573
|
-
}
|
|
1574
|
-
}
|
|
1575
|
-
}
|
|
1576
|
-
}
|
|
1577
|
-
|
|
1578
|
-
return availableSlots;
|
|
1579
|
-
}
|
|
1580
|
-
|
|
1581
|
-
/**
|
|
1582
|
-
* Fetches and creates info cards for clinic, doctor, and patient profiles
|
|
1583
|
-
* @param clinicId - ID of the clinic
|
|
1584
|
-
* @param doctorId - ID of the doctor
|
|
1585
|
-
* @param patientId - ID of the patient
|
|
1586
|
-
* @returns Object containing info cards for all profiles
|
|
1587
|
-
*/
|
|
1588
|
-
private async fetchProfileInfoCards(
|
|
1589
|
-
clinicId: string,
|
|
1590
|
-
doctorId: string,
|
|
1591
|
-
patientId: string
|
|
1592
|
-
): Promise<{
|
|
1593
|
-
clinicInfo: ClinicInfo | null;
|
|
1594
|
-
practitionerInfo: PractitionerProfileInfo | null;
|
|
1595
|
-
patientInfo: PatientProfileInfo | null;
|
|
1596
|
-
}> {
|
|
1597
|
-
try {
|
|
1598
|
-
// Fetch all profiles concurrently
|
|
1599
|
-
const [clinicDoc, practitionerDoc, patientDoc, patientSensitiveInfoDoc] =
|
|
1600
|
-
await Promise.all([
|
|
1601
|
-
getDoc(doc(this.db, CLINICS_COLLECTION, clinicId)),
|
|
1602
|
-
getDoc(doc(this.db, PRACTITIONERS_COLLECTION, doctorId)),
|
|
1603
|
-
getDoc(doc(this.db, PATIENTS_COLLECTION, patientId)),
|
|
1604
|
-
getDoc(
|
|
1605
|
-
doc(
|
|
1606
|
-
this.db,
|
|
1607
|
-
PATIENTS_COLLECTION,
|
|
1608
|
-
patientId,
|
|
1609
|
-
PATIENT_SENSITIVE_INFO_COLLECTION,
|
|
1610
|
-
patientId
|
|
1611
|
-
)
|
|
1612
|
-
),
|
|
1613
|
-
]);
|
|
1614
|
-
|
|
1615
|
-
// Create info cards
|
|
1616
|
-
const clinicInfo: ClinicInfo | null = clinicDoc.exists()
|
|
1617
|
-
? {
|
|
1618
|
-
id: clinicDoc.id,
|
|
1619
|
-
featuredPhoto: clinicDoc.data().featuredPhoto || "",
|
|
1620
|
-
name: clinicDoc.data().name,
|
|
1621
|
-
description: clinicDoc.data().description || "",
|
|
1622
|
-
location: clinicDoc.data().location,
|
|
1623
|
-
contactInfo: clinicDoc.data().contactInfo,
|
|
1624
|
-
}
|
|
1625
|
-
: null;
|
|
1626
|
-
|
|
1627
|
-
const practitionerInfo: PractitionerProfileInfo | null =
|
|
1628
|
-
practitionerDoc.exists()
|
|
1629
|
-
? {
|
|
1630
|
-
id: practitionerDoc.id,
|
|
1631
|
-
practitionerPhoto:
|
|
1632
|
-
practitionerDoc.data().basicInfo.profileImageUrl || null,
|
|
1633
|
-
name: `${practitionerDoc.data().basicInfo.firstName} ${
|
|
1634
|
-
practitionerDoc.data().basicInfo.lastName
|
|
1635
|
-
}`,
|
|
1636
|
-
email: practitionerDoc.data().basicInfo.email,
|
|
1637
|
-
phone: practitionerDoc.data().basicInfo.phoneNumber || null,
|
|
1638
|
-
certification: practitionerDoc.data().certification,
|
|
1639
|
-
}
|
|
1640
|
-
: null;
|
|
1641
|
-
|
|
1642
|
-
// First try to get data from sensitive-info subcollection
|
|
1643
|
-
let patientInfo: PatientProfileInfo | null = null;
|
|
1644
|
-
|
|
1645
|
-
if (patientSensitiveInfoDoc.exists()) {
|
|
1646
|
-
const sensitiveData = patientSensitiveInfoDoc.data();
|
|
1647
|
-
patientInfo = {
|
|
1648
|
-
id: patientId,
|
|
1649
|
-
fullName: `${sensitiveData.firstName} ${sensitiveData.lastName}`,
|
|
1650
|
-
email: sensitiveData.email || "",
|
|
1651
|
-
phone: sensitiveData.phoneNumber || null,
|
|
1652
|
-
dateOfBirth: sensitiveData.dateOfBirth || Timestamp.now(),
|
|
1653
|
-
gender: sensitiveData.gender || Gender.OTHER,
|
|
1654
|
-
};
|
|
1655
|
-
} else if (patientDoc.exists()) {
|
|
1656
|
-
// Fall back to patient document if sensitive info not available
|
|
1657
|
-
patientInfo = {
|
|
1658
|
-
id: patientDoc.id,
|
|
1659
|
-
fullName: patientDoc.data().displayName,
|
|
1660
|
-
email: patientDoc.data().contactInfo?.email || "",
|
|
1661
|
-
phone: patientDoc.data().phoneNumber || null,
|
|
1662
|
-
dateOfBirth: patientDoc.data().dateOfBirth || Timestamp.now(),
|
|
1663
|
-
gender: patientDoc.data().gender || Gender.OTHER,
|
|
1664
|
-
};
|
|
1665
|
-
}
|
|
1666
|
-
|
|
1667
|
-
return {
|
|
1668
|
-
clinicInfo,
|
|
1669
|
-
practitionerInfo,
|
|
1670
|
-
patientInfo,
|
|
1671
|
-
};
|
|
1672
|
-
} catch (error) {
|
|
1673
|
-
console.error("Error fetching profile info cards:", error);
|
|
1674
|
-
return {
|
|
1675
|
-
clinicInfo: null,
|
|
1676
|
-
practitionerInfo: null,
|
|
1677
|
-
patientInfo: null,
|
|
1678
|
-
};
|
|
1679
|
-
}
|
|
1680
|
-
}
|
|
1681
|
-
|
|
1682
|
-
// #endregion
|
|
1683
|
-
}
|
|
1
|
+
import { Auth } from "firebase/auth";
|
|
2
|
+
import { Firestore, Timestamp, serverTimestamp } from "firebase/firestore";
|
|
3
|
+
import { FirebaseApp } from "firebase/app";
|
|
4
|
+
import { BaseService } from "../base.service";
|
|
5
|
+
import {
|
|
6
|
+
CalendarEvent,
|
|
7
|
+
CalendarEventStatus,
|
|
8
|
+
CalendarEventTime,
|
|
9
|
+
CalendarEventType,
|
|
10
|
+
CalendarSyncStatus,
|
|
11
|
+
CreateCalendarEventData,
|
|
12
|
+
UpdateCalendarEventData,
|
|
13
|
+
CALENDAR_COLLECTION,
|
|
14
|
+
SyncedCalendarEvent,
|
|
15
|
+
ProcedureInfo,
|
|
16
|
+
TimeSlot,
|
|
17
|
+
CreateAppointmentParams,
|
|
18
|
+
UpdateAppointmentParams,
|
|
19
|
+
SearchCalendarEventsParams,
|
|
20
|
+
SearchLocationEnum,
|
|
21
|
+
DateRange,
|
|
22
|
+
} from "../../types/calendar";
|
|
23
|
+
import {
|
|
24
|
+
PRACTITIONERS_COLLECTION,
|
|
25
|
+
PractitionerClinicWorkingHours,
|
|
26
|
+
} from "../../types/practitioner";
|
|
27
|
+
import {
|
|
28
|
+
PATIENTS_COLLECTION,
|
|
29
|
+
Gender,
|
|
30
|
+
PATIENT_SENSITIVE_INFO_COLLECTION,
|
|
31
|
+
} from "../../types/patient";
|
|
32
|
+
import { CLINICS_COLLECTION } from "../../types/clinic";
|
|
33
|
+
import { SyncedCalendarProvider } from "../../types/calendar/synced-calendar.types";
|
|
34
|
+
import {
|
|
35
|
+
ClinicInfo,
|
|
36
|
+
PatientProfileInfo,
|
|
37
|
+
PractitionerProfileInfo,
|
|
38
|
+
} from "../../types/profile";
|
|
39
|
+
import {
|
|
40
|
+
doc,
|
|
41
|
+
getDoc,
|
|
42
|
+
collection,
|
|
43
|
+
query,
|
|
44
|
+
where,
|
|
45
|
+
getDocs,
|
|
46
|
+
setDoc,
|
|
47
|
+
updateDoc,
|
|
48
|
+
QueryConstraint,
|
|
49
|
+
CollectionReference,
|
|
50
|
+
DocumentData,
|
|
51
|
+
} from "firebase/firestore";
|
|
52
|
+
import {
|
|
53
|
+
createAppointmentSchema,
|
|
54
|
+
updateAppointmentSchema,
|
|
55
|
+
} from "../../validations/appointment.schema";
|
|
56
|
+
|
|
57
|
+
// Import utility functions
|
|
58
|
+
import {
|
|
59
|
+
createAppointmentUtil,
|
|
60
|
+
updateAppointmentUtil,
|
|
61
|
+
deleteAppointmentUtil,
|
|
62
|
+
} from "./utils/appointment.utils";
|
|
63
|
+
import { searchCalendarEventsUtil } from "./utils/calendar-event.utils";
|
|
64
|
+
import { SyncedCalendarsService } from "./synced-calendars.service";
|
|
65
|
+
import { Timestamp as FirestoreTimestamp } from "firebase-admin/firestore";
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Minimum appointment duration in minutes
|
|
69
|
+
*/
|
|
70
|
+
const MIN_APPOINTMENT_DURATION = 15;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Refactored Calendar Service
|
|
74
|
+
* Provides streamlined calendar management with proper access control and scheduling rules
|
|
75
|
+
*/
|
|
76
|
+
export class CalendarServiceV2 extends BaseService {
|
|
77
|
+
private syncedCalendarsService: SyncedCalendarsService;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Creates a new CalendarService instance
|
|
81
|
+
* @param db - Firestore instance
|
|
82
|
+
* @param auth - Firebase Auth instance
|
|
83
|
+
* @param app - Firebase App instance
|
|
84
|
+
*/
|
|
85
|
+
constructor(db: Firestore, auth: Auth, app: FirebaseApp) {
|
|
86
|
+
super(db, auth, app);
|
|
87
|
+
this.syncedCalendarsService = new SyncedCalendarsService(db, auth, app);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// #region Public API Methods
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Creates a new appointment with proper validation and scheduling rules
|
|
94
|
+
* @param params - Appointment creation parameters
|
|
95
|
+
* @returns Created calendar event
|
|
96
|
+
*/
|
|
97
|
+
async createAppointment(
|
|
98
|
+
params: CreateAppointmentParams
|
|
99
|
+
): Promise<CalendarEvent> {
|
|
100
|
+
// Validate input parameters
|
|
101
|
+
await this.validateAppointmentParams(params);
|
|
102
|
+
|
|
103
|
+
// Check clinic working hours
|
|
104
|
+
await this.validateClinicWorkingHours(params.clinicId, params.eventTime);
|
|
105
|
+
|
|
106
|
+
// Check doctor availability
|
|
107
|
+
await this.validateDoctorAvailability(
|
|
108
|
+
params.doctorId,
|
|
109
|
+
params.eventTime,
|
|
110
|
+
params.clinicId
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
// Fetch profile info cards
|
|
114
|
+
const { clinicInfo, practitionerInfo, patientInfo } =
|
|
115
|
+
await this.fetchProfileInfoCards(
|
|
116
|
+
params.clinicId,
|
|
117
|
+
params.doctorId,
|
|
118
|
+
params.patientId
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
// Create the appointment
|
|
122
|
+
const appointmentData: Omit<
|
|
123
|
+
CreateCalendarEventData,
|
|
124
|
+
"id" | "createdAt" | "updatedAt"
|
|
125
|
+
> = {
|
|
126
|
+
clinicBranchId: params.clinicId,
|
|
127
|
+
clinicBranchInfo: clinicInfo,
|
|
128
|
+
practitionerProfileId: params.doctorId,
|
|
129
|
+
practitionerProfileInfo: practitionerInfo,
|
|
130
|
+
patientProfileId: params.patientId,
|
|
131
|
+
patientProfileInfo: patientInfo,
|
|
132
|
+
procedureId: params.procedureId,
|
|
133
|
+
eventLocation: params.eventLocation,
|
|
134
|
+
eventName: "Appointment", // TODO: Add procedure name when procedure model is available
|
|
135
|
+
eventTime: params.eventTime,
|
|
136
|
+
description: params.description || "",
|
|
137
|
+
status: CalendarEventStatus.PENDING,
|
|
138
|
+
syncStatus: CalendarSyncStatus.INTERNAL,
|
|
139
|
+
eventType: CalendarEventType.APPOINTMENT,
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const appointment = await createAppointmentUtil(
|
|
143
|
+
this.db,
|
|
144
|
+
params.clinicId,
|
|
145
|
+
params.doctorId,
|
|
146
|
+
params.patientId,
|
|
147
|
+
appointmentData,
|
|
148
|
+
this.generateId.bind(this)
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
// Sync with external calendars if needed
|
|
152
|
+
await this.syncAppointmentWithExternalCalendars(appointment);
|
|
153
|
+
|
|
154
|
+
return appointment;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Updates an existing appointment
|
|
159
|
+
* @param params - Appointment update parameters
|
|
160
|
+
* @returns Updated calendar event
|
|
161
|
+
*/
|
|
162
|
+
async updateAppointment(
|
|
163
|
+
params: UpdateAppointmentParams
|
|
164
|
+
): Promise<CalendarEvent> {
|
|
165
|
+
// Validate permissions
|
|
166
|
+
await this.validateUpdatePermissions(params);
|
|
167
|
+
|
|
168
|
+
const updateData: Omit<UpdateCalendarEventData, "updatedAt"> = {
|
|
169
|
+
eventTime: params.eventTime,
|
|
170
|
+
description: params.description,
|
|
171
|
+
status: params.status,
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const appointment = await updateAppointmentUtil(
|
|
175
|
+
this.db,
|
|
176
|
+
params.clinicId,
|
|
177
|
+
params.doctorId,
|
|
178
|
+
params.patientId,
|
|
179
|
+
params.appointmentId,
|
|
180
|
+
updateData
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
// Sync with external calendars if needed
|
|
184
|
+
await this.syncAppointmentWithExternalCalendars(appointment);
|
|
185
|
+
|
|
186
|
+
return appointment;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Gets available appointment slots for a doctor at a clinic
|
|
191
|
+
* @param clinicId - ID of the clinic
|
|
192
|
+
* @param doctorId - ID of the doctor
|
|
193
|
+
* @param date - Date to check availability for
|
|
194
|
+
* @returns Array of available time slots
|
|
195
|
+
*/
|
|
196
|
+
async getAvailableSlots(
|
|
197
|
+
clinicId: string,
|
|
198
|
+
doctorId: string,
|
|
199
|
+
date: Date
|
|
200
|
+
): Promise<TimeSlot[]> {
|
|
201
|
+
// Get clinic working hours
|
|
202
|
+
const workingHours = await this.getClinicWorkingHours(clinicId, date);
|
|
203
|
+
|
|
204
|
+
// Get doctor's schedule
|
|
205
|
+
const doctorSchedule = await this.getDoctorSchedule(doctorId, date);
|
|
206
|
+
|
|
207
|
+
// Get existing appointments
|
|
208
|
+
const existingAppointments = await this.getDoctorAppointments(
|
|
209
|
+
doctorId,
|
|
210
|
+
date
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
// Calculate available slots
|
|
214
|
+
return this.calculateAvailableSlots(
|
|
215
|
+
workingHours,
|
|
216
|
+
doctorSchedule,
|
|
217
|
+
existingAppointments
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Confirms an appointment
|
|
223
|
+
* @param appointmentId - ID of the appointment
|
|
224
|
+
* @param clinicId - ID of the clinic
|
|
225
|
+
* @returns Confirmed calendar event
|
|
226
|
+
*/
|
|
227
|
+
async confirmAppointment(
|
|
228
|
+
appointmentId: string,
|
|
229
|
+
clinicId: string
|
|
230
|
+
): Promise<CalendarEvent> {
|
|
231
|
+
return this.updateAppointmentStatus(
|
|
232
|
+
appointmentId,
|
|
233
|
+
clinicId,
|
|
234
|
+
CalendarEventStatus.CONFIRMED
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Rejects an appointment
|
|
240
|
+
* @param appointmentId - ID of the appointment
|
|
241
|
+
* @param clinicId - ID of the clinic
|
|
242
|
+
* @returns Rejected calendar event
|
|
243
|
+
*/
|
|
244
|
+
async rejectAppointment(
|
|
245
|
+
appointmentId: string,
|
|
246
|
+
clinicId: string
|
|
247
|
+
): Promise<CalendarEvent> {
|
|
248
|
+
return this.updateAppointmentStatus(
|
|
249
|
+
appointmentId,
|
|
250
|
+
clinicId,
|
|
251
|
+
CalendarEventStatus.REJECTED
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Cancels an appointment
|
|
257
|
+
* @param appointmentId - ID of the appointment
|
|
258
|
+
* @param clinicId - ID of the clinic
|
|
259
|
+
* @returns Canceled calendar event
|
|
260
|
+
*/
|
|
261
|
+
async cancelAppointment(
|
|
262
|
+
appointmentId: string,
|
|
263
|
+
clinicId: string
|
|
264
|
+
): Promise<CalendarEvent> {
|
|
265
|
+
return this.updateAppointmentStatus(
|
|
266
|
+
appointmentId,
|
|
267
|
+
clinicId,
|
|
268
|
+
CalendarEventStatus.CANCELED
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Imports events from external calendars
|
|
274
|
+
* @param entityType - Type of entity (practitioner or patient)
|
|
275
|
+
* @param entityId - ID of the entity
|
|
276
|
+
* @param startDate - Start date for fetching events
|
|
277
|
+
* @param endDate - End date for fetching events
|
|
278
|
+
* @returns Number of events imported
|
|
279
|
+
*/
|
|
280
|
+
async importEventsFromExternalCalendars(
|
|
281
|
+
entityType: "doctor" | "patient",
|
|
282
|
+
entityId: string,
|
|
283
|
+
startDate: Date,
|
|
284
|
+
endDate: Date
|
|
285
|
+
): Promise<number> {
|
|
286
|
+
// Only practitioners (doctors) should sync two-way
|
|
287
|
+
// Patients only sync outwards (from our system to external calendars)
|
|
288
|
+
if (entityType === "patient") {
|
|
289
|
+
return 0;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// For doctors, get their synced calendars
|
|
293
|
+
const syncedCalendars =
|
|
294
|
+
await this.syncedCalendarsService.getPractitionerSyncedCalendars(
|
|
295
|
+
entityId
|
|
296
|
+
);
|
|
297
|
+
|
|
298
|
+
// Filter active calendars
|
|
299
|
+
const activeCalendars = syncedCalendars.filter((cal) => cal.isActive);
|
|
300
|
+
|
|
301
|
+
if (activeCalendars.length === 0) {
|
|
302
|
+
return 0;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
let importedEventsCount = 0;
|
|
306
|
+
const currentTime = Timestamp.now();
|
|
307
|
+
|
|
308
|
+
// Import from each calendar
|
|
309
|
+
for (const calendar of activeCalendars) {
|
|
310
|
+
try {
|
|
311
|
+
let externalEvents: any[] = [];
|
|
312
|
+
|
|
313
|
+
// Fetch events based on provider and entity type
|
|
314
|
+
if (calendar.provider === SyncedCalendarProvider.GOOGLE) {
|
|
315
|
+
externalEvents =
|
|
316
|
+
await this.syncedCalendarsService.fetchEventsFromPractitionerGoogleCalendar(
|
|
317
|
+
entityId,
|
|
318
|
+
calendar.id,
|
|
319
|
+
startDate,
|
|
320
|
+
endDate
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
// Add other providers as needed
|
|
324
|
+
|
|
325
|
+
// Process and import each event
|
|
326
|
+
for (const externalEvent of externalEvents) {
|
|
327
|
+
try {
|
|
328
|
+
// Convert the external event to our format
|
|
329
|
+
const convertedEvent =
|
|
330
|
+
this.syncedCalendarsService.convertGoogleEventsToPractitionerEvents(
|
|
331
|
+
entityId,
|
|
332
|
+
[externalEvent]
|
|
333
|
+
)[0];
|
|
334
|
+
|
|
335
|
+
// Skip events without valid time data
|
|
336
|
+
if (!convertedEvent.eventTime) {
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Create event data from external event
|
|
341
|
+
const eventData: Omit<
|
|
342
|
+
CreateCalendarEventData,
|
|
343
|
+
"id" | "createdAt" | "updatedAt"
|
|
344
|
+
> = {
|
|
345
|
+
// Ensure all required fields are set
|
|
346
|
+
eventName: convertedEvent.eventName || "External Event",
|
|
347
|
+
eventTime: convertedEvent.eventTime,
|
|
348
|
+
description: convertedEvent.description || "",
|
|
349
|
+
status: CalendarEventStatus.CONFIRMED,
|
|
350
|
+
syncStatus: CalendarSyncStatus.EXTERNAL,
|
|
351
|
+
eventType: CalendarEventType.BLOCKING,
|
|
352
|
+
practitionerProfileId: entityId,
|
|
353
|
+
syncedCalendarEventId: [
|
|
354
|
+
{
|
|
355
|
+
eventId: externalEvent.id,
|
|
356
|
+
syncedCalendarProvider: calendar.provider,
|
|
357
|
+
syncedAt: currentTime,
|
|
358
|
+
},
|
|
359
|
+
],
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
// Create the event in the doctor's calendar
|
|
363
|
+
const doctorEvent = await this.createDoctorBlockingEvent(
|
|
364
|
+
entityId,
|
|
365
|
+
eventData
|
|
366
|
+
);
|
|
367
|
+
|
|
368
|
+
if (doctorEvent) {
|
|
369
|
+
importedEventsCount++;
|
|
370
|
+
}
|
|
371
|
+
} catch (eventError) {
|
|
372
|
+
console.error("Error importing event:", eventError);
|
|
373
|
+
// Continue with other events even if one fails
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
} catch (calendarError) {
|
|
377
|
+
console.error(
|
|
378
|
+
`Error fetching events from calendar ${calendar.id}:`,
|
|
379
|
+
calendarError
|
|
380
|
+
);
|
|
381
|
+
// Continue with other calendars even if one fails
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
return importedEventsCount;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Creates a blocking event in a doctor's calendar
|
|
390
|
+
* @param doctorId - ID of the doctor
|
|
391
|
+
* @param eventData - Calendar event data
|
|
392
|
+
* @returns Created calendar event
|
|
393
|
+
*/
|
|
394
|
+
private async createDoctorBlockingEvent(
|
|
395
|
+
doctorId: string,
|
|
396
|
+
eventData: Omit<CreateCalendarEventData, "id" | "createdAt" | "updatedAt">
|
|
397
|
+
): Promise<CalendarEvent | null> {
|
|
398
|
+
try {
|
|
399
|
+
// Generate a unique ID for the event
|
|
400
|
+
const eventId = this.generateId();
|
|
401
|
+
|
|
402
|
+
// Create the event document reference
|
|
403
|
+
const eventRef = doc(
|
|
404
|
+
this.db,
|
|
405
|
+
PRACTITIONERS_COLLECTION,
|
|
406
|
+
doctorId,
|
|
407
|
+
CALENDAR_COLLECTION,
|
|
408
|
+
eventId
|
|
409
|
+
);
|
|
410
|
+
|
|
411
|
+
// Prepare the event data
|
|
412
|
+
const newEvent: CreateCalendarEventData = {
|
|
413
|
+
id: eventId,
|
|
414
|
+
...eventData,
|
|
415
|
+
createdAt: serverTimestamp(),
|
|
416
|
+
updatedAt: serverTimestamp(),
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
// Set the document
|
|
420
|
+
await setDoc(eventRef, newEvent);
|
|
421
|
+
|
|
422
|
+
// Return the event
|
|
423
|
+
return {
|
|
424
|
+
...newEvent,
|
|
425
|
+
createdAt: Timestamp.now(),
|
|
426
|
+
updatedAt: Timestamp.now(),
|
|
427
|
+
} as CalendarEvent;
|
|
428
|
+
} catch (error) {
|
|
429
|
+
console.error(
|
|
430
|
+
`Error creating blocking event for doctor ${doctorId}:`,
|
|
431
|
+
error
|
|
432
|
+
);
|
|
433
|
+
return null;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Periodically syncs events from external calendars for doctors
|
|
439
|
+
* This would be called via a scheduled Cloud Function
|
|
440
|
+
* @param lookbackDays - Number of days to look back for events
|
|
441
|
+
* @param lookforwardDays - Number of days to look forward for events
|
|
442
|
+
*/
|
|
443
|
+
async synchronizeExternalCalendars(
|
|
444
|
+
lookbackDays: number = 7,
|
|
445
|
+
lookforwardDays: number = 30
|
|
446
|
+
): Promise<void> {
|
|
447
|
+
try {
|
|
448
|
+
// Get all doctors who have active synced calendars
|
|
449
|
+
const practitionersRef = collection(this.db, PRACTITIONERS_COLLECTION);
|
|
450
|
+
const practitionersSnapshot = await getDocs(practitionersRef);
|
|
451
|
+
|
|
452
|
+
// Prepare date range
|
|
453
|
+
const startDate = new Date();
|
|
454
|
+
startDate.setDate(startDate.getDate() - lookbackDays);
|
|
455
|
+
|
|
456
|
+
const endDate = new Date();
|
|
457
|
+
endDate.setDate(endDate.getDate() + lookforwardDays);
|
|
458
|
+
|
|
459
|
+
// For each doctor, check their synced calendars
|
|
460
|
+
const syncPromises = [];
|
|
461
|
+
for (const docSnapshot of practitionersSnapshot.docs) {
|
|
462
|
+
const practitionerId = docSnapshot.id;
|
|
463
|
+
|
|
464
|
+
// Import events from external calendars
|
|
465
|
+
syncPromises.push(
|
|
466
|
+
this.importEventsFromExternalCalendars(
|
|
467
|
+
"doctor",
|
|
468
|
+
practitionerId,
|
|
469
|
+
startDate,
|
|
470
|
+
endDate
|
|
471
|
+
)
|
|
472
|
+
.then((count) => {
|
|
473
|
+
console.log(
|
|
474
|
+
`Imported ${count} events for doctor ${practitionerId}`
|
|
475
|
+
);
|
|
476
|
+
})
|
|
477
|
+
.catch((error) => {
|
|
478
|
+
console.error(
|
|
479
|
+
`Error importing events for doctor ${practitionerId}:`,
|
|
480
|
+
error
|
|
481
|
+
);
|
|
482
|
+
})
|
|
483
|
+
);
|
|
484
|
+
|
|
485
|
+
// Also update existing events that might have changed
|
|
486
|
+
syncPromises.push(
|
|
487
|
+
this.updateExistingEventsFromExternalCalendars(
|
|
488
|
+
practitionerId,
|
|
489
|
+
startDate,
|
|
490
|
+
endDate
|
|
491
|
+
)
|
|
492
|
+
.then((count) => {
|
|
493
|
+
console.log(
|
|
494
|
+
`Updated ${count} events for doctor ${practitionerId}`
|
|
495
|
+
);
|
|
496
|
+
})
|
|
497
|
+
.catch((error) => {
|
|
498
|
+
console.error(
|
|
499
|
+
`Error updating events for doctor ${practitionerId}:`,
|
|
500
|
+
error
|
|
501
|
+
);
|
|
502
|
+
})
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// Wait for all sync operations to complete
|
|
507
|
+
await Promise.all(syncPromises);
|
|
508
|
+
console.log("Completed external calendar synchronization");
|
|
509
|
+
} catch (error) {
|
|
510
|
+
console.error("Error synchronizing external calendars:", error);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Updates existing events that were synced from external calendars
|
|
516
|
+
* @param doctorId - ID of the doctor
|
|
517
|
+
* @param startDate - Start date for fetching events
|
|
518
|
+
* @param endDate - End date for fetching events
|
|
519
|
+
* @returns Number of events updated
|
|
520
|
+
*/
|
|
521
|
+
private async updateExistingEventsFromExternalCalendars(
|
|
522
|
+
doctorId: string,
|
|
523
|
+
startDate: Date,
|
|
524
|
+
endDate: Date
|
|
525
|
+
): Promise<number> {
|
|
526
|
+
try {
|
|
527
|
+
// Get all EXTERNAL events for this doctor within the date range
|
|
528
|
+
const eventsRef = collection(
|
|
529
|
+
this.db,
|
|
530
|
+
PRACTITIONERS_COLLECTION,
|
|
531
|
+
doctorId,
|
|
532
|
+
CALENDAR_COLLECTION
|
|
533
|
+
);
|
|
534
|
+
const q = query(
|
|
535
|
+
eventsRef,
|
|
536
|
+
where("syncStatus", "==", CalendarSyncStatus.EXTERNAL),
|
|
537
|
+
where("eventTime.start", ">=", Timestamp.fromDate(startDate)),
|
|
538
|
+
where("eventTime.start", "<=", Timestamp.fromDate(endDate))
|
|
539
|
+
);
|
|
540
|
+
|
|
541
|
+
const eventsSnapshot = await getDocs(q);
|
|
542
|
+
const events = eventsSnapshot.docs.map((doc) => ({
|
|
543
|
+
id: doc.id,
|
|
544
|
+
...doc.data(),
|
|
545
|
+
})) as CalendarEvent[];
|
|
546
|
+
|
|
547
|
+
// Get the doctor's synced calendars
|
|
548
|
+
const calendars =
|
|
549
|
+
await this.syncedCalendarsService.getPractitionerSyncedCalendars(
|
|
550
|
+
doctorId
|
|
551
|
+
);
|
|
552
|
+
const activeCalendars = calendars.filter((cal) => cal.isActive);
|
|
553
|
+
|
|
554
|
+
if (activeCalendars.length === 0 || events.length === 0) {
|
|
555
|
+
return 0;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
let updatedCount = 0;
|
|
559
|
+
|
|
560
|
+
// For each external event, check if it needs updating
|
|
561
|
+
for (const event of events) {
|
|
562
|
+
// Skip events without sync IDs
|
|
563
|
+
if (!event.syncedCalendarEventId?.length) continue;
|
|
564
|
+
|
|
565
|
+
for (const syncId of event.syncedCalendarEventId) {
|
|
566
|
+
// Find the calendar for this sync ID
|
|
567
|
+
const calendar = activeCalendars.find(
|
|
568
|
+
(cal) => cal.provider === syncId.syncedCalendarProvider
|
|
569
|
+
);
|
|
570
|
+
if (!calendar) continue;
|
|
571
|
+
|
|
572
|
+
// Check if the event exists and needs updating
|
|
573
|
+
if (syncId.syncedCalendarProvider === SyncedCalendarProvider.GOOGLE) {
|
|
574
|
+
try {
|
|
575
|
+
// Fetch the external event
|
|
576
|
+
const externalEvent = await this.fetchExternalEvent(
|
|
577
|
+
doctorId,
|
|
578
|
+
calendar,
|
|
579
|
+
syncId.eventId
|
|
580
|
+
);
|
|
581
|
+
|
|
582
|
+
// If the event was found, check if it's different from our local copy
|
|
583
|
+
if (externalEvent) {
|
|
584
|
+
// Compare basic properties (time, title, description)
|
|
585
|
+
const externalStartTime = new Date(
|
|
586
|
+
externalEvent.start.dateTime || externalEvent.start.date
|
|
587
|
+
).getTime();
|
|
588
|
+
const externalEndTime = new Date(
|
|
589
|
+
externalEvent.end.dateTime || externalEvent.end.date
|
|
590
|
+
).getTime();
|
|
591
|
+
const localStartTime = event.eventTime.start.toDate().getTime();
|
|
592
|
+
const localEndTime = event.eventTime.end.toDate().getTime();
|
|
593
|
+
|
|
594
|
+
// If times or title/description have changed, update our local copy
|
|
595
|
+
if (
|
|
596
|
+
externalStartTime !== localStartTime ||
|
|
597
|
+
externalEndTime !== localEndTime ||
|
|
598
|
+
externalEvent.summary !== event.eventName ||
|
|
599
|
+
externalEvent.description !== event.description
|
|
600
|
+
) {
|
|
601
|
+
// Update our local copy
|
|
602
|
+
await this.updateLocalEventFromExternal(
|
|
603
|
+
doctorId,
|
|
604
|
+
event.id,
|
|
605
|
+
externalEvent
|
|
606
|
+
);
|
|
607
|
+
updatedCount++;
|
|
608
|
+
}
|
|
609
|
+
} else {
|
|
610
|
+
// The event was deleted in the external calendar, mark it as canceled
|
|
611
|
+
await this.updateEventStatus(
|
|
612
|
+
doctorId,
|
|
613
|
+
event.id,
|
|
614
|
+
CalendarEventStatus.CANCELED
|
|
615
|
+
);
|
|
616
|
+
updatedCount++;
|
|
617
|
+
}
|
|
618
|
+
} catch (error) {
|
|
619
|
+
console.error(
|
|
620
|
+
`Error updating external event ${event.id}:`,
|
|
621
|
+
error
|
|
622
|
+
);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
return updatedCount;
|
|
629
|
+
} catch (error) {
|
|
630
|
+
console.error(
|
|
631
|
+
"Error updating existing events from external calendars:",
|
|
632
|
+
error
|
|
633
|
+
);
|
|
634
|
+
return 0;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* Fetches a single external event from Google Calendar
|
|
640
|
+
* @param doctorId - ID of the doctor
|
|
641
|
+
* @param calendar - Calendar information
|
|
642
|
+
* @param externalEventId - ID of the external event
|
|
643
|
+
* @returns External event data or null if not found
|
|
644
|
+
*/
|
|
645
|
+
private async fetchExternalEvent(
|
|
646
|
+
doctorId: string,
|
|
647
|
+
calendar: any,
|
|
648
|
+
externalEventId: string
|
|
649
|
+
): Promise<any | null> {
|
|
650
|
+
try {
|
|
651
|
+
if (calendar.provider === SyncedCalendarProvider.GOOGLE) {
|
|
652
|
+
// Refresh token if needed
|
|
653
|
+
// We're using the syncPractitionerEventsToGoogleCalendar to get the calendar with a refreshed token
|
|
654
|
+
const result =
|
|
655
|
+
await this.syncedCalendarsService.fetchEventFromPractitionerGoogleCalendar(
|
|
656
|
+
doctorId,
|
|
657
|
+
calendar.id,
|
|
658
|
+
externalEventId
|
|
659
|
+
);
|
|
660
|
+
|
|
661
|
+
return result;
|
|
662
|
+
}
|
|
663
|
+
return null;
|
|
664
|
+
} catch (error) {
|
|
665
|
+
console.error(`Error fetching external event ${externalEventId}:`, error);
|
|
666
|
+
return null;
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* Updates a local event with data from an external event
|
|
672
|
+
* @param doctorId - ID of the doctor
|
|
673
|
+
* @param eventId - ID of the local event
|
|
674
|
+
* @param externalEvent - External event data
|
|
675
|
+
*/
|
|
676
|
+
private async updateLocalEventFromExternal(
|
|
677
|
+
doctorId: string,
|
|
678
|
+
eventId: string,
|
|
679
|
+
externalEvent: any
|
|
680
|
+
): Promise<void> {
|
|
681
|
+
try {
|
|
682
|
+
// Create event time from external event
|
|
683
|
+
const startTime = new Date(
|
|
684
|
+
externalEvent.start.dateTime || externalEvent.start.date
|
|
685
|
+
);
|
|
686
|
+
const endTime = new Date(
|
|
687
|
+
externalEvent.end.dateTime || externalEvent.end.date
|
|
688
|
+
);
|
|
689
|
+
|
|
690
|
+
// Update the local event
|
|
691
|
+
const eventRef = doc(
|
|
692
|
+
this.db,
|
|
693
|
+
PRACTITIONERS_COLLECTION,
|
|
694
|
+
doctorId,
|
|
695
|
+
CALENDAR_COLLECTION,
|
|
696
|
+
eventId
|
|
697
|
+
);
|
|
698
|
+
|
|
699
|
+
await updateDoc(eventRef, {
|
|
700
|
+
eventName: externalEvent.summary || "External Event",
|
|
701
|
+
eventTime: {
|
|
702
|
+
start: Timestamp.fromDate(startTime),
|
|
703
|
+
end: Timestamp.fromDate(endTime),
|
|
704
|
+
},
|
|
705
|
+
description: externalEvent.description || "",
|
|
706
|
+
updatedAt: serverTimestamp(),
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
console.log(`Updated local event ${eventId} from external event`);
|
|
710
|
+
} catch (error) {
|
|
711
|
+
console.error(
|
|
712
|
+
`Error updating local event ${eventId} from external:`,
|
|
713
|
+
error
|
|
714
|
+
);
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* Updates an event's status
|
|
720
|
+
* @param doctorId - ID of the doctor
|
|
721
|
+
* @param eventId - ID of the event
|
|
722
|
+
* @param status - New status
|
|
723
|
+
*/
|
|
724
|
+
private async updateEventStatus(
|
|
725
|
+
doctorId: string,
|
|
726
|
+
eventId: string,
|
|
727
|
+
status: CalendarEventStatus
|
|
728
|
+
): Promise<void> {
|
|
729
|
+
try {
|
|
730
|
+
const eventRef = doc(
|
|
731
|
+
this.db,
|
|
732
|
+
PRACTITIONERS_COLLECTION,
|
|
733
|
+
doctorId,
|
|
734
|
+
CALENDAR_COLLECTION,
|
|
735
|
+
eventId
|
|
736
|
+
);
|
|
737
|
+
|
|
738
|
+
await updateDoc(eventRef, {
|
|
739
|
+
status,
|
|
740
|
+
updatedAt: serverTimestamp(),
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
console.log(`Updated event ${eventId} status to ${status}`);
|
|
744
|
+
} catch (error) {
|
|
745
|
+
console.error(`Error updating event ${eventId} status:`, error);
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/**
|
|
750
|
+
* Creates a scheduled job to periodically sync external calendars
|
|
751
|
+
* Note: This would be implemented using Cloud Functions in a real application
|
|
752
|
+
* This is a sample implementation to show how it could be set up
|
|
753
|
+
* @param interval - Interval in hours
|
|
754
|
+
*/
|
|
755
|
+
createScheduledSyncJob(interval: number = 3): void {
|
|
756
|
+
// This is a simplified implementation
|
|
757
|
+
// In a real application, you would use Cloud Functions with Pub/Sub
|
|
758
|
+
console.log(
|
|
759
|
+
`Setting up scheduled calendar sync job every ${interval} hours`
|
|
760
|
+
);
|
|
761
|
+
|
|
762
|
+
// Example cloud function implementation:
|
|
763
|
+
/*
|
|
764
|
+
// Using Firebase Cloud Functions (in index.ts)
|
|
765
|
+
export const syncExternalCalendars = functions.pubsub
|
|
766
|
+
.schedule('every 3 hours')
|
|
767
|
+
.onRun(async (context) => {
|
|
768
|
+
try {
|
|
769
|
+
const db = admin.firestore();
|
|
770
|
+
const auth = admin.auth();
|
|
771
|
+
const app = admin.app();
|
|
772
|
+
|
|
773
|
+
const calendarService = new CalendarServiceV2(db, auth, app);
|
|
774
|
+
await calendarService.synchronizeExternalCalendars();
|
|
775
|
+
|
|
776
|
+
console.log('External calendar sync completed successfully');
|
|
777
|
+
return null;
|
|
778
|
+
} catch (error) {
|
|
779
|
+
console.error('Error in calendar sync job:', error);
|
|
780
|
+
return null;
|
|
781
|
+
}
|
|
782
|
+
});
|
|
783
|
+
*/
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/**
|
|
787
|
+
* Searches for calendar events based on specified criteria.
|
|
788
|
+
*
|
|
789
|
+
* @param {SearchCalendarEventsParams} params - The search parameters.
|
|
790
|
+
* @param {SearchLocationEnum} params.searchLocation - The primary location to search (practitioner, patient, or clinic).
|
|
791
|
+
* @param {string} params.entityId - The ID of the entity (practitioner, patient, or clinic) to search within/for.
|
|
792
|
+
* @param {string} [params.clinicId] - Optional clinic ID to filter by.
|
|
793
|
+
* @param {string} [params.practitionerId] - Optional practitioner ID to filter by.
|
|
794
|
+
* @param {string} [params.patientId] - Optional patient ID to filter by.
|
|
795
|
+
* @param {string} [params.procedureId] - Optional procedure ID to filter by.
|
|
796
|
+
* @param {DateRange} [params.dateRange] - Optional date range to filter by (event start time).
|
|
797
|
+
* @param {CalendarEventStatus} [params.eventStatus] - Optional event status to filter by.
|
|
798
|
+
* @param {CalendarEventType} [params.eventType] - Optional event type to filter by.
|
|
799
|
+
* @returns {Promise<CalendarEvent[]>} A promise that resolves to an array of matching calendar events.
|
|
800
|
+
* @throws {Error} If the search location requires an entity ID that is not provided.
|
|
801
|
+
*/
|
|
802
|
+
async searchCalendarEvents(
|
|
803
|
+
params: SearchCalendarEventsParams
|
|
804
|
+
): Promise<CalendarEvent[]> {
|
|
805
|
+
// Use the utility function to perform the search
|
|
806
|
+
return searchCalendarEventsUtil(this.db, params);
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
/**
|
|
810
|
+
* Gets a doctor's upcoming appointments for a specific date range
|
|
811
|
+
*
|
|
812
|
+
* @param {string} doctorId - ID of the practitioner
|
|
813
|
+
* @param {Date} startDate - Start date of the range
|
|
814
|
+
* @param {Date} endDate - End date of the range
|
|
815
|
+
* @param {CalendarEventStatus} [status] - Optional status filter (defaults to CONFIRMED)
|
|
816
|
+
* @returns {Promise<CalendarEvent[]>} A promise that resolves to an array of appointments
|
|
817
|
+
*/
|
|
818
|
+
async getPractitionerUpcomingAppointments(
|
|
819
|
+
doctorId: string,
|
|
820
|
+
startDate: Date,
|
|
821
|
+
endDate: Date,
|
|
822
|
+
status: CalendarEventStatus = CalendarEventStatus.CONFIRMED
|
|
823
|
+
): Promise<CalendarEvent[]> {
|
|
824
|
+
// Create a date range for the query
|
|
825
|
+
const dateRange: DateRange = {
|
|
826
|
+
start: Timestamp.fromDate(startDate),
|
|
827
|
+
end: Timestamp.fromDate(endDate),
|
|
828
|
+
};
|
|
829
|
+
|
|
830
|
+
// Create the search parameters
|
|
831
|
+
const searchParams: SearchCalendarEventsParams = {
|
|
832
|
+
searchLocation: SearchLocationEnum.PRACTITIONER,
|
|
833
|
+
entityId: doctorId,
|
|
834
|
+
dateRange,
|
|
835
|
+
eventStatus: status,
|
|
836
|
+
eventType: CalendarEventType.APPOINTMENT,
|
|
837
|
+
};
|
|
838
|
+
|
|
839
|
+
// Search for the appointments
|
|
840
|
+
return this.searchCalendarEvents(searchParams);
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
/**
|
|
844
|
+
* Gets a patient's appointments for a specific date range
|
|
845
|
+
*
|
|
846
|
+
* @param {string} patientId - ID of the patient
|
|
847
|
+
* @param {Date} startDate - Start date of the range
|
|
848
|
+
* @param {Date} endDate - End date of the range
|
|
849
|
+
* @param {CalendarEventStatus} [status] - Optional status filter (defaults to all non-canceled appointments)
|
|
850
|
+
* @returns {Promise<CalendarEvent[]>} A promise that resolves to an array of appointments
|
|
851
|
+
*/
|
|
852
|
+
async getPatientAppointments(
|
|
853
|
+
patientId: string,
|
|
854
|
+
startDate: Date,
|
|
855
|
+
endDate: Date,
|
|
856
|
+
status?: CalendarEventStatus
|
|
857
|
+
): Promise<CalendarEvent[]> {
|
|
858
|
+
// Create a date range for the query
|
|
859
|
+
const dateRange: DateRange = {
|
|
860
|
+
start: Timestamp.fromDate(startDate),
|
|
861
|
+
end: Timestamp.fromDate(endDate),
|
|
862
|
+
};
|
|
863
|
+
|
|
864
|
+
// Create the search parameters
|
|
865
|
+
const searchParams: SearchCalendarEventsParams = {
|
|
866
|
+
searchLocation: SearchLocationEnum.PATIENT,
|
|
867
|
+
entityId: patientId,
|
|
868
|
+
dateRange,
|
|
869
|
+
eventType: CalendarEventType.APPOINTMENT,
|
|
870
|
+
};
|
|
871
|
+
|
|
872
|
+
// Add status filter if provided
|
|
873
|
+
if (status) {
|
|
874
|
+
searchParams.eventStatus = status;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
// Search for the appointments
|
|
878
|
+
return this.searchCalendarEvents(searchParams);
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
/**
|
|
882
|
+
* Gets all appointments for a clinic within a specific date range
|
|
883
|
+
*
|
|
884
|
+
* @param {string} clinicId - ID of the clinic
|
|
885
|
+
* @param {Date} startDate - Start date of the range
|
|
886
|
+
* @param {Date} endDate - End date of the range
|
|
887
|
+
* @param {string} [doctorId] - Optional doctor ID to filter by
|
|
888
|
+
* @param {CalendarEventStatus} [status] - Optional status filter
|
|
889
|
+
* @returns {Promise<CalendarEvent[]>} A promise that resolves to an array of appointments
|
|
890
|
+
*/
|
|
891
|
+
async getClinicAppointments(
|
|
892
|
+
clinicId: string,
|
|
893
|
+
startDate: Date,
|
|
894
|
+
endDate: Date,
|
|
895
|
+
doctorId?: string,
|
|
896
|
+
status?: CalendarEventStatus
|
|
897
|
+
): Promise<CalendarEvent[]> {
|
|
898
|
+
// Create a date range for the query
|
|
899
|
+
const dateRange: DateRange = {
|
|
900
|
+
start: Timestamp.fromDate(startDate),
|
|
901
|
+
end: Timestamp.fromDate(endDate),
|
|
902
|
+
};
|
|
903
|
+
|
|
904
|
+
// Create the search parameters
|
|
905
|
+
const searchParams: SearchCalendarEventsParams = {
|
|
906
|
+
searchLocation: SearchLocationEnum.CLINIC,
|
|
907
|
+
entityId: clinicId,
|
|
908
|
+
dateRange,
|
|
909
|
+
eventType: CalendarEventType.APPOINTMENT,
|
|
910
|
+
};
|
|
911
|
+
|
|
912
|
+
// Add doctor filter if provided
|
|
913
|
+
if (doctorId) {
|
|
914
|
+
searchParams.practitionerId = doctorId;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
// Add status filter if provided
|
|
918
|
+
if (status) {
|
|
919
|
+
searchParams.eventStatus = status;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
// Search for the appointments
|
|
923
|
+
return this.searchCalendarEvents(searchParams);
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
// #endregion
|
|
927
|
+
|
|
928
|
+
// #region Private Helper Methods
|
|
929
|
+
|
|
930
|
+
/**
|
|
931
|
+
* Validates appointment creation parameters
|
|
932
|
+
* @param params - Appointment parameters to validate
|
|
933
|
+
* @throws Error if validation fails
|
|
934
|
+
*/
|
|
935
|
+
private async validateAppointmentParams(
|
|
936
|
+
params: CreateAppointmentParams
|
|
937
|
+
): Promise<void> {
|
|
938
|
+
// TODO: Add custom validation logic after Zod schema validation
|
|
939
|
+
// - Check if doctor works at the clinic
|
|
940
|
+
// - Check if procedure is available at the clinic
|
|
941
|
+
// - Check if patient is eligible for the procedure
|
|
942
|
+
// - Validate time slot (15-minute increments)
|
|
943
|
+
// - Check clinic's subscription status
|
|
944
|
+
// - Check if auto-confirm is enabled
|
|
945
|
+
|
|
946
|
+
// Validate basic parameters using Zod schema
|
|
947
|
+
await createAppointmentSchema.parseAsync(params);
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
/**
|
|
951
|
+
* Validates if the event time falls within clinic working hours
|
|
952
|
+
* @param clinicId - ID of the clinic
|
|
953
|
+
* @param eventTime - Event time to validate
|
|
954
|
+
* @throws Error if validation fails
|
|
955
|
+
*/
|
|
956
|
+
private async validateClinicWorkingHours(
|
|
957
|
+
clinicId: string,
|
|
958
|
+
eventTime: CalendarEventTime
|
|
959
|
+
): Promise<void> {
|
|
960
|
+
// Get clinic working hours for the day
|
|
961
|
+
const startDate = eventTime.start.toDate();
|
|
962
|
+
const workingHours = await this.getClinicWorkingHours(clinicId, startDate);
|
|
963
|
+
|
|
964
|
+
if (workingHours.length === 0) {
|
|
965
|
+
throw new Error("Clinic is not open on this day");
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
// Find if the appointment time falls within any working hours slot
|
|
969
|
+
const startTime = startDate;
|
|
970
|
+
const endTime = eventTime.end.toDate();
|
|
971
|
+
const isWithinWorkingHours = workingHours.some((slot) => {
|
|
972
|
+
return slot.start <= startTime && slot.end >= endTime && slot.isAvailable;
|
|
973
|
+
});
|
|
974
|
+
|
|
975
|
+
if (!isWithinWorkingHours) {
|
|
976
|
+
throw new Error("Appointment time is outside clinic working hours");
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
/**
|
|
981
|
+
* Validates if the doctor is available during the event time
|
|
982
|
+
* @param doctorId - ID of the doctor
|
|
983
|
+
* @param eventTime - Event time to validate
|
|
984
|
+
* @param clinicId - ID of the clinic where the appointment is being booked
|
|
985
|
+
* @throws Error if validation fails
|
|
986
|
+
*/
|
|
987
|
+
private async validateDoctorAvailability(
|
|
988
|
+
doctorId: string,
|
|
989
|
+
eventTime: CalendarEventTime,
|
|
990
|
+
clinicId: string
|
|
991
|
+
): Promise<void> {
|
|
992
|
+
const startDate = eventTime.start.toDate();
|
|
993
|
+
const startTime = startDate;
|
|
994
|
+
const endTime = eventTime.end.toDate();
|
|
995
|
+
|
|
996
|
+
// Get doctor's document to check clinic-specific working hours
|
|
997
|
+
const practitionerRef = doc(this.db, PRACTITIONERS_COLLECTION, doctorId);
|
|
998
|
+
const practitionerDoc = await getDoc(practitionerRef);
|
|
999
|
+
|
|
1000
|
+
if (!practitionerDoc.exists()) {
|
|
1001
|
+
throw new Error(`Doctor with ID ${doctorId} not found`);
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
const practitioner = practitionerDoc.data();
|
|
1005
|
+
|
|
1006
|
+
// Check if doctor works at the specified clinic
|
|
1007
|
+
if (!practitioner.clinics.includes(clinicId)) {
|
|
1008
|
+
throw new Error("Doctor does not work at this clinic");
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
// Get doctor's clinic-specific working hours
|
|
1012
|
+
const clinicWorkingHours = practitioner.clinicWorkingHours?.find(
|
|
1013
|
+
(hours: PractitionerClinicWorkingHours) =>
|
|
1014
|
+
hours.clinicId === clinicId && hours.isActive
|
|
1015
|
+
);
|
|
1016
|
+
|
|
1017
|
+
if (!clinicWorkingHours) {
|
|
1018
|
+
throw new Error("Doctor does not have working hours set for this clinic");
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
// Get the day of the week (0 = Sunday, 1 = Monday, etc.)
|
|
1022
|
+
const dayOfWeek = startDate.getDay();
|
|
1023
|
+
const dayKey = [
|
|
1024
|
+
"sunday",
|
|
1025
|
+
"monday",
|
|
1026
|
+
"tuesday",
|
|
1027
|
+
"wednesday",
|
|
1028
|
+
"thursday",
|
|
1029
|
+
"friday",
|
|
1030
|
+
"saturday",
|
|
1031
|
+
][dayOfWeek];
|
|
1032
|
+
const daySchedule = clinicWorkingHours.workingHours[dayKey];
|
|
1033
|
+
|
|
1034
|
+
if (!daySchedule) {
|
|
1035
|
+
throw new Error("Doctor is not working on this day at this clinic");
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
// Convert working hours to Date objects for comparison
|
|
1039
|
+
const [startHour, startMinute] = daySchedule.start.split(":").map(Number);
|
|
1040
|
+
const [endHour, endMinute] = daySchedule.end.split(":").map(Number);
|
|
1041
|
+
|
|
1042
|
+
const scheduleStart = new Date(startDate);
|
|
1043
|
+
scheduleStart.setHours(startHour, startMinute, 0, 0);
|
|
1044
|
+
|
|
1045
|
+
const scheduleEnd = new Date(startDate);
|
|
1046
|
+
scheduleEnd.setHours(endHour, endMinute, 0, 0);
|
|
1047
|
+
|
|
1048
|
+
// Check if the appointment time is within doctor's working hours
|
|
1049
|
+
if (startTime < scheduleStart || endTime > scheduleEnd) {
|
|
1050
|
+
throw new Error(
|
|
1051
|
+
"Appointment time is outside doctor's working hours at this clinic"
|
|
1052
|
+
);
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
// Get existing appointments
|
|
1056
|
+
const appointments = await this.getDoctorAppointments(doctorId, startDate);
|
|
1057
|
+
|
|
1058
|
+
// Check for overlapping appointments
|
|
1059
|
+
const hasOverlap = appointments.some((appointment) => {
|
|
1060
|
+
const appointmentStart = appointment.eventTime.start.toDate();
|
|
1061
|
+
const appointmentEnd = appointment.eventTime.end.toDate();
|
|
1062
|
+
return (
|
|
1063
|
+
(startTime >= appointmentStart && startTime < appointmentEnd) ||
|
|
1064
|
+
(endTime > appointmentStart && endTime <= appointmentEnd) ||
|
|
1065
|
+
(startTime <= appointmentStart && endTime >= appointmentEnd)
|
|
1066
|
+
);
|
|
1067
|
+
});
|
|
1068
|
+
|
|
1069
|
+
if (hasOverlap) {
|
|
1070
|
+
throw new Error("Doctor has another appointment during this time");
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
/**
|
|
1075
|
+
* Updates appointment status
|
|
1076
|
+
* @param appointmentId - ID of the appointment
|
|
1077
|
+
* @param clinicId - ID of the clinic
|
|
1078
|
+
* @param status - New status
|
|
1079
|
+
* @returns Updated calendar event
|
|
1080
|
+
*/
|
|
1081
|
+
private async updateAppointmentStatus(
|
|
1082
|
+
appointmentId: string,
|
|
1083
|
+
clinicId: string,
|
|
1084
|
+
status: CalendarEventStatus
|
|
1085
|
+
): Promise<CalendarEvent> {
|
|
1086
|
+
// Get the appointment
|
|
1087
|
+
const baseCollectionPath = `${CLINICS_COLLECTION}/${clinicId}/${CALENDAR_COLLECTION}`;
|
|
1088
|
+
const appointmentRef = doc(this.db, baseCollectionPath, appointmentId);
|
|
1089
|
+
const appointmentDoc = await getDoc(appointmentRef);
|
|
1090
|
+
|
|
1091
|
+
if (!appointmentDoc.exists()) {
|
|
1092
|
+
throw new Error(`Appointment with ID ${appointmentId} not found`);
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
const appointment = appointmentDoc.data() as CalendarEvent;
|
|
1096
|
+
|
|
1097
|
+
// Validate that the appointment belongs to the specified clinic
|
|
1098
|
+
if (appointment.clinicBranchId !== clinicId) {
|
|
1099
|
+
throw new Error("Appointment does not belong to the specified clinic");
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
// Validate the status transition
|
|
1103
|
+
this.validateStatusTransition(appointment.status, status);
|
|
1104
|
+
|
|
1105
|
+
// Update the appointment
|
|
1106
|
+
const updateParams: UpdateAppointmentParams = {
|
|
1107
|
+
appointmentId,
|
|
1108
|
+
clinicId,
|
|
1109
|
+
eventTime: appointment.eventTime,
|
|
1110
|
+
description: appointment.description || "",
|
|
1111
|
+
doctorId: appointment.practitionerProfileId || "",
|
|
1112
|
+
patientId: appointment.patientProfileId || "",
|
|
1113
|
+
status,
|
|
1114
|
+
};
|
|
1115
|
+
|
|
1116
|
+
// Validate update parameters
|
|
1117
|
+
await this.validateUpdatePermissions(updateParams);
|
|
1118
|
+
|
|
1119
|
+
// Update the appointment
|
|
1120
|
+
return this.updateAppointment(updateParams);
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
/**
|
|
1124
|
+
* Validates status transition
|
|
1125
|
+
* @param currentStatus - Current status
|
|
1126
|
+
* @param newStatus - New status
|
|
1127
|
+
* @throws Error if transition is invalid
|
|
1128
|
+
*/
|
|
1129
|
+
private validateStatusTransition(
|
|
1130
|
+
currentStatus: CalendarEventStatus,
|
|
1131
|
+
newStatus: CalendarEventStatus
|
|
1132
|
+
): void {
|
|
1133
|
+
// Define valid status transitions
|
|
1134
|
+
const validTransitions: Record<CalendarEventStatus, CalendarEventStatus[]> =
|
|
1135
|
+
{
|
|
1136
|
+
[CalendarEventStatus.PENDING]: [
|
|
1137
|
+
CalendarEventStatus.CONFIRMED,
|
|
1138
|
+
CalendarEventStatus.REJECTED,
|
|
1139
|
+
CalendarEventStatus.CANCELED,
|
|
1140
|
+
],
|
|
1141
|
+
[CalendarEventStatus.CONFIRMED]: [
|
|
1142
|
+
CalendarEventStatus.CANCELED,
|
|
1143
|
+
CalendarEventStatus.COMPLETED,
|
|
1144
|
+
CalendarEventStatus.RESCHEDULED,
|
|
1145
|
+
CalendarEventStatus.NO_SHOW,
|
|
1146
|
+
],
|
|
1147
|
+
[CalendarEventStatus.REJECTED]: [],
|
|
1148
|
+
[CalendarEventStatus.CANCELED]: [],
|
|
1149
|
+
[CalendarEventStatus.RESCHEDULED]: [
|
|
1150
|
+
CalendarEventStatus.CONFIRMED,
|
|
1151
|
+
CalendarEventStatus.CANCELED,
|
|
1152
|
+
],
|
|
1153
|
+
[CalendarEventStatus.COMPLETED]: [],
|
|
1154
|
+
[CalendarEventStatus.NO_SHOW]: [],
|
|
1155
|
+
};
|
|
1156
|
+
|
|
1157
|
+
// Check if transition is valid
|
|
1158
|
+
if (!validTransitions[currentStatus].includes(newStatus)) {
|
|
1159
|
+
throw new Error(
|
|
1160
|
+
`Invalid status transition from ${currentStatus} to ${newStatus}`
|
|
1161
|
+
);
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
/**
|
|
1166
|
+
* Syncs appointment with external calendars based on entity type and status
|
|
1167
|
+
* @param appointment - Calendar event to sync
|
|
1168
|
+
*/
|
|
1169
|
+
private async syncAppointmentWithExternalCalendars(
|
|
1170
|
+
appointment: CalendarEvent
|
|
1171
|
+
): Promise<void> {
|
|
1172
|
+
if (!appointment.practitionerProfileId || !appointment.patientProfileId) {
|
|
1173
|
+
return;
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
try {
|
|
1177
|
+
// Get synced calendars for doctor and patient (no longer sync with clinic)
|
|
1178
|
+
const [doctorCalendars, patientCalendars] = await Promise.all([
|
|
1179
|
+
this.syncedCalendarsService.getPractitionerSyncedCalendars(
|
|
1180
|
+
appointment.practitionerProfileId
|
|
1181
|
+
),
|
|
1182
|
+
this.syncedCalendarsService.getPatientSyncedCalendars(
|
|
1183
|
+
appointment.patientProfileId
|
|
1184
|
+
),
|
|
1185
|
+
]);
|
|
1186
|
+
|
|
1187
|
+
// Filter active calendars
|
|
1188
|
+
const activeDoctorCalendars = doctorCalendars.filter(
|
|
1189
|
+
(cal) => cal.isActive
|
|
1190
|
+
);
|
|
1191
|
+
const activePatientCalendars = patientCalendars.filter(
|
|
1192
|
+
(cal) => cal.isActive
|
|
1193
|
+
);
|
|
1194
|
+
|
|
1195
|
+
// Skip if there are no active calendars
|
|
1196
|
+
if (
|
|
1197
|
+
activeDoctorCalendars.length === 0 &&
|
|
1198
|
+
activePatientCalendars.length === 0
|
|
1199
|
+
) {
|
|
1200
|
+
return;
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
// Only sync INTERNAL events (those created within our system)
|
|
1204
|
+
if (appointment.syncStatus !== CalendarSyncStatus.INTERNAL) {
|
|
1205
|
+
return;
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
// For doctors: Only sync CONFIRMED status events
|
|
1209
|
+
if (
|
|
1210
|
+
appointment.status === CalendarEventStatus.CONFIRMED &&
|
|
1211
|
+
activeDoctorCalendars.length > 0
|
|
1212
|
+
) {
|
|
1213
|
+
await Promise.all(
|
|
1214
|
+
activeDoctorCalendars.map((calendar) =>
|
|
1215
|
+
this.syncEventToExternalCalendar(appointment, calendar, "doctor")
|
|
1216
|
+
)
|
|
1217
|
+
);
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
// For patients: Sync all events EXCEPT CANCELED and REJECTED
|
|
1221
|
+
if (
|
|
1222
|
+
appointment.status !== CalendarEventStatus.CANCELED &&
|
|
1223
|
+
appointment.status !== CalendarEventStatus.REJECTED &&
|
|
1224
|
+
activePatientCalendars.length > 0
|
|
1225
|
+
) {
|
|
1226
|
+
await Promise.all(
|
|
1227
|
+
activePatientCalendars.map((calendar) =>
|
|
1228
|
+
this.syncEventToExternalCalendar(appointment, calendar, "patient")
|
|
1229
|
+
)
|
|
1230
|
+
);
|
|
1231
|
+
}
|
|
1232
|
+
} catch (error) {
|
|
1233
|
+
console.error("Error syncing with external calendars:", error);
|
|
1234
|
+
// Don't throw error as this is not critical for appointment creation
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
/**
|
|
1239
|
+
* Syncs a single event to an external calendar
|
|
1240
|
+
* @param appointment - Calendar event to sync
|
|
1241
|
+
* @param calendar - External calendar to sync with
|
|
1242
|
+
* @param entityType - Type of entity owning the calendar
|
|
1243
|
+
*/
|
|
1244
|
+
private async syncEventToExternalCalendar(
|
|
1245
|
+
appointment: CalendarEvent,
|
|
1246
|
+
calendar: any,
|
|
1247
|
+
entityType: "doctor" | "patient"
|
|
1248
|
+
): Promise<void> {
|
|
1249
|
+
try {
|
|
1250
|
+
// Create a copy of the appointment to modify for external syncing
|
|
1251
|
+
const eventToSync = { ...appointment };
|
|
1252
|
+
|
|
1253
|
+
// Prepare event title based on status and entity type
|
|
1254
|
+
let eventTitle = appointment.eventName;
|
|
1255
|
+
const clinicName = appointment.clinicBranchInfo?.name || "Clinic";
|
|
1256
|
+
|
|
1257
|
+
// Format title appropriately
|
|
1258
|
+
if (entityType === "patient") {
|
|
1259
|
+
eventTitle = `[${appointment.status}] ${eventTitle} @ ${clinicName}`;
|
|
1260
|
+
} else {
|
|
1261
|
+
eventTitle = `${eventTitle} - Patient: ${
|
|
1262
|
+
appointment.patientProfileInfo?.fullName || "Unknown"
|
|
1263
|
+
} @ ${clinicName}`;
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
// Update the event name for external sync
|
|
1267
|
+
eventToSync.eventName = eventTitle;
|
|
1268
|
+
|
|
1269
|
+
// Check if this event was previously synced with this calendar
|
|
1270
|
+
const existingSyncId = appointment.syncedCalendarEventId?.find(
|
|
1271
|
+
(sync) => sync.syncedCalendarProvider === calendar.provider
|
|
1272
|
+
)?.eventId;
|
|
1273
|
+
|
|
1274
|
+
// If we have a synced event ID, we should update the existing event
|
|
1275
|
+
// If not, create a new event
|
|
1276
|
+
|
|
1277
|
+
if (calendar.provider === SyncedCalendarProvider.GOOGLE) {
|
|
1278
|
+
const result =
|
|
1279
|
+
await this.syncedCalendarsService.syncPractitionerEventsToGoogleCalendar(
|
|
1280
|
+
entityType === "doctor"
|
|
1281
|
+
? appointment.practitionerProfileId!
|
|
1282
|
+
: appointment.patientProfileId!,
|
|
1283
|
+
calendar.id,
|
|
1284
|
+
[eventToSync],
|
|
1285
|
+
existingSyncId // Pass existing sync ID if we have one
|
|
1286
|
+
);
|
|
1287
|
+
|
|
1288
|
+
// If sync was successful and we've created a new event (no existing sync),
|
|
1289
|
+
// we should update our local event with the new sync ID
|
|
1290
|
+
if (result.success && result.eventIds?.length && !existingSyncId) {
|
|
1291
|
+
// Update the appointment with the new sync ID
|
|
1292
|
+
const newSyncEvent: SyncedCalendarEvent = {
|
|
1293
|
+
eventId: result.eventIds[0],
|
|
1294
|
+
syncedCalendarProvider: calendar.provider,
|
|
1295
|
+
syncedAt: Timestamp.now(),
|
|
1296
|
+
};
|
|
1297
|
+
|
|
1298
|
+
// Update the event in the database with the new sync ID
|
|
1299
|
+
await this.updateEventWithSyncId(
|
|
1300
|
+
entityType === "doctor"
|
|
1301
|
+
? appointment.practitionerProfileId!
|
|
1302
|
+
: appointment.patientProfileId!,
|
|
1303
|
+
entityType,
|
|
1304
|
+
appointment.id,
|
|
1305
|
+
newSyncEvent
|
|
1306
|
+
);
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
} catch (error) {
|
|
1310
|
+
console.error(`Error syncing with ${entityType}'s calendar:`, error);
|
|
1311
|
+
// Don't throw error as this is not critical
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
/**
|
|
1316
|
+
* Updates an event with a new sync ID
|
|
1317
|
+
* @param entityId - ID of the entity (doctor or patient)
|
|
1318
|
+
* @param entityType - Type of entity
|
|
1319
|
+
* @param eventId - ID of the event
|
|
1320
|
+
* @param syncEvent - Sync event information
|
|
1321
|
+
*/
|
|
1322
|
+
private async updateEventWithSyncId(
|
|
1323
|
+
entityId: string,
|
|
1324
|
+
entityType: "doctor" | "patient",
|
|
1325
|
+
eventId: string,
|
|
1326
|
+
syncEvent: SyncedCalendarEvent
|
|
1327
|
+
): Promise<void> {
|
|
1328
|
+
try {
|
|
1329
|
+
// Determine the collection path based on entity type
|
|
1330
|
+
const collectionPath =
|
|
1331
|
+
entityType === "doctor"
|
|
1332
|
+
? `${PRACTITIONERS_COLLECTION}/${entityId}/${CALENDAR_COLLECTION}`
|
|
1333
|
+
: `${PATIENTS_COLLECTION}/${entityId}/${CALENDAR_COLLECTION}`;
|
|
1334
|
+
|
|
1335
|
+
// Get the event reference
|
|
1336
|
+
const eventRef = doc(this.db, collectionPath, eventId);
|
|
1337
|
+
const eventDoc = await getDoc(eventRef);
|
|
1338
|
+
|
|
1339
|
+
if (eventDoc.exists()) {
|
|
1340
|
+
const event = eventDoc.data() as CalendarEvent;
|
|
1341
|
+
const syncIds = [...(event.syncedCalendarEventId || [])];
|
|
1342
|
+
|
|
1343
|
+
// Check if we already have this sync ID
|
|
1344
|
+
const existingSyncIndex = syncIds.findIndex(
|
|
1345
|
+
(sync) =>
|
|
1346
|
+
sync.syncedCalendarProvider === syncEvent.syncedCalendarProvider
|
|
1347
|
+
);
|
|
1348
|
+
|
|
1349
|
+
if (existingSyncIndex >= 0) {
|
|
1350
|
+
// Update the existing sync ID
|
|
1351
|
+
syncIds[existingSyncIndex] = syncEvent;
|
|
1352
|
+
} else {
|
|
1353
|
+
// Add the new sync ID
|
|
1354
|
+
syncIds.push(syncEvent);
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
// Update the event
|
|
1358
|
+
await updateDoc(eventRef, {
|
|
1359
|
+
syncedCalendarEventId: syncIds,
|
|
1360
|
+
updatedAt: serverTimestamp(),
|
|
1361
|
+
});
|
|
1362
|
+
|
|
1363
|
+
console.log(
|
|
1364
|
+
`Updated event ${eventId} with sync ID ${syncEvent.eventId}`
|
|
1365
|
+
);
|
|
1366
|
+
}
|
|
1367
|
+
} catch (error) {
|
|
1368
|
+
console.error("Error updating event with sync ID:", error);
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
/**
|
|
1373
|
+
* Validates update permissions and parameters
|
|
1374
|
+
* @param params - Update parameters to validate
|
|
1375
|
+
*/
|
|
1376
|
+
private async validateUpdatePermissions(
|
|
1377
|
+
params: UpdateAppointmentParams
|
|
1378
|
+
): Promise<void> {
|
|
1379
|
+
// TODO: Add custom validation logic after Zod schema validation
|
|
1380
|
+
// - Check if user has permission to update the appointment
|
|
1381
|
+
// - Check if the appointment exists
|
|
1382
|
+
// - Check if the new status transition is valid
|
|
1383
|
+
// - Check if the new time slot is valid
|
|
1384
|
+
// - Validate against clinic's business rules
|
|
1385
|
+
|
|
1386
|
+
// Validate basic parameters using Zod schema
|
|
1387
|
+
await updateAppointmentSchema.parseAsync(params);
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
/**
|
|
1391
|
+
* Gets clinic working hours for a specific date
|
|
1392
|
+
* @param clinicId - ID of the clinic
|
|
1393
|
+
* @param date - Date to get working hours for
|
|
1394
|
+
* @returns Working hours for the clinic
|
|
1395
|
+
*/
|
|
1396
|
+
private async getClinicWorkingHours(
|
|
1397
|
+
clinicId: string,
|
|
1398
|
+
date: Date
|
|
1399
|
+
): Promise<TimeSlot[]> {
|
|
1400
|
+
// Get clinic document
|
|
1401
|
+
const clinicRef = doc(this.db, CLINICS_COLLECTION, clinicId);
|
|
1402
|
+
const clinicDoc = await getDoc(clinicRef);
|
|
1403
|
+
|
|
1404
|
+
if (!clinicDoc.exists()) {
|
|
1405
|
+
throw new Error(`Clinic with ID ${clinicId} not found`);
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
// TODO: Implement proper working hours retrieval from clinic data model
|
|
1409
|
+
// For now, return default working hours (9 AM - 5 PM)
|
|
1410
|
+
const workingHours: TimeSlot[] = [];
|
|
1411
|
+
const dayOfWeek = date.getDay();
|
|
1412
|
+
|
|
1413
|
+
// Skip weekends (0 = Sunday, 6 = Saturday)
|
|
1414
|
+
if (dayOfWeek === 0 || dayOfWeek === 6) {
|
|
1415
|
+
return workingHours;
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
// Create working hours slot (9 AM - 5 PM)
|
|
1419
|
+
const workingDate = new Date(date);
|
|
1420
|
+
workingDate.setHours(9, 0, 0, 0);
|
|
1421
|
+
const startTime = new Date(workingDate);
|
|
1422
|
+
|
|
1423
|
+
workingDate.setHours(17, 0, 0, 0);
|
|
1424
|
+
const endTime = new Date(workingDate);
|
|
1425
|
+
|
|
1426
|
+
workingHours.push({
|
|
1427
|
+
start: startTime,
|
|
1428
|
+
end: endTime,
|
|
1429
|
+
isAvailable: true,
|
|
1430
|
+
});
|
|
1431
|
+
|
|
1432
|
+
return workingHours;
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
/**
|
|
1436
|
+
* Gets doctor's schedule for a specific date
|
|
1437
|
+
* @param doctorId - ID of the doctor
|
|
1438
|
+
* @param date - Date to get schedule for
|
|
1439
|
+
* @returns Doctor's schedule
|
|
1440
|
+
*/
|
|
1441
|
+
private async getDoctorSchedule(
|
|
1442
|
+
doctorId: string,
|
|
1443
|
+
date: Date
|
|
1444
|
+
): Promise<TimeSlot[]> {
|
|
1445
|
+
// Get doctor document
|
|
1446
|
+
const practitionerRef = doc(this.db, PRACTITIONERS_COLLECTION, doctorId);
|
|
1447
|
+
const practitionerDoc = await getDoc(practitionerRef);
|
|
1448
|
+
|
|
1449
|
+
if (!practitionerDoc.exists()) {
|
|
1450
|
+
throw new Error(`Doctor with ID ${doctorId} not found`);
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
// TODO: Implement proper schedule retrieval from practitioner data model
|
|
1454
|
+
// For now, return default schedule (9 AM - 5 PM)
|
|
1455
|
+
const schedule: TimeSlot[] = [];
|
|
1456
|
+
const dayOfWeek = date.getDay();
|
|
1457
|
+
|
|
1458
|
+
// Skip weekends (0 = Sunday, 6 = Saturday)
|
|
1459
|
+
if (dayOfWeek === 0 || dayOfWeek === 6) {
|
|
1460
|
+
return schedule;
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
// Create schedule slot (9 AM - 5 PM)
|
|
1464
|
+
const scheduleDate = new Date(date);
|
|
1465
|
+
scheduleDate.setHours(9, 0, 0, 0);
|
|
1466
|
+
const startTime = new Date(scheduleDate);
|
|
1467
|
+
|
|
1468
|
+
scheduleDate.setHours(17, 0, 0, 0);
|
|
1469
|
+
const endTime = new Date(scheduleDate);
|
|
1470
|
+
|
|
1471
|
+
schedule.push({
|
|
1472
|
+
start: startTime,
|
|
1473
|
+
end: endTime,
|
|
1474
|
+
isAvailable: true,
|
|
1475
|
+
});
|
|
1476
|
+
|
|
1477
|
+
return schedule;
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
/**
|
|
1481
|
+
* Gets doctor's appointments for a specific date
|
|
1482
|
+
* @param doctorId - ID of the doctor
|
|
1483
|
+
* @param date - Date to get appointments for
|
|
1484
|
+
* @returns Array of calendar events
|
|
1485
|
+
*/
|
|
1486
|
+
private async getDoctorAppointments(
|
|
1487
|
+
doctorId: string,
|
|
1488
|
+
date: Date
|
|
1489
|
+
): Promise<CalendarEvent[]> {
|
|
1490
|
+
// Create start and end timestamps for the day
|
|
1491
|
+
const startOfDay = new Date(date);
|
|
1492
|
+
startOfDay.setHours(0, 0, 0, 0);
|
|
1493
|
+
const endOfDay = new Date(date);
|
|
1494
|
+
endOfDay.setHours(23, 59, 59, 999);
|
|
1495
|
+
|
|
1496
|
+
// Query appointments for the doctor on the specified date
|
|
1497
|
+
const appointmentsRef = collection(this.db, CALENDAR_COLLECTION);
|
|
1498
|
+
const q = query(
|
|
1499
|
+
appointmentsRef,
|
|
1500
|
+
where("practitionerProfileId", "==", doctorId),
|
|
1501
|
+
where("eventTime.start", ">=", Timestamp.fromDate(startOfDay)),
|
|
1502
|
+
where("eventTime.start", "<=", Timestamp.fromDate(endOfDay)),
|
|
1503
|
+
where("status", "in", [
|
|
1504
|
+
CalendarEventStatus.CONFIRMED,
|
|
1505
|
+
CalendarEventStatus.PENDING,
|
|
1506
|
+
])
|
|
1507
|
+
);
|
|
1508
|
+
|
|
1509
|
+
const querySnapshot = await getDocs(q);
|
|
1510
|
+
return querySnapshot.docs.map((doc) => doc.data() as CalendarEvent);
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
/**
|
|
1514
|
+
* Calculates available time slots based on working hours, schedule and existing appointments
|
|
1515
|
+
* @param workingHours - Clinic working hours
|
|
1516
|
+
* @param doctorSchedule - Doctor's schedule
|
|
1517
|
+
* @param existingAppointments - Existing appointments
|
|
1518
|
+
* @returns Array of available time slots
|
|
1519
|
+
*/
|
|
1520
|
+
private calculateAvailableSlots(
|
|
1521
|
+
workingHours: TimeSlot[],
|
|
1522
|
+
doctorSchedule: TimeSlot[],
|
|
1523
|
+
existingAppointments: CalendarEvent[]
|
|
1524
|
+
): TimeSlot[] {
|
|
1525
|
+
const availableSlots: TimeSlot[] = [];
|
|
1526
|
+
|
|
1527
|
+
// First, find overlapping time slots between clinic hours and doctor schedule
|
|
1528
|
+
for (const workingHour of workingHours) {
|
|
1529
|
+
for (const scheduleSlot of doctorSchedule) {
|
|
1530
|
+
// Find overlap between working hours and doctor schedule
|
|
1531
|
+
const overlapStart = new Date(
|
|
1532
|
+
Math.max(workingHour.start.getTime(), scheduleSlot.start.getTime())
|
|
1533
|
+
);
|
|
1534
|
+
const overlapEnd = new Date(
|
|
1535
|
+
Math.min(workingHour.end.getTime(), scheduleSlot.end.getTime())
|
|
1536
|
+
);
|
|
1537
|
+
|
|
1538
|
+
// If there is an overlap and both slots are available
|
|
1539
|
+
if (
|
|
1540
|
+
overlapStart < overlapEnd &&
|
|
1541
|
+
workingHour.isAvailable &&
|
|
1542
|
+
scheduleSlot.isAvailable
|
|
1543
|
+
) {
|
|
1544
|
+
// Create 15-minute slots within the overlap period
|
|
1545
|
+
let slotStart = new Date(overlapStart);
|
|
1546
|
+
while (slotStart < overlapEnd) {
|
|
1547
|
+
const slotEnd = new Date(
|
|
1548
|
+
slotStart.getTime() + MIN_APPOINTMENT_DURATION * 60 * 1000
|
|
1549
|
+
);
|
|
1550
|
+
|
|
1551
|
+
// Check if this slot overlaps with any existing appointments
|
|
1552
|
+
const hasOverlap = existingAppointments.some((appointment) => {
|
|
1553
|
+
const appointmentStart = appointment.eventTime.start.toDate();
|
|
1554
|
+
const appointmentEnd = appointment.eventTime.end.toDate();
|
|
1555
|
+
return (
|
|
1556
|
+
(slotStart >= appointmentStart && slotStart < appointmentEnd) ||
|
|
1557
|
+
(slotEnd > appointmentStart && slotEnd <= appointmentEnd)
|
|
1558
|
+
);
|
|
1559
|
+
});
|
|
1560
|
+
|
|
1561
|
+
if (!hasOverlap && slotEnd <= overlapEnd) {
|
|
1562
|
+
availableSlots.push({
|
|
1563
|
+
start: new Date(slotStart),
|
|
1564
|
+
end: new Date(slotEnd),
|
|
1565
|
+
isAvailable: true,
|
|
1566
|
+
});
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
// Move to next slot
|
|
1570
|
+
slotStart = new Date(
|
|
1571
|
+
slotStart.getTime() + MIN_APPOINTMENT_DURATION * 60 * 1000
|
|
1572
|
+
);
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
return availableSlots;
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
/**
|
|
1582
|
+
* Fetches and creates info cards for clinic, doctor, and patient profiles
|
|
1583
|
+
* @param clinicId - ID of the clinic
|
|
1584
|
+
* @param doctorId - ID of the doctor
|
|
1585
|
+
* @param patientId - ID of the patient
|
|
1586
|
+
* @returns Object containing info cards for all profiles
|
|
1587
|
+
*/
|
|
1588
|
+
private async fetchProfileInfoCards(
|
|
1589
|
+
clinicId: string,
|
|
1590
|
+
doctorId: string,
|
|
1591
|
+
patientId: string
|
|
1592
|
+
): Promise<{
|
|
1593
|
+
clinicInfo: ClinicInfo | null;
|
|
1594
|
+
practitionerInfo: PractitionerProfileInfo | null;
|
|
1595
|
+
patientInfo: PatientProfileInfo | null;
|
|
1596
|
+
}> {
|
|
1597
|
+
try {
|
|
1598
|
+
// Fetch all profiles concurrently
|
|
1599
|
+
const [clinicDoc, practitionerDoc, patientDoc, patientSensitiveInfoDoc] =
|
|
1600
|
+
await Promise.all([
|
|
1601
|
+
getDoc(doc(this.db, CLINICS_COLLECTION, clinicId)),
|
|
1602
|
+
getDoc(doc(this.db, PRACTITIONERS_COLLECTION, doctorId)),
|
|
1603
|
+
getDoc(doc(this.db, PATIENTS_COLLECTION, patientId)),
|
|
1604
|
+
getDoc(
|
|
1605
|
+
doc(
|
|
1606
|
+
this.db,
|
|
1607
|
+
PATIENTS_COLLECTION,
|
|
1608
|
+
patientId,
|
|
1609
|
+
PATIENT_SENSITIVE_INFO_COLLECTION,
|
|
1610
|
+
patientId
|
|
1611
|
+
)
|
|
1612
|
+
),
|
|
1613
|
+
]);
|
|
1614
|
+
|
|
1615
|
+
// Create info cards
|
|
1616
|
+
const clinicInfo: ClinicInfo | null = clinicDoc.exists()
|
|
1617
|
+
? {
|
|
1618
|
+
id: clinicDoc.id,
|
|
1619
|
+
featuredPhoto: clinicDoc.data().featuredPhoto || "",
|
|
1620
|
+
name: clinicDoc.data().name,
|
|
1621
|
+
description: clinicDoc.data().description || "",
|
|
1622
|
+
location: clinicDoc.data().location,
|
|
1623
|
+
contactInfo: clinicDoc.data().contactInfo,
|
|
1624
|
+
}
|
|
1625
|
+
: null;
|
|
1626
|
+
|
|
1627
|
+
const practitionerInfo: PractitionerProfileInfo | null =
|
|
1628
|
+
practitionerDoc.exists()
|
|
1629
|
+
? {
|
|
1630
|
+
id: practitionerDoc.id,
|
|
1631
|
+
practitionerPhoto:
|
|
1632
|
+
practitionerDoc.data().basicInfo.profileImageUrl || null,
|
|
1633
|
+
name: `${practitionerDoc.data().basicInfo.firstName} ${
|
|
1634
|
+
practitionerDoc.data().basicInfo.lastName
|
|
1635
|
+
}`,
|
|
1636
|
+
email: practitionerDoc.data().basicInfo.email,
|
|
1637
|
+
phone: practitionerDoc.data().basicInfo.phoneNumber || null,
|
|
1638
|
+
certification: practitionerDoc.data().certification,
|
|
1639
|
+
}
|
|
1640
|
+
: null;
|
|
1641
|
+
|
|
1642
|
+
// First try to get data from sensitive-info subcollection
|
|
1643
|
+
let patientInfo: PatientProfileInfo | null = null;
|
|
1644
|
+
|
|
1645
|
+
if (patientSensitiveInfoDoc.exists()) {
|
|
1646
|
+
const sensitiveData = patientSensitiveInfoDoc.data();
|
|
1647
|
+
patientInfo = {
|
|
1648
|
+
id: patientId,
|
|
1649
|
+
fullName: `${sensitiveData.firstName} ${sensitiveData.lastName}`,
|
|
1650
|
+
email: sensitiveData.email || "",
|
|
1651
|
+
phone: sensitiveData.phoneNumber || null,
|
|
1652
|
+
dateOfBirth: sensitiveData.dateOfBirth || Timestamp.now(),
|
|
1653
|
+
gender: sensitiveData.gender || Gender.OTHER,
|
|
1654
|
+
};
|
|
1655
|
+
} else if (patientDoc.exists()) {
|
|
1656
|
+
// Fall back to patient document if sensitive info not available
|
|
1657
|
+
patientInfo = {
|
|
1658
|
+
id: patientDoc.id,
|
|
1659
|
+
fullName: patientDoc.data().displayName,
|
|
1660
|
+
email: patientDoc.data().contactInfo?.email || "",
|
|
1661
|
+
phone: patientDoc.data().phoneNumber || null,
|
|
1662
|
+
dateOfBirth: patientDoc.data().dateOfBirth || Timestamp.now(),
|
|
1663
|
+
gender: patientDoc.data().gender || Gender.OTHER,
|
|
1664
|
+
};
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
return {
|
|
1668
|
+
clinicInfo,
|
|
1669
|
+
practitionerInfo,
|
|
1670
|
+
patientInfo,
|
|
1671
|
+
};
|
|
1672
|
+
} catch (error) {
|
|
1673
|
+
console.error("Error fetching profile info cards:", error);
|
|
1674
|
+
return {
|
|
1675
|
+
clinicInfo: null,
|
|
1676
|
+
practitionerInfo: null,
|
|
1677
|
+
patientInfo: null,
|
|
1678
|
+
};
|
|
1679
|
+
}
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
// #endregion
|
|
1683
|
+
}
|