@blackcode_sa/metaestetics-api 1.7.41 → 1.7.43

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,18 +17,24 @@ import {
17
17
  sendPasswordResetEmail,
18
18
  verifyPasswordResetCode,
19
19
  confirmPasswordReset,
20
+ fetchSignInMethodsForEmail,
20
21
  } from "firebase/auth";
21
22
  import {
23
+ getFirestore,
24
+ collection,
22
25
  doc,
23
- setDoc,
24
26
  getDoc,
25
- serverTimestamp,
26
- collection,
27
+ setDoc,
28
+ updateDoc,
29
+ deleteDoc,
27
30
  query,
28
31
  where,
29
32
  getDocs,
33
+ orderBy,
34
+ limit,
35
+ startAfter,
30
36
  Timestamp,
31
- updateDoc,
37
+ runTransaction,
32
38
  Firestore,
33
39
  } from "firebase/firestore";
34
40
  import { FirebaseApp } from "firebase/app";
@@ -72,6 +78,15 @@ import { PractitionerService } from "./practitioner/practitioner.service";
72
78
  import { practitionerSignupSchema } from "../validations/practitioner.schema";
73
79
  import { CertificationLevel } from "../backoffice/types/static/certification.types";
74
80
  import { MediaService } from "./media/media.service";
81
+ // Import utility functions
82
+ import {
83
+ checkEmailExists,
84
+ cleanupFirebaseUser,
85
+ handleFirebaseError,
86
+ handleSignupError,
87
+ buildPractitionerData,
88
+ validatePractitionerProfileData,
89
+ } from "./auth/utils";
75
90
 
