@blackcode_sa/metaestetics-api 1.5.0 → 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.
@@ -17,11 +17,20 @@ import {
17
17
  CreatePractitionerData,
18
18
  UpdatePractitionerData,
19
19
  PRACTITIONERS_COLLECTION,
20
+ REGISTER_TOKENS_COLLECTION,
21
+ PractitionerStatus,
22
+ CreateDraftPractitionerData,
23
+ PractitionerToken,
24
+ CreatePractitionerTokenData,
25
+ PractitionerTokenStatus,
20
26
  } from "../../types/practitioner";
21
27
  import { ClinicService } from "../clinic/clinic.service";
22
28
  import {
23
29
  practitionerSchema,
24
30
  createPractitionerSchema,
31
+ createDraftPractitionerSchema,
32
+ practitionerTokenSchema,
33
+ createPractitionerTokenSchema,
25
34
  } from "../../validations/practitioner.schema";
26
35
  import { z } from "zod";
27
36
  import { Auth } from "firebase/auth";
@@ -95,6 +104,7 @@ export class PractitionerService extends BaseService {
95
104
  clinicWorkingHours: validatedData.clinicWorkingHours || [],
96
105
  isActive: validatedData.isActive,
97
106
  isVerified: validatedData.isVerified,
107
+ status: validatedData.status || PractitionerStatus.ACTIVE,
98
108
  createdAt: serverTimestamp(),
99
109
  updatedAt: serverTimestamp(),
100
110
  };
@@ -126,6 +136,278 @@ export class PractitionerService extends BaseService {
126
136
  }
127
137
  }
128
138
 
