@blackcode_sa/metaestetics-api 1.4.18 → 1.5.1

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