@blackcode_sa/metaestetics-api 1.13.21 → 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 +363 -0
- package/dist/index.mjs +363 -0
- 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 +318 -0
package/dist/index.mjs
CHANGED
|
@@ -11738,6 +11738,240 @@ var PractitionerService = class extends BaseService {
|
|
|
11738
11738
|
return null;
|
|
11739
11739
|
}
|
|
11740
11740
|
}
|
|
11741
|
+
/**
|
|
11742
|
+
* Finds all draft practitioner profiles by email address
|
|
11743
|
+
* Used when a doctor signs in with Google to show all clinic invitations
|
|
11744
|
+
*
|
|
11745
|
+
* @param email - Email address to search for
|
|
11746
|
+
* @returns Array of draft practitioner profiles with clinic information
|
|
11747
|
+
*
|
|
11748
|
+
* @remarks
|
|
11749
|
+
* Requires Firestore composite index on:
|
|
11750
|
+
* - Collection: practitioners
|
|
11751
|
+
* - Fields: basicInfo.email (Ascending), status (Ascending), userRef (Ascending)
|
|
11752
|
+
*/
|
|
11753
|
+
async getDraftProfilesByEmail(email) {
|
|
11754
|
+
try {
|
|
11755
|
+
const normalizedEmail = email.toLowerCase().trim();
|
|
11756
|
+
console.log("[PRACTITIONER] Searching for all draft practitioners by email", {
|
|
11757
|
+
email: normalizedEmail
|
|
11758
|
+
});
|
|
11759
|
+
const q = query13(
|
|
11760
|
+
collection13(this.db, PRACTITIONERS_COLLECTION),
|
|
11761
|
+
where13("basicInfo.email", "==", normalizedEmail),
|
|
11762
|
+
where13("status", "==", "draft" /* DRAFT */),
|
|
11763
|
+
where13("userRef", "==", "")
|
|
11764
|
+
);
|
|
11765
|
+
const querySnapshot = await getDocs13(q);
|
|
11766
|
+
if (querySnapshot.empty) {
|
|
11767
|
+
console.log("[PRACTITIONER] No draft practitioners found for email", {
|
|
11768
|
+
email: normalizedEmail
|
|
11769
|
+
});
|
|
11770
|
+
return [];
|
|
11771
|
+
}
|
|
11772
|
+
const draftPractitioners = querySnapshot.docs.map(
|
|
11773
|
+
(doc47) => doc47.data()
|
|
11774
|
+
);
|
|
11775
|
+
console.log("[PRACTITIONER] Found draft practitioners", {
|
|
11776
|
+
email: normalizedEmail,
|
|
11777
|
+
count: draftPractitioners.length,
|
|
11778
|
+
practitionerIds: draftPractitioners.map((p) => p.id)
|
|
11779
|
+
});
|
|
11780
|
+
return draftPractitioners;
|
|
11781
|
+
} catch (error) {
|
|
11782
|
+
console.error(
|
|
11783
|
+
"[PRACTITIONER] Error finding draft practitioners by email:",
|
|
11784
|
+
error
|
|
11785
|
+
);
|
|
11786
|
+
return [];
|
|
11787
|
+
}
|
|
11788
|
+
}
|
|
11789
|
+
/**
|
|
11790
|
+
* Claims a draft practitioner profile and links it to a user account
|
|
11791
|
+
* Used when a doctor selects which clinic(s) to join after Google Sign-In
|
|
11792
|
+
*
|
|
11793
|
+
* @param practitionerId - ID of the draft practitioner profile to claim
|
|
11794
|
+
* @param userId - ID of the user account to link the profile to
|
|
11795
|
+
* @returns The claimed practitioner profile
|
|
11796
|
+
*/
|
|
11797
|
+
async claimDraftProfileWithGoogle(practitionerId, userId) {
|
|
11798
|
+
try {
|
|
11799
|
+
console.log("[PRACTITIONER] Claiming draft profile with Google", {
|
|
11800
|
+
practitionerId,
|
|
11801
|
+
userId
|
|
11802
|
+
});
|
|
11803
|
+
const practitioner = await this.getPractitioner(practitionerId);
|
|
11804
|
+
if (!practitioner) {
|
|
11805
|
+
throw new Error(`Practitioner ${practitionerId} not found`);
|
|
11806
|
+
}
|
|
11807
|
+
if (practitioner.status !== "draft" /* DRAFT */) {
|
|
11808
|
+
throw new Error("This practitioner profile has already been claimed");
|
|
11809
|
+
}
|
|
11810
|
+
const existingPractitioner = await this.getPractitionerByUserRef(userId);
|
|
11811
|
+
if (existingPractitioner) {
|
|
11812
|
+
console.log("[PRACTITIONER] User already has profile, merging clinics");
|
|
11813
|
+
const mergedClinics = Array.from(/* @__PURE__ */ new Set([
|
|
11814
|
+
...existingPractitioner.clinics,
|
|
11815
|
+
...practitioner.clinics
|
|
11816
|
+
]));
|
|
11817
|
+
const mergedWorkingHours = [...existingPractitioner.clinicWorkingHours];
|
|
11818
|
+
for (const workingHours of practitioner.clinicWorkingHours) {
|
|
11819
|
+
if (!mergedWorkingHours.find((wh) => wh.clinicId === workingHours.clinicId)) {
|
|
11820
|
+
mergedWorkingHours.push(workingHours);
|
|
11821
|
+
}
|
|
11822
|
+
}
|
|
11823
|
+
const mergedClinicsInfo = [...existingPractitioner.clinicsInfo];
|
|
11824
|
+
for (const clinicInfo of practitioner.clinicsInfo) {
|
|
11825
|
+
if (!mergedClinicsInfo.find((ci) => ci.id === clinicInfo.id)) {
|
|
11826
|
+
mergedClinicsInfo.push(clinicInfo);
|
|
11827
|
+
}
|
|
11828
|
+
}
|
|
11829
|
+
const updatedPractitioner2 = await this.updatePractitioner(existingPractitioner.id, {
|
|
11830
|
+
clinics: mergedClinics,
|
|
11831
|
+
clinicWorkingHours: mergedWorkingHours,
|
|
11832
|
+
clinicsInfo: mergedClinicsInfo
|
|
11833
|
+
});
|
|
11834
|
+
await deleteDoc4(doc20(this.db, PRACTITIONERS_COLLECTION, practitionerId));
|
|
11835
|
+
const activeTokens2 = await this.getPractitionerActiveTokens(practitionerId);
|
|
11836
|
+
for (const token of activeTokens2) {
|
|
11837
|
+
await this.markTokenAsUsed(token.id, practitionerId, userId);
|
|
11838
|
+
}
|
|
11839
|
+
return updatedPractitioner2;
|
|
11840
|
+
}
|
|
11841
|
+
const updatedPractitioner = await this.updatePractitioner(practitioner.id, {
|
|
11842
|
+
userRef: userId,
|
|
11843
|
+
status: "active" /* ACTIVE */
|
|
11844
|
+
});
|
|
11845
|
+
const activeTokens = await this.getPractitionerActiveTokens(practitionerId);
|
|
11846
|
+
for (const token of activeTokens) {
|
|
11847
|
+
await this.markTokenAsUsed(token.id, practitionerId, userId);
|
|
11848
|
+
}
|
|
11849
|
+
console.log("[PRACTITIONER] Draft profile claimed successfully", {
|
|
11850
|
+
practitionerId: updatedPractitioner.id,
|
|
11851
|
+
userId
|
|
11852
|
+
});
|
|
11853
|
+
return updatedPractitioner;
|
|
11854
|
+
} catch (error) {
|
|
11855
|
+
console.error(
|
|
11856
|
+
"[PRACTITIONER] Error claiming draft profile with Google:",
|
|
11857
|
+
error
|
|
11858
|
+
);
|
|
11859
|
+
throw error;
|
|
11860
|
+
}
|
|
11861
|
+
}
|
|
11862
|
+
/**
|
|
11863
|
+
* Claims multiple draft practitioner profiles and merges them into one profile
|
|
11864
|
+
* Used when a doctor selects multiple clinics to join after Google Sign-In
|
|
11865
|
+
*
|
|
11866
|
+
* @param practitionerIds - Array of draft practitioner profile IDs to claim
|
|
11867
|
+
* @param userId - ID of the user account to link the profiles to
|
|
11868
|
+
* @returns The claimed practitioner profile (first one becomes main, others merged)
|
|
11869
|
+
*/
|
|
11870
|
+
async claimMultipleDraftProfilesWithGoogle(practitionerIds, userId) {
|
|
11871
|
+
try {
|
|
11872
|
+
if (practitionerIds.length === 0) {
|
|
11873
|
+
throw new Error("No practitioner IDs provided");
|
|
11874
|
+
}
|
|
11875
|
+
console.log("[PRACTITIONER] Claiming multiple draft profiles with Google", {
|
|
11876
|
+
practitionerIds,
|
|
11877
|
+
userId,
|
|
11878
|
+
count: practitionerIds.length
|
|
11879
|
+
});
|
|
11880
|
+
const draftProfiles = await Promise.all(
|
|
11881
|
+
practitionerIds.map((id) => this.getPractitioner(id))
|
|
11882
|
+
);
|
|
11883
|
+
const validDrafts = draftProfiles.filter((p) => {
|
|
11884
|
+
if (!p) return false;
|
|
11885
|
+
if (p.status !== "draft" /* DRAFT */) {
|
|
11886
|
+
throw new Error(`Practitioner ${p.id} has already been claimed`);
|
|
11887
|
+
}
|
|
11888
|
+
return true;
|
|
11889
|
+
});
|
|
11890
|
+
if (validDrafts.length === 0) {
|
|
11891
|
+
throw new Error("No valid draft profiles found");
|
|
11892
|
+
}
|
|
11893
|
+
const existingPractitioner = await this.getPractitionerByUserRef(userId);
|
|
11894
|
+
if (existingPractitioner) {
|
|
11895
|
+
let mergedClinics2 = new Set(existingPractitioner.clinics);
|
|
11896
|
+
let mergedWorkingHours2 = [...existingPractitioner.clinicWorkingHours];
|
|
11897
|
+
let mergedClinicsInfo2 = [...existingPractitioner.clinicsInfo];
|
|
11898
|
+
for (const draft of validDrafts) {
|
|
11899
|
+
draft.clinics.forEach((clinicId) => mergedClinics2.add(clinicId));
|
|
11900
|
+
for (const workingHours of draft.clinicWorkingHours) {
|
|
11901
|
+
if (!mergedWorkingHours2.find((wh) => wh.clinicId === workingHours.clinicId)) {
|
|
11902
|
+
mergedWorkingHours2.push(workingHours);
|
|
11903
|
+
}
|
|
11904
|
+
}
|
|
11905
|
+
for (const clinicInfo of draft.clinicsInfo) {
|
|
11906
|
+
if (!mergedClinicsInfo2.find((ci) => ci.id === clinicInfo.id)) {
|
|
11907
|
+
mergedClinicsInfo2.push(clinicInfo);
|
|
11908
|
+
}
|
|
11909
|
+
}
|
|
11910
|
+
}
|
|
11911
|
+
const updatedPractitioner2 = await this.updatePractitioner(existingPractitioner.id, {
|
|
11912
|
+
clinics: Array.from(mergedClinics2),
|
|
11913
|
+
clinicWorkingHours: mergedWorkingHours2,
|
|
11914
|
+
clinicsInfo: mergedClinicsInfo2
|
|
11915
|
+
});
|
|
11916
|
+
for (const draft of validDrafts) {
|
|
11917
|
+
await deleteDoc4(doc20(this.db, PRACTITIONERS_COLLECTION, draft.id));
|
|
11918
|
+
const activeTokens = await this.getPractitionerActiveTokens(draft.id);
|
|
11919
|
+
for (const token of activeTokens) {
|
|
11920
|
+
await this.markTokenAsUsed(token.id, draft.id, userId);
|
|
11921
|
+
}
|
|
11922
|
+
}
|
|
11923
|
+
return updatedPractitioner2;
|
|
11924
|
+
}
|
|
11925
|
+
const mainDraft = validDrafts[0];
|
|
11926
|
+
const otherDrafts = validDrafts.slice(1);
|
|
11927
|
+
let mergedClinics = new Set(mainDraft.clinics);
|
|
11928
|
+
let mergedWorkingHours = [...mainDraft.clinicWorkingHours];
|
|
11929
|
+
let mergedClinicsInfo = [...mainDraft.clinicsInfo];
|
|
11930
|
+
for (const draft of otherDrafts) {
|
|
11931
|
+
draft.clinics.forEach((clinicId) => mergedClinics.add(clinicId));
|
|
11932
|
+
for (const workingHours of draft.clinicWorkingHours) {
|
|
11933
|
+
if (!mergedWorkingHours.find((wh) => wh.clinicId === workingHours.clinicId)) {
|
|
11934
|
+
mergedWorkingHours.push(workingHours);
|
|
11935
|
+
}
|
|
11936
|
+
}
|
|
11937
|
+
for (const clinicInfo of draft.clinicsInfo) {
|
|
11938
|
+
if (!mergedClinicsInfo.find((ci) => ci.id === clinicInfo.id)) {
|
|
11939
|
+
mergedClinicsInfo.push(clinicInfo);
|
|
11940
|
+
}
|
|
11941
|
+
}
|
|
11942
|
+
}
|
|
11943
|
+
const updatedPractitioner = await this.updatePractitioner(mainDraft.id, {
|
|
11944
|
+
userRef: userId,
|
|
11945
|
+
status: "active" /* ACTIVE */,
|
|
11946
|
+
clinics: Array.from(mergedClinics),
|
|
11947
|
+
clinicWorkingHours: mergedWorkingHours,
|
|
11948
|
+
clinicsInfo: mergedClinicsInfo
|
|
11949
|
+
});
|
|
11950
|
+
const mainActiveTokens = await this.getPractitionerActiveTokens(mainDraft.id);
|
|
11951
|
+
for (const token of mainActiveTokens) {
|
|
11952
|
+
await this.markTokenAsUsed(token.id, mainDraft.id, userId);
|
|
11953
|
+
}
|
|
11954
|
+
for (const draft of otherDrafts) {
|
|
11955
|
+
await deleteDoc4(doc20(this.db, PRACTITIONERS_COLLECTION, draft.id));
|
|
11956
|
+
const activeTokens = await this.getPractitionerActiveTokens(draft.id);
|
|
11957
|
+
for (const token of activeTokens) {
|
|
11958
|
+
await this.markTokenAsUsed(token.id, draft.id, userId);
|
|
11959
|
+
}
|
|
11960
|
+
}
|
|
11961
|
+
console.log("[PRACTITIONER] Multiple draft profiles claimed successfully", {
|
|
11962
|
+
practitionerId: updatedPractitioner.id,
|
|
11963
|
+
userId,
|
|
11964
|
+
mergedCount: validDrafts.length
|
|
11965
|
+
});
|
|
11966
|
+
return updatedPractitioner;
|
|
11967
|
+
} catch (error) {
|
|
11968
|
+
console.error(
|
|
11969
|
+
"[PRACTITIONER] Error claiming multiple draft profiles with Google:",
|
|
11970
|
+
error
|
|
11971
|
+
);
|
|
11972
|
+
throw error;
|
|
11973
|
+
}
|
|
11974
|
+
}
|
|
11741
11975
|
/**
|
|
11742
11976
|
* Dohvata sve zdravstvene radnike za određenu kliniku sa statusom ACTIVE
|
|
11743
11977
|
*/
|
|
@@ -15874,6 +16108,135 @@ var AuthService = class extends BaseService {
|
|
|
15874
16108
|
throw handleFirebaseError(error);
|
|
15875
16109
|
}
|
|
15876
16110
|
}
|
|
16111
|
+
/**
|
|
16112
|
+
* Signs up or signs in a practitioner with Google authentication.
|
|
16113
|
+
* Checks for existing practitioner account or draft profiles.
|
|
16114
|
+
*
|
|
16115
|
+
* @param idToken - The Google ID token obtained from the mobile app
|
|
16116
|
+
* @returns Object containing user, practitioner (if exists), and draft profiles (if any)
|
|
16117
|
+
*/
|
|
16118
|
+
async signUpPractitionerWithGoogle(idToken) {
|
|
16119
|
+
try {
|
|
16120
|
+
console.log("[AUTH] Starting practitioner Google Sign-In/Sign-Up");
|
|
16121
|
+
let email;
|
|
16122
|
+
try {
|
|
16123
|
+
const payloadBase64 = idToken.split(".")[1];
|
|
16124
|
+
const payloadJson = globalThis.atob ? globalThis.atob(payloadBase64) : Buffer.from(payloadBase64, "base64").toString("utf8");
|
|
16125
|
+
const payload = JSON.parse(payloadJson);
|
|
16126
|
+
email = payload.email;
|
|
16127
|
+
} catch (decodeError) {
|
|
16128
|
+
console.error("[AUTH] Failed to decode email from Google ID token:", decodeError);
|
|
16129
|
+
throw new AuthError(
|
|
16130
|
+
"Unable to read email from Google token. Please try again.",
|
|
16131
|
+
"AUTH/INVALID_GOOGLE_TOKEN",
|
|
16132
|
+
400
|
|
16133
|
+
);
|
|
16134
|
+
}
|
|
16135
|
+
if (!email) {
|
|
16136
|
+
throw new AuthError(
|
|
16137
|
+
"Unable to read email from Google token. Please try again.",
|
|
16138
|
+
"AUTH/INVALID_GOOGLE_TOKEN",
|
|
16139
|
+
400
|
|
16140
|
+
);
|
|
16141
|
+
}
|
|
16142
|
+
const normalizedEmail = email.toLowerCase().trim();
|
|
16143
|
+
console.log("[AUTH] Extracted email from Google token:", normalizedEmail);
|
|
16144
|
+
const methods = await fetchSignInMethodsForEmail2(this.auth, normalizedEmail);
|
|
16145
|
+
const hasGoogleMethod = methods.includes(GoogleAuthProvider.GOOGLE_SIGN_IN_METHOD);
|
|
16146
|
+
const hasEmailMethod = methods.includes(EmailAuthProvider.EMAIL_PASSWORD_SIGN_IN_METHOD);
|
|
16147
|
+
const practitionerService = new PractitionerService(this.db, this.auth, this.app);
|
|
16148
|
+
if (hasGoogleMethod) {
|
|
16149
|
+
console.log("[AUTH] User exists with Google provider, signing in");
|
|
16150
|
+
const credential2 = GoogleAuthProvider.credential(idToken);
|
|
16151
|
+
const { user: firebaseUser2 } = await signInWithCredential(this.auth, credential2);
|
|
16152
|
+
const existingUser2 = await this.userService.getUserById(firebaseUser2.uid);
|
|
16153
|
+
if (!existingUser2) {
|
|
16154
|
+
await firebaseSignOut(this.auth);
|
|
16155
|
+
throw new AuthError(
|
|
16156
|
+
"No account found. Please contact support.",
|
|
16157
|
+
"AUTH/USER_NOT_FOUND",
|
|
16158
|
+
404
|
|
16159
|
+
);
|
|
16160
|
+
}
|
|
16161
|
+
let practitioner2 = null;
|
|
16162
|
+
if (existingUser2.practitionerProfile) {
|
|
16163
|
+
practitioner2 = await practitionerService.getPractitioner(existingUser2.practitionerProfile);
|
|
16164
|
+
}
|
|
16165
|
+
const draftProfiles2 = await practitionerService.getDraftProfilesByEmail(normalizedEmail);
|
|
16166
|
+
return {
|
|
16167
|
+
user: existingUser2,
|
|
16168
|
+
practitioner: practitioner2,
|
|
16169
|
+
draftProfiles: draftProfiles2
|
|
16170
|
+
};
|
|
16171
|
+
}
|
|
16172
|
+
if (hasEmailMethod && !hasGoogleMethod) {
|
|
16173
|
+
console.log("[AUTH] User exists with email/password only");
|
|
16174
|
+
throw new AuthError(
|
|
16175
|
+
"An account with this email already exists. Please sign in with your email and password, then link your Google account in settings.",
|
|
16176
|
+
"AUTH/EMAIL_ALREADY_EXISTS",
|
|
16177
|
+
409
|
|
16178
|
+
);
|
|
16179
|
+
}
|
|
16180
|
+
console.log("[AUTH] Signing in with Google credential");
|
|
16181
|
+
const credential = GoogleAuthProvider.credential(idToken);
|
|
16182
|
+
let firebaseUser;
|
|
16183
|
+
try {
|
|
16184
|
+
const result = await signInWithCredential(this.auth, credential);
|
|
16185
|
+
firebaseUser = result.user;
|
|
16186
|
+
} catch (error) {
|
|
16187
|
+
if (error.code === "auth/account-exists-with-different-credential") {
|
|
16188
|
+
throw new AuthError(
|
|
16189
|
+
"An account with this email already exists. Please sign in with your email and password, then link your Google account in settings.",
|
|
16190
|
+
"AUTH/EMAIL_ALREADY_EXISTS",
|
|
16191
|
+
409
|
|
16192
|
+
);
|
|
16193
|
+
}
|
|
16194
|
+
throw error;
|
|
16195
|
+
}
|
|
16196
|
+
let existingUser = null;
|
|
16197
|
+
try {
|
|
16198
|
+
const existingUserDoc = await this.userService.getUserById(firebaseUser.uid);
|
|
16199
|
+
if (existingUserDoc) {
|
|
16200
|
+
existingUser = existingUserDoc;
|
|
16201
|
+
console.log("[AUTH] Found existing User document");
|
|
16202
|
+
}
|
|
16203
|
+
} catch (error) {
|
|
16204
|
+
console.error("[AUTH] Error checking for existing user:", error);
|
|
16205
|
+
}
|
|
16206
|
+
console.log("[AUTH] Checking for draft profiles");
|
|
16207
|
+
const draftProfiles = await practitionerService.getDraftProfilesByEmail(normalizedEmail);
|
|
16208
|
+
let user;
|
|
16209
|
+
if (existingUser) {
|
|
16210
|
+
user = existingUser;
|
|
16211
|
+
console.log("[AUTH] Using existing user account");
|
|
16212
|
+
} else {
|
|
16213
|
+
user = await this.userService.createUser(firebaseUser, ["practitioner" /* PRACTITIONER */], {
|
|
16214
|
+
skipProfileCreation: true
|
|
16215
|
+
});
|
|
16216
|
+
console.log("[AUTH] Created new user account");
|
|
16217
|
+
}
|
|
16218
|
+
let practitioner = null;
|
|
16219
|
+
if (user.practitionerProfile) {
|
|
16220
|
+
practitioner = await practitionerService.getPractitioner(user.practitionerProfile);
|
|
16221
|
+
}
|
|
16222
|
+
console.log("[AUTH] Google Sign-In complete", {
|
|
16223
|
+
userId: user.uid,
|
|
16224
|
+
hasPractitioner: !!practitioner,
|
|
16225
|
+
draftProfilesCount: draftProfiles.length
|
|
16226
|
+
});
|
|
16227
|
+
return {
|
|
16228
|
+
user,
|
|
16229
|
+
practitioner,
|
|
16230
|
+
draftProfiles
|
|
16231
|
+
};
|
|
16232
|
+
} catch (error) {
|
|
16233
|
+
console.error("[AUTH] Error in signUpPractitionerWithGoogle:", error);
|
|
16234
|
+
if (error instanceof AuthError) {
|
|
16235
|
+
throw error;
|
|
16236
|
+
}
|
|
16237
|
+
throw handleFirebaseError(error);
|
|
16238
|
+
}
|
|
16239
|
+
}
|
|
15877
16240
|
/**
|
|
15878
16241
|
* Links a Google account to the currently signed-in user using an ID token.
|
|
15879
16242
|
* This is used to upgrade an anonymous user or to allow an existing user
|
package/package.json
CHANGED
|
@@ -78,10 +78,21 @@ export const practitionerInvitationTemplate = `
|
|
|
78
78
|
|
|
79
79
|
<p>This token will expire on <strong>{{expirationDate}}</strong>.</p>
|
|
80
80
|
|
|
81
|
-
<p>
|
|
81
|
+
<p><strong>You have two options to create your account:</strong></p>
|
|
82
|
+
|
|
83
|
+
<p><strong>Option 1: Sign in with Google (Recommended)</strong></p>
|
|
84
|
+
<ol>
|
|
85
|
+
<li>Open the MetaEsthetics Doctor App</li>
|
|
86
|
+
<li>Click "Sign in with Google" on the login screen</li>
|
|
87
|
+
<li>Select your Google account (use the email address: {{practitionerEmail}})</li>
|
|
88
|
+
<li>You'll see an invitation to join {{clinicName}} - simply select it and join!</li>
|
|
89
|
+
</ol>
|
|
90
|
+
|
|
91
|
+
<p><strong>Option 2: Use Email/Password with Token</strong></p>
|
|
82
92
|
<ol>
|
|
83
93
|
<li>Visit {{registrationUrl}}</li>
|
|
84
|
-
<li>
|
|
94
|
+
<li>Click "Claim Existing Profile with Token"</li>
|
|
95
|
+
<li>Enter your email ({{practitionerEmail}}) and create a password</li>
|
|
85
96
|
<li>When prompted, enter the token above</li>
|
|
86
97
|
</ol>
|
|
87
98
|
|
|
@@ -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
|