139
+ /**
140
+ * Kreira novi draft profil zdravstvenog radnika bez povezanog korisnika
141
+ * Koristi se od strane administratora klinike za kreiranje profila i kasnije pozivanje
142
+ * @param data Podaci za kreiranje draft profila
143
+ * @param createdBy ID administratora koji kreira profil
144
+ * @param clinicId ID klinike za koju se kreira profil
145
+ * @returns Objekt koji sadrži kreirani draft profil i token za registraciju
146
+ */
147
+ async createDraftPractitioner(
148
+ data: CreateDraftPractitionerData,
149
+ createdBy: string,
150
+ clinicId: string
151
+ ): Promise<{ practitioner: Practitioner; token: PractitionerToken }> {
152
+ try {
153
+ // Validacija ulaznih podataka
154
+ const validatedData = createDraftPractitionerSchema.parse(data);
155
+
156
+ // Provera da li klinika postoji
157
+ const clinic = await this.getClinicService().getClinic(clinicId);
158
+ if (!clinic) {
159
+ throw new Error(`Clinic ${clinicId} not found`);
160
+ }
161
+
162
+ // Priprema podataka za kreiranje profila
163
+ const clinics = data.clinics || [clinicId];
164
+
165
+ // Provera da li sve dodatno navedene klinike postoje
166
+ if (data.clinics) {
167
+ for (const cId of data.clinics) {
168
+ if (cId !== clinicId) {
169
+ const otherClinic = await this.getClinicService().getClinic(cId);
170
+ if (!otherClinic) {
171
+ throw new Error(`Clinic ${cId} not found`);
172
+ }
173
+ }
174
+ }
175
+ }
176
+
177
+ const practitionerId = this.generateId();
178
+ const practitionerData: Omit<Practitioner, "createdAt" | "updatedAt"> & {
179
+ createdAt: ReturnType<typeof serverTimestamp>;
180
+ updatedAt: ReturnType<typeof serverTimestamp>;
181
+ } = {
182
+ id: practitionerId,
183
+ userRef: "", // Prazno - biće popunjeno kada korisnik kreira nalog
184
+ basicInfo: validatedData.basicInfo,
185
+ certification: validatedData.certification,
186
+ clinics: clinics,
187
+ clinicWorkingHours: validatedData.clinicWorkingHours || [],
188
+ isActive:
189
+ validatedData.isActive !== undefined ? validatedData.isActive : false,
190
+ isVerified:
191
+ validatedData.isVerified !== undefined
192
+ ? validatedData.isVerified
193
+ : false,
194
+ status: PractitionerStatus.DRAFT,
195
+ createdAt: serverTimestamp(),
196
+ updatedAt: serverTimestamp(),
197
+ };
198
+
199
+ // Validacija kompletnog objekta
200
+ // Koristimo privremeni userRef za validaciju, biće prazan u bazi
201
+ practitionerSchema.parse({
202
+ ...practitionerData,
203
+ userRef: "temp-for-validation",
204
+ createdAt: Timestamp.now(),
205
+ updatedAt: Timestamp.now(),
206
+ });
207
+
208
+ // Čuvamo u Firestore
209
+ await setDoc(
210
+ doc(this.db, PRACTITIONERS_COLLECTION, practitionerData.id),
211
+ practitionerData
212
+ );
213
+
214
+ const savedPractitioner = await this.getPractitioner(practitionerData.id);
215
+ if (!savedPractitioner) {
216
+ throw new Error("Failed to create draft practitioner profile");
217
+ }
218
+
219
+ // Automatski kreiramo token za registraciju
220
+ const tokenString = this.generateId().slice(0, 6).toUpperCase();
221
+
222
+ // Default expiration is 7 days from now
223
+ const expiration = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
224
+
225
+ const token: PractitionerToken = {
226
+ id: this.generateId(),
227
+ token: tokenString,
228
+ practitionerId: practitionerId,
229
+ email: practitionerData.basicInfo.email,
230
+ clinicId: clinicId,
231
+ status: PractitionerTokenStatus.ACTIVE,
232
+ createdBy: createdBy,
233
+ createdAt: Timestamp.now(),
234
+ expiresAt: Timestamp.fromDate(expiration),
235
+ };
236
+
237
+ // Validate token object
238
+ practitionerTokenSchema.parse(token);
239
+
240
+ // Store the token in the practitioner document's register_tokens subcollection
241
+ const tokenPath = `${PRACTITIONERS_COLLECTION}/${practitionerId}/${REGISTER_TOKENS_COLLECTION}/${token.id}`;
242
+ await setDoc(doc(this.db, tokenPath), token);
243
+
244
+ // Ovde bi bilo slanje emaila sa tokenom, ali to ćemo implementirati kasnije
245
+ // TODO: Implement email sending with Cloud Functions
246
+
247
+ return { practitioner: savedPractitioner, token };
248
+ } catch (error) {
249
+ if (error instanceof z.ZodError) {
250
+ throw new Error("Invalid practitioner data: " + error.message);
251
+ }
252
+ throw error;
253
+ }
254
+ }
255
+
256
+ /**
257
+ * Creates a token for inviting practitioner to claim their profile
258
+ * @param data Data for creating token
259
+ * @param createdBy ID of the user creating the token
260
+ * @returns Created token
261
+ */
262
+ async createPractitionerToken(
263
+ data: CreatePractitionerTokenData,
264
+ createdBy: string
265
+ ): Promise<PractitionerToken> {
266
+ try {
267
+ // Validate data
268
+ const validatedData = createPractitionerTokenSchema.parse(data);
269
+
270
+ // Check if practitioner exists and is in DRAFT status
271
+ const practitioner = await this.getPractitioner(
272
+ validatedData.practitionerId
273
+ );
274
+ if (!practitioner) {
275
+ throw new Error("Practitioner not found");
276
+ }
277
+
278
+ if (practitioner.status !== PractitionerStatus.DRAFT) {
279
+ throw new Error(
280
+ "Can only create tokens for practitioners in DRAFT status"
281
+ );
282
+ }
283
+
284
+ // Check if clinic exists and practitioner belongs to it
285
+ const clinic = await this.getClinicService().getClinic(
286
+ validatedData.clinicId
287
+ );
288
+ if (!clinic) {
289
+ throw new Error(`Clinic ${validatedData.clinicId} not found`);
290
+ }
291
+
292
+ if (!practitioner.clinics.includes(validatedData.clinicId)) {
293
+ throw new Error("Practitioner is not associated with this clinic");
294
+ }
295
+
296
+ // Default expiration is 7 days from now if not specified
297
+ const expiration =
298
+ validatedData.expiresAt ||
299
+ new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
300
+
301
+ // Generate a token (6 characters) using generateId from BaseService
302
+ const tokenString = this.generateId().slice(0, 6).toUpperCase();
303
+
304
+ const token: PractitionerToken = {
305
+ id: this.generateId(),
306
+ token: tokenString,
307
+ practitionerId: validatedData.practitionerId,
308
+ email: validatedData.email,
309
+ clinicId: validatedData.clinicId,
310
+ status: PractitionerTokenStatus.ACTIVE,
311
+ createdBy: createdBy,
312
+ createdAt: Timestamp.now(),
313
+ expiresAt: Timestamp.fromDate(expiration),
314
+ };
315
+
316
+ // Validate token object
317
+ practitionerTokenSchema.parse(token);
318
+
319
+ // Store the token in the practitioner document's register_tokens subcollection
320
+ const tokenPath = `${PRACTITIONERS_COLLECTION}/${validatedData.practitionerId}/${REGISTER_TOKENS_COLLECTION}/${token.id}`;
321
+ await setDoc(doc(this.db, tokenPath), token);
322
+
323
+ return token;
324
+ } catch (error) {
325
+ if (error instanceof z.ZodError) {
326
+ throw new Error("Invalid token data: " + error.message);
327
+ }
328
+ throw error;
329
+ }
330
+ }
331
+
332
+ /**
333
+ * Gets active tokens for a practitioner
334
+ * @param practitionerId ID of the practitioner
335
+ * @returns Array of active tokens
336
+ */
337
+ async getPractitionerActiveTokens(
338
+ practitionerId: string
339
+ ): Promise<PractitionerToken[]> {
340
+ const tokensRef = collection(
341
+ this.db,
342
+ `${PRACTITIONERS_COLLECTION}/${practitionerId}/${REGISTER_TOKENS_COLLECTION}`
343
+ );
344
+
345
+ const q = query(
346
+ tokensRef,
347
+ where("status", "==", PractitionerTokenStatus.ACTIVE),
348
+ where("expiresAt", ">", Timestamp.now())
349
+ );
350
+
351
+ const querySnapshot = await getDocs(q);
352
+ return querySnapshot.docs.map((doc) => doc.data() as PractitionerToken);
353
+ }
354
+
355
+ /**
356
+ * Gets a token by its string value and validates it
357
+ * @param tokenString The token string to find
358
+ * @returns The token if found and valid, null otherwise
359
+ */
360
+ async validateToken(tokenString: string): Promise<PractitionerToken | null> {
361
+ // We need to search through all practitioners' register_tokens subcollections
362
+ const practitionersRef = collection(this.db, PRACTITIONERS_COLLECTION);
363
+ const practitionersSnapshot = await getDocs(practitionersRef);
364
+
365
+ for (const practitionerDoc of practitionersSnapshot.docs) {
366
+ const practitionerId = practitionerDoc.id;
367
+ const tokensRef = collection(
368
+ this.db,
369
+ `${PRACTITIONERS_COLLECTION}/${practitionerId}/${REGISTER_TOKENS_COLLECTION}`
370
+ );
371
+
372
+ const q = query(
373
+ tokensRef,
374
+ where("token", "==", tokenString),
375
+ where("status", "==", PractitionerTokenStatus.ACTIVE),
376
+ where("expiresAt", ">", Timestamp.now())
377
+ );
378
+
379
+ const tokenSnapshot = await getDocs(q);
380
+ if (!tokenSnapshot.empty) {
381
+ return tokenSnapshot.docs[0].data() as PractitionerToken;
382
+ }
383
+ }
384
+
385
+ return null;
386
+ }
387
+
388
+ /**
389
+ * Marks a token as used
390
+ * @param tokenId ID of the token
391
+ * @param practitionerId ID of the practitioner
392
+ * @param userId ID of the user using the token
393
+ */
394
+ async markTokenAsUsed(
395
+ tokenId: string,
396
+ practitionerId: string,
397
+ userId: string
398
+ ): Promise<void> {
399
+ const tokenRef = doc(
400
+ this.db,
401
+ `${PRACTITIONERS_COLLECTION}/${practitionerId}/${REGISTER_TOKENS_COLLECTION}/${tokenId}`
402
+ );
403
+
404
+ await updateDoc(tokenRef, {
405
+ status: PractitionerTokenStatus.USED,
406
+ usedBy: userId,
407
+ usedAt: Timestamp.now(),
408
+ });
409
+ }
410
+
129
411
  /**
130
412
  * Dohvata zdravstvenog radnika po ID-u
131
413
  */
