@blackcode_sa/metaestetics-api 1.5.15 → 1.5.17

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@blackcode_sa/metaestetics-api",
3
3
  "private": false,
4
- "version": "1.5.15",
4
+ "version": "1.5.17",
5
5
  "description": "Firebase authentication service with anonymous upgrade support",
6
6
  "main": "./dist/index.js",
7
7
  "module": "./dist/index.mjs",
package/src/index.ts CHANGED
@@ -96,6 +96,8 @@ export type {
96
96
  UpdateMedicationData,
97
97
  Allergy,
98
98
  PatientProfileComplete,
99
+ SearchPatientsParams,
100
+ RequesterInfo,
99
101
  } from "./types/patient";
100
102
  export {
101
103
  Gender,
@@ -16,6 +16,9 @@ import {
16
16
  TimeSlot,
17
17
  CreateAppointmentParams,
18
18
  UpdateAppointmentParams,
19
+ SearchCalendarEventsParams,
20
+ SearchLocationEnum,
21
+ DateRange,
19
22
  } from "../../types/calendar";
20
23
  import {
21
24
  PRACTITIONERS_COLLECTION,
@@ -42,6 +45,9 @@ import {
42
45
  getDocs,
43
46
  setDoc,
44
47
  updateDoc,
48
+ QueryConstraint,
49
+ CollectionReference,
50
+ DocumentData,
45
51
  } from "firebase/firestore";
46
52
  import {
47
53
  createAppointmentSchema,
@@ -54,6 +60,7 @@ import {
54
60
  updateAppointmentUtil,
55
61
  deleteAppointmentUtil,
56
62
  } from "./utils/appointment.utils";
63
+ import { searchCalendarEventsUtil } from "./utils/calendar-event.utils";
57
64
  import { SyncedCalendarsService } from "./synced-calendars.service";
58
65
  import { Timestamp as FirestoreTimestamp } from "firebase-admin/firestore";
59
66
 
@@ -776,6 +783,146 @@ export class CalendarServiceV2 extends BaseService {
776
783
  */
777
784
  }
778
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
+
779
926
  // #endregion
780
927
 
781
928
  // #region Private Helper Methods
@@ -24,6 +24,8 @@ import {
24
24
  CreateCalendarEventData,
25
25
  UpdateCalendarEventData,
26
26
  CALENDAR_COLLECTION,
27
+ SearchCalendarEventsParams,
28
+ SearchLocationEnum,
27
29
  } from "../../../types/calendar";
28
30
  import { PRACTITIONERS_COLLECTION } from "../../../types/practitioner";
29
31
  import { PATIENTS_COLLECTION } from "../../../types/patient";
@@ -508,3 +510,137 @@ export async function deleteClinicCalendarEventUtil(
508
510
  );
509
511
  await deleteDoc(eventRef);
510
512
  }
513
+
514
+ /**
515
+ * Searches for calendar events based on specified criteria
516
+ * @param db - Firestore instance
517
+ * @param params - Search parameters
518
+ * @param params.searchLocation - The primary location to search (practitioner, patient, or clinic)
519
+ * @param params.entityId - The ID of the entity (practitioner, patient, or clinic) to search within/for
520
+ * @param params.clinicId - Optional clinic ID to filter by
521
+ * @param params.practitionerId - Optional practitioner ID to filter by
522
+ * @param params.patientId - Optional patient ID to filter by
523
+ * @param params.procedureId - Optional procedure ID to filter by
524
+ * @param params.dateRange - Optional date range to filter by (event start time)
525
+ * @param params.eventStatus - Optional event status to filter by
526
+ * @param params.eventType - Optional event type to filter by
527
+ * @returns Promise resolving to an array of matching calendar events
528
+ */
529
+ export async function searchCalendarEventsUtil(
530
+ db: Firestore,
531
+ params: SearchCalendarEventsParams
532
+ ): Promise<CalendarEvent[]> {
533
+ const { searchLocation, entityId, ...filters } = params;
534
+
535
+ let baseCollectionPath: string;
536
+ const constraints: QueryConstraint[] = [];
537
+
538
+ // Determine the base collection and apply initial filter based on searchLocation
539
+ switch (searchLocation) {
540
+ case SearchLocationEnum.PRACTITIONER:
541
+ if (!entityId) {
542
+ throw new Error(
543
+ "Practitioner ID (entityId) is required when searching practitioner calendar."
544
+ );
545
+ }
546
+ baseCollectionPath = `${PRACTITIONERS_COLLECTION}/${entityId}/${CALENDAR_COLLECTION}`;
547
+ // If practitionerId filter is provided, it must match the entityId for this search location
548
+ if (filters.practitionerId && filters.practitionerId !== entityId) {
549
+ console.warn(
550
+ `Provided practitionerId filter (${filters.practitionerId}) does not match search entityId (${entityId}). Returning empty results.`
551
+ );
552
+ return [];
553
+ }
554
+ // Ensure we don't add a redundant filter if the caller also specified it
555
+ filters.practitionerId = undefined;
556
+ break;
557
+
558
+ case SearchLocationEnum.PATIENT:
559
+ if (!entityId) {
560
+ throw new Error(
561
+ "Patient ID (entityId) is required when searching patient calendar."
562
+ );
563
+ }
564
+ baseCollectionPath = `${PATIENTS_COLLECTION}/${entityId}/${CALENDAR_COLLECTION}`;
565
+ // If patientId filter is provided, it must match the entityId for this search location
566
+ if (filters.patientId && filters.patientId !== entityId) {
567
+ console.warn(
568
+ `Provided patientId filter (${filters.patientId}) does not match search entityId (${entityId}). Returning empty results.`
569
+ );
570
+ return [];
571
+ }
572
+ // Ensure we don't add a redundant filter if the caller also specified it
573
+ filters.patientId = undefined;
574
+ break;
575
+
576
+ case SearchLocationEnum.CLINIC:
577
+ if (!entityId) {
578
+ throw new Error(
579
+ "Clinic ID (entityId) is required when searching clinic-related events."
580
+ );
581
+ }
582
+ // Search the root CALENDAR_COLLECTION for events associated with the clinic
583
+ baseCollectionPath = CALENDAR_COLLECTION;
584
+ constraints.push(where("clinicBranchId", "==", entityId));
585
+ // If clinicId filter is provided, it must match the entityId for this search location
586
+ if (filters.clinicId && filters.clinicId !== entityId) {
587
+ console.warn(
588
+ `Provided clinicId filter (${filters.clinicId}) does not match search entityId (${entityId}). Returning empty results.`
589
+ );
590
+ return [];
591
+ }
592
+ // Ensure we don't add a redundant filter if the caller also specified it
593
+ filters.clinicId = undefined; // Already handled by the base query
594
+ break;
595
+
596
+ default:
597
+ throw new Error(`Invalid search location: ${searchLocation}`);
598
+ }
599
+
600
+ const collectionRef = collection(db, baseCollectionPath);
601
+
602
+ // Apply optional filters
603
+ if (filters.clinicId) {
604
+ constraints.push(where("clinicBranchId", "==", filters.clinicId));
605
+ }
606
+ if (filters.practitionerId) {
607
+ constraints.push(
608
+ where("practitionerProfileId", "==", filters.practitionerId)
609
+ );
610
+ }
611
+ if (filters.patientId) {
612
+ constraints.push(where("patientProfileId", "==", filters.patientId));
613
+ }
614
+ if (filters.procedureId) {
615
+ constraints.push(where("procedureId", "==", filters.procedureId));
616
+ }
617
+ if (filters.eventStatus) {
618
+ constraints.push(where("status", "==", filters.eventStatus));
619
+ }
620
+ if (filters.eventType) {
621
+ constraints.push(where("eventType", "==", filters.eventType));
622
+ }
623
+ if (filters.dateRange) {
624
+ // Firestore requires range filters on the same field
625
+ constraints.push(where("eventTime.start", ">=", filters.dateRange.start));
626
+ constraints.push(where("eventTime.start", "<=", filters.dateRange.end));
627
+ // Note: You might need to order by eventTime.start for range filters to work efficiently
628
+ // constraints.push(orderBy("eventTime.start"));
629
+ }
630
+
631
+ // Build and execute the query
632
+ try {
633
+ const finalQuery = query(collectionRef, ...constraints);
634
+ const querySnapshot = await getDocs(finalQuery);
635
+
636
+ const events = querySnapshot.docs.map(
637
+ (doc) => ({ id: doc.id, ...doc.data() } as CalendarEvent)
638
+ );
639
+
640
+ return events;
641
+ } catch (error) {
642
+ console.error("Error searching calendar events:", error);
643
+ // Depending on requirements, you might want to return an empty array or re-throw
644
+ return [];
645
+ }
646
+ }
@@ -1,8 +1,8 @@
1
- // Export all calendar event utilities
1
+ // Export core calendar utilities
2
2
  export * from "./calendar-event.utils";
3
-
4
- // Export all synced calendar utilities
5
3
  export * from "./synced-calendar.utils";
6
-
7
- // Export all Google Calendar utilities
8
4
  export * from "./google-calendar.utils";
5
+ export * from "./appointment.utils";
6
+
7
+ // Note: Other utility files (clinic.utils.ts, patient.utils.ts, practitioner.utils.ts, docs.utils.ts)
8
+ // should be imported directly where needed to avoid naming conflicts
@@ -478,6 +478,7 @@ export async function createAdminToken(
478
478
 
479
479
  const now = Timestamp.now();
480
480
  const expiresInDays = data?.expiresInDays || 7; // Default 7 days
481
+ const email = data?.email || null;
481
482
  const expiresAt = new Timestamp(
482
483
  now.seconds + expiresInDays * 24 * 60 * 60,
483
484
  now.nanoseconds
@@ -487,11 +488,13 @@ export async function createAdminToken(
487
488
  id: generateId(),
488
489
  token: generateId(),
489
490
  status: AdminTokenStatus.ACTIVE,
491
+ email,
490
492
  createdAt: now,
491
493
  expiresAt,
492
494
  };
493
495
 
494
496
  // Dodajemo token u grupu
497
+ // Ovo treba promeniti, staviti admin tokene u sub-kolekciju u klinickoj grupi
495
498
  await updateClinicGroup(
496
499
  db,
497
500
  groupId,
@@ -29,6 +29,8 @@ import {
29
29
  UpdateMedicationData,
30
30
  PatientDoctor,
31
31
  PatientClinic,
32
+ SearchPatientsParams,
33
+ RequesterInfo,
32
34
  } from "../../types/patient";
33
35
  import { Auth } from "firebase/auth";
34
36
  import { Firestore } from "firebase/firestore";
@@ -48,6 +50,7 @@ import {
48
50
  deleteProfilePhotoUtil,
49
51
  updatePatientProfileUtil,
50
52
  updatePatientProfileByUserRefUtil,
53
+ searchPatientsUtil,
51
54
  } from "./utils/profile.utils";
52
55
 
53
56
  import {
@@ -468,4 +471,30 @@ export class PatientService extends BaseService {
468
471
  ): Promise<PatientProfile> {
469
472
  return updatePatientProfileByUserRefUtil(this.db, userRef, data);
470
473
  }
474
+
475
+ /**
476
+ * Searches for patient profiles based on clinic/practitioner association.
477
+ * Requires information about the requester for security checks.
478
+ *
479
+ * @param {SearchPatientsParams} params - The search criteria (clinicId, practitionerId).
480
+ * @param {RequesterInfo} requester - Information about the user performing the search (ID, role, associated IDs).
481
+ * @returns {Promise<PatientProfile[]>} A promise resolving to an array of matching patient profiles.
482
+ */
483
+ async searchPatients(
484
+ params: SearchPatientsParams,
485
+ requester: RequesterInfo
486
+ ): Promise<PatientProfile[]> {
487
+ // We can potentially add more service-level logic here in the future,
488
+ // like fetching additional data or enriching the results.
489
+ // For now, we delegate directly to the utility function.
490
+ console.log(
491
+ `[PatientService.searchPatients] Initiating search with params:`,
492
+ params,
493
+ `by requester:`,
494
+ requester
495
+ );
496
+
497
+ // The utility function already handles validation and security checks.
498
+ return searchPatientsUtil(this.db, params, requester);
499
+ }
471
500
  }
@@ -2,6 +2,7 @@ import {
2
2
  getDoc,
3
3
  updateDoc,
4
4
  arrayUnion,
5
+ arrayRemove,
5
6
  Firestore,
6
7
  serverTimestamp,
7
8
  Timestamp,
@@ -27,10 +28,32 @@ export const addDoctorUtil = async (
27
28
  isActive: true,
28
29
  };
29
30
 
30
- await updateDoc(getPatientDocRef(db, patientId), {
31
- doctors: arrayUnion(newDoctor),
31
+ const patientDoc = await getDoc(getPatientDocRef(db, patientId));
32
+ if (!patientDoc.exists()) throw new Error("Patient profile not found");
33
+ const patientData = patientDoc.data() as PatientProfile;
34
+ const existingDoctorIndex = patientData.doctors?.findIndex(
35
+ (d) => d.userRef === doctorRef
36
+ );
37
+
38
+ const updates: any = {
32
39
  updatedAt: serverTimestamp(),
33
- });
40
+ doctorIds: arrayUnion(doctorRef),
41
+ };
42
+
43
+ if (existingDoctorIndex !== undefined && existingDoctorIndex > -1) {
44
+ const updatedDoctors = [...patientData.doctors];
45
+ updatedDoctors[existingDoctorIndex] = {
46
+ ...updatedDoctors[existingDoctorIndex],
47
+ isActive: true,
48
+ assignedAt: Timestamp.now(),
49
+ assignedBy,
50
+ };
51
+ updates.doctors = updatedDoctors;
52
+ } else {
53
+ updates.doctors = arrayUnion(newDoctor);
54
+ }
55
+
56
+ await updateDoc(getPatientDocRef(db, patientId), updates);
34
57
  };
35
58
 
36
59
  export const removeDoctorUtil = async (
@@ -38,16 +61,19 @@ export const removeDoctorUtil = async (
38
61
  patientId: string,
39
62
  doctorRef: string
40
63
  ): Promise<void> => {
41
- const patientDoc = await getDoc(getPatientDocRef(db, patientId));
64
+ const patientDocRef = getPatientDocRef(db, patientId);
65
+ const patientDoc = await getDoc(patientDocRef);
42
66
  if (!patientDoc.exists()) throw new Error("Patient profile not found");
43
67
 
44
68
  const patientData = patientDoc.data() as PatientProfile;
45
- const updatedDoctors = patientData.doctors.map((doctor) =>
46
- doctor.userRef === doctorRef ? { ...doctor, isActive: false } : doctor
47
- );
48
69
 
49
- await updateDoc(getPatientDocRef(db, patientId), {
50
- doctors: updatedDoctors,
70
+ // Filter out the doctor object by userRef
71
+ const updatedDoctors =
72
+ patientData.doctors?.filter((doctor) => doctor.userRef !== doctorRef) || []; // Ensure it's an array even if doctors field didn't exist or was empty
73
+
74
+ await updateDoc(patientDocRef, {
75
+ doctors: updatedDoctors, // Set the filtered array
76
+ doctorIds: arrayRemove(doctorRef), // Remove ID from the denormalized list
51
77
  updatedAt: serverTimestamp(),
52
78
  });
53
79
  };
@@ -66,10 +92,32 @@ export const addClinicUtil = async (
66
92
  isActive: true,
67
93
  };
68
94
 
69
- await updateDoc(getPatientDocRef(db, patientId), {
70
- clinics: arrayUnion(newClinic),
95
+ const patientDoc = await getDoc(getPatientDocRef(db, patientId));
96
+ if (!patientDoc.exists()) throw new Error("Patient profile not found");
97
+ const patientData = patientDoc.data() as PatientProfile;
98
+ const existingClinicIndex = patientData.clinics?.findIndex(
99
+ (c) => c.clinicId === clinicId
100
+ );
101
+
102
+ const updates: any = {
71
103
  updatedAt: serverTimestamp(),
72
- });
104
+ clinicIds: arrayUnion(clinicId),
105
+ };
106
+
107
+ if (existingClinicIndex !== undefined && existingClinicIndex > -1) {
108
+ const updatedClinics = [...patientData.clinics];
109
+ updatedClinics[existingClinicIndex] = {
110
+ ...updatedClinics[existingClinicIndex],
111
+ isActive: true,
112
+ assignedAt: Timestamp.now(),
113
+ assignedBy,
114
+ };
115
+ updates.clinics = updatedClinics;
116
+ } else {
117
+ updates.clinics = arrayUnion(newClinic);
118
+ }
119
+
120
+ await updateDoc(getPatientDocRef(db, patientId), updates);
73
121
  };
74
122
 
75
123
  export const removeClinicUtil = async (
@@ -77,16 +125,19 @@ export const removeClinicUtil = async (
77
125
  patientId: string,
78
126
  clinicId: string
79
127
  ): Promise<void> => {
80
- const patientDoc = await getDoc(getPatientDocRef(db, patientId));
128
+ const patientDocRef = getPatientDocRef(db, patientId);
129
+ const patientDoc = await getDoc(patientDocRef);
81
130
  if (!patientDoc.exists()) throw new Error("Patient profile not found");
82
131
 
83
132
  const patientData = patientDoc.data() as PatientProfile;
84
- const updatedClinics = patientData.clinics.map((clinic) =>
85
- clinic.clinicId === clinicId ? { ...clinic, isActive: false } : clinic
86
- );
87
133
 
88
- await updateDoc(getPatientDocRef(db, patientId), {
89
- clinics: updatedClinics,
134
+ // Filter out the clinic object by clinicId
135
+ const updatedClinics =
136
+ patientData.clinics?.filter((clinic) => clinic.clinicId !== clinicId) || []; // Ensure it's an array
137
+
138
+ await updateDoc(patientDocRef, {
139
+ clinics: updatedClinics, // Set the filtered array
140
+ clinicIds: arrayRemove(clinicId), // Remove ID from the denormalized list
90
141
  updatedAt: serverTimestamp(),
91
142
  });
92
143
  };