@blackcode_sa/metaestetics-api 1.13.21 → 1.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/admin/index.js +13 -2
- package/dist/admin/index.mjs +13 -2
- package/dist/index.d.mts +43 -0
- package/dist/index.d.ts +43 -0
- package/dist/index.js +477 -0
- package/dist/index.mjs +477 -0
- package/package.json +1 -1
- package/src/admin/mailing/practitionerInvite/templates/invitation.template.ts +13 -2
- package/src/services/auth/auth.service.ts +290 -0
- package/src/services/practitioner/practitioner.service.ts +343 -0
|
@@ -1002,6 +1002,296 @@ export class AuthService extends BaseService {
|
|
|
1002
1002
|
}
|
|
1003
1003
|
}
|
|
1004
1004
|
|
|
1005
|
+
/**
|
|
1006
|
+
* Signs up or signs in a practitioner with Google authentication.
|
|
1007
|
+
* Checks for existing practitioner account or draft profiles.
|
|
1008
|
+
*
|
|
1009
|
+
* @param idToken - The Google ID token obtained from the mobile app
|
|
1010
|
+
* @returns Object containing user, practitioner (if exists), and draft profiles (if any)
|
|
1011
|
+
*/
|
|
1012
|
+
async signUpPractitionerWithGoogle(
|
|
1013
|
+
idToken: string
|
|
1014
|
+
): Promise<{
|
|
1015
|
+
user: User;
|
|
1016
|
+
practitioner: Practitioner | null;
|
|
1017
|
+
draftProfiles: Practitioner[];
|
|
1018
|
+
}> {
|
|
1019
|
+
try {
|
|
1020
|
+
console.log('[AUTH] Starting practitioner Google Sign-In/Sign-Up');
|
|
1021
|
+
|
|
1022
|
+
// Extract email from Google ID token
|
|
1023
|
+
let email: string | undefined;
|
|
1024
|
+
try {
|
|
1025
|
+
const payloadBase64 = idToken.split('.')[1];
|
|
1026
|
+
const payloadJson = globalThis.atob
|
|
1027
|
+
? globalThis.atob(payloadBase64)
|
|
1028
|
+
: Buffer.from(payloadBase64, 'base64').toString('utf8');
|
|
1029
|
+
const payload = JSON.parse(payloadJson);
|
|
1030
|
+
email = payload.email as string | undefined;
|
|
1031
|
+
} catch (decodeError) {
|
|
1032
|
+
console.error('[AUTH] Failed to decode email from Google ID token:', decodeError);
|
|
1033
|
+
throw new AuthError(
|
|
1034
|
+
'Unable to read email from Google token. Please try again.',
|
|
1035
|
+
'AUTH/INVALID_GOOGLE_TOKEN',
|
|
1036
|
+
400,
|
|
1037
|
+
);
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
if (!email) {
|
|
1041
|
+
throw new AuthError(
|
|
1042
|
+
'Unable to read email from Google token. Please try again.',
|
|
1043
|
+
'AUTH/INVALID_GOOGLE_TOKEN',
|
|
1044
|
+
400,
|
|
1045
|
+
);
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
const normalizedEmail = email.toLowerCase().trim();
|
|
1049
|
+
console.log('[AUTH] Extracted email from Google token:', normalizedEmail);
|
|
1050
|
+
|
|
1051
|
+
// Check if user already exists in Firebase Auth
|
|
1052
|
+
const methods = await fetchSignInMethodsForEmail(this.auth, normalizedEmail);
|
|
1053
|
+
const hasGoogleMethod = methods.includes(GoogleAuthProvider.GOOGLE_SIGN_IN_METHOD);
|
|
1054
|
+
const hasEmailMethod = methods.includes(EmailAuthProvider.EMAIL_PASSWORD_SIGN_IN_METHOD);
|
|
1055
|
+
|
|
1056
|
+
const practitionerService = new PractitionerService(this.db, this.auth, this.app);
|
|
1057
|
+
|
|
1058
|
+
// Case 1: User exists with Google provider - sign in and check for practitioner profile
|
|
1059
|
+
if (hasGoogleMethod) {
|
|
1060
|
+
console.log('[AUTH] User exists with Google provider, signing in');
|
|
1061
|
+
const credential = GoogleAuthProvider.credential(idToken);
|
|
1062
|
+
const { user: firebaseUser } = await signInWithCredential(this.auth, credential);
|
|
1063
|
+
|
|
1064
|
+
let existingUser: User | null = null;
|
|
1065
|
+
try {
|
|
1066
|
+
existingUser = await this.userService.getUserById(firebaseUser.uid);
|
|
1067
|
+
console.log('[AUTH] User document found:', existingUser.uid);
|
|
1068
|
+
} catch (userError: any) {
|
|
1069
|
+
// User document doesn't exist in Firestore
|
|
1070
|
+
console.log('[AUTH] User document not found in Firestore, checking for draft profiles', {
|
|
1071
|
+
errorCode: userError?.code,
|
|
1072
|
+
errorMessage: userError?.message,
|
|
1073
|
+
errorType: userError?.constructor?.name,
|
|
1074
|
+
isAuthError: userError instanceof AuthError,
|
|
1075
|
+
});
|
|
1076
|
+
|
|
1077
|
+
// Check for draft profiles before signing out
|
|
1078
|
+
const practitionerService = new PractitionerService(this.db, this.auth, this.app);
|
|
1079
|
+
const draftProfiles = await practitionerService.getDraftProfilesByEmail(normalizedEmail);
|
|
1080
|
+
|
|
1081
|
+
console.log('[AUTH] Draft profiles check result:', {
|
|
1082
|
+
email: normalizedEmail,
|
|
1083
|
+
draftProfilesCount: draftProfiles.length,
|
|
1084
|
+
draftProfileIds: draftProfiles.map(p => p.id),
|
|
1085
|
+
});
|
|
1086
|
+
|
|
1087
|
+
if (draftProfiles.length === 0) {
|
|
1088
|
+
// No draft profiles - sign out and throw error
|
|
1089
|
+
console.log('[AUTH] No draft profiles found, signing out and throwing error');
|
|
1090
|
+
try {
|
|
1091
|
+
await firebaseSignOut(this.auth);
|
|
1092
|
+
} catch (signOutError) {
|
|
1093
|
+
console.warn('[AUTH] Error signing out Firebase user (non-critical):', signOutError);
|
|
1094
|
+
}
|
|
1095
|
+
throw new AuthError(
|
|
1096
|
+
'No clinic invitation found for this email. Please contact your clinic administrator to receive an invitation, or use the token provided by your clinic.',
|
|
1097
|
+
'AUTH/NO_DRAFT_PROFILES',
|
|
1098
|
+
404,
|
|
1099
|
+
);
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
// Draft profiles exist - create user document and continue
|
|
1103
|
+
console.log('[AUTH] Draft profiles found, creating user document');
|
|
1104
|
+
existingUser = await this.userService.createUser(firebaseUser, [UserRole.PRACTITIONER], {
|
|
1105
|
+
skipProfileCreation: true,
|
|
1106
|
+
});
|
|
1107
|
+
console.log('[AUTH] Created user document for existing Firebase user with draft profiles:', existingUser.uid);
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
if (!existingUser) {
|
|
1111
|
+
await firebaseSignOut(this.auth);
|
|
1112
|
+
throw new AuthError(
|
|
1113
|
+
'No clinic invitation found for this email. Please contact your clinic administrator to receive an invitation, or use the token provided by your clinic.',
|
|
1114
|
+
'AUTH/NO_DRAFT_PROFILES',
|
|
1115
|
+
404,
|
|
1116
|
+
);
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
// Check if user has practitioner profile
|
|
1120
|
+
let practitioner: Practitioner | null = null;
|
|
1121
|
+
if (existingUser.practitionerProfile) {
|
|
1122
|
+
practitioner = await practitionerService.getPractitioner(existingUser.practitionerProfile);
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
// Check for any new draft profiles
|
|
1126
|
+
const draftProfiles = await practitionerService.getDraftProfilesByEmail(normalizedEmail);
|
|
1127
|
+
|
|
1128
|
+
return {
|
|
1129
|
+
user: existingUser,
|
|
1130
|
+
practitioner,
|
|
1131
|
+
draftProfiles,
|
|
1132
|
+
};
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
// Case 2: User exists with email/password - need to link Google provider
|
|
1136
|
+
// Firebase doesn't allow linking without being signed in first
|
|
1137
|
+
// So we need to ask user to sign in with email/password first, then link
|
|
1138
|
+
if (hasEmailMethod && !hasGoogleMethod) {
|
|
1139
|
+
console.log('[AUTH] User exists with email/password only');
|
|
1140
|
+
throw new AuthError(
|
|
1141
|
+
'An account with this email already exists. Please sign in with your email and password, then link your Google account in settings.',
|
|
1142
|
+
'AUTH/EMAIL_ALREADY_EXISTS',
|
|
1143
|
+
409,
|
|
1144
|
+
);
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
// Case 3: New user - sign in with Google and check for draft profiles
|
|
1148
|
+
console.log('[AUTH] Signing in with Google credential');
|
|
1149
|
+
const credential = GoogleAuthProvider.credential(idToken);
|
|
1150
|
+
|
|
1151
|
+
let firebaseUser: FirebaseUser;
|
|
1152
|
+
try {
|
|
1153
|
+
const result = await signInWithCredential(this.auth, credential);
|
|
1154
|
+
firebaseUser = result.user;
|
|
1155
|
+
} catch (error: any) {
|
|
1156
|
+
// If sign-in fails because email already exists with different provider
|
|
1157
|
+
if (error.code === 'auth/account-exists-with-different-credential') {
|
|
1158
|
+
throw new AuthError(
|
|
1159
|
+
'An account with this email already exists. Please sign in with your email and password, then link your Google account in settings.',
|
|
1160
|
+
'AUTH/EMAIL_ALREADY_EXISTS',
|
|
1161
|
+
409,
|
|
1162
|
+
);
|
|
1163
|
+
}
|
|
1164
|
+
throw error;
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
// Check for existing User document (in case user had email/password account that was just linked)
|
|
1168
|
+
let existingUser: User | null = null;
|
|
1169
|
+
try {
|
|
1170
|
+
const existingUserDoc = await this.userService.getUserById(firebaseUser.uid);
|
|
1171
|
+
if (existingUserDoc) {
|
|
1172
|
+
existingUser = existingUserDoc;
|
|
1173
|
+
console.log('[AUTH] Found existing User document');
|
|
1174
|
+
}
|
|
1175
|
+
} catch (error) {
|
|
1176
|
+
console.error('[AUTH] Error checking for existing user:', error);
|
|
1177
|
+
// Continue with new user creation
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
// Check for draft profiles
|
|
1181
|
+
console.log('[AUTH] Checking for draft profiles');
|
|
1182
|
+
let draftProfiles: Practitioner[] = [];
|
|
1183
|
+
try {
|
|
1184
|
+
draftProfiles = await practitionerService.getDraftProfilesByEmail(normalizedEmail);
|
|
1185
|
+
console.log('[AUTH] Draft profiles check complete', { count: draftProfiles.length });
|
|
1186
|
+
} catch (draftCheckError: any) {
|
|
1187
|
+
console.error('[AUTH] Error checking draft profiles:', draftCheckError);
|
|
1188
|
+
// If checking draft profiles fails, sign out and throw appropriate error
|
|
1189
|
+
try {
|
|
1190
|
+
await firebaseSignOut(this.auth);
|
|
1191
|
+
} catch (signOutError) {
|
|
1192
|
+
console.warn('[AUTH] Error signing out Firebase user (non-critical):', signOutError);
|
|
1193
|
+
}
|
|
1194
|
+
throw new AuthError(
|
|
1195
|
+
'No clinic invitation found for this email. Please contact your clinic administrator to receive an invitation, or use the token provided by your clinic.',
|
|
1196
|
+
'AUTH/NO_DRAFT_PROFILES',
|
|
1197
|
+
404,
|
|
1198
|
+
);
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
let user: User;
|
|
1202
|
+
if (existingUser) {
|
|
1203
|
+
// User exists - use existing account
|
|
1204
|
+
user = existingUser;
|
|
1205
|
+
console.log('[AUTH] Using existing user account');
|
|
1206
|
+
} else {
|
|
1207
|
+
// For new users: Only create account if there are draft profiles OR if user already has a practitioner profile
|
|
1208
|
+
// Since doctors can only join via clinic invitation, we should not create accounts without invitations
|
|
1209
|
+
if (draftProfiles.length === 0) {
|
|
1210
|
+
console.log('[AUTH] No draft profiles found, signing out and throwing error');
|
|
1211
|
+
// Sign out the Firebase user since we're not creating an account
|
|
1212
|
+
// Wrap in try-catch to handle any sign-out errors gracefully
|
|
1213
|
+
try {
|
|
1214
|
+
await firebaseSignOut(this.auth);
|
|
1215
|
+
} catch (signOutError) {
|
|
1216
|
+
console.warn('[AUTH] Error signing out Firebase user (non-critical):', signOutError);
|
|
1217
|
+
// Continue anyway - the important part is we're not creating the account
|
|
1218
|
+
}
|
|
1219
|
+
const noDraftError = new AuthError(
|
|
1220
|
+
'No clinic invitation found for this email. Please contact your clinic administrator to receive an invitation, or use the token provided by your clinic.',
|
|
1221
|
+
'AUTH/NO_DRAFT_PROFILES',
|
|
1222
|
+
404,
|
|
1223
|
+
);
|
|
1224
|
+
console.log('[AUTH] Throwing NO_DRAFT_PROFILES error:', noDraftError.code);
|
|
1225
|
+
throw noDraftError;
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
// Create new user document only if draft profiles exist
|
|
1229
|
+
user = await this.userService.createUser(firebaseUser, [UserRole.PRACTITIONER], {
|
|
1230
|
+
skipProfileCreation: true,
|
|
1231
|
+
});
|
|
1232
|
+
console.log('[AUTH] Created new user account with draft profiles available');
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
// Check if user already has practitioner profile
|
|
1236
|
+
let practitioner: Practitioner | null = null;
|
|
1237
|
+
if (user.practitionerProfile) {
|
|
1238
|
+
practitioner = await practitionerService.getPractitioner(user.practitionerProfile);
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
console.log('[AUTH] Google Sign-In complete', {
|
|
1242
|
+
userId: user.uid,
|
|
1243
|
+
hasPractitioner: !!practitioner,
|
|
1244
|
+
draftProfilesCount: draftProfiles.length,
|
|
1245
|
+
});
|
|
1246
|
+
|
|
1247
|
+
return {
|
|
1248
|
+
user,
|
|
1249
|
+
practitioner,
|
|
1250
|
+
draftProfiles,
|
|
1251
|
+
};
|
|
1252
|
+
} catch (error: any) {
|
|
1253
|
+
console.error('[AUTH] Error in signUpPractitionerWithGoogle:', error);
|
|
1254
|
+
console.error('[AUTH] Error type:', error?.constructor?.name);
|
|
1255
|
+
console.error('[AUTH] Error instanceof AuthError:', error instanceof AuthError);
|
|
1256
|
+
console.error('[AUTH] Error code:', error?.code);
|
|
1257
|
+
console.error('[AUTH] Error message:', error?.message);
|
|
1258
|
+
|
|
1259
|
+
// Preserve AuthError instances (like NO_DRAFT_PROFILES) without wrapping
|
|
1260
|
+
if (error instanceof AuthError) {
|
|
1261
|
+
console.log('[AUTH] Preserving AuthError:', error.code);
|
|
1262
|
+
throw error;
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
// Check if error message contains NO_DRAFT_PROFILES before wrapping
|
|
1266
|
+
const errorMessage = error?.message || error?.toString() || '';
|
|
1267
|
+
if (errorMessage.includes('NO_DRAFT_PROFILES') || errorMessage.includes('clinic invitation')) {
|
|
1268
|
+
console.log('[AUTH] Detected clinic invitation error in message, converting to AuthError');
|
|
1269
|
+
throw new AuthError(
|
|
1270
|
+
'No clinic invitation found for this email. Please contact your clinic administrator to receive an invitation, or use the token provided by your clinic.',
|
|
1271
|
+
'AUTH/NO_DRAFT_PROFILES',
|
|
1272
|
+
404,
|
|
1273
|
+
);
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
// For other errors, wrap them but preserve the original message if it's helpful
|
|
1277
|
+
const wrappedError = handleFirebaseError(error);
|
|
1278
|
+
console.log('[AUTH] Wrapped error:', wrappedError.message);
|
|
1279
|
+
|
|
1280
|
+
// If the wrapped error message is generic, try to preserve more context
|
|
1281
|
+
if (wrappedError.message.includes('permissions') || wrappedError.message.includes('Account creation failed')) {
|
|
1282
|
+
// This might be a permissions error during sign-out or user creation
|
|
1283
|
+
// If we got here and there were no draft profiles, it's likely the same issue
|
|
1284
|
+
throw new AuthError(
|
|
1285
|
+
'No clinic invitation found for this email. Please contact your clinic administrator to receive an invitation, or use the token provided by your clinic.',
|
|
1286
|
+
'AUTH/NO_DRAFT_PROFILES',
|
|
1287
|
+
404,
|
|
1288
|
+
);
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
throw wrappedError;
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1005
1295
|
/**
|
|
1006
1296
|
* Links a Google account to the currently signed-in user using an ID token.
|
|
1007
1297
|
* This is used to upgrade an anonymous user or to allow an existing user
|
|
@@ -158,6 +158,11 @@ export class PractitionerService extends BaseService {
|
|
|
158
158
|
): Promise<PractitionerBasicInfo> {
|
|
159
159
|
const processedBasicInfo = { ...basicInfo };
|
|
160
160
|
|
|
161
|
+
// Normalize email to lowercase to ensure consistent matching
|
|
162
|
+
if (processedBasicInfo.email) {
|
|
163
|
+
processedBasicInfo.email = processedBasicInfo.email.toLowerCase().trim();
|
|
164
|
+
}
|
|
165
|
+
|
|
161
166
|
// Handle profile photo upload if needed
|
|
162
167
|
if (basicInfo.profileImageUrl) {
|
|
163
168
|
const uploadedUrl = await this.handleProfilePhotoUpload(
|
|
@@ -714,6 +719,344 @@ export class PractitionerService extends BaseService {
|
|
|
714
719
|
}
|
|
715
720
|
}
|
|
716
721
|
|
|
722
|
+
/**
|
|
723
|
+
* Finds all draft practitioner profiles by email address
|
|
724
|
+
* Used when a doctor signs in with Google to show all clinic invitations
|
|
725
|
+
*
|
|
726
|
+
* @param email - Email address to search for
|
|
727
|
+
* @returns Array of draft practitioner profiles with clinic information
|
|
728
|
+
*
|
|
729
|
+
* @remarks
|
|
730
|
+
* Requires Firestore composite index on:
|
|
731
|
+
* - Collection: practitioners
|
|
732
|
+
* - Fields: basicInfo.email (Ascending), status (Ascending), userRef (Ascending)
|
|
733
|
+
*/
|
|
734
|
+
async getDraftProfilesByEmail(
|
|
735
|
+
email: string
|
|
736
|
+
): Promise<Practitioner[]> {
|
|
737
|
+
try {
|
|
738
|
+
const normalizedEmail = email.toLowerCase().trim();
|
|
739
|
+
|
|
740
|
+
console.log("[PRACTITIONER] Searching for all draft practitioners by email", {
|
|
741
|
+
email: normalizedEmail,
|
|
742
|
+
originalEmail: email,
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
const q = query(
|
|
746
|
+
collection(this.db, PRACTITIONERS_COLLECTION),
|
|
747
|
+
where("basicInfo.email", "==", normalizedEmail),
|
|
748
|
+
where("status", "==", PractitionerStatus.DRAFT),
|
|
749
|
+
where("userRef", "==", "")
|
|
750
|
+
);
|
|
751
|
+
|
|
752
|
+
const querySnapshot = await getDocs(q);
|
|
753
|
+
|
|
754
|
+
if (querySnapshot.empty) {
|
|
755
|
+
console.log("[PRACTITIONER] No draft practitioners found for email", {
|
|
756
|
+
email: normalizedEmail,
|
|
757
|
+
originalEmail: email,
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
// Debug: Try to find ANY practitioners with this email (regardless of status)
|
|
761
|
+
const debugQ = query(
|
|
762
|
+
collection(this.db, PRACTITIONERS_COLLECTION),
|
|
763
|
+
where("basicInfo.email", "==", normalizedEmail),
|
|
764
|
+
limit(5)
|
|
765
|
+
);
|
|
766
|
+
const debugSnapshot = await getDocs(debugQ);
|
|
767
|
+
console.log("[PRACTITIONER] Debug: Found practitioners with this email (any status):", {
|
|
768
|
+
count: debugSnapshot.size,
|
|
769
|
+
practitioners: debugSnapshot.docs.map(doc => ({
|
|
770
|
+
id: doc.id,
|
|
771
|
+
email: doc.data().basicInfo?.email,
|
|
772
|
+
status: doc.data().status,
|
|
773
|
+
userRef: doc.data().userRef,
|
|
774
|
+
})),
|
|
775
|
+
});
|
|
776
|
+
|
|
777
|
+
return [];
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
const draftPractitioners = querySnapshot.docs.map(
|
|
781
|
+
(doc) => doc.data() as Practitioner
|
|
782
|
+
);
|
|
783
|
+
|
|
784
|
+
console.log("[PRACTITIONER] Found draft practitioners", {
|
|
785
|
+
email: normalizedEmail,
|
|
786
|
+
count: draftPractitioners.length,
|
|
787
|
+
practitionerIds: draftPractitioners.map((p) => p.id),
|
|
788
|
+
});
|
|
789
|
+
|
|
790
|
+
return draftPractitioners;
|
|
791
|
+
} catch (error) {
|
|
792
|
+
console.error(
|
|
793
|
+
"[PRACTITIONER] Error finding draft practitioners by email:",
|
|
794
|
+
error
|
|
795
|
+
);
|
|
796
|
+
// If query fails (e.g., index not created), return empty array
|
|
797
|
+
return [];
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/**
|
|
802
|
+
* Claims a draft practitioner profile and links it to a user account
|
|
803
|
+
* Used when a doctor selects which clinic(s) to join after Google Sign-In
|
|
804
|
+
*
|
|
805
|
+
* @param practitionerId - ID of the draft practitioner profile to claim
|
|
806
|
+
* @param userId - ID of the user account to link the profile to
|
|
807
|
+
* @returns The claimed practitioner profile
|
|
808
|
+
*/
|
|
809
|
+
async claimDraftProfileWithGoogle(
|
|
810
|
+
practitionerId: string,
|
|
811
|
+
userId: string
|
|
812
|
+
): Promise<Practitioner> {
|
|
813
|
+
try {
|
|
814
|
+
console.log("[PRACTITIONER] Claiming draft profile with Google", {
|
|
815
|
+
practitionerId,
|
|
816
|
+
userId,
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
// Get the draft practitioner profile
|
|
820
|
+
const practitioner = await this.getPractitioner(practitionerId);
|
|
821
|
+
if (!practitioner) {
|
|
822
|
+
throw new Error(`Practitioner ${practitionerId} not found`);
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
// Ensure practitioner is in DRAFT status
|
|
826
|
+
if (practitioner.status !== PractitionerStatus.DRAFT) {
|
|
827
|
+
throw new Error("This practitioner profile has already been claimed");
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
// Check if user already has a practitioner profile
|
|
831
|
+
const existingPractitioner = await this.getPractitionerByUserRef(userId);
|
|
832
|
+
if (existingPractitioner) {
|
|
833
|
+
// User already has a profile - merge clinics from draft profile into existing profile
|
|
834
|
+
console.log("[PRACTITIONER] User already has profile, merging clinics");
|
|
835
|
+
|
|
836
|
+
// Merge clinics (avoid duplicates)
|
|
837
|
+
const mergedClinics = Array.from(new Set([
|
|
838
|
+
...existingPractitioner.clinics,
|
|
839
|
+
...practitioner.clinics,
|
|
840
|
+
]));
|
|
841
|
+
|
|
842
|
+
// Merge clinic working hours
|
|
843
|
+
const mergedWorkingHours = [...existingPractitioner.clinicWorkingHours];
|
|
844
|
+
for (const workingHours of practitioner.clinicWorkingHours) {
|
|
845
|
+
if (!mergedWorkingHours.find(wh => wh.clinicId === workingHours.clinicId)) {
|
|
846
|
+
mergedWorkingHours.push(workingHours);
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
// Merge clinics info (avoid duplicates)
|
|
851
|
+
const mergedClinicsInfo = [...existingPractitioner.clinicsInfo];
|
|
852
|
+
for (const clinicInfo of practitioner.clinicsInfo) {
|
|
853
|
+
if (!mergedClinicsInfo.find(ci => ci.id === clinicInfo.id)) {
|
|
854
|
+
mergedClinicsInfo.push(clinicInfo);
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
// Update existing practitioner with merged data
|
|
859
|
+
const updatedPractitioner = await this.updatePractitioner(existingPractitioner.id, {
|
|
860
|
+
clinics: mergedClinics,
|
|
861
|
+
clinicWorkingHours: mergedWorkingHours,
|
|
862
|
+
clinicsInfo: mergedClinicsInfo,
|
|
863
|
+
});
|
|
864
|
+
|
|
865
|
+
// Delete the draft profile since we've merged it
|
|
866
|
+
await deleteDoc(doc(this.db, PRACTITIONERS_COLLECTION, practitionerId));
|
|
867
|
+
|
|
868
|
+
// Mark all active tokens for the draft practitioner as used
|
|
869
|
+
const activeTokens = await this.getPractitionerActiveTokens(practitionerId);
|
|
870
|
+
for (const token of activeTokens) {
|
|
871
|
+
await this.markTokenAsUsed(token.id, practitionerId, userId);
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
return updatedPractitioner;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
// Claim the profile by linking it to the user
|
|
878
|
+
const updatedPractitioner = await this.updatePractitioner(practitioner.id, {
|
|
879
|
+
userRef: userId,
|
|
880
|
+
status: PractitionerStatus.ACTIVE,
|
|
881
|
+
});
|
|
882
|
+
|
|
883
|
+
// Mark all active tokens for this practitioner as used
|
|
884
|
+
const activeTokens = await this.getPractitionerActiveTokens(practitionerId);
|
|
885
|
+
for (const token of activeTokens) {
|
|
886
|
+
await this.markTokenAsUsed(token.id, practitionerId, userId);
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
console.log("[PRACTITIONER] Draft profile claimed successfully", {
|
|
890
|
+
practitionerId: updatedPractitioner.id,
|
|
891
|
+
userId,
|
|
892
|
+
});
|
|
893
|
+
|
|
894
|
+
return updatedPractitioner;
|
|
895
|
+
} catch (error) {
|
|
896
|
+
console.error(
|
|
897
|
+
"[PRACTITIONER] Error claiming draft profile with Google:",
|
|
898
|
+
error
|
|
899
|
+
);
|
|
900
|
+
throw error;
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
/**
|
|
905
|
+
* Claims multiple draft practitioner profiles and merges them into one profile
|
|
906
|
+
* Used when a doctor selects multiple clinics to join after Google Sign-In
|
|
907
|
+
*
|
|
908
|
+
* @param practitionerIds - Array of draft practitioner profile IDs to claim
|
|
909
|
+
* @param userId - ID of the user account to link the profiles to
|
|
910
|
+
* @returns The claimed practitioner profile (first one becomes main, others merged)
|
|
911
|
+
*/
|
|
912
|
+
async claimMultipleDraftProfilesWithGoogle(
|
|
913
|
+
practitionerIds: string[],
|
|
914
|
+
userId: string
|
|
915
|
+
): Promise<Practitioner> {
|
|
916
|
+
try {
|
|
917
|
+
if (practitionerIds.length === 0) {
|
|
918
|
+
throw new Error("No practitioner IDs provided");
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
console.log("[PRACTITIONER] Claiming multiple draft profiles with Google", {
|
|
922
|
+
practitionerIds,
|
|
923
|
+
userId,
|
|
924
|
+
count: practitionerIds.length,
|
|
925
|
+
});
|
|
926
|
+
|
|
927
|
+
// Get all draft profiles
|
|
928
|
+
const draftProfiles = await Promise.all(
|
|
929
|
+
practitionerIds.map(id => this.getPractitioner(id))
|
|
930
|
+
);
|
|
931
|
+
|
|
932
|
+
// Filter out nulls and ensure all are drafts
|
|
933
|
+
const validDrafts = draftProfiles.filter((p): p is Practitioner => {
|
|
934
|
+
if (!p) return false;
|
|
935
|
+
if (p.status !== PractitionerStatus.DRAFT) {
|
|
936
|
+
throw new Error(`Practitioner ${p.id} has already been claimed`);
|
|
937
|
+
}
|
|
938
|
+
return true;
|
|
939
|
+
});
|
|
940
|
+
|
|
941
|
+
if (validDrafts.length === 0) {
|
|
942
|
+
throw new Error("No valid draft profiles found");
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
// Check if user already has a practitioner profile
|
|
946
|
+
const existingPractitioner = await this.getPractitionerByUserRef(userId);
|
|
947
|
+
|
|
948
|
+
if (existingPractitioner) {
|
|
949
|
+
// Merge all draft profiles into existing profile
|
|
950
|
+
let mergedClinics = new Set(existingPractitioner.clinics);
|
|
951
|
+
let mergedWorkingHours = [...existingPractitioner.clinicWorkingHours];
|
|
952
|
+
let mergedClinicsInfo = [...existingPractitioner.clinicsInfo];
|
|
953
|
+
|
|
954
|
+
for (const draft of validDrafts) {
|
|
955
|
+
// Merge clinics
|
|
956
|
+
draft.clinics.forEach(clinicId => mergedClinics.add(clinicId));
|
|
957
|
+
|
|
958
|
+
// Merge working hours
|
|
959
|
+
for (const workingHours of draft.clinicWorkingHours) {
|
|
960
|
+
if (!mergedWorkingHours.find(wh => wh.clinicId === workingHours.clinicId)) {
|
|
961
|
+
mergedWorkingHours.push(workingHours);
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
// Merge clinics info
|
|
966
|
+
for (const clinicInfo of draft.clinicsInfo) {
|
|
967
|
+
if (!mergedClinicsInfo.find(ci => ci.id === clinicInfo.id)) {
|
|
968
|
+
mergedClinicsInfo.push(clinicInfo);
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
// Update existing practitioner
|
|
974
|
+
const updatedPractitioner = await this.updatePractitioner(existingPractitioner.id, {
|
|
975
|
+
clinics: Array.from(mergedClinics),
|
|
976
|
+
clinicWorkingHours: mergedWorkingHours,
|
|
977
|
+
clinicsInfo: mergedClinicsInfo,
|
|
978
|
+
});
|
|
979
|
+
|
|
980
|
+
// Delete all draft profiles
|
|
981
|
+
for (const draft of validDrafts) {
|
|
982
|
+
await deleteDoc(doc(this.db, PRACTITIONERS_COLLECTION, draft.id));
|
|
983
|
+
|
|
984
|
+
// Mark all active tokens as used
|
|
985
|
+
const activeTokens = await this.getPractitionerActiveTokens(draft.id);
|
|
986
|
+
for (const token of activeTokens) {
|
|
987
|
+
await this.markTokenAsUsed(token.id, draft.id, userId);
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
return updatedPractitioner;
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
// Use first draft as the main profile, merge others into it
|
|
995
|
+
const mainDraft = validDrafts[0];
|
|
996
|
+
const otherDrafts = validDrafts.slice(1);
|
|
997
|
+
|
|
998
|
+
// Merge clinics from other drafts
|
|
999
|
+
let mergedClinics = new Set(mainDraft.clinics);
|
|
1000
|
+
let mergedWorkingHours = [...mainDraft.clinicWorkingHours];
|
|
1001
|
+
let mergedClinicsInfo = [...mainDraft.clinicsInfo];
|
|
1002
|
+
|
|
1003
|
+
for (const draft of otherDrafts) {
|
|
1004
|
+
draft.clinics.forEach(clinicId => mergedClinics.add(clinicId));
|
|
1005
|
+
|
|
1006
|
+
for (const workingHours of draft.clinicWorkingHours) {
|
|
1007
|
+
if (!mergedWorkingHours.find(wh => wh.clinicId === workingHours.clinicId)) {
|
|
1008
|
+
mergedWorkingHours.push(workingHours);
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
for (const clinicInfo of draft.clinicsInfo) {
|
|
1013
|
+
if (!mergedClinicsInfo.find(ci => ci.id === clinicInfo.id)) {
|
|
1014
|
+
mergedClinicsInfo.push(clinicInfo);
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
// Claim the main profile
|
|
1020
|
+
const updatedPractitioner = await this.updatePractitioner(mainDraft.id, {
|
|
1021
|
+
userRef: userId,
|
|
1022
|
+
status: PractitionerStatus.ACTIVE,
|
|
1023
|
+
clinics: Array.from(mergedClinics),
|
|
1024
|
+
clinicWorkingHours: mergedWorkingHours,
|
|
1025
|
+
clinicsInfo: mergedClinicsInfo,
|
|
1026
|
+
});
|
|
1027
|
+
|
|
1028
|
+
// Mark all active tokens for main profile as used
|
|
1029
|
+
const mainActiveTokens = await this.getPractitionerActiveTokens(mainDraft.id);
|
|
1030
|
+
for (const token of mainActiveTokens) {
|
|
1031
|
+
await this.markTokenAsUsed(token.id, mainDraft.id, userId);
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
// Delete other draft profiles
|
|
1035
|
+
for (const draft of otherDrafts) {
|
|
1036
|
+
await deleteDoc(doc(this.db, PRACTITIONERS_COLLECTION, draft.id));
|
|
1037
|
+
|
|
1038
|
+
const activeTokens = await this.getPractitionerActiveTokens(draft.id);
|
|
1039
|
+
for (const token of activeTokens) {
|
|
1040
|
+
await this.markTokenAsUsed(token.id, draft.id, userId);
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
console.log("[PRACTITIONER] Multiple draft profiles claimed successfully", {
|
|
1045
|
+
practitionerId: updatedPractitioner.id,
|
|
1046
|
+
userId,
|
|
1047
|
+
mergedCount: validDrafts.length,
|
|
1048
|
+
});
|
|
1049
|
+
|
|
1050
|
+
return updatedPractitioner;
|
|
1051
|
+
} catch (error) {
|
|
1052
|
+
console.error(
|
|
1053
|
+
"[PRACTITIONER] Error claiming multiple draft profiles with Google:",
|
|
1054
|
+
error
|
|
1055
|
+
);
|
|
1056
|
+
throw error;
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
|
|
717
1060
|
/**
|
|
718
1061
|
* Dohvata sve zdravstvene radnike za određenu kliniku sa statusom ACTIVE
|
|
719
1062
|
*/
|