@@ -167,7 +449,24 @@ export class PractitionerService extends BaseService {
167
449
  const q = query(
168
450
  collection(this.db, PRACTITIONERS_COLLECTION),
169
451
  where("clinics", "array-contains", clinicId),
170
- where("isActive", "==", true)
452
+ where("isActive", "==", true),
453
+ where("status", "==", PractitionerStatus.ACTIVE)
454
+ );
455
+
456
+ const querySnapshot = await getDocs(q);
457
+ return querySnapshot.docs.map((doc) => doc.data() as Practitioner);
458
+ }
459
+
460
+ /**
461
+ * Dohvata sve draft zdravstvene radnike za određenu kliniku
462
+ */
463
+ async getDraftPractitionersByClinic(
464
+ clinicId: string
465
+ ): Promise<Practitioner[]> {
466
+ const q = query(
467
+ collection(this.db, PRACTITIONERS_COLLECTION),
468
+ where("clinics", "array-contains", clinicId),
469
+ where("status", "==", PractitionerStatus.DRAFT)
171
470
  );
172
471
 
173
472
  const querySnapshot = await getDocs(q);
@@ -303,4 +602,49 @@ export class PractitionerService extends BaseService {
303
602
 
304
603
  await deleteDoc(doc(this.db, PRACTITIONERS_COLLECTION, practitionerId));
305
604
  }
605
+
606
+ /**
607
+ * Validates a registration token and claims the associated draft practitioner profile
608
+ * @param tokenString The token provided by the practitioner
609
+ * @param userId The ID of the user claiming the profile
610
+ * @returns The claimed practitioner profile or null if token is invalid
611
+ */
612
+ async validateTokenAndClaimProfile(
613
+ tokenString: string,
614
+ userId: string
615
+ ): Promise<Practitioner | null> {
616
+ // Find the token
617
+ const token = await this.validateToken(tokenString);
618
+ if (!token) {
619
+ return null; // Token not found or not valid
620
+ }
621
+
622
+ // Get the practitioner profile
623
+ const practitioner = await this.getPractitioner(token.practitionerId);
624
+ if (!practitioner) {
625
+ return null; // Practitioner not found
626
+ }
627
+
628
+ // Ensure practitioner is in DRAFT status
629
+ if (practitioner.status !== PractitionerStatus.DRAFT) {
630
+ throw new Error("This practitioner profile has already been claimed");
631
+ }
632
+
633
+ // Check if user already has a practitioner profile
634
+ const existingPractitioner = await this.getPractitionerByUserRef(userId);
635
+ if (existingPractitioner) {
636
+ throw new Error("User already has a practitioner profile");
637
+ }
638
+
639
+ // Claim the profile by linking it to the user
640
+ const updatedPractitioner = await this.updatePractitioner(practitioner.id, {
641
+ userRef: userId,
642
+ status: PractitionerStatus.ACTIVE,
643
+ });
644
+
645
+ // Mark the token as used
646
+ await this.markTokenAsUsed(token.id, token.practitionerId, userId);
647
+
648
+ return updatedPractitioner;
649
+ }
306
650
  }