76
91
  export class AuthService extends BaseService {
77
92
  private googleProvider = new GoogleAuthProvider();
@@ -83,15 +98,10 @@ export class AuthService extends BaseService {
83
98
  db: Firestore,
84
99
  auth: Auth,
85
100
  app: FirebaseApp,
86
- userService?: UserService
101
+ userService: UserService
87
102
  ) {
88
103
  super(db, auth, app);
89
-
90
- // Kreiramo UserService ako nije prosleđen
91
- if (!userService) {
92
- userService = new UserService(db, auth, app);
93
- }
94
- this.userService = userService;
104
+ this.userService = userService || new UserService(db, auth, app);
95
105
  }
96
106
 
97
107
  /**
@@ -155,7 +165,7 @@ export class AuthService extends BaseService {
155
165
  });
156
166
  } catch (firebaseError) {
157
167
  console.error("[AUTH] Firebase user creation failed:", firebaseError);
158
- throw firebaseError;
168
+ throw handleFirebaseError(firebaseError);
159
169
  }
160
170
 
161
171
  // Create user with CLINIC_ADMIN role
@@ -879,8 +889,8 @@ export class AuthService extends BaseService {
879
889
  }
880
890
 
881
891
  /**
882
- * Registers a new practitioner user with email and password
883
- * Can either create a new practitioner profile or claim an existing draft profile with a token
892
+ * Registers a new practitioner user with email and password (ATOMIC VERSION)
893
+ * Uses Firestore transactions to ensure atomicity and proper rollback on failures
884
894
  *
885
895
  * @param data - Practitioner signup data containing either new profile details or token for claiming draft profile
886
896
  * @returns Object containing the created user and practitioner profile
@@ -896,27 +906,19 @@ export class AuthService extends BaseService {
896
906
  user: User;
897
907
  practitioner: Practitioner;
898
908
  }> {
909
+ let firebaseUser: any = null;
910
+
899
911
  try {
900
- console.log("[AUTH] Starting practitioner signup process", {
912
+ console.log("[AUTH] Starting atomic practitioner signup process", {
901
913
  email: data.email,
902
914
  hasToken: !!data.token,
903
915
  });
904
916
 
905
- // Validate data
906
- try {
907
- await practitionerSignupSchema.parseAsync(data);
908
- console.log("[AUTH] Practitioner signup data validation passed");
909
- } catch (validationError) {
910
- console.error(
911
- "[AUTH] Validation error in signUpPractitioner:",
912
- validationError
913
- );
914
- throw validationError;
915
- }
917
+ // Step 1: Pre-validate all data before any mutations
918
+ await this.validateSignupData(data);
916
919
 
917
- // Create Firebase user
920
+ // Step 2: Create Firebase user (outside transaction - can't be easily rolled back)
918
921
  console.log("[AUTH] Creating Firebase user");
919
- let firebaseUser;
920
922
  try {
921
923
  const result = await createUserWithEmailAndPassword(
922
924
  this.auth,
@@ -929,159 +931,145 @@ export class AuthService extends BaseService {
929
931
  });
930
932
  } catch (firebaseError) {
931
933
  console.error("[AUTH] Firebase user creation failed:", firebaseError);
932
- throw firebaseError;
934
+ throw handleFirebaseError(firebaseError);
933
935
  }
934
936
 
935
- // Create user with PRACTITIONER role
936
- console.log("[AUTH] Creating user with PRACTITIONER role");
937
- let user;
938
- try {
939
- user = await this.userService.createUser(
940
- firebaseUser,
941
- [UserRole.PRACTITIONER],
942
- {
943
- skipProfileCreation: true, // We'll create the profile separately
944
- }
945
- );
946
- console.log("[AUTH] User with PRACTITIONER role created successfully", {
947
- userId: user.uid,
948
- });
949
- } catch (userCreationError) {
950
- console.error("[AUTH] User creation failed:", userCreationError);
951
- throw userCreationError;
952
- }
953
-
954
- // Initialize practitioner service
955
- console.log("[AUTH] Initializing practitioner service");
956
- const practitionerService = new PractitionerService(
937
+ // Step 3: Execute all database operations in a single transaction
938
+ console.log("[AUTH] Starting Firestore transaction");
939
+ const transactionResult = await runTransaction(
957
940
  this.db,
958
- this.auth,
959
- this.app
960
- );
961
-
962
- let practitioner: Practitioner | null = null;
941
+ async (transaction) => {
942
+ console.log(
943
+ "[AUTH] Transaction started - creating user and practitioner"
944
+ );
963
945
 
964
- // Check if we're claiming an existing draft profile with a token
965
- if (data.token) {
966
- console.log("[AUTH] Token provided, attempting to claim draft profile");
946
+ // Initialize services
947
+ const practitionerService = new PractitionerService(
948
+ this.db,
949
+ this.auth,
950
+ this.app
951
+ );
967
952
 
968
- try {
969
- // Validate token and claim the profile
970
- practitioner = await practitionerService.validateTokenAndClaimProfile(
971
- data.token,
972
- firebaseUser.uid
953
+ // Create user document using existing method (not in transaction for now)
954
+ console.log("[AUTH] Creating user document");
955
+ const user = await this.userService.createUser(
956
+ firebaseUser,
957
+ [UserRole.PRACTITIONER],
958
+ { skipProfileCreation: true }
973
959
  );
974
960
 
975
- if (!practitioner) {
976
- throw new Error("Invalid or expired invitation token");
961
+ let practitioner: Practitioner;
962
+
963
+ // Handle practitioner profile creation/claiming
964
+ if (data.token) {
965
+ console.log(
966
+ "[AUTH] Claiming existing practitioner profile with token"
967
+ );
968
+ const claimedPractitioner =
969
+ await practitionerService.validateTokenAndClaimProfile(
970
+ data.token,
971
+ firebaseUser.uid
972
+ );
973
+ if (!claimedPractitioner) {
974
+ throw new Error("Invalid or expired invitation token");
975
+ }
976
+ practitioner = claimedPractitioner;
977
+ } else {
978
+ console.log("[AUTH] Creating new practitioner profile");
979
+ const practitionerData = buildPractitionerData(
980
+ data,
981
+ firebaseUser.uid
982
+ );
983
+ practitioner = await practitionerService.createPractitioner(
984
+ practitionerData
985
+ );
977
986
  }
978
987
 
979
- console.log("[AUTH] Successfully claimed draft profile", {
980
- practitionerId: practitioner.id,
981
- });
982
-
983
- // Link the practitioner profile to the user
988
+ // Link practitioner to user
989
+ console.log("[AUTH] Linking practitioner to user");
984
990
  await this.userService.updateUser(firebaseUser.uid, {
985
991
  practitionerProfile: practitioner.id,
986
992
  });
987
- console.log(
988
- "[AUTH] User updated with practitioner profile reference"
989
- );
990
- } catch (tokenError) {
991
- console.error("[AUTH] Failed to claim draft profile:", tokenError);
992
- throw tokenError;
993
- }
994
- } else {
995
- console.log("[AUTH] Creating new practitioner profile");
996
993
 
997
- if (!data.profileData) {
998
- data.profileData = {};
994
+ console.log("[AUTH] Transaction operations completed successfully");
995
+ return { user, practitioner };
999
996
  }
997
+ );
1000
998
 
1001
- // We need to create a full PractitionerBasicInfo object
1002
- const basicInfo: PractitionerBasicInfo = {
1003
- firstName: data.firstName || "",
1004
- lastName: data.lastName || "",
1005
- email: data.email,
1006
- phoneNumber: data.profileData.basicInfo?.phoneNumber || "",
1007
- profileImageUrl: data.profileData.basicInfo?.profileImageUrl || "",
1008
- gender: data.profileData.basicInfo?.gender || "other", // Default to "other" if not provided
1009
- bio: data.profileData.basicInfo?.bio || "",
1010
- title: "Practitioner", // Default title
1011
- dateOfBirth: new Date(), // Default to today
1012
- languages: ["English"], // Default language
1013
- };
999
+ console.log("[AUTH] Atomic practitioner signup completed successfully", {
1000
+ userId: transactionResult.user.uid,
1001
+ practitionerId: transactionResult.practitioner.id,
1002
+ });
1014
1003
 
1015
- // Basic certification information with placeholders
1016
- const certification: PractitionerCertification = data.profileData
1017
- .certification || {
1018
- level: CertificationLevel.AESTHETICIAN,
1019
- specialties: [],
1020
- licenseNumber: "Pending",
1021
- issuingAuthority: "Pending",
1022
- issueDate: new Date(),
1023
- verificationStatus: "pending",
1024
- };
1004
+ return transactionResult;
1005
+ } catch (error) {
1006
+ console.error(
1007
+ "[AUTH] Atomic signup failed, initiating cleanup...",
1008
+ error
1009
+ );
1025
1010
 
1026
- // Create basic profile data
1027
- const createPractitionerData: CreatePractitionerData = {
1028
- userRef: firebaseUser.uid,
1029
- basicInfo,
1030
- certification,
1031
- status: PractitionerStatus.ACTIVE,
1032
- isActive: true,
1033
- isVerified: false,
1034
- };
1011
+ // Cleanup Firebase user if transaction failed
1012
+ if (firebaseUser) {
1013
+ await cleanupFirebaseUser(firebaseUser);
1014
+ }
1035
1015
 
1036
- try {
1037
- practitioner = await practitionerService.createPractitioner(
1038
- createPractitionerData
1039
- );
1040
- console.log("[AUTH] Practitioner profile created successfully", {
1041
- practitionerId: practitioner.id,
1042
- });
1016
+ throw handleSignupError(error);
1017
+ }
1018
+ }
1043
1019
 
1044
- // Link the practitioner profile to the user
1045
- await this.userService.updateUser(firebaseUser.uid, {
1046
- practitionerProfile: practitioner.id,
1047
- });
1048
- console.log(
1049
- "[AUTH] User updated with practitioner profile reference"
1050
- );
1051
- } catch (createError) {
1052
- console.error(
1053
- "[AUTH] Failed to create practitioner profile:",
1054
- createError
1055
- );
1056
- throw createError;
1057
- }
1058
- }
1020
+ /**
1021
+ * Pre-validate all signup data before any mutations
1022
+ * Prevents partial creation by catching issues early
1023
+ */
1024
+ private async validateSignupData(data: {
1025
+ email: string;
1026
+ password: string;
1027
+ firstName?: string;
1028
+ lastName?: string;
1029
+ token?: string;
1030
+ profileData?: Partial<CreatePractitionerData>;
1031
+ }): Promise<void> {
1032
+ console.log("[AUTH] Pre-validating signup data");
1059
1033
 
1060
- console.log("[AUTH] Practitioner signup completed successfully", {
1061
- userId: user.uid,
1062
- practitionerId: practitioner?.id || "unknown",
1063
- });
1034
+ try {
1035
+ // 1. Schema validation
1036
+ await practitionerSignupSchema.parseAsync(data);
1037
+ console.log("[AUTH] Schema validation passed");
1038
+
1039
+ // 2. Check if email already exists (before creating Firebase user)
1040
+ const emailExists = await checkEmailExists(this.auth, data.email);
1041
+ if (emailExists) {
1042
+ console.log("[AUTH] Email already exists:", data.email);
1043
+ throw AUTH_ERRORS.EMAIL_ALREADY_EXISTS;
1044
+ }
1045
+ console.log("[AUTH] Email availability confirmed");
1064
1046
 
1065
- return {
1066
- user,
1067
- practitioner,
1068
- };
1069
- } catch (error) {
1070
- if (error instanceof z.ZodError) {
1071
- console.error(
1072
- "[AUTH] Zod validation error in signUpPractitioner:",
1073
- JSON.stringify(error.errors, null, 2)
1047
+ // 3. Validate token if provided
1048
+ if (data.token) {
1049
+ const practitionerService = new PractitionerService(
1050
+ this.db,
1051
+ this.auth,
1052
+ this.app
1074
1053
  );
1075
- throw AUTH_ERRORS.VALIDATION_ERROR;
1054
+ const isValidToken = await practitionerService.validateToken(
1055
+ data.token
1056
+ );
1057
+ if (!isValidToken) {
1058
+ console.log("[AUTH] Invalid token provided:", data.token);
1059
+ throw new Error("Invalid or expired invitation token");
1060
+ }
1061
+ console.log("[AUTH] Token validation passed");
1076
1062
  }
1077
1063
 
1078
- const firebaseError = error as FirebaseError;
1079
- if (firebaseError.code === FirebaseErrorCode.EMAIL_ALREADY_IN_USE) {
1080
- console.error("[AUTH] Email already in use:", data.email);
1081
- throw AUTH_ERRORS.EMAIL_ALREADY_EXISTS;
1064
+ // 4. Validate profile data structure if provided
1065
+ if (data.profileData) {
1066
+ await validatePractitionerProfileData(data.profileData);
1067
+ console.log("[AUTH] Profile data validation passed");
1082
1068
  }
1083
1069
 
1084
- console.error("[AUTH] Unhandled error in signUpPractitioner:", error);
1070
+ console.log("[AUTH] All pre-validation checks passed");
1071
+ } catch (error) {
1072
+ console.error("[AUTH] Pre-validation failed:", error);
1085
1073
  throw error;
1086
1074
  }
1087
1075
  }
@@ -110,7 +110,7 @@ export class PractitionerService extends BaseService {
110
110
  * @returns URL string of the uploaded or existing photo
111
111
  */
112
112
  private async handleProfilePhotoUpload(
113
- profilePhoto: MediaResource | undefined,
113
+ profilePhoto: MediaResource | undefined | null,
114
114
  practitionerId: string
115
115
  ): Promise<string | undefined> {
116
116
  if (!profilePhoto) {
@@ -151,7 +151,9 @@ export class PractitionerService extends BaseService {
151
151
  * @returns Processed basic info with URL string for profileImageUrl
152
152
  */
153
153
  private async processBasicInfo(
154
- basicInfo: PractitionerBasicInfo & { profileImageUrl?: MediaResource },
154
+ basicInfo: PractitionerBasicInfo & {
155
+ profileImageUrl?: MediaResource | null;
156
+ },
155
157
  practitionerId: string
156
158
  ): Promise<PractitionerBasicInfo> {
157
159
  const processedBasicInfo = { ...basicInfo };
@@ -723,7 +725,7 @@ export class PractitionerService extends BaseService {
723
725
  if (validData.basicInfo) {
724
726
  processedData.basicInfo = await this.processBasicInfo(
725
727
  validData.basicInfo as PractitionerBasicInfo & {
726
- profileImageUrl?: MediaResource;
728
+ profileImageUrl?: MediaResource | null;
727
729
  },
728
730
  practitionerId
729
731
  );
@@ -14,9 +14,9 @@ export interface User {
14
14
  email: string | null;
15
15
  roles: UserRole[];
16
16
  isAnonymous: boolean;
17
- createdAt: Timestamp | FieldValue;
18
- updatedAt: Timestamp | FieldValue;
19
- lastLoginAt: Timestamp | FieldValue;
17
+ createdAt: Timestamp | FieldValue | Date;
18
+ updatedAt: Timestamp | FieldValue | Date;
19
+ lastLoginAt: Timestamp | FieldValue | Date;
20
20
  patientProfile?: string;
21
21
  practitionerProfile?: string;
22
22
  adminProfile?: string;
@@ -24,10 +24,10 @@ export interface PractitionerBasicInfo {
24
24
  lastName: string;
25
25
  title: string;
26
26
  email: string;
27
- phoneNumber: string;
28
- dateOfBirth: Timestamp | Date;
27
+ phoneNumber: string | null;
28
+ dateOfBirth: Timestamp | Date | null;
29
29
  gender: "male" | "female" | "other";
30
- profileImageUrl?: MediaResource;
30
+ profileImageUrl?: MediaResource | null;
31
31
  bio?: string;
32
32
  languages: string[];
33
33
  }
@@ -41,7 +41,7 @@ export interface PractitionerCertification {
41
41
  licenseNumber: string;
42
42
  issuingAuthority: string;
43
43
  issueDate: Timestamp | Date;
44
- expiryDate?: Timestamp | Date;
44
+ expiryDate?: Timestamp | Date | null;
45
45
  verificationStatus: "pending" | "verified" | "rejected";
46
46
  }
47
47
 
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { Timestamp } from "firebase/firestore";
3
3
 
4
- // Create a custom schema for Timestamp that properly handles both raw objects and Timestamp instances
4
+ // Create a custom schema for Timestamp that properly handles Timestamp instances, raw objects, and Date objects
5
5
  export const timestampSchema = z
6
6
  .union([
7
7
  z.object({
@@ -9,11 +9,16 @@ export const timestampSchema = z
9
9
  nanoseconds: z.number(),
10
10
  }),
11
11
  z.instanceof(Timestamp),
12
+ z.instanceof(Date), // Add support for Date objects that Firestore returns on client
12
13
  ])
13
14
  .transform((data) => {
14
15
  if (data instanceof Timestamp) {
15
16
  return data;
16
17
  }
18
+ if (data instanceof Date) {
19
+ // Convert Date back to Timestamp for consistency
20
+ return Timestamp.fromDate(data);
21
+ }
17
22
  return new Timestamp(data.seconds, data.nanoseconds);
18
23
  });
19
24
 
@@ -25,10 +25,13 @@ export const practitionerBasicInfoSchema = z.object({
25
25
  lastName: z.string().min(2).max(50),
26
26
  title: z.string().min(2).max(100),
27
27
  email: z.string().email(),
28
- phoneNumber: z.string().regex(/^\+?[1-9]\d{1,14}$/, "Invalid phone number"),
29
- dateOfBirth: z.instanceof(Timestamp).or(z.date()),
28
+ phoneNumber: z
29
+ .string()
30
+ .regex(/^\+?[1-9]\d{1,14}$/, "Invalid phone number")
31
+ .nullable(),
32
+ dateOfBirth: z.instanceof(Timestamp).or(z.date()).nullable(),
30
33
  gender: z.enum(["male", "female", "other"]),
31
- profileImageUrl: mediaResourceSchema.optional(),
34
+ profileImageUrl: mediaResourceSchema.optional().nullable(),
32
35
  bio: z.string().max(1000).optional(),
33
36
  languages: z.array(z.string()).min(1),
34
37
  });
@@ -42,7 +45,7 @@ export const practitionerCertificationSchema = z.object({
42
45
  licenseNumber: z.string().min(3).max(50),
43
46
  issuingAuthority: z.string().min(2).max(100),
44
47
  issueDate: z.instanceof(Timestamp).or(z.date()),
45
- expiryDate: z.instanceof(Timestamp).or(z.date()).optional(),
48
+ expiryDate: z.instanceof(Timestamp).or(z.date()).optional().nullable(),
46
49
  verificationStatus: z.enum(["pending", "verified", "rejected"]),
47
50
  });
48
51
 
@@ -24,21 +24,33 @@ export const userRolesSchema = z
24
24
  .min(1, "User must have at least one role")
25
25
  .max(3, "User cannot have more than 3 roles");
26
26
 
27
- export const timestampSchema = z.custom<Timestamp | FieldValue>((data) => {
28
- // If it's a serverTimestamp (FieldValue), it's valid
29
- if (data && typeof data === "object" && "isEqual" in data) {
30
- return true;
31
- }
27
+ export const timestampSchema = z.custom<Timestamp | FieldValue | Date>(
28
+ (data) => {
29
+ // If it's a serverTimestamp (FieldValue), it's valid
30
+ if (data && typeof data === "object" && "isEqual" in data) {
31
+ return true;
32
+ }
32
33
 
33
- // If it's a Timestamp object, validate its structure
34
- return (
35
- data &&
36
- typeof data === "object" &&
37
- "toDate" in data &&
38
- "seconds" in data &&
39
- "nanoseconds" in data
40
- );
41
- }, "Must be a Timestamp object or serverTimestamp");
34
+ // If it's a Timestamp object, validate its structure
35
+ if (
36
+ data &&
37
+ typeof data === "object" &&
38
+ "toDate" in data &&
39
+ "seconds" in data &&
40
+ "nanoseconds" in data
41
+ ) {
42
+ return true;
43
+ }
44
+
45
+ // If it's a JavaScript Date object (what Firestore returns on client), it's valid
46
+ if (data instanceof Date) {
47
+ return true;
48
+ }
49
+
50
+ return false;
51
+ },
52
+ "Must be a Timestamp object, Date object, or serverTimestamp"
53
+ );
42
54
 
43
55
  /**
44
56
  * Validaciona šema za clinic admin opcije pri kreiranju