@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.
- package/package.json +1 -1
- package/src/domains/account-deletion/infrastructure/services/account-deletion.service.ts +126 -59
- package/src/domains/account-deletion/infrastructure/services/reauthentication.service.ts +27 -2
- package/src/domains/auth/infrastructure/services/apple-auth.service.ts +12 -5
- package/src/domains/auth/infrastructure/services/auth-listener.service.ts +2 -2
- package/src/domains/auth/infrastructure/services/email-auth.service.ts +23 -22
- package/src/domains/auth/infrastructure/services/google-auth.service.ts +12 -5
- package/src/domains/auth/infrastructure/services/user-document-builder.util.ts +15 -5
- package/src/domains/auth/presentation/hooks/useAnonymousAuth.ts +9 -1
- package/src/domains/auth/presentation/hooks/useGoogleOAuth.ts +1 -0
- package/src/domains/firestore/index.ts +14 -0
- package/src/domains/firestore/infrastructure/config/FirestoreClient.ts +16 -3
- package/src/domains/firestore/infrastructure/middleware/QueryDeduplicationMiddleware.ts +15 -8
- package/src/domains/firestore/infrastructure/repositories/BasePaginatedRepository.ts +5 -19
- package/src/domains/firestore/infrastructure/services/RequestLoggerService.ts +16 -0
- package/src/domains/firestore/utils/deduplication/pending-query-manager.util.ts +7 -0
- package/src/domains/firestore/utils/mapper/enrichment-mapper.util.ts +9 -2
- package/src/domains/firestore/utils/query/modifiers.util.ts +6 -0
- package/src/domains/firestore/utils/transaction/transaction.util.ts +2 -1
- package/src/domains/firestore/utils/validation/cursor-validator.util.ts +73 -0
- package/src/domains/firestore/utils/validation/date-validator.util.ts +35 -0
- package/src/domains/firestore/utils/validation/field-validator.util.ts +29 -0
- package/src/shared/domain/guards/firebase-error.guard.ts +8 -2
- package/src/shared/domain/utils/error-handlers/error-messages.ts +19 -0
- package/src/shared/domain/utils/executors/batch-executors.util.ts +6 -4
- package/src/shared/domain/utils/index.ts +5 -0
- 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.
|
|
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
|
-
|
|
33
|
-
|
|
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]
|
|
38
|
+
console.log("[deleteCurrentUser] Deletion already in progress");
|
|
38
39
|
}
|
|
39
40
|
return {
|
|
40
41
|
success: false,
|
|
41
|
-
error: { code: "auth/
|
|
42
|
+
error: { code: "auth/operation-in-progress", message: "Account deletion already in progress" },
|
|
42
43
|
requiresReauth: false
|
|
43
44
|
};
|
|
44
45
|
}
|
|
45
46
|
|
|
46
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
}
|
|
49
|
+
try {
|
|
50
|
+
const auth = getFirebaseAuth();
|
|
51
|
+
const user = auth?.currentUser;
|
|
61
52
|
|
|
62
|
-
|
|
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]
|
|
55
|
+
console.log("[deleteCurrentUser] Auth not ready");
|
|
73
56
|
}
|
|
74
|
-
return
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
77
|
+
|
|
78
|
+
const provider = getUserAuthProvider(user);
|
|
86
79
|
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
87
|
-
console.log("[deleteCurrentUser]
|
|
80
|
+
console.log("[deleteCurrentUser] User provider:", provider);
|
|
88
81
|
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
99
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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",
|
|
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
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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",
|
|
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",
|
|
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
|
-
|
|
47
|
-
|
|
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",
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
101
|
-
|
|
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",
|
|
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",
|
|
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
|
-
|
|
143
|
-
|
|
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
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
//
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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
|
|
64
|
-
|
|
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
|
-
//
|
|
75
|
-
|
|
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
|
*/
|
|
@@ -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
|
-
//
|
|
69
|
-
const
|
|
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(
|
|
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 (
|
|
12
|
-
return result
|
|
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
|
-
|
|
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
|
}
|
|
@@ -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
|
-
|
|
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
|
}
|