@@ -5,6 +5,7 @@ import {
5
5
  } from "../../backoffice/types/static/certification.types";
6
6
 
7
7
  export const PRACTITIONERS_COLLECTION = "practitioners";
8
+ export const REGISTER_TOKENS_COLLECTION = "register_tokens";
8
9
 
9
10
  /**
10
11
  * Osnovne informacije o zdravstvenom radniku
@@ -54,6 +55,24 @@ export interface PractitionerClinicWorkingHours {
54
55
  updatedAt: Timestamp;
55
56
  }
56
57
 
58
+ /**
59
+ * Status of practitioner profile
60
+ */
61
+ export enum PractitionerStatus {
62
+ DRAFT = "draft",
63
+ ACTIVE = "active",
64
+ }
65
+
66
+ /**
67
+ * Token status for practitioner invitations
68
+ */
69
+ export enum PractitionerTokenStatus {
70
+ ACTIVE = "active",
71
+ USED = "used",
72
+ EXPIRED = "expired",
73
+ REVOKED = "revoked",
74
+ }
75
+
57
76
  /**
58
77
  * Interfejs za zdravstvenog radnika
59
78
  */
@@ -66,6 +85,7 @@ export interface Practitioner {
66
85
  clinicWorkingHours: PractitionerClinicWorkingHours[]; // Radno vreme za svaku kliniku
67
86
  isActive: boolean;
68
87
  isVerified: boolean;
88
+ status: PractitionerStatus;
69
89
  createdAt: Timestamp;
70
90
  updatedAt: Timestamp;
71
91
  }
