@blackcode_sa/metaestetics-api 1.13.20 → 1.14.0
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 +489 -11
- package/dist/index.mjs +489 -11
- package/package.json +1 -1
- package/src/admin/mailing/practitionerInvite/templates/invitation.template.ts +13 -2
- package/src/services/auth/auth.service.ts +173 -0
- package/src/services/practitioner/practitioner.service.ts +417 -9
- package/src/validations/patient/medical-info.schema.ts +55 -3
|
@@ -1002,6 +1002,179 @@ 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
|
+
const existingUser = await this.userService.getUserById(firebaseUser.uid);
|
|
1065
|
+
if (!existingUser) {
|
|
1066
|
+
await firebaseSignOut(this.auth);
|
|
1067
|
+
throw new AuthError(
|
|
1068
|
+
'No account found. Please contact support.',
|
|
1069
|
+
'AUTH/USER_NOT_FOUND',
|
|
1070
|
+
404,
|
|
1071
|
+
);
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
// Check if user has practitioner profile
|
|
1075
|
+
let practitioner: Practitioner | null = null;
|
|
1076
|
+
if (existingUser.practitionerProfile) {
|
|
1077
|
+
practitioner = await practitionerService.getPractitioner(existingUser.practitionerProfile);
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
// Check for any new draft profiles
|
|
1081
|
+
const draftProfiles = await practitionerService.getDraftProfilesByEmail(normalizedEmail);
|
|
1082
|
+
|
|
1083
|
+
return {
|
|
1084
|
+
user: existingUser,
|
|
1085
|
+
practitioner,
|
|
1086
|
+
draftProfiles,
|
|
1087
|
+
};
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
// Case 2: User exists with email/password - need to link Google provider
|
|
1091
|
+
// Firebase doesn't allow linking without being signed in first
|
|
1092
|
+
// So we need to ask user to sign in with email/password first, then link
|
|
1093
|
+
if (hasEmailMethod && !hasGoogleMethod) {
|
|
1094
|
+
console.log('[AUTH] User exists with email/password only');
|
|
1095
|
+
throw new AuthError(
|
|
1096
|
+
'An account with this email already exists. Please sign in with your email and password, then link your Google account in settings.',
|
|
1097
|
+
'AUTH/EMAIL_ALREADY_EXISTS',
|
|
1098
|
+
409,
|
|
1099
|
+
);
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
// Case 3: New user - sign in with Google and check for draft profiles
|
|
1103
|
+
console.log('[AUTH] Signing in with Google credential');
|
|
1104
|
+
const credential = GoogleAuthProvider.credential(idToken);
|
|
1105
|
+
|
|
1106
|
+
let firebaseUser: FirebaseUser;
|
|
1107
|
+
try {
|
|
1108
|
+
const result = await signInWithCredential(this.auth, credential);
|
|
1109
|
+
firebaseUser = result.user;
|
|
1110
|
+
} catch (error: any) {
|
|
1111
|
+
// If sign-in fails because email already exists with different provider
|
|
1112
|
+
if (error.code === 'auth/account-exists-with-different-credential') {
|
|
1113
|
+
throw new AuthError(
|
|
1114
|
+
'An account with this email already exists. Please sign in with your email and password, then link your Google account in settings.',
|
|
1115
|
+
'AUTH/EMAIL_ALREADY_EXISTS',
|
|
1116
|
+
409,
|
|
1117
|
+
);
|
|
1118
|
+
}
|
|
1119
|
+
throw error;
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
// Check for existing User document (in case user had email/password account that was just linked)
|
|
1123
|
+
let existingUser: User | null = null;
|
|
1124
|
+
try {
|
|
1125
|
+
const existingUserDoc = await this.userService.getUserById(firebaseUser.uid);
|
|
1126
|
+
if (existingUserDoc) {
|
|
1127
|
+
existingUser = existingUserDoc;
|
|
1128
|
+
console.log('[AUTH] Found existing User document');
|
|
1129
|
+
}
|
|
1130
|
+
} catch (error) {
|
|
1131
|
+
console.error('[AUTH] Error checking for existing user:', error);
|
|
1132
|
+
// Continue with new user creation
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
// Check for draft profiles
|
|
1136
|
+
console.log('[AUTH] Checking for draft profiles');
|
|
1137
|
+
const draftProfiles = await practitionerService.getDraftProfilesByEmail(normalizedEmail);
|
|
1138
|
+
|
|
1139
|
+
let user: User;
|
|
1140
|
+
if (existingUser) {
|
|
1141
|
+
// User exists - use existing account
|
|
1142
|
+
user = existingUser;
|
|
1143
|
+
console.log('[AUTH] Using existing user account');
|
|
1144
|
+
} else {
|
|
1145
|
+
// Create new user document
|
|
1146
|
+
user = await this.userService.createUser(firebaseUser, [UserRole.PRACTITIONER], {
|
|
1147
|
+
skipProfileCreation: true,
|
|
1148
|
+
});
|
|
1149
|
+
console.log('[AUTH] Created new user account');
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
// Check if user already has practitioner profile
|
|
1153
|
+
let practitioner: Practitioner | null = null;
|
|
1154
|
+
if (user.practitionerProfile) {
|
|
1155
|
+
practitioner = await practitionerService.getPractitioner(user.practitionerProfile);
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
console.log('[AUTH] Google Sign-In complete', {
|
|
1159
|
+
userId: user.uid,
|
|
1160
|
+
hasPractitioner: !!practitioner,
|
|
1161
|
+
draftProfilesCount: draftProfiles.length,
|
|
1162
|
+
});
|
|
1163
|
+
|
|
1164
|
+
return {
|
|
1165
|
+
user,
|
|
1166
|
+
practitioner,
|
|
1167
|
+
draftProfiles,
|
|
1168
|
+
};
|
|
1169
|
+
} catch (error) {
|
|
1170
|
+
console.error('[AUTH] Error in signUpPractitionerWithGoogle:', error);
|
|
1171
|
+
if (error instanceof AuthError) {
|
|
1172
|
+
throw error;
|
|
1173
|
+
}
|
|
1174
|
+
throw handleFirebaseError(error);
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1005
1178
|
/**
|
|
1006
1179
|
* Links a Google account to the currently signed-in user using an ID token.
|
|
1007
1180
|
* This is used to upgrade an anonymous user or to allow an existing user
|
|
@@ -714,6 +714,324 @@ export class PractitionerService extends BaseService {
|
|
|
714
714
|
}
|
|
715
715
|
}
|
|
716
716
|
|
|
717
|
+
/**
|
|
718
|
+
* Finds all draft practitioner profiles by email address
|
|
719
|
+
* Used when a doctor signs in with Google to show all clinic invitations
|
|
720
|
+
*
|
|
721
|
+
* @param email - Email address to search for
|
|
722
|
+
* @returns Array of draft practitioner profiles with clinic information
|
|
723
|
+
*
|
|
724
|
+
* @remarks
|
|
725
|
+
* Requires Firestore composite index on:
|
|
726
|
+
* - Collection: practitioners
|
|
727
|
+
* - Fields: basicInfo.email (Ascending), status (Ascending), userRef (Ascending)
|
|
728
|
+
*/
|
|
729
|
+
async getDraftProfilesByEmail(
|
|
730
|
+
email: string
|
|
731
|
+
): Promise<Practitioner[]> {
|
|
732
|
+
try {
|
|
733
|
+
const normalizedEmail = email.toLowerCase().trim();
|
|
734
|
+
|
|
735
|
+
console.log("[PRACTITIONER] Searching for all draft practitioners by email", {
|
|
736
|
+
email: normalizedEmail,
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
const q = query(
|
|
740
|
+
collection(this.db, PRACTITIONERS_COLLECTION),
|
|
741
|
+
where("basicInfo.email", "==", normalizedEmail),
|
|
742
|
+
where("status", "==", PractitionerStatus.DRAFT),
|
|
743
|
+
where("userRef", "==", "")
|
|
744
|
+
);
|
|
745
|
+
|
|
746
|
+
const querySnapshot = await getDocs(q);
|
|
747
|
+
|
|
748
|
+
if (querySnapshot.empty) {
|
|
749
|
+
console.log("[PRACTITIONER] No draft practitioners found for email", {
|
|
750
|
+
email: normalizedEmail,
|
|
751
|
+
});
|
|
752
|
+
return [];
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
const draftPractitioners = querySnapshot.docs.map(
|
|
756
|
+
(doc) => doc.data() as Practitioner
|
|
757
|
+
);
|
|
758
|
+
|
|
759
|
+
console.log("[PRACTITIONER] Found draft practitioners", {
|
|
760
|
+
email: normalizedEmail,
|
|
761
|
+
count: draftPractitioners.length,
|
|
762
|
+
practitionerIds: draftPractitioners.map((p) => p.id),
|
|
763
|
+
});
|
|
764
|
+
|
|
765
|
+
return draftPractitioners;
|
|
766
|
+
} catch (error) {
|
|
767
|
+
console.error(
|
|
768
|
+
"[PRACTITIONER] Error finding draft practitioners by email:",
|
|
769
|
+
error
|
|
770
|
+
);
|
|
771
|
+
// If query fails (e.g., index not created), return empty array
|
|
772
|
+
return [];
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/**
|
|
777
|
+
* Claims a draft practitioner profile and links it to a user account
|
|
778
|
+
* Used when a doctor selects which clinic(s) to join after Google Sign-In
|
|
779
|
+
*
|
|
780
|
+
* @param practitionerId - ID of the draft practitioner profile to claim
|
|
781
|
+
* @param userId - ID of the user account to link the profile to
|
|
782
|
+
* @returns The claimed practitioner profile
|
|
783
|
+
*/
|
|
784
|
+
async claimDraftProfileWithGoogle(
|
|
785
|
+
practitionerId: string,
|
|
786
|
+
userId: string
|
|
787
|
+
): Promise<Practitioner> {
|
|
788
|
+
try {
|
|
789
|
+
console.log("[PRACTITIONER] Claiming draft profile with Google", {
|
|
790
|
+
practitionerId,
|
|
791
|
+
userId,
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
// Get the draft practitioner profile
|
|
795
|
+
const practitioner = await this.getPractitioner(practitionerId);
|
|
796
|
+
if (!practitioner) {
|
|
797
|
+
throw new Error(`Practitioner ${practitionerId} not found`);
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
// Ensure practitioner is in DRAFT status
|
|
801
|
+
if (practitioner.status !== PractitionerStatus.DRAFT) {
|
|
802
|
+
throw new Error("This practitioner profile has already been claimed");
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
// Check if user already has a practitioner profile
|
|
806
|
+
const existingPractitioner = await this.getPractitionerByUserRef(userId);
|
|
807
|
+
if (existingPractitioner) {
|
|
808
|
+
// User already has a profile - merge clinics from draft profile into existing profile
|
|
809
|
+
console.log("[PRACTITIONER] User already has profile, merging clinics");
|
|
810
|
+
|
|
811
|
+
// Merge clinics (avoid duplicates)
|
|
812
|
+
const mergedClinics = Array.from(new Set([
|
|
813
|
+
...existingPractitioner.clinics,
|
|
814
|
+
...practitioner.clinics,
|
|
815
|
+
]));
|
|
816
|
+
|
|
817
|
+
// Merge clinic working hours
|
|
818
|
+
const mergedWorkingHours = [...existingPractitioner.clinicWorkingHours];
|
|
819
|
+
for (const workingHours of practitioner.clinicWorkingHours) {
|
|
820
|
+
if (!mergedWorkingHours.find(wh => wh.clinicId === workingHours.clinicId)) {
|
|
821
|
+
mergedWorkingHours.push(workingHours);
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
// Merge clinics info (avoid duplicates)
|
|
826
|
+
const mergedClinicsInfo = [...existingPractitioner.clinicsInfo];
|
|
827
|
+
for (const clinicInfo of practitioner.clinicsInfo) {
|
|
828
|
+
if (!mergedClinicsInfo.find(ci => ci.id === clinicInfo.id)) {
|
|
829
|
+
mergedClinicsInfo.push(clinicInfo);
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
// Update existing practitioner with merged data
|
|
834
|
+
const updatedPractitioner = await this.updatePractitioner(existingPractitioner.id, {
|
|
835
|
+
clinics: mergedClinics,
|
|
836
|
+
clinicWorkingHours: mergedWorkingHours,
|
|
837
|
+
clinicsInfo: mergedClinicsInfo,
|
|
838
|
+
});
|
|
839
|
+
|
|
840
|
+
// Delete the draft profile since we've merged it
|
|
841
|
+
await deleteDoc(doc(this.db, PRACTITIONERS_COLLECTION, practitionerId));
|
|
842
|
+
|
|
843
|
+
// Mark all active tokens for the draft practitioner as used
|
|
844
|
+
const activeTokens = await this.getPractitionerActiveTokens(practitionerId);
|
|
845
|
+
for (const token of activeTokens) {
|
|
846
|
+
await this.markTokenAsUsed(token.id, practitionerId, userId);
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
return updatedPractitioner;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
// Claim the profile by linking it to the user
|
|
853
|
+
const updatedPractitioner = await this.updatePractitioner(practitioner.id, {
|
|
854
|
+
userRef: userId,
|
|
855
|
+
status: PractitionerStatus.ACTIVE,
|
|
856
|
+
});
|
|
857
|
+
|
|
858
|
+
// Mark all active tokens for this practitioner as used
|
|
859
|
+
const activeTokens = await this.getPractitionerActiveTokens(practitionerId);
|
|
860
|
+
for (const token of activeTokens) {
|
|
861
|
+
await this.markTokenAsUsed(token.id, practitionerId, userId);
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
console.log("[PRACTITIONER] Draft profile claimed successfully", {
|
|
865
|
+
practitionerId: updatedPractitioner.id,
|
|
866
|
+
userId,
|
|
867
|
+
});
|
|
868
|
+
|
|
869
|
+
return updatedPractitioner;
|
|
870
|
+
} catch (error) {
|
|
871
|
+
console.error(
|
|
872
|
+
"[PRACTITIONER] Error claiming draft profile with Google:",
|
|
873
|
+
error
|
|
874
|
+
);
|
|
875
|
+
throw error;
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
/**
|
|
880
|
+
* Claims multiple draft practitioner profiles and merges them into one profile
|
|
881
|
+
* Used when a doctor selects multiple clinics to join after Google Sign-In
|
|
882
|
+
*
|
|
883
|
+
* @param practitionerIds - Array of draft practitioner profile IDs to claim
|
|
884
|
+
* @param userId - ID of the user account to link the profiles to
|
|
885
|
+
* @returns The claimed practitioner profile (first one becomes main, others merged)
|
|
886
|
+
*/
|
|
887
|
+
async claimMultipleDraftProfilesWithGoogle(
|
|
888
|
+
practitionerIds: string[],
|
|
889
|
+
userId: string
|
|
890
|
+
): Promise<Practitioner> {
|
|
891
|
+
try {
|
|
892
|
+
if (practitionerIds.length === 0) {
|
|
893
|
+
throw new Error("No practitioner IDs provided");
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
console.log("[PRACTITIONER] Claiming multiple draft profiles with Google", {
|
|
897
|
+
practitionerIds,
|
|
898
|
+
userId,
|
|
899
|
+
count: practitionerIds.length,
|
|
900
|
+
});
|
|
901
|
+
|
|
902
|
+
// Get all draft profiles
|
|
903
|
+
const draftProfiles = await Promise.all(
|
|
904
|
+
practitionerIds.map(id => this.getPractitioner(id))
|
|
905
|
+
);
|
|
906
|
+
|
|
907
|
+
// Filter out nulls and ensure all are drafts
|
|
908
|
+
const validDrafts = draftProfiles.filter((p): p is Practitioner => {
|
|
909
|
+
if (!p) return false;
|
|
910
|
+
if (p.status !== PractitionerStatus.DRAFT) {
|
|
911
|
+
throw new Error(`Practitioner ${p.id} has already been claimed`);
|
|
912
|
+
}
|
|
913
|
+
return true;
|
|
914
|
+
});
|
|
915
|
+
|
|
916
|
+
if (validDrafts.length === 0) {
|
|
917
|
+
throw new Error("No valid draft profiles found");
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
// Check if user already has a practitioner profile
|
|
921
|
+
const existingPractitioner = await this.getPractitionerByUserRef(userId);
|
|
922
|
+
|
|
923
|
+
if (existingPractitioner) {
|
|
924
|
+
// Merge all draft profiles into existing profile
|
|
925
|
+
let mergedClinics = new Set(existingPractitioner.clinics);
|
|
926
|
+
let mergedWorkingHours = [...existingPractitioner.clinicWorkingHours];
|
|
927
|
+
let mergedClinicsInfo = [...existingPractitioner.clinicsInfo];
|
|
928
|
+
|
|
929
|
+
for (const draft of validDrafts) {
|
|
930
|
+
// Merge clinics
|
|
931
|
+
draft.clinics.forEach(clinicId => mergedClinics.add(clinicId));
|
|
932
|
+
|
|
933
|
+
// Merge working hours
|
|
934
|
+
for (const workingHours of draft.clinicWorkingHours) {
|
|
935
|
+
if (!mergedWorkingHours.find(wh => wh.clinicId === workingHours.clinicId)) {
|
|
936
|
+
mergedWorkingHours.push(workingHours);
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
// Merge clinics info
|
|
941
|
+
for (const clinicInfo of draft.clinicsInfo) {
|
|
942
|
+
if (!mergedClinicsInfo.find(ci => ci.id === clinicInfo.id)) {
|
|
943
|
+
mergedClinicsInfo.push(clinicInfo);
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
// Update existing practitioner
|
|
949
|
+
const updatedPractitioner = await this.updatePractitioner(existingPractitioner.id, {
|
|
950
|
+
clinics: Array.from(mergedClinics),
|
|
951
|
+
clinicWorkingHours: mergedWorkingHours,
|
|
952
|
+
clinicsInfo: mergedClinicsInfo,
|
|
953
|
+
});
|
|
954
|
+
|
|
955
|
+
// Delete all draft profiles
|
|
956
|
+
for (const draft of validDrafts) {
|
|
957
|
+
await deleteDoc(doc(this.db, PRACTITIONERS_COLLECTION, draft.id));
|
|
958
|
+
|
|
959
|
+
// Mark all active tokens as used
|
|
960
|
+
const activeTokens = await this.getPractitionerActiveTokens(draft.id);
|
|
961
|
+
for (const token of activeTokens) {
|
|
962
|
+
await this.markTokenAsUsed(token.id, draft.id, userId);
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
return updatedPractitioner;
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
// Use first draft as the main profile, merge others into it
|
|
970
|
+
const mainDraft = validDrafts[0];
|
|
971
|
+
const otherDrafts = validDrafts.slice(1);
|
|
972
|
+
|
|
973
|
+
// Merge clinics from other drafts
|
|
974
|
+
let mergedClinics = new Set(mainDraft.clinics);
|
|
975
|
+
let mergedWorkingHours = [...mainDraft.clinicWorkingHours];
|
|
976
|
+
let mergedClinicsInfo = [...mainDraft.clinicsInfo];
|
|
977
|
+
|
|
978
|
+
for (const draft of otherDrafts) {
|
|
979
|
+
draft.clinics.forEach(clinicId => mergedClinics.add(clinicId));
|
|
980
|
+
|
|
981
|
+
for (const workingHours of draft.clinicWorkingHours) {
|
|
982
|
+
if (!mergedWorkingHours.find(wh => wh.clinicId === workingHours.clinicId)) {
|
|
983
|
+
mergedWorkingHours.push(workingHours);
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
for (const clinicInfo of draft.clinicsInfo) {
|
|
988
|
+
if (!mergedClinicsInfo.find(ci => ci.id === clinicInfo.id)) {
|
|
989
|
+
mergedClinicsInfo.push(clinicInfo);
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
// Claim the main profile
|
|
995
|
+
const updatedPractitioner = await this.updatePractitioner(mainDraft.id, {
|
|
996
|
+
userRef: userId,
|
|
997
|
+
status: PractitionerStatus.ACTIVE,
|
|
998
|
+
clinics: Array.from(mergedClinics),
|
|
999
|
+
clinicWorkingHours: mergedWorkingHours,
|
|
1000
|
+
clinicsInfo: mergedClinicsInfo,
|
|
1001
|
+
});
|
|
1002
|
+
|
|
1003
|
+
// Mark all active tokens for main profile as used
|
|
1004
|
+
const mainActiveTokens = await this.getPractitionerActiveTokens(mainDraft.id);
|
|
1005
|
+
for (const token of mainActiveTokens) {
|
|
1006
|
+
await this.markTokenAsUsed(token.id, mainDraft.id, userId);
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
// Delete other draft profiles
|
|
1010
|
+
for (const draft of otherDrafts) {
|
|
1011
|
+
await deleteDoc(doc(this.db, PRACTITIONERS_COLLECTION, draft.id));
|
|
1012
|
+
|
|
1013
|
+
const activeTokens = await this.getPractitionerActiveTokens(draft.id);
|
|
1014
|
+
for (const token of activeTokens) {
|
|
1015
|
+
await this.markTokenAsUsed(token.id, draft.id, userId);
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
console.log("[PRACTITIONER] Multiple draft profiles claimed successfully", {
|
|
1020
|
+
practitionerId: updatedPractitioner.id,
|
|
1021
|
+
userId,
|
|
1022
|
+
mergedCount: validDrafts.length,
|
|
1023
|
+
});
|
|
1024
|
+
|
|
1025
|
+
return updatedPractitioner;
|
|
1026
|
+
} catch (error) {
|
|
1027
|
+
console.error(
|
|
1028
|
+
"[PRACTITIONER] Error claiming multiple draft profiles with Google:",
|
|
1029
|
+
error
|
|
1030
|
+
);
|
|
1031
|
+
throw error;
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
|
|
717
1035
|
/**
|
|
718
1036
|
* Dohvata sve zdravstvene radnike za određenu kliniku sa statusom ACTIVE
|
|
719
1037
|
*/
|
|
@@ -1546,6 +1864,10 @@ export class PractitionerService extends BaseService {
|
|
|
1546
1864
|
clinicId: string
|
|
1547
1865
|
): Promise<void> {
|
|
1548
1866
|
try {
|
|
1867
|
+
console.log(
|
|
1868
|
+
`[EnableFreeConsultation] Starting for practitioner ${practitionerId} in clinic ${clinicId}`
|
|
1869
|
+
);
|
|
1870
|
+
|
|
1549
1871
|
// First, ensure the free consultation infrastructure exists
|
|
1550
1872
|
await this.ensureFreeConsultationInfrastructure();
|
|
1551
1873
|
|
|
@@ -1573,9 +1895,15 @@ export class PractitionerService extends BaseService {
|
|
|
1573
1895
|
);
|
|
1574
1896
|
}
|
|
1575
1897
|
|
|
1576
|
-
//
|
|
1898
|
+
// CRITICAL: Double-check for existing procedures to prevent race conditions
|
|
1899
|
+
// Fetch procedures again right before creation/update
|
|
1900
|
+
// IMPORTANT: Pass false for excludeDraftPractitioners to work with draft practitioners
|
|
1577
1901
|
const [activeProcedures, inactiveProcedures] = await Promise.all([
|
|
1578
|
-
this.getProcedureService().getProceduresByPractitioner(
|
|
1902
|
+
this.getProcedureService().getProceduresByPractitioner(
|
|
1903
|
+
practitionerId,
|
|
1904
|
+
undefined, // clinicBranchId
|
|
1905
|
+
false // excludeDraftPractitioners - allow draft practitioners
|
|
1906
|
+
),
|
|
1579
1907
|
this.getProcedureService().getInactiveProceduresByPractitioner(
|
|
1580
1908
|
practitionerId
|
|
1581
1909
|
),
|
|
@@ -1585,31 +1913,86 @@ export class PractitionerService extends BaseService {
|
|
|
1585
1913
|
const allProcedures = [...activeProcedures, ...inactiveProcedures];
|
|
1586
1914
|
|
|
1587
1915
|
// Check if free consultation already exists (active or inactive)
|
|
1588
|
-
const
|
|
1916
|
+
const existingConsultations = allProcedures.filter(
|
|
1589
1917
|
(procedure) =>
|
|
1590
1918
|
procedure.technology.id === "free-consultation-tech" &&
|
|
1591
1919
|
procedure.clinicBranchId === clinicId
|
|
1592
1920
|
);
|
|
1593
1921
|
|
|
1922
|
+
console.log(
|
|
1923
|
+
`[EnableFreeConsultation] Found ${existingConsultations.length} existing free consultation(s)`
|
|
1924
|
+
);
|
|
1925
|
+
|
|
1926
|
+
// If multiple consultations exist, log a warning and clean up duplicates
|
|
1927
|
+
if (existingConsultations.length > 1) {
|
|
1928
|
+
console.warn(
|
|
1929
|
+
`[EnableFreeConsultation] WARNING: Found ${existingConsultations.length} duplicate free consultations for practitioner ${practitionerId} in clinic ${clinicId}`
|
|
1930
|
+
);
|
|
1931
|
+
// Keep the first one, deactivate the rest
|
|
1932
|
+
for (let i = 1; i < existingConsultations.length; i++) {
|
|
1933
|
+
console.log(
|
|
1934
|
+
`[EnableFreeConsultation] Deactivating duplicate consultation ${existingConsultations[i].id}`
|
|
1935
|
+
);
|
|
1936
|
+
await this.getProcedureService().deactivateProcedure(
|
|
1937
|
+
existingConsultations[i].id
|
|
1938
|
+
);
|
|
1939
|
+
}
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1942
|
+
const existingConsultation = existingConsultations[0];
|
|
1943
|
+
|
|
1594
1944
|
if (existingConsultation) {
|
|
1595
1945
|
if (existingConsultation.isActive) {
|
|
1596
1946
|
console.log(
|
|
1597
|
-
`Free consultation already active for practitioner ${practitionerId} in clinic ${clinicId}`
|
|
1947
|
+
`[EnableFreeConsultation] Free consultation already active for practitioner ${practitionerId} in clinic ${clinicId}`
|
|
1598
1948
|
);
|
|
1599
1949
|
return;
|
|
1600
1950
|
} else {
|
|
1601
1951
|
// Reactivate the existing disabled consultation
|
|
1952
|
+
console.log(
|
|
1953
|
+
`[EnableFreeConsultation] Reactivating existing consultation ${existingConsultation.id}`
|
|
1954
|
+
);
|
|
1602
1955
|
await this.getProcedureService().updateProcedure(
|
|
1603
1956
|
existingConsultation.id,
|
|
1604
1957
|
{ isActive: true }
|
|
1605
1958
|
);
|
|
1606
1959
|
console.log(
|
|
1607
|
-
`Reactivated existing free consultation for practitioner ${practitionerId} in clinic ${clinicId}`
|
|
1960
|
+
`[EnableFreeConsultation] Reactivated existing free consultation for practitioner ${practitionerId} in clinic ${clinicId}`
|
|
1608
1961
|
);
|
|
1609
1962
|
return;
|
|
1610
1963
|
}
|
|
1611
1964
|
}
|
|
1612
1965
|
|
|
1966
|
+
// Final check before creating - race condition guard
|
|
1967
|
+
// Fetch one more time to ensure no procedure was created in parallel
|
|
1968
|
+
console.log(
|
|
1969
|
+
`[EnableFreeConsultation] Final race condition check before creating new procedure`
|
|
1970
|
+
);
|
|
1971
|
+
const finalCheckProcedures =
|
|
1972
|
+
await this.getProcedureService().getProceduresByPractitioner(
|
|
1973
|
+
practitionerId,
|
|
1974
|
+
undefined, // clinicBranchId
|
|
1975
|
+
false // excludeDraftPractitioners - allow draft practitioners
|
|
1976
|
+
);
|
|
1977
|
+
const raceConditionCheck = finalCheckProcedures.find(
|
|
1978
|
+
(procedure) =>
|
|
1979
|
+
procedure.technology.id === "free-consultation-tech" &&
|
|
1980
|
+
procedure.clinicBranchId === clinicId
|
|
1981
|
+
);
|
|
1982
|
+
|
|
1983
|
+
if (raceConditionCheck) {
|
|
1984
|
+
console.log(
|
|
1985
|
+
`[EnableFreeConsultation] Race condition detected! Procedure was created by another request. Using existing procedure ${raceConditionCheck.id}`
|
|
1986
|
+
);
|
|
1987
|
+
if (!raceConditionCheck.isActive) {
|
|
1988
|
+
await this.getProcedureService().updateProcedure(
|
|
1989
|
+
raceConditionCheck.id,
|
|
1990
|
+
{ isActive: true }
|
|
1991
|
+
);
|
|
1992
|
+
}
|
|
1993
|
+
return;
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1613
1996
|
// Create procedure data for free consultation (without productId or productsMetadata)
|
|
1614
1997
|
const consultationData: Omit<CreateProcedureData, "productId"> = {
|
|
1615
1998
|
name: "Free Consultation",
|
|
@@ -1631,16 +2014,19 @@ export class PractitionerService extends BaseService {
|
|
|
1631
2014
|
};
|
|
1632
2015
|
|
|
1633
2016
|
// Create the consultation procedure using the special method
|
|
2017
|
+
console.log(
|
|
2018
|
+
`[EnableFreeConsultation] Creating new free consultation procedure`
|
|
2019
|
+
);
|
|
1634
2020
|
await this.getProcedureService().createConsultationProcedure(
|
|
1635
2021
|
consultationData
|
|
1636
2022
|
);
|
|
1637
2023
|
|
|
1638
2024
|
console.log(
|
|
1639
|
-
`
|
|
2025
|
+
`[EnableFreeConsultation] Successfully created free consultation for practitioner ${practitionerId} in clinic ${clinicId}`
|
|
1640
2026
|
);
|
|
1641
2027
|
} catch (error) {
|
|
1642
2028
|
console.error(
|
|
1643
|
-
`Error enabling free consultation for practitioner ${practitionerId} in clinic ${clinicId}:`,
|
|
2029
|
+
`[EnableFreeConsultation] Error enabling free consultation for practitioner ${practitionerId} in clinic ${clinicId}:`,
|
|
1644
2030
|
error
|
|
1645
2031
|
);
|
|
1646
2032
|
throw error;
|
|
@@ -1764,10 +2150,18 @@ export class PractitionerService extends BaseService {
|
|
|
1764
2150
|
|
|
1765
2151
|
// Find the free consultation procedure for this practitioner in this clinic
|
|
1766
2152
|
// Use the more specific search by technology ID instead of name
|
|
2153
|
+
// IMPORTANT: Pass false for excludeDraftPractitioners to allow disabling for draft practitioners
|
|
1767
2154
|
const existingProcedures =
|
|
1768
2155
|
await this.getProcedureService().getProceduresByPractitioner(
|
|
1769
|
-
practitionerId
|
|
2156
|
+
practitionerId,
|
|
2157
|
+
undefined, // clinicBranchId (optional)
|
|
2158
|
+
false // excludeDraftPractitioners - must be false to find procedures for draft practitioners
|
|
1770
2159
|
);
|
|
2160
|
+
|
|
2161
|
+
console.log(
|
|
2162
|
+
`[DisableFreeConsultation] Found ${existingProcedures.length} procedures for practitioner ${practitionerId}`
|
|
2163
|
+
);
|
|
2164
|
+
|
|
1771
2165
|
const freeConsultation = existingProcedures.find(
|
|
1772
2166
|
(procedure) =>
|
|
1773
2167
|
procedure.technology.id === "free-consultation-tech" &&
|
|
@@ -1777,10 +2171,24 @@ export class PractitionerService extends BaseService {
|
|
|
1777
2171
|
|
|
1778
2172
|
if (!freeConsultation) {
|
|
1779
2173
|
console.log(
|
|
1780
|
-
`No active free consultation found for practitioner ${practitionerId} in clinic ${clinicId}`
|
|
2174
|
+
`[DisableFreeConsultation] No active free consultation found for practitioner ${practitionerId} in clinic ${clinicId}`
|
|
2175
|
+
);
|
|
2176
|
+
console.log(
|
|
2177
|
+
`[DisableFreeConsultation] Existing procedures:`,
|
|
2178
|
+
existingProcedures.map(p => ({
|
|
2179
|
+
id: p.id,
|
|
2180
|
+
name: p.name,
|
|
2181
|
+
technologyId: p.technology?.id,
|
|
2182
|
+
clinicBranchId: p.clinicBranchId,
|
|
2183
|
+
isActive: p.isActive
|
|
2184
|
+
}))
|
|
1781
2185
|
);
|
|
1782
2186
|
return;
|
|
1783
2187
|
}
|
|
2188
|
+
|
|
2189
|
+
console.log(
|
|
2190
|
+
`[DisableFreeConsultation] Found free consultation procedure ${freeConsultation.id}, deactivating...`
|
|
2191
|
+
);
|
|
1784
2192
|
|
|
1785
2193
|
// Deactivate the consultation procedure
|
|
1786
2194
|
await this.getProcedureService().deactivateProcedure(freeConsultation.id);
|