@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.
@@ -8,6 +8,11 @@ import {
8
8
  increment,
9
9
  Firestore,
10
10
  Timestamp,
11
+ collection,
12
+ query,
13
+ where,
14
+ getDocs,
15
+ QueryConstraint,
11
16
  } from "firebase/firestore";
12
17
  import {
13
18
  ref,
@@ -21,10 +26,15 @@ import {
21
26
  PatientProfile,
22
27
  CreatePatientProfileData,
23
28
  Gender,
29
+ SearchPatientsParams,
30
+ RequesterInfo,
31
+ PATIENTS_COLLECTION,
24
32
  } from "../../../types/patient";
25
33
  import {
26
34
  patientProfileSchema,
27
35
  createPatientProfileSchema,
36
+ searchPatientsSchema,
37
+ requesterInfoSchema,
28
38
  } from "../../../validations/patient.schema";
29
39
  import {
30
40
  getPatientDocRef,
@@ -65,6 +75,8 @@ export const createPatientProfileUtil = async (
65
75
  isVerified: validatedData.isVerified,
66
76
  doctors: validatedData.doctors || [],
67
77
  clinics: validatedData.clinics || [],
78
+ doctorIds: validatedData.doctors?.map((d) => d.userRef) || [],
79
+ clinicIds: validatedData.clinics?.map((c) => c.clinicId) || [],
68
80
  createdAt: serverTimestamp(),
69
81
  updatedAt: serverTimestamp(),
70
82
  };
@@ -398,3 +410,107 @@ export const testCreateSubDocuments = async (
398
410
  throw error;
399
411
  }
400
412
  };
413
+
414
+ /**
415
+ * Searches for patient profiles based on clinic and/or practitioner association.
416
+ * Applies security checks based on the requester's role and associations.
417
+ *
418
+ * @param {Firestore} db - Firestore instance.
419
+ * @param {SearchPatientsParams} params - Search criteria (clinicId, practitionerId).
420
+ * @param {RequesterInfo} requester - Information about the user performing the search.
421
+ * @returns {Promise<PatientProfile[]>} A promise resolving to an array of matching patient profiles.
422
+ */
423
+ export const searchPatientsUtil = async (
424
+ db: Firestore,
425
+ params: SearchPatientsParams,
426
+ requester: RequesterInfo
427
+ ): Promise<PatientProfile[]> => {
428
+ // Validate input
429
+ searchPatientsSchema.parse(params);
430
+ requesterInfoSchema.parse(requester);
431
+
432
+ const constraints: QueryConstraint[] = [];
433
+ const patientsCollectionRef = collection(db, PATIENTS_COLLECTION);
434
+
435
+ // --- Security Checks & Initial Filtering ---
436
+
437
+ if (requester.role === "clinic_admin") {
438
+ // Clinic admin can only search within their own clinic
439
+ if (!requester.associatedClinicId) {
440
+ throw new Error(
441
+ "Associated clinic ID is required for clinic admin search."
442
+ );
443
+ }
444
+ // If the search params specify a different clinic, it's an invalid request for this admin.
445
+ if (params.clinicId && params.clinicId !== requester.associatedClinicId) {
446
+ console.warn(
447
+ `Clinic admin (${requester.id}) attempted to search outside their associated clinic (${requester.associatedClinicId})`
448
+ );
449
+ return []; // Or throw an error
450
+ }
451
+
452
+ // **Mandatory filter**: Ensure patients belong to the admin's clinic.
453
+ constraints.push(
454
+ where("clinicIds", "array-contains", requester.associatedClinicId)
455
+ );
456
+
457
+ // Optional filter: If practitionerId is also provided, filter by that practitioner *within the admin's clinic*.
458
+ if (params.practitionerId) {
459
+ constraints.push(
460
+ where("doctorIds", "array-contains", params.practitionerId)
461
+ );
462
+ // We might need an additional check here if the practitioner MUST work at the admin's clinic.
463
+ // This would require fetching practitioner data or having denormalized clinic IDs on the practitioner.
464
+ }
465
+ } else if (requester.role === "practitioner") {
466
+ // Practitioner can only search for their own patients.
467
+ if (!requester.associatedPractitionerId) {
468
+ throw new Error(
469
+ "Associated practitioner ID is required for practitioner search."
470
+ );
471
+ }
472
+ // If the search params specify a different practitioner, it's invalid.
473
+ if (
474
+ params.practitionerId &&
475
+ params.practitionerId !== requester.associatedPractitionerId
476
+ ) {
477
+ console.warn(
478
+ `Practitioner (${requester.id}) attempted to search for patients of another practitioner (${params.practitionerId})`
479
+ );
480
+ return []; // Or throw an error
481
+ }
482
+
483
+ // **Mandatory filter**: Ensure patients are associated with this practitioner.
484
+ constraints.push(
485
+ where("doctorIds", "array-contains", requester.associatedPractitionerId)
486
+ );
487
+
488
+ // Optional filter: If clinicId is provided, filter by patients of this practitioner *at that specific clinic*.
489
+ if (params.clinicId) {
490
+ constraints.push(where("clinicIds", "array-contains", params.clinicId));
491
+ // Similar to above, we might need to check if the practitioner actually works at this clinic.
492
+ }
493
+ } else {
494
+ // Should not happen due to validation, but good practice to handle.
495
+ throw new Error("Invalid requester role.");
496
+ }
497
+
498
+ // --- Execute Query ---
499
+ try {
500
+ const finalQuery = query(patientsCollectionRef, ...constraints);
501
+ const querySnapshot = await getDocs(finalQuery);
502
+
503
+ const patients = querySnapshot.docs.map(
504
+ (doc) => doc.data() as PatientProfile
505
+ );
506
+
507
+ console.log(
508
+ `[searchPatientsUtil] Found ${patients.length} patients matching criteria.`
509
+ );
510
+ return patients;
511
+ } catch (error) {
512
+ console.error("[searchPatientsUtil] Error searching patients:", error);
513
+ // Consider logging more details or re-throwing a specific error type
514
+ return []; // Return empty array on error
515
+ }
516
+ };
@@ -185,3 +185,44 @@ export interface UpdateAppointmentParams {
185
185
  * Collection names for calendar
186
186
  */
187
187
  export const CALENDAR_COLLECTION = "calendar";
188
+
189
+ /**
190
+ * Enum for specifying the primary search location for calendar events
191
+ */
192
+ export enum SearchLocationEnum {
193
+ PRACTITIONER = "practitioner",
194
+ PATIENT = "patient",
195
+ CLINIC = "clinic", // Represents searching events associated with a clinic
196
+ }
197
+
198
+ /**
199
+ * Interface for defining a date range
200
+ */
201
+ export interface DateRange {
202
+ start: Timestamp;
203
+ end: Timestamp;
204
+ }
205
+
206
+ /**
207
+ * Interface for general calendar event search parameters
208
+ */
209
+ export interface SearchCalendarEventsParams {
210
+ /** The primary location to search within (practitioner, patient, or clinic calendar). */
211
+ searchLocation: SearchLocationEnum;
212
+ /** The ID of the entity (practitioner, patient, or clinic) whose calendar/events are being searched. */
213
+ entityId: string;
214
+ /** Optional filter for clinic ID. If searchLocation is CLINIC, this is implicitly applied using entityId. */
215
+ clinicId?: string;
216
+ /** Optional filter for practitioner ID. */
217
+ practitionerId?: string;
218
+ /** Optional filter for patient ID. */
219
+ patientId?: string;
220
+ /** Optional filter for procedure ID. */
221
+ procedureId?: string;
222
+ /** Optional filter for a specific date range (based on event start time). */
223
+ dateRange?: DateRange;
224
+ /** Optional filter for event status. */
225
+ eventStatus?: CalendarEventStatus;
226
+ /** Optional filter for event type. */
227
+ eventType?: CalendarEventType;
228
+ }
@@ -149,6 +149,7 @@ export enum AdminTokenStatus {
149
149
  export interface AdminToken {
150
150
  id: string;
151
151
  token: string;
152
+ email?: string | null;
152
153
  status: AdminTokenStatus;
153
154
  usedByUserRef?: string;
154
155
  createdAt: Timestamp;
@@ -234,6 +235,7 @@ export interface UpdateClinicGroupData extends Partial<CreateClinicGroupData> {
234
235
  */
235
236
  export interface CreateAdminTokenData {
236
237
  expiresInDays?: number; // How many days the token is valid, default 7
238
+ email?: string | null;
237
239
  }
238
240
 
239
241
  /**
@@ -172,6 +172,8 @@ export interface PatientProfile {
172
172
  dateOfBirth?: Timestamp | null;
173
173
  doctors: PatientDoctor[]; // Lista doktora pacijenta
174
174
  clinics: PatientClinic[]; // Lista klinika pacijenta
175
+ doctorIds: string[]; // Denormalized array for querying
176
+ clinicIds: string[]; // Denormalized array for querying
175
177
  createdAt: Timestamp;
176
178
  updatedAt: Timestamp;
177
179
  }
@@ -188,14 +190,42 @@ export interface CreatePatientProfileData {
188
190
  isVerified: boolean;
189
191
  doctors?: PatientDoctor[];
190
192
  clinics?: PatientClinic[];
193
+ doctorIds?: string[]; // Initialize as empty or with initial doctors
194
+ clinicIds?: string[]; // Initialize as empty or with initial clinics
191
195
  }
192
196
 
193
197
  /**
194
198
  * Tip za ažuriranje Patient profila
195
199
  */
196
200
  export interface UpdatePatientProfileData
197
- extends Partial<CreatePatientProfileData> {
201
+ extends Partial<Omit<PatientProfile, "id" | "createdAt" | "updatedAt">> {
202
+ // Use Omit to exclude base fields
198
203
  updatedAt?: FieldValue;
204
+ // Note: doctors, clinics, doctorIds, clinicIds should ideally be updated via specific methods (add/removeDoctor/Clinic)
205
+ }
206
+
207
+ /**
208
+ * Parameters for searching patient profiles.
209
+ */
210
+ export interface SearchPatientsParams {
211
+ /** Optional: Filter patients associated with this clinic ID. */
212
+ clinicId?: string;
213
+ /** Optional: Filter patients associated with this practitioner ID. */
214
+ practitionerId?: string;
215
+ }
216
+
217
+ /**
218
+ * Information about the entity requesting the patient search.
219
+ */
220
+ export interface RequesterInfo {
221
+ /** ID of the clinic admin user or practitioner user making the request. */
222
+ id: string;
223
+ /** Role of the requester, determining the search context. */
224
+ role: "clinic_admin" | "practitioner";
225
+ /** If role is 'clinic_admin', this is the associated clinic ID. */
226
+ associatedClinicId?: string;
227
+ /** If role is 'practitioner', this is the associated practitioner profile ID. */
228
+ associatedPractitionerId?: string;
199
229
  }
200
230
 
201
231
  export * from "./medical-info.types";
@@ -1,11 +1,11 @@
1
- import { Timestamp, FieldValue } from "firebase/firestore";
1
+ import { Timestamp, FieldValue } from 'firebase/firestore';
2
2
  import {
3
3
  CertificationLevel,
4
4
  CertificationSpecialty,
5
- } from "../../backoffice/types/static/certification.types";
5
+ } from '../../backoffice/types/static/certification.types';
6
6
 
7
- export const PRACTITIONERS_COLLECTION = "practitioners";
8
- export const REGISTER_TOKENS_COLLECTION = "register_tokens";
7
+ export const PRACTITIONERS_COLLECTION = 'practitioners';
8
+ export const REGISTER_TOKENS_COLLECTION = 'register_tokens';
9
9
 
10
10
  /**
11
11
  * Osnovne informacije o zdravstvenom radniku
@@ -17,7 +17,7 @@ export interface PractitionerBasicInfo {
17
17
  email: string;
18
18
  phoneNumber: string;
19
19
  dateOfBirth: Timestamp;
20
- gender: "male" | "female" | "other";
20
+ gender: 'male' | 'female' | 'other';
21
21
  profileImageUrl?: string;
22
22
  bio?: string;
23
23
  languages: string[];
@@ -33,7 +33,7 @@ export interface PractitionerCertification {
33
33
  issuingAuthority: string;
34
34
  issueDate: Timestamp;
35
35
  expiryDate?: Timestamp;
36
- verificationStatus: "pending" | "verified" | "rejected";
36
+ verificationStatus: 'pending' | 'verified' | 'rejected';
37
37
  }
38
38
 
39
39
  /**
@@ -59,18 +59,18 @@ export interface PractitionerClinicWorkingHours {
59
59
  * Status of practitioner profile
60
60
  */
61
61
  export enum PractitionerStatus {
62
- DRAFT = "draft",
63
- ACTIVE = "active",
62
+ DRAFT = 'draft',
63
+ ACTIVE = 'active',
64
64
  }
65
65
 
66
66
  /**
67
67
  * Token status for practitioner invitations
68
68
  */
69
69
  export enum PractitionerTokenStatus {
70
- ACTIVE = "active",
71
- USED = "used",
72
- EXPIRED = "expired",
73
- REVOKED = "revoked",
70
+ ACTIVE = 'active',
71
+ USED = 'used',
72
+ EXPIRED = 'expired',
73
+ REVOKED = 'revoked',
74
74
  }
75
75
 
76
76
  /**
@@ -119,8 +119,7 @@ export interface CreateDraftPractitionerData {
119
119
  /**
120
120
  * Tip za ažuriranje zdravstvenog radnika
121
121
  */
122
- export interface UpdatePractitionerData
123
- extends Partial<CreatePractitionerData> {
122
+ export interface UpdatePractitionerData extends Partial<CreatePractitionerData> {
124
123
  updatedAt?: FieldValue;
125
124
  }
126
125
 
@@ -172,6 +172,7 @@ export const clinicAdminSchema = z.object({
172
172
  export const adminTokenSchema = z.object({
173
173
  id: z.string(),
174
174
  token: z.string(),
175
+ email: z.string().email().optional().nullable(),
175
176
  status: z.nativeEnum(AdminTokenStatus),
176
177
  usedByUserRef: z.string().optional(),
177
178
  createdAt: z.instanceof(Date).or(z.instanceof(Timestamp)), // Timestamp
@@ -183,6 +184,7 @@ export const adminTokenSchema = z.object({
183
184
  */
184
185
  export const createAdminTokenSchema = z.object({
185
186
  expiresInDays: z.number().min(1).max(30).optional(),
187
+ email: z.string().email().optional().nullable(),
186
188
  });
187
189
 
188
190
  /**
@@ -115,6 +115,8 @@ export const patientProfileSchema = z.object({
115
115
  isVerified: z.boolean(),
116
116
  doctors: z.array(patientDoctorSchema),
117
117
  clinics: z.array(patientClinicSchema),
118
+ doctorIds: z.array(z.string()),
119
+ clinicIds: z.array(z.string()),
118
120
  createdAt: z.instanceof(Timestamp),
119
121
  updatedAt: z.instanceof(Timestamp),
120
122
  });
@@ -132,6 +134,8 @@ export const createPatientProfileSchema = z.object({
132
134
  isVerified: z.boolean(),
133
135
  doctors: z.array(patientDoctorSchema).optional(),
134
136
  clinics: z.array(patientClinicSchema).optional(),
137
+ doctorIds: z.array(z.string()).optional(),
138
+ clinicIds: z.array(z.string()).optional(),
135
139
  });
136
140
 
137
141
  /**
@@ -152,4 +156,43 @@ export const createPatientSensitiveInfoSchema = z.object({
152
156
  emergencyContacts: z.array(emergencyContactSchema).optional(),
153
157
  });
154
158
 
159
+ /**
160
+ * Schema for validating patient search parameters.
161
+ */
162
+ export const searchPatientsSchema = z
163
+ .object({
164
+ clinicId: z.string().optional(),
165
+ practitionerId: z.string().optional(),
166
+ })
167
+ .refine((data) => data.clinicId || data.practitionerId, {
168
+ message: "At least one of clinicId or practitionerId must be provided",
169
+ path: [], // Optional: specify a path like ['clinicId'] or ['practitionerId']
170
+ });
171
+
172
+ /**
173
+ * Schema for validating requester information during patient search.
174
+ */
175
+ export const requesterInfoSchema = z
176
+ .object({
177
+ id: z.string(),
178
+ role: z.enum(["clinic_admin", "practitioner"]),
179
+ associatedClinicId: z.string().optional(),
180
+ associatedPractitionerId: z.string().optional(),
181
+ })
182
+ .refine(
183
+ (data) => {
184
+ if (data.role === "clinic_admin") {
185
+ return !!data.associatedClinicId;
186
+ } else if (data.role === "practitioner") {
187
+ return !!data.associatedPractitionerId;
188
+ }
189
+ return false;
190
+ },
191
+ {
192
+ message:
193
+ "Associated ID (clinic or practitioner) is required based on role",
194
+ path: ["associatedClinicId", "associatedPractitionerId"],
195
+ }
196
+ );
197
+
155
198
  export * from "./patient/medical-info.schema";