@@ -81,6 +101,19 @@ export interface CreatePractitionerData {
81
101
  clinicWorkingHours?: PractitionerClinicWorkingHours[];
82
102
  isActive: boolean;
83
103
  isVerified: boolean;
104
+ status?: PractitionerStatus;
105
+ }
106
+
107
+ /**
108
+ * Tip za kreiranje draft profila zdravstvenog radnika
109
+ */
110
+ export interface CreateDraftPractitionerData {
111
+ basicInfo: PractitionerBasicInfo;
112
+ certification: PractitionerCertification;
113
+ clinics?: string[];
114
+ clinicWorkingHours?: PractitionerClinicWorkingHours[];
115
+ isActive?: boolean;
116
+ isVerified?: boolean;
84
117
  }
85
118
 
86
119
  /**
@@ -134,3 +167,30 @@ export interface PractitionerWorkingHours {
134
167
  createdAt: Timestamp;
135
168
  updatedAt: Timestamp;
136
169
  }
170
+
171
+ /**
172
+ * Token za pozivanje zdravstvenog radnika
173
+ */
174
+ export interface PractitionerToken {
175
+ id: string;
176
+ token: string;
177
+ practitionerId: string;
178
+ email: string;
179
+ clinicId: string;
180
+ status: PractitionerTokenStatus;
181
+ createdBy: string;
182
+ createdAt: Timestamp;
183
+ expiresAt: Timestamp;
184
+ usedBy?: string;
185
+ usedAt?: Timestamp;
186
+ }
187
+
188
+ /**
189
+ * Tip za kreiranje tokena za zdravstvenog radnika
190
+ */
191
+ export interface CreatePractitionerTokenData {
192
+ practitionerId: string;
193
+ email: string;
194
+ clinicId: string;
195
+ expiresAt?: Date;
196
+ }
@@ -4,6 +4,10 @@ import {
4
4
  CertificationLevel,
5
5
  CertificationSpecialty,
6
6
  } from "../backoffice/types/static/certification.types";
7
+ import {
8
+ PractitionerStatus,
9
+ PractitionerTokenStatus,
10
+ } from "../types/practitioner";
7
11
 
8
12
  /**
9
13
  * Šema za validaciju osnovnih informacija o zdravstvenom radniku
@@ -118,6 +122,7 @@ export const practitionerSchema = z.object({
118
122
  clinicWorkingHours: z.array(practitionerClinicWorkingHoursSchema),
119
123
  isActive: z.boolean(),
120
124
  isVerified: z.boolean(),
125
+ status: z.nativeEnum(PractitionerStatus),
121
126
  createdAt: z.instanceof(Timestamp),
122
127
  updatedAt: z.instanceof(Timestamp),
123
128
  });
@@ -133,4 +138,44 @@ export const createPractitionerSchema = z.object({
133
138
  clinicWorkingHours: z.array(practitionerClinicWorkingHoursSchema).optional(),
134
139
  isActive: z.boolean(),
135
140
  isVerified: z.boolean(),
141
+ status: z.nativeEnum(PractitionerStatus).optional(),
142
+ });
143
+
144
+ /**
145
+ * Šema za validaciju podataka pri kreiranju draft profila zdravstvenog radnika
146
+ */
147
+ export const createDraftPractitionerSchema = z.object({
148
+ basicInfo: practitionerBasicInfoSchema,
149
+ certification: practitionerCertificationSchema,
150
+ clinics: z.array(z.string()).optional(),
151
+ clinicWorkingHours: z.array(practitionerClinicWorkingHoursSchema).optional(),
152
+ isActive: z.boolean().optional().default(false),
153
+ isVerified: z.boolean().optional().default(false),
154
+ });
155
+
156
+ /**
157
+ * Šema za validaciju tokena za zdravstvenog radnika
158
+ */
159
+ export const practitionerTokenSchema = z.object({
160
+ id: z.string().min(1),
161
+ token: z.string().min(6),
162
+ practitionerId: z.string().min(1),
163
+ email: z.string().email(),
164
+ clinicId: z.string().min(1),
165
+ status: z.nativeEnum(PractitionerTokenStatus),
166
+ createdBy: z.string().min(1),
167
+ createdAt: z.instanceof(Timestamp),
168
+ expiresAt: z.instanceof(Timestamp),
169
+ usedBy: z.string().optional(),
170
+ usedAt: z.instanceof(Timestamp).optional(),
171
+ });
172
+
173
+ /**
174
+ * Šema za validaciju podataka pri kreiranju tokena za zdravstvenog radnika
175
+ */
176
+ export const createPractitionerTokenSchema = z.object({
177
+ practitionerId: z.string().min(1),
178
+ email: z.string().email(),
179
+ clinicId: z.string().min(1),
180
+ expiresAt: z.date().optional(),
136
181
  });