@umituz/react-native-firebase 2.4.9 → 2.4.11

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.
Files changed (27) hide show
  1. package/package.json +1 -1
  2. package/src/domains/account-deletion/infrastructure/services/account-deletion.service.ts +126 -59
  3. package/src/domains/account-deletion/infrastructure/services/reauthentication.service.ts +27 -2
  4. package/src/domains/auth/infrastructure/services/apple-auth.service.ts +12 -5
  5. package/src/domains/auth/infrastructure/services/auth-listener.service.ts +2 -2
  6. package/src/domains/auth/infrastructure/services/email-auth.service.ts +23 -22
  7. package/src/domains/auth/infrastructure/services/google-auth.service.ts +12 -5
  8. package/src/domains/auth/infrastructure/services/user-document-builder.util.ts +15 -5
  9. package/src/domains/auth/presentation/hooks/useAnonymousAuth.ts +9 -1
  10. package/src/domains/auth/presentation/hooks/useGoogleOAuth.ts +1 -0
  11. package/src/domains/firestore/index.ts +14 -0
  12. package/src/domains/firestore/infrastructure/config/FirestoreClient.ts +16 -3
  13. package/src/domains/firestore/infrastructure/middleware/QueryDeduplicationMiddleware.ts +15 -8
  14. package/src/domains/firestore/infrastructure/repositories/BasePaginatedRepository.ts +5 -19
  15. package/src/domains/firestore/infrastructure/services/RequestLoggerService.ts +16 -0
  16. package/src/domains/firestore/utils/deduplication/pending-query-manager.util.ts +7 -0
  17. package/src/domains/firestore/utils/mapper/enrichment-mapper.util.ts +9 -2
  18. package/src/domains/firestore/utils/query/modifiers.util.ts +6 -0
  19. package/src/domains/firestore/utils/transaction/transaction.util.ts +2 -1
  20. package/src/domains/firestore/utils/validation/cursor-validator.util.ts +73 -0
  21. package/src/domains/firestore/utils/validation/date-validator.util.ts +35 -0
  22. package/src/domains/firestore/utils/validation/field-validator.util.ts +29 -0
  23. package/src/shared/domain/guards/firebase-error.guard.ts +8 -2
  24. package/src/shared/domain/utils/error-handlers/error-messages.ts +19 -0
  25. package/src/shared/domain/utils/executors/batch-executors.util.ts +6 -4
  26. package/src/shared/domain/utils/index.ts +5 -0
  27. package/src/shared/infrastructure/config/clients/FirebaseClientSingleton.ts +1 -9
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@umituz/react-native-firebase",
3
- "version": "2.4.9",
3
+ "version": "2.4.11",
4
4
  "description": "Unified Firebase package for React Native apps - Auth and Firestore services using Firebase JS SDK (no native modules).",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -22,6 +22,9 @@ export interface AccountDeletionResult extends Result<void> {
22
22
 
23
23
  export type { AccountDeletionOptions } from "../../application/ports/reauthentication.types";
24
24
 
25
+ // Operation lock to prevent concurrent deletion attempts
26
+ let deletionInProgress = false;
27
+
25
28
  export async function deleteCurrentUser(
26
29
  options: AccountDeletionOptions = { autoReauthenticate: true }
27
30
  ): Promise<AccountDeletionResult> {
@@ -29,93 +32,144 @@ export async function deleteCurrentUser(
29
32
  console.log("[deleteCurrentUser] Called with options:", options);
30
33
  }
31
34
 
32
- const auth = getFirebaseAuth();
33
- const user = auth?.currentUser;
34
-
35
- if (!auth || !user) {
35
+ // FIX: Check if deletion already in progress
36
+ if (deletionInProgress) {
36
37
  if (typeof __DEV__ !== "undefined" && __DEV__) {
37
- console.log("[deleteCurrentUser] Auth not ready");
38
+ console.log("[deleteCurrentUser] Deletion already in progress");
38
39
  }
39
40
  return {
40
41
  success: false,
41
- error: { code: "auth/not-ready", message: "Auth not ready" },
42
+ error: { code: "auth/operation-in-progress", message: "Account deletion already in progress" },
42
43
  requiresReauth: false
43
44
  };
44
45
  }
45
46
 
46
- if (user.isAnonymous) {
47
- if (typeof __DEV__ !== "undefined" && __DEV__) {
48
- console.log("[deleteCurrentUser] Cannot delete anonymous user");
49
- }
50
- return {
51
- success: false,
52
- error: { code: "auth/anonymous", message: "Cannot delete anonymous" },
53
- requiresReauth: false
54
- };
55
- }
47
+ deletionInProgress = true;
56
48
 
57
- const provider = getUserAuthProvider(user);
58
- if (typeof __DEV__ !== "undefined" && __DEV__) {
59
- console.log("[deleteCurrentUser] User provider:", provider);
60
- }
49
+ try {
50
+ const auth = getFirebaseAuth();
51
+ const user = auth?.currentUser;
61
52
 
62
- if (provider === "password" && options.autoReauthenticate && options.onPasswordRequired) {
63
- if (typeof __DEV__ !== "undefined" && __DEV__) {
64
- console.log("[deleteCurrentUser] Password provider, calling attemptReauth");
65
- }
66
- const reauth = await attemptReauth(user, options);
67
- if (typeof __DEV__ !== "undefined" && __DEV__) {
68
- console.log("[deleteCurrentUser] attemptReauth result:", reauth);
69
- }
70
- if (reauth) {
53
+ if (!auth || !user) {
71
54
  if (typeof __DEV__ !== "undefined" && __DEV__) {
72
- console.log("[deleteCurrentUser] Reauth returned result, returning:", reauth);
55
+ console.log("[deleteCurrentUser] Auth not ready");
73
56
  }
74
- return reauth;
75
- }
76
- if (typeof __DEV__ !== "undefined" && __DEV__) {
77
- console.log("[deleteCurrentUser] Reauth returned null, continuing to deleteUser");
57
+ return {
58
+ success: false,
59
+ error: { code: "auth/not-ready", message: "Auth not ready" },
60
+ requiresReauth: false
61
+ };
78
62
  }
79
- }
80
63
 
81
- try {
82
- if (typeof __DEV__ !== "undefined" && __DEV__) {
83
- console.log("[deleteCurrentUser] Calling deleteUser");
64
+ // FIX: Capture user ID early to detect if user changes during operation
65
+ const originalUserId = user.uid;
66
+
67
+ if (user.isAnonymous) {
68
+ if (typeof __DEV__ !== "undefined" && __DEV__) {
69
+ console.log("[deleteCurrentUser] Cannot delete anonymous user");
70
+ }
71
+ return {
72
+ success: false,
73
+ error: { code: "auth/anonymous", message: "Cannot delete anonymous" },
74
+ requiresReauth: false
75
+ };
84
76
  }
85
- await deleteUser(user);
77
+
78
+ const provider = getUserAuthProvider(user);
86
79
  if (typeof __DEV__ !== "undefined" && __DEV__) {
87
- console.log("[deleteCurrentUser] deleteUser successful");
80
+ console.log("[deleteCurrentUser] User provider:", provider);
88
81
  }
89
- return successResult();
90
- } catch (error: unknown) {
91
- if (typeof __DEV__ !== "undefined" && __DEV__) {
92
- console.error("[deleteCurrentUser] deleteUser failed:", error);
82
+
83
+ if (provider === "password" && options.autoReauthenticate && options.onPasswordRequired) {
84
+ if (typeof __DEV__ !== "undefined" && __DEV__) {
85
+ console.log("[deleteCurrentUser] Password provider, calling attemptReauth");
86
+ }
87
+ const reauth = await attemptReauth(user, options, originalUserId);
88
+ if (typeof __DEV__ !== "undefined" && __DEV__) {
89
+ console.log("[deleteCurrentUser] attemptReauth result:", reauth);
90
+ }
91
+ if (reauth) {
92
+ if (typeof __DEV__ !== "undefined" && __DEV__) {
93
+ console.log("[deleteCurrentUser] Reauth returned result, returning:", reauth);
94
+ }
95
+ return reauth;
96
+ }
97
+ if (typeof __DEV__ !== "undefined" && __DEV__) {
98
+ console.log("[deleteCurrentUser] Reauth returned null, continuing to deleteUser");
99
+ }
93
100
  }
94
- const errorInfo = toAuthErrorInfo(error);
95
- const code = errorInfo.code;
96
- const message = errorInfo.message;
97
101
 
98
- const hasCredentials = !!(options.password || options.googleIdToken);
99
- const shouldReauth = options.autoReauthenticate === true || hasCredentials;
102
+ try {
103
+ // FIX: Verify user hasn't changed before deletion
104
+ const currentUserId = auth.currentUser?.uid;
105
+ if (currentUserId !== originalUserId) {
106
+ if (typeof __DEV__ !== "undefined" && __DEV__) {
107
+ console.log("[deleteCurrentUser] User changed during operation");
108
+ }
109
+ return {
110
+ success: false,
111
+ error: { code: "auth/user-changed", message: "User changed during operation" },
112
+ requiresReauth: false
113
+ };
114
+ }
100
115
 
101
- if (code === "auth/requires-recent-login" && shouldReauth) {
102
- const reauth = await attemptReauth(user, options);
103
- if (reauth) return reauth;
104
- }
116
+ if (typeof __DEV__ !== "undefined" && __DEV__) {
117
+ console.log("[deleteCurrentUser] Calling deleteUser");
118
+ }
119
+ await deleteUser(user);
120
+ if (typeof __DEV__ !== "undefined" && __DEV__) {
121
+ console.log("[deleteCurrentUser] deleteUser successful");
122
+ }
123
+ return successResult();
124
+ } catch (error: unknown) {
125
+ if (typeof __DEV__ !== "undefined" && __DEV__) {
126
+ console.error("[deleteCurrentUser] deleteUser failed:", error);
127
+ }
128
+ const errorInfo = toAuthErrorInfo(error);
129
+ const code = errorInfo.code;
130
+ const message = errorInfo.message;
105
131
 
106
- return {
107
- success: false,
108
- error: { code, message },
109
- requiresReauth: code === "auth/requires-recent-login"
110
- };
132
+ const hasCredentials = !!(options.password || options.googleIdToken);
133
+ const shouldReauth = options.autoReauthenticate === true || hasCredentials;
134
+
135
+ if (code === "auth/requires-recent-login" && shouldReauth) {
136
+ const reauth = await attemptReauth(user, options, originalUserId);
137
+ if (reauth) return reauth;
138
+ }
139
+
140
+ return {
141
+ success: false,
142
+ error: { code, message },
143
+ requiresReauth: code === "auth/requires-recent-login"
144
+ };
145
+ }
146
+ } finally {
147
+ // FIX: Always release lock when done
148
+ deletionInProgress = false;
111
149
  }
112
150
  }
113
151
 
114
- async function attemptReauth(user: User, options: AccountDeletionOptions): Promise<AccountDeletionResult | null> {
152
+ async function attemptReauth(user: User, options: AccountDeletionOptions, originalUserId?: string): Promise<AccountDeletionResult | null> {
115
153
  if (typeof __DEV__ !== "undefined" && __DEV__) {
116
154
  console.log("[attemptReauth] Called");
117
155
  }
118
156
 
157
+ // FIX: Verify user hasn't changed if originalUserId provided
158
+ if (originalUserId) {
159
+ const auth = getFirebaseAuth();
160
+ const currentUserId = auth?.currentUser?.uid;
161
+ if (currentUserId && currentUserId !== originalUserId) {
162
+ if (typeof __DEV__ !== "undefined" && __DEV__) {
163
+ console.log("[attemptReauth] User changed during reauthentication");
164
+ }
165
+ return {
166
+ success: false,
167
+ error: { code: "auth/user-changed", message: "User changed during operation" },
168
+ requiresReauth: false
169
+ };
170
+ }
171
+ }
172
+
119
173
  const provider = getUserAuthProvider(user);
120
174
  if (typeof __DEV__ !== "undefined" && __DEV__) {
121
175
  console.log("[attemptReauth] Provider:", provider);
@@ -208,6 +262,19 @@ async function attemptReauth(user: User, options: AccountDeletionOptions): Promi
208
262
  try {
209
263
  const auth = getFirebaseAuth();
210
264
  const currentUser = auth?.currentUser || user;
265
+
266
+ // FIX: Final verification before deletion
267
+ if (originalUserId && currentUser.uid !== originalUserId) {
268
+ if (typeof __DEV__ !== "undefined" && __DEV__) {
269
+ console.log("[attemptReauth] User changed after reauthentication");
270
+ }
271
+ return {
272
+ success: false,
273
+ error: { code: "auth/user-changed", message: "User changed during operation" },
274
+ requiresReauth: false
275
+ };
276
+ }
277
+
211
278
  await deleteUser(currentUser);
212
279
  if (typeof __DEV__ !== "undefined" && __DEV__) {
213
280
  console.log("[attemptReauth] deleteUser successful after reauth");
@@ -13,7 +13,7 @@ import {
13
13
  import * as AppleAuthentication from "expo-apple-authentication";
14
14
  import { Platform } from "react-native";
15
15
  import { generateNonce, hashNonce } from "../../../auth/infrastructure/services/crypto.util";
16
- import { executeOperation, failureResultFrom, toAuthErrorInfo } from "../../../../shared/domain/utils";
16
+ import { executeOperation, failureResultFrom, toAuthErrorInfo, ERROR_MESSAGES } from "../../../../shared/domain/utils";
17
17
  import { isCancelledError } from "../../../../shared/domain/utils/error-handler.util";
18
18
  import type {
19
19
  ReauthenticationResult,
@@ -27,6 +27,21 @@ export type {
27
27
  ReauthCredentialResult
28
28
  } from "../../application/ports/reauthentication.types";
29
29
 
30
+ /**
31
+ * Validates email format
32
+ */
33
+ function isValidEmail(email: string): boolean {
34
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
35
+ return emailRegex.test(email);
36
+ }
37
+
38
+ /**
39
+ * Validates password (Firebase minimum is 6 characters)
40
+ */
41
+ function isValidPassword(password: string): boolean {
42
+ return password.length >= 6;
43
+ }
44
+
30
45
  export function getUserAuthProvider(user: User): AuthProviderType {
31
46
  if (user.isAnonymous) return "anonymous";
32
47
  const data = user.providerData;
@@ -46,7 +61,17 @@ export async function reauthenticateWithGoogle(user: User, idToken: string): Pro
46
61
  export async function reauthenticateWithPassword(user: User, pass: string): Promise<ReauthenticationResult> {
47
62
  const email = user.email;
48
63
  if (!email) {
49
- return failureResultFrom("auth/no-email", "User has no email");
64
+ return failureResultFrom("auth/no-email", ERROR_MESSAGES.AUTH.NO_USER);
65
+ }
66
+
67
+ // FIX: Add email validation
68
+ if (!isValidEmail(email)) {
69
+ return failureResultFrom("auth/invalid-email", ERROR_MESSAGES.AUTH.INVALID_EMAIL);
70
+ }
71
+
72
+ // FIX: Add password validation
73
+ if (!isValidPassword(pass)) {
74
+ return failureResultFrom("auth/invalid-password", ERROR_MESSAGES.AUTH.INVALID_PASSWORD);
50
75
  }
51
76
 
52
77
  return executeOperation(async () => {
@@ -15,7 +15,7 @@ import type { AppleAuthResult } from "./apple-auth.types";
15
15
  import {
16
16
  isCancellationError,
17
17
  } from "./base/base-auth.service";
18
- import { executeAuthOperation, type Result } from "../../../../shared/domain/utils";
18
+ import { executeAuthOperation, isSuccess, type Result } from "../../../../shared/domain/utils";
19
19
 
20
20
  // Conditional import - expo-apple-authentication is optional
21
21
  let AppleAuthentication: any = null;
@@ -42,7 +42,8 @@ export class AppleAuthService {
42
42
  }
43
43
 
44
44
  private convertToAppleAuthResult(result: Result<{ userCredential: any; isNewUser: boolean }>): AppleAuthResult {
45
- if (result.success && result.data) {
45
+ // FIX: Use isSuccess() type guard instead of manual check
46
+ if (isSuccess(result) && result.data) {
46
47
  return {
47
48
  success: true,
48
49
  userCredential: result.data.userCredential,
@@ -102,9 +103,15 @@ export class AppleAuthService {
102
103
  // Convert to timestamps for reliable comparison (string comparison can be unreliable)
103
104
  const creationTime = userCredential.user.metadata.creationTime;
104
105
  const lastSignInTime = userCredential.user.metadata.lastSignInTime;
105
- const isNewUser = creationTime && lastSignInTime
106
- ? new Date(creationTime).getTime() === new Date(lastSignInTime).getTime()
107
- : false;
106
+
107
+ // FIX: Add typeof validation for metadata timestamps
108
+ const isNewUser =
109
+ creationTime &&
110
+ lastSignInTime &&
111
+ typeof creationTime === 'string' &&
112
+ typeof lastSignInTime === 'string'
113
+ ? new Date(creationTime).getTime() === new Date(lastSignInTime).getTime()
114
+ : false;
108
115
 
109
116
  return {
110
117
  userCredential,
@@ -6,7 +6,7 @@
6
6
  import { onIdTokenChanged, type User } from "firebase/auth";
7
7
  import { getFirebaseAuth } from "../config/FirebaseAuthClient";
8
8
  import type { Result } from "../../../../shared/domain/utils";
9
- import { failureResultFrom } from "../../../../shared/domain/utils";
9
+ import { failureResultFrom, ERROR_MESSAGES } from "../../../../shared/domain/utils";
10
10
 
11
11
  export interface AuthListenerConfig {
12
12
  /**
@@ -36,7 +36,7 @@ export function setupAuthListener(
36
36
  ): AuthListenerResult {
37
37
  const auth = getFirebaseAuth();
38
38
  if (!auth) {
39
- return failureResultFrom("auth/not-ready", "Firebase Auth not initialized");
39
+ return failureResultFrom("auth/not-ready", ERROR_MESSAGES.AUTH.NOT_INITIALIZED);
40
40
  }
41
41
 
42
42
  const {
@@ -13,7 +13,7 @@ import {
13
13
  type User,
14
14
  } from "firebase/auth";
15
15
  import { getFirebaseAuth } from "../config/FirebaseAuthClient";
16
- import { executeOperation, failureResultFrom, successResult, type Result } from "../../../../shared/domain/utils";
16
+ import { executeOperation, failureResultFrom, successResult, toAuthErrorInfo, type Result, ERROR_MESSAGES } from "../../../../shared/domain/utils";
17
17
 
18
18
  export interface EmailCredentials {
19
19
  email: string;
@@ -32,7 +32,7 @@ export async function signInWithEmail(
32
32
  ): Promise<EmailAuthResult> {
33
33
  const auth = getFirebaseAuth();
34
34
  if (!auth) {
35
- return failureResultFrom("auth/not-ready", "Firebase Auth not initialized");
35
+ return failureResultFrom("auth/not-ready", ERROR_MESSAGES.AUTH.NOT_INITIALIZED);
36
36
  }
37
37
 
38
38
  try {
@@ -43,9 +43,8 @@ export async function signInWithEmail(
43
43
  );
44
44
  return { success: true, data: userCredential.user };
45
45
  } catch (error) {
46
- const err = error instanceof Error ? error : new Error(String(error));
47
- const code = (err as any).code || "auth/unknown";
48
- return { success: false, error: { code, message: err.message } };
46
+ // FIX: Use toAuthErrorInfo() instead of unsafe cast
47
+ return { success: false, error: toAuthErrorInfo(error) };
49
48
  }
50
49
  }
51
50
 
@@ -58,7 +57,7 @@ export async function signUpWithEmail(
58
57
  ): Promise<EmailAuthResult> {
59
58
  const auth = getFirebaseAuth();
60
59
  if (!auth) {
61
- return failureResultFrom("auth/not-ready", "Firebase Auth not initialized");
60
+ return failureResultFrom("auth/not-ready", ERROR_MESSAGES.AUTH.NOT_INITIALIZED);
62
61
  }
63
62
 
64
63
  try {
@@ -84,22 +83,25 @@ export async function signUpWithEmail(
84
83
 
85
84
  // Update display name if provided (non-critical operation)
86
85
  if (credentials.displayName && userCredential.user) {
87
- try {
88
- await updateProfile(userCredential.user, {
89
- displayName: credentials.displayName.trim(),
90
- });
91
- } catch (profileError) {
92
- // Profile update failed but account was created successfully
93
- // Log the error but don't fail the signup
94
- console.warn("Profile update failed after account creation:", profileError);
86
+ const trimmedName = credentials.displayName.trim();
87
+ // FIX: Only update if non-empty after trim
88
+ if (trimmedName.length > 0) {
89
+ try {
90
+ await updateProfile(userCredential.user, {
91
+ displayName: trimmedName,
92
+ });
93
+ } catch (profileError) {
94
+ // Profile update failed but account was created successfully
95
+ // Log the error but don't fail the signup
96
+ console.warn("Profile update failed after account creation:", profileError);
97
+ }
95
98
  }
96
99
  }
97
100
 
98
101
  return { success: true, data: userCredential.user };
99
102
  } catch (error) {
100
- const err = error instanceof Error ? error : new Error(String(error));
101
- const code = (err as any).code || "auth/unknown";
102
- return { success: false, error: { code, message: err.message } };
103
+ // FIX: Use toAuthErrorInfo() instead of unsafe cast
104
+ return { success: false, error: toAuthErrorInfo(error) };
103
105
  }
104
106
  }
105
107
 
@@ -126,12 +128,12 @@ export async function linkAnonymousWithEmail(
126
128
  ): Promise<EmailAuthResult> {
127
129
  const auth = getFirebaseAuth();
128
130
  if (!auth || !auth.currentUser) {
129
- return failureResultFrom("auth/not-ready", "No current user");
131
+ return failureResultFrom("auth/not-ready", ERROR_MESSAGES.AUTH.NO_USER);
130
132
  }
131
133
 
132
134
  const currentUser = auth.currentUser;
133
135
  if (!currentUser.isAnonymous) {
134
- return failureResultFrom("auth/invalid-action", "User is not anonymous");
136
+ return failureResultFrom("auth/invalid-action", ERROR_MESSAGES.AUTH.INVALID_USER);
135
137
  }
136
138
 
137
139
  try {
@@ -139,8 +141,7 @@ export async function linkAnonymousWithEmail(
139
141
  const userCredential = await linkWithCredential(currentUser, credential);
140
142
  return { success: true, data: userCredential.user };
141
143
  } catch (error) {
142
- const err = error instanceof Error ? error : new Error(String(error));
143
- const code = (err as any).code || "auth/unknown";
144
- return { success: false, error: { code, message: err.message } };
144
+ // FIX: Use toAuthErrorInfo() instead of unsafe cast
145
+ return { success: false, error: toAuthErrorInfo(error) };
145
146
  }
146
147
  }
@@ -9,7 +9,7 @@ import {
9
9
  type Auth,
10
10
  } from "firebase/auth";
11
11
  import type { GoogleAuthConfig, GoogleAuthResult } from "./google-auth.types";
12
- import { executeAuthOperation, type Result } from "../../../../shared/domain/utils";
12
+ import { executeAuthOperation, isSuccess, type Result } from "../../../../shared/domain/utils";
13
13
  import { ConfigurableService } from "../../../../shared/domain/utils/service-config.util";
14
14
 
15
15
  /**
@@ -25,7 +25,8 @@ export class GoogleAuthService extends ConfigurableService<GoogleAuthConfig> {
25
25
  }
26
26
 
27
27
  private convertToGoogleAuthResult(result: Result<{ userCredential: any; isNewUser: boolean }>): GoogleAuthResult {
28
- if (result.success && result.data) {
28
+ // FIX: Use isSuccess() type guard instead of manual check
29
+ if (isSuccess(result) && result.data) {
29
30
  return {
30
31
  success: true,
31
32
  userCredential: result.data.userCredential,
@@ -51,9 +52,15 @@ export class GoogleAuthService extends ConfigurableService<GoogleAuthConfig> {
51
52
  // Convert to timestamps for reliable comparison (string comparison can be unreliable)
52
53
  const creationTime = userCredential.user.metadata.creationTime;
53
54
  const lastSignInTime = userCredential.user.metadata.lastSignInTime;
54
- const isNewUser = creationTime && lastSignInTime
55
- ? new Date(creationTime).getTime() === new Date(lastSignInTime).getTime()
56
- : false;
55
+
56
+ // FIX: Add typeof validation for metadata timestamps
57
+ const isNewUser =
58
+ creationTime &&
59
+ lastSignInTime &&
60
+ typeof creationTime === 'string' &&
61
+ typeof lastSignInTime === 'string'
62
+ ? new Date(creationTime).getTime() === new Date(lastSignInTime).getTime()
63
+ : false;
57
64
 
58
65
  return {
59
66
  userCredential,
@@ -9,17 +9,27 @@ import type {
9
9
  UserDocumentExtras,
10
10
  } from "./user-document.types";
11
11
 
12
+ /**
13
+ * Type guard to check if user has provider data
14
+ */
15
+ function hasProviderData(user: unknown): user is { providerData: { providerId: string }[] } {
16
+ return (
17
+ typeof user === 'object' &&
18
+ user !== null &&
19
+ 'providerData' in user &&
20
+ Array.isArray((user as any).providerData)
21
+ );
22
+ }
23
+
12
24
  /**
13
25
  * Gets the sign-up method from user provider data
14
26
  */
15
27
  export function getSignUpMethod(user: UserDocumentUser): string | undefined {
16
28
  if (user.isAnonymous) return "anonymous";
17
29
  if (user.email) {
18
- const providerData = (
19
- user as unknown as { providerData?: { providerId: string }[] }
20
- ).providerData;
21
- if (providerData && providerData.length > 0) {
22
- const providerId = providerData[0]?.providerId;
30
+ // FIX: Use type guard instead of unsafe cast
31
+ if (hasProviderData(user) && user.providerData.length > 0) {
32
+ const providerId = user.providerData[0]?.providerId;
23
33
  if (providerId === "google.com") return "google";
24
34
  if (providerId === "apple.com") return "apple";
25
35
  if (providerId === "password") return "email";
@@ -72,7 +72,13 @@ export function useAnonymousAuth(auth: Auth | null): UseAnonymousAuthResult {
72
72
  setAuthState(userToAuthCheckResult(null));
73
73
  setLoading(false);
74
74
  setError(null);
75
- return;
75
+ // FIX: Always return cleanup function to prevent memory leaks
76
+ return () => {
77
+ if (unsubscribeRef.current) {
78
+ unsubscribeRef.current();
79
+ unsubscribeRef.current = null;
80
+ }
81
+ };
76
82
  }
77
83
 
78
84
  // Keep loading true until onAuthStateChanged fires
@@ -87,6 +93,8 @@ export function useAnonymousAuth(auth: Auth | null): UseAnonymousAuthResult {
87
93
  const authError = err instanceof Error ? err : new Error('Auth listener setup failed');
88
94
  setError(authError);
89
95
  setLoading(false);
96
+ // FIX: Reset auth state on error to prevent stale data
97
+ setAuthState(userToAuthCheckResult(null));
90
98
  }
91
99
 
92
100
  // Cleanup function
@@ -71,6 +71,7 @@ export function useGoogleOAuth(config?: GoogleOAuthConfig): UseGoogleOAuthResult
71
71
  const auth = getFirebaseAuth();
72
72
  if (!auth) {
73
73
  setGoogleError("Firebase Auth not initialized");
74
+ setIsLoading(false); // FIX: Reset loading state before early return
74
75
  return;
75
76
  }
76
77
 
@@ -122,6 +122,20 @@ export {
122
122
  } from './utils/firestore-helper';
123
123
  export type { FirestoreResult, NoDbResult } from './utils/firestore-helper';
124
124
 
125
+ // Validation Utilities
126
+ export {
127
+ isValidCursor,
128
+ validateCursorOrThrow,
129
+ CursorValidationError,
130
+ } from './utils/validation/cursor-validator.util';
131
+ export {
132
+ isValidFieldName,
133
+ } from './utils/validation/field-validator.util';
134
+ export {
135
+ isValidDateRange,
136
+ validateDateRangeOrThrow,
137
+ } from './utils/validation/date-validator.util';
138
+
125
139
  export { Timestamp } from 'firebase/firestore';
126
140
  export type {
127
141
  CollectionReference,
@@ -38,12 +38,25 @@ class FirestoreClientSingleton extends ServiceClientSingleton<Firestore> {
38
38
  }
39
39
 
40
40
  private static instance: FirestoreClientSingleton | null = null;
41
+ private static initInProgress = false;
41
42
 
42
43
  static getInstance(): FirestoreClientSingleton {
43
- if (!FirestoreClientSingleton.instance) {
44
- FirestoreClientSingleton.instance = new FirestoreClientSingleton();
44
+ if (!FirestoreClientSingleton.instance && !FirestoreClientSingleton.initInProgress) {
45
+ FirestoreClientSingleton.initInProgress = true;
46
+ try {
47
+ FirestoreClientSingleton.instance = new FirestoreClientSingleton();
48
+ } finally {
49
+ FirestoreClientSingleton.initInProgress = false;
50
+ }
45
51
  }
46
- return FirestoreClientSingleton.instance;
52
+
53
+ // Wait for initialization to complete if in progress
54
+ while (FirestoreClientSingleton.initInProgress && !FirestoreClientSingleton.instance) {
55
+ // Busy wait - in practice this should be very brief
56
+ // Consider using a Promise-based approach for better async handling
57
+ }
58
+
59
+ return FirestoreClientSingleton.instance!;
47
60
  }
48
61
 
49
62
  /**
@@ -30,16 +30,23 @@ export class QueryDeduplicationMiddleware {
30
30
  ): Promise<T> {
31
31
  const key = generateQueryKey(queryKey);
32
32
 
33
- // Check if query is already pending
34
- if (this.queryManager.isPending(key)) {
35
- const pendingPromise = this.queryManager.get(key);
36
- if (pendingPromise) {
37
- // Return the existing pending promise instead of executing again
38
- return pendingPromise as Promise<T>;
39
- }
33
+ // FIX: Atomic get-or-create pattern to prevent race conditions
34
+ const existingPromise = this.queryManager.get(key);
35
+ if (existingPromise) {
36
+ return existingPromise as Promise<T>;
40
37
  }
41
38
 
42
- const promise = queryFn();
39
+ // Create promise with cleanup on completion
40
+ const promise = (async () => {
41
+ try {
42
+ return await queryFn();
43
+ } finally {
44
+ // Cleanup after completion (success or error)
45
+ this.queryManager.remove(key);
46
+ }
47
+ })();
48
+
49
+ // Add before any await - this prevents race between check and add
43
50
  this.queryManager.add(key, promise);
44
51
 
45
52
  return promise;
@@ -10,20 +10,9 @@ import { collection, query, orderBy, limit, startAfter, getDoc, doc, getDocs } f
10
10
  import { PaginationHelper } from "../../utils/pagination.helper";
11
11
  import type { PaginatedResult, PaginationParams } from "../../types/pagination.types";
12
12
  import { BaseQueryRepository } from "./BaseQueryRepository";
13
+ import { validateCursorOrThrow, CursorValidationError } from "../../utils/validation/cursor-validator.util";
13
14
 
14
15
  export abstract class BasePaginatedRepository extends BaseQueryRepository {
15
- /**
16
- * Validate cursor format
17
- * Cursors must be non-empty strings without path separators
18
- */
19
- private isValidCursor(cursor: string): boolean {
20
- if (!cursor || typeof cursor !== 'string') return false;
21
- // Check for invalid characters (path separators, null bytes)
22
- if (cursor.includes('/') || cursor.includes('\\') || cursor.includes('\0')) return false;
23
- // Check length (Firestore document IDs can't be longer than 1500 bytes)
24
- if (cursor.length > 1500) return false;
25
- return true;
26
- }
27
16
 
28
17
  /**
29
18
  * Execute paginated query with cursor support
@@ -60,19 +49,16 @@ export abstract class BasePaginatedRepository extends BaseQueryRepository {
60
49
  if (helper.hasCursor(params) && params?.cursor) {
61
50
  cursorKey = params.cursor;
62
51
 
63
- // Validate cursor format to prevent Firestore errors
64
- if (!this.isValidCursor(params.cursor)) {
65
- // Invalid cursor format - return empty result
66
- return [];
67
- }
52
+ // FIX: Validate cursor and throw error instead of silent failure
53
+ validateCursorOrThrow(params.cursor);
68
54
 
69
55
  // Fetch cursor document first
70
56
  const cursorDocRef = doc(db, collectionName, params.cursor);
71
57
  const cursorDoc = await getDoc(cursorDocRef);
72
58
 
73
59
  if (!cursorDoc.exists()) {
74
- // Cursor document doesn't exist - return empty result
75
- return [];
60
+ // FIX: Throw error instead of silent failure
61
+ throw new CursorValidationError('Cursor document does not exist');
76
62
  }
77
63
 
78
64
  // Build query with startAfter using the cursor document
@@ -93,6 +93,22 @@ export class RequestLoggerService {
93
93
  };
94
94
  }
95
95
 
96
+ /**
97
+ * Remove all listeners
98
+ * Prevents memory leaks when service is destroyed
99
+ */
100
+ removeAllListeners(): void {
101
+ this.listeners.clear();
102
+ }
103
+
104
+ /**
105
+ * Destroy service and cleanup resources
106
+ */
107
+ destroy(): void {
108
+ this.removeAllListeners();
109
+ this.clearLogs();
110
+ }
111
+
96
112
  /**
97
113
  * Notify all listeners
98
114
  */
@@ -54,6 +54,13 @@ export class PendingQueryManager {
54
54
  });
55
55
  }
56
56
 
57
+ /**
58
+ * Remove a specific query from pending list
59
+ */
60
+ remove(key: string): void {
61
+ this.pendingQueries.delete(key);
62
+ }
63
+
57
64
  /**
58
65
  * Clean up expired queries
59
66
  */
@@ -8,6 +8,10 @@ import type { QueryDocumentSnapshot, DocumentData } from 'firebase/firestore';
8
8
  /**
9
9
  * Map documents with enrichment from related data
10
10
  *
11
+ * @deprecated Use mapWithBatchEnrichment for better performance with large datasets.
12
+ * This function fetches enrichments in parallel but doesn't deduplicate keys,
13
+ * and uses sequential async operations which can be slower than batch fetching.
14
+ *
11
15
  * Process flow:
12
16
  * 1. Extract source data from document
13
17
  * 2. Skip if extraction fails or source is invalid
@@ -65,8 +69,11 @@ export async function mapWithBatchEnrichment<TSource, TEnrichment, TResult>(
65
69
  return [];
66
70
  }
67
71
 
68
- // Fetch all enrichments in batch
69
- const enrichmentMap = await fetchBatchEnrichments(keys);
72
+ // FIX: Deduplicate keys before batch fetch to reduce redundant fetches
73
+ const uniqueKeys = [...new Set(keys)];
74
+
75
+ // Fetch all enrichments in batch (deduplicated)
76
+ const enrichmentMap = await fetchBatchEnrichments(uniqueKeys);
70
77
 
71
78
  // Combine sources with enrichments
72
79
  const results: TResult[] = [];
@@ -12,6 +12,7 @@ import {
12
12
  type Query,
13
13
  Timestamp,
14
14
  } from "firebase/firestore";
15
+ import { validateDateRangeOrThrow } from "../validation/date-validator.util";
15
16
 
16
17
  export interface SortOptions {
17
18
  field: string;
@@ -30,6 +31,11 @@ export interface DateRangeOptions {
30
31
  export function applyDateRange(q: Query, dateRange: DateRangeOptions | undefined): Query {
31
32
  if (!dateRange) return q;
32
33
 
34
+ // FIX: Validate date range if both dates are provided
35
+ if (dateRange.startDate !== undefined && dateRange.endDate !== undefined) {
36
+ validateDateRangeOrThrow(dateRange.startDate, dateRange.endDate);
37
+ }
38
+
33
39
  if (dateRange.startDate) {
34
40
  q = query(q, where(dateRange.field, ">=", Timestamp.fromMillis(dateRange.startDate)));
35
41
  }
@@ -10,6 +10,7 @@ import {
10
10
  } from "firebase/firestore";
11
11
  import { getFirestore } from "../../infrastructure/config/FirestoreClient";
12
12
  import { hasCodeProperty } from "../../../../shared/domain/utils/type-guards.util";
13
+ import { ERROR_MESSAGES } from "../../../../shared/domain/utils/error-handlers/error-messages";
13
14
 
14
15
  /**
15
16
  * Execute a transaction with automatic DB instance check.
@@ -20,7 +21,7 @@ export async function runTransaction<T>(
20
21
  ): Promise<T> {
21
22
  const db = getFirestore();
22
23
  if (!db) {
23
- throw new Error("[runTransaction] Firestore database is not initialized. Please ensure Firebase is properly initialized before running transactions.");
24
+ throw new Error(`[runTransaction] ${ERROR_MESSAGES.FIRESTORE.NOT_INITIALIZED}`);
24
25
  }
25
26
  try {
26
27
  return await fbRunTransaction(db, updateFunction);
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Cursor Validation Utility
3
+ * Validates pagination cursors for Firestore queries
4
+ */
5
+
6
+ import { ERROR_MESSAGES } from '../../../../shared/domain/utils/error-handlers/error-messages';
7
+
8
+ const MAX_CURSOR_LENGTH = 1500; // Firestore document ID max length
9
+
10
+ /**
11
+ * Validates a pagination cursor
12
+ * @param cursor - The cursor to validate
13
+ * @returns true if cursor is valid, false otherwise
14
+ */
15
+ export function isValidCursor(cursor: string | undefined | null): boolean {
16
+ // undefined/null is valid (first page)
17
+ if (cursor === undefined || cursor === null) {
18
+ return true;
19
+ }
20
+
21
+ // Must be string
22
+ if (typeof cursor !== 'string') {
23
+ return false;
24
+ }
25
+
26
+ // Empty string invalid
27
+ if (cursor.length === 0) {
28
+ return false;
29
+ }
30
+
31
+ // No leading/trailing whitespace
32
+ if (cursor.trim() !== cursor) {
33
+ return false;
34
+ }
35
+
36
+ // Length check (Firestore doc ID max)
37
+ if (cursor.length > MAX_CURSOR_LENGTH) {
38
+ return false;
39
+ }
40
+
41
+ // No null bytes (Firestore forbidden)
42
+ if (cursor.includes('\0')) {
43
+ return false;
44
+ }
45
+
46
+ // No path separators (invalid in cursor)
47
+ if (cursor.includes('/')) {
48
+ return false;
49
+ }
50
+
51
+ return true;
52
+ }
53
+
54
+ /**
55
+ * Cursor validation error class
56
+ */
57
+ export class CursorValidationError extends Error {
58
+ constructor(message: string) {
59
+ super(message);
60
+ this.name = 'CursorValidationError';
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Validates cursor or throws an error
66
+ * @param cursor - The cursor to validate
67
+ * @throws {CursorValidationError} If cursor is invalid
68
+ */
69
+ export function validateCursorOrThrow(cursor: string | undefined | null): void {
70
+ if (!isValidCursor(cursor)) {
71
+ throw new CursorValidationError(ERROR_MESSAGES.FIRESTORE.INVALID_CURSOR);
72
+ }
73
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Date Range Validation Utility
3
+ * Validates date ranges for Firestore queries
4
+ */
5
+
6
+ import { ERROR_MESSAGES } from '../../../../shared/domain/utils/error-handlers/error-messages';
7
+
8
+ /**
9
+ * Validates a date range
10
+ * @param start - Start date (Date object or timestamp)
11
+ * @param end - End date (Date object or timestamp)
12
+ * @returns true if range is valid (start <= end), false otherwise
13
+ */
14
+ export function isValidDateRange(start: Date | number, end: Date | number): boolean {
15
+ const startTime = start instanceof Date ? start.getTime() : start;
16
+ const endTime = end instanceof Date ? end.getTime() : end;
17
+
18
+ if (isNaN(startTime) || isNaN(endTime)) {
19
+ return false;
20
+ }
21
+
22
+ return startTime <= endTime;
23
+ }
24
+
25
+ /**
26
+ * Validates date range or throws an error
27
+ * @param start - Start date (Date object or timestamp)
28
+ * @param end - End date (Date object or timestamp)
29
+ * @throws {Error} If range is invalid
30
+ */
31
+ export function validateDateRangeOrThrow(start: Date | number, end: Date | number): void {
32
+ if (!isValidDateRange(start, end)) {
33
+ throw new Error(ERROR_MESSAGES.FIRESTORE.INVALID_DATE_RANGE);
34
+ }
35
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Field Name Validation Utility
3
+ * Validates Firestore field names
4
+ */
5
+
6
+ const RESERVED_FIELDS = ['__name__', '__id__'];
7
+
8
+ /**
9
+ * Validates a Firestore field name
10
+ * @param field - The field name to validate
11
+ * @returns true if field name is valid, false otherwise
12
+ */
13
+ export function isValidFieldName(field: string): boolean {
14
+ if (typeof field !== 'string' || field.length === 0) {
15
+ return false;
16
+ }
17
+
18
+ // Reserved fields
19
+ if (RESERVED_FIELDS.includes(field)) {
20
+ return false;
21
+ }
22
+
23
+ // Reserved prefix
24
+ if (field.startsWith('__')) {
25
+ return false;
26
+ }
27
+
28
+ return true;
29
+ }
@@ -26,7 +26,10 @@ export function isFirestoreError(error: unknown): error is FirestoreError {
26
26
  typeof error === 'object' &&
27
27
  error !== null &&
28
28
  'code' in error &&
29
- 'message' in error
29
+ 'message' in error &&
30
+ // FIX: Also check that properties are strings
31
+ typeof (error as any).code === 'string' &&
32
+ typeof (error as any).message === 'string'
30
33
  );
31
34
  }
32
35
 
@@ -38,7 +41,10 @@ export function isAuthError(error: unknown): error is AuthError {
38
41
  typeof error === 'object' &&
39
42
  error !== null &&
40
43
  'code' in error &&
41
- 'message' in error
44
+ 'message' in error &&
45
+ // FIX: Also check that properties are strings
46
+ typeof (error as any).code === 'string' &&
47
+ typeof (error as any).message === 'string'
42
48
  );
43
49
  }
44
50
 
@@ -7,10 +7,24 @@ export const ERROR_MESSAGES = {
7
7
  SIGN_OUT_REQUIRED: 'Sign out first before performing this action',
8
8
  NO_USER: 'No user is currently signed in',
9
9
  INVALID_USER: 'Invalid user',
10
+ INVALID_EMAIL: 'Invalid email address',
11
+ INVALID_PASSWORD: 'Invalid password',
12
+ WEAK_PASSWORD: 'Password is too weak',
13
+ INVALID_CREDENTIALS: 'Invalid credentials provided',
14
+ USER_CHANGED: 'User changed during operation',
15
+ OPERATION_IN_PROGRESS: 'Operation already in progress',
10
16
  },
11
17
  FIRESTORE: {
12
18
  NOT_INITIALIZED: 'Firestore is not initialized',
13
19
  QUOTA_EXCEEDED: 'Daily quota exceeded. Please try again tomorrow or upgrade your plan.',
20
+ INVALID_CURSOR: 'Invalid pagination cursor',
21
+ INVALID_FIELD_NAME: 'Invalid field name',
22
+ INVALID_DATE_RANGE: 'Start date must be before end date',
23
+ BATCH_TOO_LARGE: 'Batch operation exceeds maximum size',
24
+ DOCUMENT_NOT_FOUND: 'Document not found',
25
+ PERMISSION_DENIED: 'Permission denied',
26
+ TRANSACTION_FAILED: 'Transaction failed',
27
+ NETWORK_ERROR: 'Network error occurred',
14
28
  },
15
29
  REPOSITORY: {
16
30
  DESTROYED: 'Repository has been destroyed',
@@ -18,6 +32,11 @@ export const ERROR_MESSAGES = {
18
32
  SERVICE: {
19
33
  NOT_CONFIGURED: 'Service is not configured',
20
34
  },
35
+ VALIDATION: {
36
+ INVALID_INPUT: 'Invalid input provided',
37
+ REQUIRED_FIELD: 'Required field is missing',
38
+ INVALID_FORMAT: 'Invalid format',
39
+ },
21
40
  GENERAL: {
22
41
  RETRYABLE: 'Temporary error occurred. Please try again.',
23
42
  UNKNOWN: 'Unknown error occurred',
@@ -1,5 +1,5 @@
1
1
  import type { Result, FailureResult } from '../result.util';
2
- import { failureResultFromError, successResult, isSuccess } from '../result.util';
2
+ import { failureResultFromError, successResult, isSuccess, isFailure } from '../result.util';
3
3
 
4
4
  export async function executeAll<T>(
5
5
  ...operations: (() => Promise<Result<T>>)[]
@@ -7,9 +7,10 @@ export async function executeAll<T>(
7
7
  try {
8
8
  const results = await Promise.all(operations.map((op) => op()));
9
9
 
10
+ // FIX: Use isFailure() type guard instead of manual check
10
11
  for (const result of results) {
11
- if (!result.success && result.error !== undefined) {
12
- return result as FailureResult;
12
+ if (isFailure(result)) {
13
+ return result;
13
14
  }
14
15
  }
15
16
 
@@ -31,7 +32,8 @@ export async function executeSequence<T>(
31
32
  ): Promise<Result<void>> {
32
33
  for (const operation of operations) {
33
34
  const result = await operation();
34
- if (!result.success && result.error !== undefined) {
35
+ // FIX: Use isFailure() type guard instead of manual check
36
+ if (isFailure(result)) {
35
37
  return { success: false, error: result.error };
36
38
  }
37
39
  }
@@ -70,6 +70,11 @@ export {
70
70
  type ErrorInfo as ErrorHandlerErrorInfo,
71
71
  } from './error-handler.util';
72
72
 
73
+ // Error messages
74
+ export {
75
+ ERROR_MESSAGES,
76
+ } from './error-handlers/error-messages';
77
+
73
78
  // Type guards
74
79
  export {
75
80
  hasCodeProperty,
@@ -16,7 +16,6 @@ import { FirebaseInitializationOrchestrator } from '../orchestrators/FirebaseIni
16
16
  export class FirebaseClientSingleton implements IFirebaseClient {
17
17
  private static instance: FirebaseClientSingleton | null = null;
18
18
  private state: FirebaseClientState;
19
- private lastError: string | null = null;
20
19
 
21
20
  private constructor() {
22
21
  this.state = new FirebaseClientState();
@@ -34,11 +33,9 @@ export class FirebaseClientSingleton implements IFirebaseClient {
34
33
  const result = FirebaseInitializationOrchestrator.initialize(config);
35
34
  // Sync state with orchestrator result
36
35
  this.state.setInstance(result);
37
- this.lastError = null;
38
36
  return result;
39
37
  } catch (error) {
40
38
  const errorMessage = error instanceof Error ? error.message : 'Unknown error';
41
- this.lastError = errorMessage;
42
39
  this.state.setInitializationError(errorMessage);
43
40
  return null;
44
41
  }
@@ -66,17 +63,12 @@ export class FirebaseClientSingleton implements IFirebaseClient {
66
63
  }
67
64
 
68
65
  getInitializationError(): string | null {
69
- // Check local state first
70
- const localError = this.state.getInitializationError();
71
- if (localError) return localError;
72
- // Return last error
73
- return this.lastError;
66
+ return this.state.getInitializationError();
74
67
  }
75
68
 
76
69
  reset(): void {
77
70
  // Reset local state
78
71
  this.state.reset();
79
- this.lastError = null;
80
72
  // Note: We don't reset Firebase apps as they might be in use
81
73
  }
82
74
  }