@blackcode_sa/metaestetics-api 1.15.14 → 1.15.17-staging.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.d.mts +377 -222
- package/dist/admin/index.d.ts +377 -222
- package/dist/admin/index.js +625 -206
- package/dist/admin/index.mjs +624 -206
- package/dist/backoffice/index.d.mts +24 -0
- package/dist/backoffice/index.d.ts +24 -0
- package/dist/index.d.mts +297 -9
- package/dist/index.d.ts +297 -9
- package/dist/index.js +1144 -632
- package/dist/index.mjs +1139 -619
- package/package.json +2 -1
- package/src/__mocks__/firstore.ts +10 -10
- package/src/admin/aggregation/README.md +79 -79
- package/src/admin/aggregation/appointment/README.md +151 -129
- package/src/admin/aggregation/appointment/appointment.aggregation.service.ts +2137 -2091
- package/src/admin/aggregation/appointment/index.ts +1 -1
- package/src/admin/aggregation/clinic/README.md +52 -52
- package/src/admin/aggregation/clinic/clinic.aggregation.service.ts +966 -966
- package/src/admin/aggregation/clinic/index.ts +1 -1
- package/src/admin/aggregation/forms/README.md +13 -13
- package/src/admin/aggregation/forms/filled-forms.aggregation.service.ts +322 -322
- package/src/admin/aggregation/forms/index.ts +1 -1
- package/src/admin/aggregation/index.ts +8 -8
- package/src/admin/aggregation/patient/README.md +27 -27
- package/src/admin/aggregation/patient/index.ts +1 -1
- package/src/admin/aggregation/patient/patient.aggregation.service.ts +141 -141
- package/src/admin/aggregation/practitioner/README.md +42 -42
- package/src/admin/aggregation/practitioner/index.ts +1 -1
- package/src/admin/aggregation/practitioner/practitioner.aggregation.service.ts +433 -433
- package/src/admin/aggregation/practitioner-invite/index.ts +1 -1
- package/src/admin/aggregation/practitioner-invite/practitioner-invite.aggregation.service.ts +961 -961
- package/src/admin/aggregation/procedure/README.md +43 -43
- package/src/admin/aggregation/procedure/index.ts +1 -1
- package/src/admin/aggregation/procedure/procedure.aggregation.service.ts +702 -702
- package/src/admin/aggregation/reviews/index.ts +1 -1
- package/src/admin/aggregation/reviews/reviews.aggregation.service.ts +689 -689
- package/src/admin/analytics/analytics.admin.service.ts +278 -278
- package/src/admin/analytics/index.ts +2 -2
- package/src/admin/booking/README.md +184 -125
- package/src/admin/booking/booking.admin.ts +1330 -1073
- package/src/admin/booking/booking.calculator.ts +850 -712
- package/src/admin/booking/booking.types.ts +76 -59
- package/src/admin/booking/index.ts +3 -3
- package/src/admin/booking/timezones-problem.md +185 -185
- package/src/admin/calendar/README.md +62 -7
- package/src/admin/calendar/calendar.admin.service.ts +345 -345
- package/src/admin/calendar/index.ts +2 -1
- package/src/admin/calendar/resource-calendar.admin.ts +198 -0
- package/src/admin/documentation-templates/document-manager.admin.ts +260 -260
- package/src/admin/documentation-templates/index.ts +1 -1
- package/src/admin/free-consultation/free-consultation-utils.admin.ts +148 -148
- package/src/admin/free-consultation/index.ts +1 -1
- package/src/admin/index.ts +83 -83
- package/src/admin/logger/index.ts +78 -78
- package/src/admin/mailing/README.md +139 -139
- package/src/admin/mailing/appointment/appointment.mailing.service.ts +1253 -1253
- package/src/admin/mailing/appointment/index.ts +1 -1
- package/src/admin/mailing/appointment/templates/patient/appointment-confirmed.html +40 -40
- package/src/admin/mailing/base.mailing.service.ts +208 -208
- package/src/admin/mailing/clinicWelcome/clinicWelcome.mailing.ts +292 -292
- package/src/admin/mailing/clinicWelcome/index.ts +1 -1
- package/src/admin/mailing/clinicWelcome/templates/welcome.template.ts +225 -225
- package/src/admin/mailing/index.ts +5 -5
- package/src/admin/mailing/patientInvite/index.ts +2 -2
- package/src/admin/mailing/patientInvite/patientInvite.mailing.ts +415 -415
- package/src/admin/mailing/patientInvite/templates/invitation.template.ts +105 -105
- package/src/admin/mailing/practitionerInvite/existing-practitioner-invite.mailing.ts +611 -611
- package/src/admin/mailing/practitionerInvite/index.ts +2 -2
- package/src/admin/mailing/practitionerInvite/practitionerInvite.mailing.ts +395 -395
- package/src/admin/mailing/practitionerInvite/templates/existing-practitioner-invitation.template.ts +155 -155
- package/src/admin/mailing/practitionerInvite/templates/invitation.template.ts +101 -101
- package/src/admin/mailing/practitionerInvite/templates/invite-accepted-notification.template.ts +228 -228
- package/src/admin/mailing/practitionerInvite/templates/invite-rejected-notification.template.ts +242 -242
- package/src/admin/notifications/index.ts +1 -1
- package/src/admin/notifications/notifications.admin.ts +818 -818
- package/src/admin/requirements/README.md +128 -128
- package/src/admin/requirements/index.ts +1 -1
- package/src/admin/requirements/patient-requirements.admin.service.ts +475 -475
- package/src/admin/users/index.ts +1 -1
- package/src/admin/users/user-profile.admin.ts +405 -405
- package/src/backoffice/constants/certification.constants.ts +13 -13
- package/src/backoffice/constants/index.ts +1 -1
- package/src/backoffice/errors/backoffice.errors.ts +181 -181
- package/src/backoffice/errors/index.ts +1 -1
- package/src/backoffice/expo-safe/README.md +26 -26
- package/src/backoffice/expo-safe/index.ts +41 -41
- package/src/backoffice/index.ts +5 -5
- package/src/backoffice/services/FIXES_README.md +102 -102
- package/src/backoffice/services/README.md +57 -57
- package/src/backoffice/services/analytics.service.proposal.md +863 -863
- package/src/backoffice/services/analytics.service.summary.md +143 -143
- package/src/backoffice/services/brand.service.ts +260 -260
- package/src/backoffice/services/category.service.ts +384 -384
- package/src/backoffice/services/constants.service.ts +385 -385
- package/src/backoffice/services/documentation-template.service.ts +202 -202
- package/src/backoffice/services/index.ts +10 -10
- package/src/backoffice/services/migrate-products.ts +116 -116
- package/src/backoffice/services/product.service.ts +557 -557
- package/src/backoffice/services/requirement.service.ts +235 -235
- package/src/backoffice/services/subcategory.service.ts +461 -461
- package/src/backoffice/services/technology.service.ts +1153 -1153
- package/src/backoffice/types/README.md +12 -12
- package/src/backoffice/types/admin-constants.types.ts +69 -69
- package/src/backoffice/types/brand.types.ts +29 -29
- package/src/backoffice/types/category.types.ts +67 -67
- package/src/backoffice/types/documentation-templates.types.ts +28 -28
- package/src/backoffice/types/index.ts +10 -10
- package/src/backoffice/types/procedure-product.types.ts +38 -38
- package/src/backoffice/types/product.types.ts +239 -239
- package/src/backoffice/types/requirement.types.ts +63 -63
- package/src/backoffice/types/static/README.md +18 -18
- package/src/backoffice/types/static/blocking-condition.types.ts +21 -21
- package/src/backoffice/types/static/certification.types.ts +37 -37
- package/src/backoffice/types/static/contraindication.types.ts +19 -19
- package/src/backoffice/types/static/index.ts +6 -6
- package/src/backoffice/types/static/pricing.types.ts +16 -16
- package/src/backoffice/types/static/procedure-family.types.ts +14 -14
- package/src/backoffice/types/static/treatment-benefit.types.ts +22 -22
- package/src/backoffice/types/subcategory.types.ts +34 -34
- package/src/backoffice/types/technology.types.ts +168 -168
- package/src/backoffice/validations/index.ts +1 -1
- package/src/backoffice/validations/schemas.ts +164 -164
- package/src/config/__mocks__/firebase.ts +99 -99
- package/src/config/firebase.ts +78 -78
- package/src/config/index.ts +17 -17
- package/src/config/tiers.config.ts +255 -229
- package/src/errors/auth.error.ts +6 -6
- package/src/errors/auth.errors.ts +211 -211
- package/src/errors/clinic.errors.ts +32 -32
- package/src/errors/firebase.errors.ts +47 -47
- package/src/errors/user.errors.ts +99 -99
- package/src/index.backup.ts +407 -407
- package/src/index.ts +6 -6
- package/src/locales/en.ts +31 -31
- package/src/recommender/admin/index.ts +1 -1
- package/src/recommender/admin/services/recommender.service.admin.ts +5 -5
- package/src/recommender/front/index.ts +1 -1
- package/src/recommender/front/services/onboarding.service.ts +5 -5
- package/src/recommender/front/services/recommender.service.ts +3 -3
- package/src/recommender/index.ts +1 -1
- package/src/services/PATIENTAUTH.MD +197 -197
- package/src/services/README.md +106 -106
- package/src/services/__tests__/auth/auth.mock.test.ts +17 -17
- package/src/services/__tests__/auth/auth.setup.ts +298 -298
- package/src/services/__tests__/auth.service.test.ts +310 -310
- package/src/services/__tests__/base.service.test.ts +36 -36
- package/src/services/__tests__/user.service.test.ts +530 -530
- package/src/services/analytics/ARCHITECTURE.md +199 -199
- package/src/services/analytics/CLOUD_FUNCTIONS.md +225 -225
- package/src/services/analytics/GROUPED_ANALYTICS.md +501 -501
- package/src/services/analytics/QUICK_START.md +393 -393
- package/src/services/analytics/README.md +304 -304
- package/src/services/analytics/SUMMARY.md +141 -141
- package/src/services/analytics/TRENDS.md +380 -380
- package/src/services/analytics/USAGE_GUIDE.md +518 -518
- package/src/services/analytics/analytics-cloud.service.ts +222 -222
- package/src/services/analytics/analytics.service.ts +2148 -2148
- package/src/services/analytics/index.ts +4 -4
- package/src/services/analytics/review-analytics.service.ts +941 -941
- package/src/services/analytics/utils/appointment-filtering.utils.ts +138 -138
- package/src/services/analytics/utils/cost-calculation.utils.ts +182 -182
- package/src/services/analytics/utils/grouping.utils.ts +434 -434
- package/src/services/analytics/utils/stored-analytics.utils.ts +347 -347
- package/src/services/analytics/utils/time-calculation.utils.ts +186 -186
- package/src/services/analytics/utils/trend-calculation.utils.ts +200 -200
- package/src/services/appointment/README.md +17 -17
- package/src/services/appointment/appointment.service.ts +2943 -2941
- package/src/services/appointment/index.ts +1 -1
- package/src/services/appointment/utils/appointment.utils.ts +620 -620
- package/src/services/appointment/utils/extended-procedure.utils.ts +354 -354
- package/src/services/appointment/utils/form-initialization.utils.ts +516 -516
- package/src/services/appointment/utils/recommended-procedure.utils.ts +195 -195
- package/src/services/appointment/utils/zone-management.utils.ts +468 -468
- package/src/services/appointment/utils/zone-photo.utils.ts +302 -302
- package/src/services/auth/auth.service.ts +1435 -1435
- package/src/services/auth/auth.v2.service.ts +961 -961
- package/src/services/auth/index.ts +7 -7
- package/src/services/auth/utils/error.utils.ts +90 -90
- package/src/services/auth/utils/firebase.utils.ts +49 -49
- package/src/services/auth/utils/index.ts +21 -21
- package/src/services/auth/utils/practitioner.utils.ts +125 -125
- package/src/services/base.service.ts +41 -41
- package/src/services/calendar/calendar.service.ts +1077 -1077
- package/src/services/calendar/calendar.v2.service.ts +1693 -1693
- package/src/services/calendar/calendar.v3.service.ts +313 -313
- package/src/services/calendar/externalCalendar.service.ts +178 -178
- package/src/services/calendar/index.ts +5 -5
- package/src/services/calendar/synced-calendars.service.ts +743 -743
- package/src/services/calendar/utils/appointment.utils.ts +265 -265
- package/src/services/calendar/utils/calendar-event.utils.ts +676 -676
- package/src/services/calendar/utils/clinic.utils.ts +237 -237
- package/src/services/calendar/utils/docs.utils.ts +157 -157
- package/src/services/calendar/utils/google-calendar.utils.ts +697 -697
- package/src/services/calendar/utils/index.ts +8 -8
- package/src/services/calendar/utils/patient.utils.ts +198 -198
- package/src/services/calendar/utils/practitioner.utils.ts +221 -221
- package/src/services/calendar/utils/synced-calendar.utils.ts +472 -472
- package/src/services/clinic/README.md +204 -204
- package/src/services/clinic/__tests__/clinic-admin.service.test.ts +265 -265
- package/src/services/clinic/__tests__/clinic-group.service.test.ts +222 -222
- package/src/services/clinic/__tests__/clinic.service.test.ts +302 -302
- package/src/services/clinic/billing-transactions.service.ts +217 -217
- package/src/services/clinic/clinic-admin.service.ts +202 -202
- package/src/services/clinic/clinic-group.service.ts +310 -310
- package/src/services/clinic/clinic.service.ts +720 -720
- package/src/services/clinic/index.ts +5 -5
- package/src/services/clinic/practitioner-invite.service.ts +519 -519
- package/src/services/clinic/utils/admin.utils.ts +551 -551
- package/src/services/clinic/utils/clinic-group.utils.ts +646 -646
- package/src/services/clinic/utils/clinic.utils.ts +1023 -1023
- package/src/services/clinic/utils/filter.utils.d.ts +23 -23
- package/src/services/clinic/utils/filter.utils.ts +462 -462
- package/src/services/clinic/utils/index.ts +10 -10
- package/src/services/clinic/utils/photos.utils.ts +188 -188
- package/src/services/clinic/utils/search.utils.ts +83 -83
- package/src/services/clinic/utils/tag.utils.ts +124 -124
- package/src/services/documentation-templates/documentation-template.service.ts +537 -537
- package/src/services/documentation-templates/filled-document.service.ts +597 -597
- package/src/services/documentation-templates/index.ts +2 -2
- package/src/services/index.ts +16 -15
- package/src/services/media/index.ts +1 -1
- package/src/services/media/media.service.ts +418 -418
- package/src/services/notifications/__tests__/notification.service.test.ts +242 -242
- package/src/services/notifications/index.ts +1 -1
- package/src/services/notifications/notification.service.ts +215 -215
- package/src/services/patient/README.md +48 -48
- package/src/services/patient/To-Do.md +43 -43
- package/src/services/patient/__tests__/patient.service.test.ts +286 -286
- package/src/services/patient/index.ts +2 -2
- package/src/services/patient/patient.service.ts +1021 -1021
- package/src/services/patient/patientRequirements.service.ts +309 -309
- package/src/services/patient/utils/aesthetic-analysis.utils.ts +176 -176
- package/src/services/patient/utils/body-assessment.utils.ts +159 -159
- package/src/services/patient/utils/clinic.utils.ts +159 -159
- package/src/services/patient/utils/docs.utils.ts +142 -142
- package/src/services/patient/utils/hair-scalp-assessment.utils.ts +158 -158
- package/src/services/patient/utils/index.ts +9 -9
- package/src/services/patient/utils/location.utils.ts +126 -126
- package/src/services/patient/utils/medical-stuff.utils.ts +143 -143
- package/src/services/patient/utils/medical.utils.ts +458 -458
- package/src/services/patient/utils/practitioner.utils.ts +260 -260
- package/src/services/patient/utils/pre-surgical-assessment.utils.ts +161 -161
- package/src/services/patient/utils/profile.utils.ts +510 -510
- package/src/services/patient/utils/sensitive.utils.ts +260 -260
- package/src/services/patient/utils/skin-quality-assessment.utils.ts +160 -160
- package/src/services/patient/utils/token.utils.ts +211 -211
- package/src/services/practitioner/README.md +145 -145
- package/src/services/practitioner/index.ts +1 -1
- package/src/services/practitioner/practitioner.service.ts +2355 -2354
- package/src/services/procedure/README.md +163 -163
- package/src/services/procedure/index.ts +1 -1
- package/src/services/procedure/procedure.service.ts +2521 -2521
- package/src/services/resource/README.md +119 -0
- package/src/services/resource/index.ts +1 -0
- package/src/services/resource/resource.service.ts +555 -0
- package/src/services/reviews/index.ts +1 -1
- package/src/services/reviews/reviews.service.ts +745 -745
- package/src/services/tier-enforcement.ts +240 -240
- package/src/services/user/index.ts +1 -1
- package/src/services/user/user.service.ts +533 -533
- package/src/services/user/user.v2.service.ts +467 -467
- package/src/types/analytics/analytics.types.ts +597 -597
- package/src/types/analytics/grouped-analytics.types.ts +173 -173
- package/src/types/analytics/index.ts +4 -4
- package/src/types/analytics/stored-analytics.types.ts +137 -137
- package/src/types/appointment/index.ts +524 -517
- package/src/types/calendar/index.ts +261 -260
- package/src/types/calendar/synced-calendar.types.ts +66 -66
- package/src/types/clinic/index.ts +530 -529
- package/src/types/clinic/practitioner-invite.types.ts +91 -91
- package/src/types/clinic/preferences.types.ts +159 -159
- package/src/types/clinic/rbac.types.ts +64 -63
- package/src/types/clinic/to-do +3 -3
- package/src/types/documentation-templates/index.ts +308 -308
- package/src/types/index.ts +50 -47
- package/src/types/notifications/README.md +77 -77
- package/src/types/notifications/index.ts +300 -300
- package/src/types/patient/aesthetic-analysis.types.ts +66 -66
- package/src/types/patient/allergies.ts +58 -58
- package/src/types/patient/body-assessment.types.ts +93 -93
- package/src/types/patient/hair-scalp-assessment.types.ts +98 -98
- package/src/types/patient/index.ts +279 -279
- package/src/types/patient/medical-info.types.ts +152 -152
- package/src/types/patient/patient-requirements.ts +92 -92
- package/src/types/patient/pre-surgical-assessment.types.ts +95 -95
- package/src/types/patient/skin-quality-assessment.types.ts +105 -105
- package/src/types/patient/token.types.ts +61 -61
- package/src/types/practitioner/index.ts +208 -208
- package/src/types/procedure/index.ts +189 -183
- package/src/types/profile/index.ts +39 -39
- package/src/types/resource/README.md +153 -0
- package/src/types/resource/index.ts +199 -0
- package/src/types/reviews/index.ts +132 -132
- package/src/types/tz-lookup.d.ts +4 -4
- package/src/types/user/index.ts +60 -60
- package/src/utils/TIMESTAMPS.md +176 -176
- package/src/utils/TimestampUtils.ts +241 -241
- package/src/utils/index.ts +1 -1
- package/src/validations/README.md +94 -0
- package/src/validations/appointment.schema.ts +589 -589
- package/src/validations/calendar.schema.ts +225 -225
- package/src/validations/clinic.schema.ts +494 -494
- package/src/validations/common.schema.ts +25 -25
- package/src/validations/documentation-templates/index.ts +1 -1
- package/src/validations/documentation-templates/template.schema.ts +220 -220
- package/src/validations/documentation-templates.schema.ts +10 -10
- package/src/validations/index.ts +21 -20
- package/src/validations/media.schema.ts +10 -10
- package/src/validations/notification.schema.ts +90 -90
- package/src/validations/patient/aesthetic-analysis.schema.ts +55 -55
- package/src/validations/patient/body-assessment.schema.ts +82 -82
- package/src/validations/patient/hair-scalp-assessment.schema.ts +70 -70
- package/src/validations/patient/medical-info.schema.ts +177 -177
- package/src/validations/patient/patient-requirements.schema.ts +84 -84
- package/src/validations/patient/pre-surgical-assessment.schema.ts +78 -78
- package/src/validations/patient/skin-quality-assessment.schema.ts +70 -70
- package/src/validations/patient/token.schema.ts +29 -29
- package/src/validations/patient.schema.ts +217 -217
- package/src/validations/practitioner.schema.ts +224 -224
- package/src/validations/procedure-product.schema.ts +41 -41
- package/src/validations/procedure.schema.ts +136 -124
- package/src/validations/profile-info.schema.ts +41 -41
- package/src/validations/resource.schema.ts +57 -0
- package/src/validations/reviews.schema.ts +195 -195
- package/src/validations/schemas.ts +109 -109
- package/src/validations/shared.schema.ts +78 -78
|
@@ -1,1435 +1,1435 @@
|
|
|
1
|
-
import {
|
|
2
|
-
Auth,
|
|
3
|
-
User as FirebaseUser,
|
|
4
|
-
signInWithEmailAndPassword,
|
|
5
|
-
createUserWithEmailAndPassword,
|
|
6
|
-
signInAnonymously as firebaseSignInAnonymously,
|
|
7
|
-
signOut as firebaseSignOut,
|
|
8
|
-
GoogleAuthProvider,
|
|
9
|
-
signInWithPopup,
|
|
10
|
-
signInWithRedirect,
|
|
11
|
-
getRedirectResult,
|
|
12
|
-
linkWithCredential,
|
|
13
|
-
EmailAuthProvider,
|
|
14
|
-
onAuthStateChanged,
|
|
15
|
-
sendPasswordResetEmail,
|
|
16
|
-
verifyPasswordResetCode,
|
|
17
|
-
confirmPasswordReset,
|
|
18
|
-
fetchSignInMethodsForEmail,
|
|
19
|
-
signInWithCredential,
|
|
20
|
-
OAuthProvider,
|
|
21
|
-
} from 'firebase/auth';
|
|
22
|
-
import {
|
|
23
|
-
getFirestore,
|
|
24
|
-
collection,
|
|
25
|
-
doc,
|
|
26
|
-
getDoc,
|
|
27
|
-
setDoc,
|
|
28
|
-
updateDoc,
|
|
29
|
-
deleteDoc,
|
|
30
|
-
query,
|
|
31
|
-
where,
|
|
32
|
-
getDocs,
|
|
33
|
-
orderBy,
|
|
34
|
-
limit,
|
|
35
|
-
startAfter,
|
|
36
|
-
Timestamp,
|
|
37
|
-
runTransaction,
|
|
38
|
-
Firestore,
|
|
39
|
-
} from 'firebase/firestore';
|
|
40
|
-
import { FirebaseApp } from 'firebase/app';
|
|
41
|
-
import { User, UserRole, USERS_COLLECTION } from '../../types';
|
|
42
|
-
import { z } from 'zod';
|
|
43
|
-
import { emailSchema, passwordSchema, userRoleSchema } from '../../validations/schemas';
|
|
44
|
-
import { AuthError, AUTH_ERRORS } from '../../errors/auth.errors';
|
|
45
|
-
import { FirebaseErrorCode } from '../../errors/firebase.errors';
|
|
46
|
-
import { FirebaseError } from '../../errors/firebase.errors';
|
|
47
|
-
import { BaseService } from '../base.service';
|
|
48
|
-
import { UserService } from '../user/user.service';
|
|
49
|
-
import {
|
|
50
|
-
ClinicGroup,
|
|
51
|
-
AdminToken,
|
|
52
|
-
AdminTokenStatus,
|
|
53
|
-
CreateClinicGroupData,
|
|
54
|
-
CreateClinicAdminData,
|
|
55
|
-
ContactPerson,
|
|
56
|
-
ClinicAdminSignupData,
|
|
57
|
-
SubscriptionModel,
|
|
58
|
-
CLINIC_GROUPS_COLLECTION,
|
|
59
|
-
ClinicAdmin,
|
|
60
|
-
} from '../../types/clinic';
|
|
61
|
-
import { clinicAdminSignupSchema } from '../../validations/clinic.schema';
|
|
62
|
-
import { ClinicGroupService } from '../clinic/clinic-group.service';
|
|
63
|
-
import { ClinicAdminService } from '../clinic/clinic-admin.service';
|
|
64
|
-
import { ClinicService } from '../clinic/clinic.service';
|
|
65
|
-
import {
|
|
66
|
-
Practitioner,
|
|
67
|
-
CreatePractitionerData,
|
|
68
|
-
PractitionerStatus,
|
|
69
|
-
PractitionerBasicInfo,
|
|
70
|
-
PractitionerCertification,
|
|
71
|
-
} from '../../types/practitioner';
|
|
72
|
-
import { PractitionerService } from '../practitioner/practitioner.service';
|
|
73
|
-
import { practitionerSignupSchema } from '../../validations/practitioner.schema';
|
|
74
|
-
import { CertificationLevel } from '../../backoffice/types/static/certification.types';
|
|
75
|
-
import { MediaService } from '../media/media.service';
|
|
76
|
-
// Import utility functions
|
|
77
|
-
import {
|
|
78
|
-
checkEmailExists,
|
|
79
|
-
cleanupFirebaseUser,
|
|
80
|
-
handleFirebaseError,
|
|
81
|
-
handleSignupError,
|
|
82
|
-
buildPractitionerData,
|
|
83
|
-
validatePractitionerProfileData,
|
|
84
|
-
} from './utils';
|
|
85
|
-
|
|
86
|
-
export class AuthService extends BaseService {
|
|
87
|
-
private googleProvider = new GoogleAuthProvider();
|
|
88
|
-
private userService: UserService;
|
|
89
|
-
|
|
90
|
-
constructor(db: Firestore, auth: Auth, app: FirebaseApp, userService: UserService) {
|
|
91
|
-
super(db, auth, app);
|
|
92
|
-
this.userService = userService || new UserService(db, auth, app);
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* Waits for Firebase Auth state to settle after sign-in.
|
|
97
|
-
* In React Native with AsyncStorage persistence, auth state may not be immediately available.
|
|
98
|
-
*/
|
|
99
|
-
private async waitForAuthStateToSettle(expectedUid: string, timeoutMs: number = 5000): Promise<void> {
|
|
100
|
-
if (this.auth.currentUser?.uid === expectedUid) {
|
|
101
|
-
await new Promise(resolve => setTimeout(resolve, 200));
|
|
102
|
-
if (this.auth.currentUser?.uid === expectedUid) {
|
|
103
|
-
return;
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
return new Promise((resolve, reject) => {
|
|
108
|
-
const startTime = Date.now();
|
|
109
|
-
let resolved = false;
|
|
110
|
-
|
|
111
|
-
const unsubscribe = onAuthStateChanged(this.auth, (user) => {
|
|
112
|
-
if (resolved) return;
|
|
113
|
-
|
|
114
|
-
const currentUid = user?.uid || null;
|
|
115
|
-
|
|
116
|
-
if (currentUid === expectedUid) {
|
|
117
|
-
setTimeout(() => {
|
|
118
|
-
if (resolved) return;
|
|
119
|
-
|
|
120
|
-
if (this.auth.currentUser?.uid === expectedUid) {
|
|
121
|
-
resolved = true;
|
|
122
|
-
unsubscribe();
|
|
123
|
-
clearTimeout(timeout);
|
|
124
|
-
resolve();
|
|
125
|
-
}
|
|
126
|
-
}, 300);
|
|
127
|
-
}
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
const timeout = setTimeout(() => {
|
|
131
|
-
if (resolved) return;
|
|
132
|
-
resolved = true;
|
|
133
|
-
unsubscribe();
|
|
134
|
-
reject(new Error(`Timeout waiting for auth state to settle. Expected: ${expectedUid}, Got: ${this.auth.currentUser?.uid || 'NULL'}`));
|
|
135
|
-
}, timeoutMs);
|
|
136
|
-
});
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
/**
|
|
140
|
-
* Registruje novog korisnika sa email-om i lozinkom
|
|
141
|
-
*/
|
|
142
|
-
async signUp(
|
|
143
|
-
email: string,
|
|
144
|
-
password: string,
|
|
145
|
-
initialRole: UserRole = UserRole.PATIENT,
|
|
146
|
-
options?: {
|
|
147
|
-
patientInviteToken?: string;
|
|
148
|
-
},
|
|
149
|
-
): Promise<User> {
|
|
150
|
-
const { user: firebaseUser } = await createUserWithEmailAndPassword(this.auth, email, password);
|
|
151
|
-
|
|
152
|
-
return this.userService.createUser(firebaseUser, [initialRole], options);
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
/**
|
|
156
|
-
* Registers a new clinic admin user with email and password
|
|
157
|
-
* Can either create a new clinic group or join an existing one with a token
|
|
158
|
-
*
|
|
159
|
-
* @param data - Clinic admin signup data
|
|
160
|
-
* @returns Object containing the created user, clinic group, and clinic admin
|
|
161
|
-
*/
|
|
162
|
-
async signUpClinicAdmin(data: ClinicAdminSignupData): Promise<{
|
|
163
|
-
user: User;
|
|
164
|
-
clinicGroup: ClinicGroup;
|
|
165
|
-
clinicAdmin: ClinicAdmin;
|
|
166
|
-
}> {
|
|
167
|
-
try {
|
|
168
|
-
console.log('[AUTH] Starting clinic admin signup process', {
|
|
169
|
-
email: data.email,
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
// Validate data
|
|
173
|
-
try {
|
|
174
|
-
await clinicAdminSignupSchema.parseAsync(data);
|
|
175
|
-
console.log('[AUTH] Clinic admin signup data validation passed');
|
|
176
|
-
} catch (validationError) {
|
|
177
|
-
console.error('[AUTH] Validation error in signUpClinicAdmin:', validationError);
|
|
178
|
-
throw validationError;
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
// Create Firebase user
|
|
182
|
-
console.log('[AUTH] Creating Firebase user');
|
|
183
|
-
let firebaseUser;
|
|
184
|
-
try {
|
|
185
|
-
const result = await createUserWithEmailAndPassword(this.auth, data.email, data.password);
|
|
186
|
-
firebaseUser = result.user;
|
|
187
|
-
console.log('[AUTH] Firebase user created successfully', {
|
|
188
|
-
uid: firebaseUser.uid,
|
|
189
|
-
});
|
|
190
|
-
} catch (firebaseError) {
|
|
191
|
-
console.error('[AUTH] Firebase user creation failed:', firebaseError);
|
|
192
|
-
throw handleFirebaseError(firebaseError);
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
// Create user with CLINIC_ADMIN role
|
|
196
|
-
console.log('[AUTH] Creating user with CLINIC_ADMIN role');
|
|
197
|
-
let user;
|
|
198
|
-
try {
|
|
199
|
-
user = await this.userService.createUser(firebaseUser, [UserRole.CLINIC_ADMIN], {
|
|
200
|
-
skipProfileCreation: true,
|
|
201
|
-
});
|
|
202
|
-
console.log('[AUTH] User with CLINIC_ADMIN role created successfully', {
|
|
203
|
-
userId: user.uid,
|
|
204
|
-
});
|
|
205
|
-
} catch (userCreationError) {
|
|
206
|
-
console.error('[AUTH] User creation failed:', userCreationError);
|
|
207
|
-
throw userCreationError;
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
// Create contact person object
|
|
211
|
-
const contactPerson: ContactPerson = {
|
|
212
|
-
firstName: data.firstName,
|
|
213
|
-
lastName: data.lastName,
|
|
214
|
-
title: data.title,
|
|
215
|
-
email: data.email,
|
|
216
|
-
phoneNumber: data.phoneNumber,
|
|
217
|
-
};
|
|
218
|
-
console.log('[AUTH] Contact person object created');
|
|
219
|
-
|
|
220
|
-
// Initialize services
|
|
221
|
-
console.log('[AUTH] Initializing clinic services');
|
|
222
|
-
const clinicAdminService = new ClinicAdminService(this.db, this.auth, this.app);
|
|
223
|
-
const clinicGroupService = new ClinicGroupService(
|
|
224
|
-
this.db,
|
|
225
|
-
this.auth,
|
|
226
|
-
this.app,
|
|
227
|
-
clinicAdminService,
|
|
228
|
-
);
|
|
229
|
-
const mediaService = new MediaService(this.db, this.auth, this.app);
|
|
230
|
-
const clinicService = new ClinicService(
|
|
231
|
-
this.db,
|
|
232
|
-
this.auth,
|
|
233
|
-
this.app,
|
|
234
|
-
clinicGroupService,
|
|
235
|
-
clinicAdminService,
|
|
236
|
-
mediaService,
|
|
237
|
-
);
|
|
238
|
-
|
|
239
|
-
// Set services to resolve circular dependencies
|
|
240
|
-
clinicAdminService.setServices(clinicGroupService, clinicService);
|
|
241
|
-
console.log('[AUTH] Services initialized and circular dependencies resolved');
|
|
242
|
-
|
|
243
|
-
let clinicGroup: ClinicGroup | null = null;
|
|
244
|
-
let adminProfile: ClinicAdmin | null = null;
|
|
245
|
-
|
|
246
|
-
if (data.isCreatingNewGroup) {
|
|
247
|
-
console.log('[AUTH] Creating new clinic group flow');
|
|
248
|
-
// Create new clinic group
|
|
249
|
-
if (!data.clinicGroupData) {
|
|
250
|
-
console.error('[AUTH] Clinic group data is missing');
|
|
251
|
-
throw new Error('Clinic group data is required when creating a new group');
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
// First create the clinic admin without a group
|
|
255
|
-
console.log('[AUTH] Creating clinic admin first (without group)');
|
|
256
|
-
const createClinicAdminData: CreateClinicAdminData = {
|
|
257
|
-
userRef: firebaseUser.uid,
|
|
258
|
-
isGroupOwner: true,
|
|
259
|
-
clinicsManaged: [],
|
|
260
|
-
contactInfo: contactPerson,
|
|
261
|
-
roleTitle: data.title,
|
|
262
|
-
isActive: true,
|
|
263
|
-
// No clinicGroupId yet
|
|
264
|
-
};
|
|
265
|
-
|
|
266
|
-
try {
|
|
267
|
-
adminProfile = await clinicAdminService.createClinicAdmin(createClinicAdminData);
|
|
268
|
-
console.log('[AUTH] Clinic admin created successfully', {
|
|
269
|
-
adminId: adminProfile.id,
|
|
270
|
-
});
|
|
271
|
-
} catch (adminCreationError) {
|
|
272
|
-
console.error('[AUTH] Clinic admin creation failed:', adminCreationError);
|
|
273
|
-
throw adminCreationError;
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
// Update user document with admin profile reference
|
|
277
|
-
try {
|
|
278
|
-
console.log('[AUTH] Updating user with admin profile reference');
|
|
279
|
-
user = await this.userService.updateUser(firebaseUser.uid, {
|
|
280
|
-
adminProfile: adminProfile.id,
|
|
281
|
-
});
|
|
282
|
-
console.log('[AUTH] User updated with admin profile reference successfully');
|
|
283
|
-
} catch (userUpdateError) {
|
|
284
|
-
console.error('[AUTH] Failed to update user with admin profile:', userUpdateError);
|
|
285
|
-
throw userUpdateError;
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
// Then create clinic group
|
|
289
|
-
const createClinicGroupData: CreateClinicGroupData = {
|
|
290
|
-
name: data.clinicGroupData.name,
|
|
291
|
-
hqLocation: data.clinicGroupData.hqLocation,
|
|
292
|
-
contactInfo: data.clinicGroupData.contactInfo,
|
|
293
|
-
contactPerson: contactPerson,
|
|
294
|
-
ownerId: adminProfile.id, // Use admin profile ID, not user UID
|
|
295
|
-
isActive: true,
|
|
296
|
-
logo: data.clinicGroupData.logo || null,
|
|
297
|
-
subscriptionModel:
|
|
298
|
-
data.clinicGroupData.subscriptionModel || SubscriptionModel.NO_SUBSCRIPTION,
|
|
299
|
-
onboarding: {
|
|
300
|
-
completed: false,
|
|
301
|
-
step: 1,
|
|
302
|
-
},
|
|
303
|
-
};
|
|
304
|
-
console.log('[AUTH] Clinic group data prepared', {
|
|
305
|
-
groupName: createClinicGroupData.name,
|
|
306
|
-
});
|
|
307
|
-
|
|
308
|
-
// Create clinic group
|
|
309
|
-
try {
|
|
310
|
-
clinicGroup = await clinicGroupService.createClinicGroup(
|
|
311
|
-
createClinicGroupData,
|
|
312
|
-
adminProfile.id, // Use admin profile ID, not user UID
|
|
313
|
-
false, // This is not a default group since we're providing complete data
|
|
314
|
-
);
|
|
315
|
-
console.log('[AUTH] Clinic group created successfully', {
|
|
316
|
-
groupId: clinicGroup.id,
|
|
317
|
-
});
|
|
318
|
-
|
|
319
|
-
// Now update the admin with the group ID
|
|
320
|
-
console.log('[AUTH] Updating admin with clinic group ID');
|
|
321
|
-
await clinicAdminService.updateClinicAdmin(adminProfile.id, {
|
|
322
|
-
// Use admin profile ID, not user UID
|
|
323
|
-
clinicGroupId: clinicGroup.id,
|
|
324
|
-
});
|
|
325
|
-
console.log('[AUTH] Admin updated with clinic group ID successfully');
|
|
326
|
-
|
|
327
|
-
// Get the updated admin profile
|
|
328
|
-
adminProfile = await clinicAdminService.getClinicAdmin(adminProfile.id);
|
|
329
|
-
} catch (groupCreationError) {
|
|
330
|
-
console.error('[AUTH] Clinic group creation failed:', groupCreationError);
|
|
331
|
-
throw groupCreationError;
|
|
332
|
-
}
|
|
333
|
-
} else {
|
|
334
|
-
console.log('[AUTH] Joining existing clinic group flow');
|
|
335
|
-
// Join existing clinic group with token
|
|
336
|
-
if (!data.inviteToken) {
|
|
337
|
-
console.error('[AUTH] Invite token is missing');
|
|
338
|
-
throw new Error('Invite token is required when joining an existing group');
|
|
339
|
-
}
|
|
340
|
-
console.log('[AUTH] Invite token provided', {
|
|
341
|
-
token: data.inviteToken,
|
|
342
|
-
});
|
|
343
|
-
|
|
344
|
-
// Find the token in the database
|
|
345
|
-
console.log('[AUTH] Searching for token in clinic groups');
|
|
346
|
-
const groupsRef = collection(this.db, CLINIC_GROUPS_COLLECTION);
|
|
347
|
-
const q = query(groupsRef);
|
|
348
|
-
const querySnapshot = await getDocs(q);
|
|
349
|
-
|
|
350
|
-
let foundGroup: ClinicGroup | null = null;
|
|
351
|
-
let foundToken: AdminToken | null = null;
|
|
352
|
-
|
|
353
|
-
console.log('[AUTH] Found', querySnapshot.size, 'clinic groups to check');
|
|
354
|
-
for (const docSnapshot of querySnapshot.docs) {
|
|
355
|
-
const group = docSnapshot.data() as ClinicGroup;
|
|
356
|
-
console.log('[AUTH] Checking group', {
|
|
357
|
-
groupId: group.id,
|
|
358
|
-
groupName: group.name,
|
|
359
|
-
});
|
|
360
|
-
|
|
361
|
-
// Find the token in the group's tokens
|
|
362
|
-
const token = group.adminTokens.find(t => {
|
|
363
|
-
const isMatch =
|
|
364
|
-
t.token === data.inviteToken &&
|
|
365
|
-
t.status === AdminTokenStatus.ACTIVE &&
|
|
366
|
-
new Date(t.expiresAt.toDate()) > new Date();
|
|
367
|
-
|
|
368
|
-
console.log('[AUTH] Checking token', {
|
|
369
|
-
tokenId: t.id,
|
|
370
|
-
tokenMatch: t.token === data.inviteToken,
|
|
371
|
-
tokenStatus: t.status,
|
|
372
|
-
tokenActive: t.status === AdminTokenStatus.ACTIVE,
|
|
373
|
-
tokenExpiry: new Date(t.expiresAt.toDate()),
|
|
374
|
-
tokenExpired: new Date(t.expiresAt.toDate()) <= new Date(),
|
|
375
|
-
isMatch,
|
|
376
|
-
});
|
|
377
|
-
|
|
378
|
-
return isMatch;
|
|
379
|
-
});
|
|
380
|
-
|
|
381
|
-
if (token) {
|
|
382
|
-
foundGroup = group;
|
|
383
|
-
foundToken = token;
|
|
384
|
-
console.log('[AUTH] Found matching token in group', {
|
|
385
|
-
groupId: group.id,
|
|
386
|
-
tokenId: token.id,
|
|
387
|
-
});
|
|
388
|
-
break;
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
if (!foundGroup || !foundToken) {
|
|
393
|
-
console.error('[AUTH] No valid token found in any clinic group');
|
|
394
|
-
throw new Error('Invalid or expired invite token');
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
clinicGroup = foundGroup;
|
|
398
|
-
|
|
399
|
-
// Create clinic admin
|
|
400
|
-
console.log('[AUTH] Creating clinic admin');
|
|
401
|
-
const createClinicAdminData: CreateClinicAdminData = {
|
|
402
|
-
userRef: firebaseUser.uid,
|
|
403
|
-
clinicGroupId: foundGroup.id,
|
|
404
|
-
isGroupOwner: false,
|
|
405
|
-
clinicsManaged: [],
|
|
406
|
-
contactInfo: contactPerson,
|
|
407
|
-
roleTitle: data.title,
|
|
408
|
-
isActive: true,
|
|
409
|
-
};
|
|
410
|
-
|
|
411
|
-
try {
|
|
412
|
-
adminProfile = await clinicAdminService.createClinicAdmin(createClinicAdminData);
|
|
413
|
-
console.log('[AUTH] Clinic admin created successfully', {
|
|
414
|
-
adminId: adminProfile.id,
|
|
415
|
-
});
|
|
416
|
-
} catch (adminCreationError) {
|
|
417
|
-
console.error('[AUTH] Clinic admin creation failed:', adminCreationError);
|
|
418
|
-
throw adminCreationError;
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
// Mark token as used
|
|
422
|
-
try {
|
|
423
|
-
await clinicGroupService.verifyAndUseAdminToken(
|
|
424
|
-
foundGroup.id,
|
|
425
|
-
data.inviteToken,
|
|
426
|
-
firebaseUser.uid,
|
|
427
|
-
);
|
|
428
|
-
console.log('[AUTH] Token marked as used successfully');
|
|
429
|
-
} catch (tokenUseError) {
|
|
430
|
-
console.error('[AUTH] Failed to mark token as used:', tokenUseError);
|
|
431
|
-
throw tokenUseError;
|
|
432
|
-
}
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
console.log('[AUTH] Clinic admin signup completed successfully', {
|
|
436
|
-
userId: user.uid,
|
|
437
|
-
clinicGroupId: clinicGroup.id,
|
|
438
|
-
clinicAdminId: adminProfile?.id || 'unknown',
|
|
439
|
-
});
|
|
440
|
-
|
|
441
|
-
// Ensure we have all required data before returning
|
|
442
|
-
if (!clinicGroup || !adminProfile) {
|
|
443
|
-
throw new Error('Failed to create or retrieve clinic group or admin profile');
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
return {
|
|
447
|
-
user,
|
|
448
|
-
clinicGroup,
|
|
449
|
-
clinicAdmin: adminProfile,
|
|
450
|
-
};
|
|
451
|
-
} catch (error) {
|
|
452
|
-
if (error instanceof z.ZodError) {
|
|
453
|
-
console.error(
|
|
454
|
-
'[AUTH] Zod validation error in signUpClinicAdmin:',
|
|
455
|
-
JSON.stringify(error.errors, null, 2),
|
|
456
|
-
);
|
|
457
|
-
throw AUTH_ERRORS.VALIDATION_ERROR;
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
const firebaseError = error as FirebaseError;
|
|
461
|
-
if (firebaseError.code === FirebaseErrorCode.EMAIL_ALREADY_IN_USE) {
|
|
462
|
-
console.error('[AUTH] Email already in use:', data.email);
|
|
463
|
-
throw AUTH_ERRORS.EMAIL_ALREADY_EXISTS;
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
console.error('[AUTH] Unhandled error in signUpClinicAdmin:', error);
|
|
467
|
-
throw error;
|
|
468
|
-
}
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
/**
|
|
472
|
-
* Prijavljuje korisnika sa email-om i lozinkom
|
|
473
|
-
*/
|
|
474
|
-
async signIn(email: string, password: string): Promise<User> {
|
|
475
|
-
const { user: firebaseUser } = await signInWithEmailAndPassword(this.auth, email, password);
|
|
476
|
-
|
|
477
|
-
return this.userService.getOrCreateUser(firebaseUser);
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
/**
|
|
481
|
-
* Prijavljuje korisnika sa email-om i lozinkom samo za clinic_admin role
|
|
482
|
-
* @param email - Email korisnika
|
|
483
|
-
* @param password - Lozinka korisnika
|
|
484
|
-
* @returns Objekat koji sadrži korisnika, admin profil i grupu klinika
|
|
485
|
-
* @throws {AUTH_ERRORS.INVALID_ROLE} Ako korisnik nema clinic_admin rolu
|
|
486
|
-
* @throws {AUTH_ERRORS.NOT_FOUND} Ako admin profil nije pronađen
|
|
487
|
-
*/
|
|
488
|
-
async signInClinicAdmin(
|
|
489
|
-
email: string,
|
|
490
|
-
password: string,
|
|
491
|
-
): Promise<{
|
|
492
|
-
user: User;
|
|
493
|
-
clinicAdmin: ClinicAdmin;
|
|
494
|
-
clinicGroup: ClinicGroup;
|
|
495
|
-
}> {
|
|
496
|
-
try {
|
|
497
|
-
// Initialize required services
|
|
498
|
-
const clinicAdminService = new ClinicAdminService(this.db, this.auth, this.app);
|
|
499
|
-
const clinicGroupService = new ClinicGroupService(
|
|
500
|
-
this.db,
|
|
501
|
-
this.auth,
|
|
502
|
-
this.app,
|
|
503
|
-
clinicAdminService,
|
|
504
|
-
);
|
|
505
|
-
const mediaService = new MediaService(this.db, this.auth, this.app);
|
|
506
|
-
const clinicService = new ClinicService(
|
|
507
|
-
this.db,
|
|
508
|
-
this.auth,
|
|
509
|
-
this.app,
|
|
510
|
-
clinicGroupService,
|
|
511
|
-
clinicAdminService,
|
|
512
|
-
mediaService,
|
|
513
|
-
);
|
|
514
|
-
|
|
515
|
-
// Set services to resolve circular dependencies
|
|
516
|
-
clinicAdminService.setServices(clinicGroupService, clinicService);
|
|
517
|
-
|
|
518
|
-
// Sign in with email/password
|
|
519
|
-
const { user: firebaseUser } = await signInWithEmailAndPassword(this.auth, email, password);
|
|
520
|
-
|
|
521
|
-
// Get or create user
|
|
522
|
-
const user = await this.userService.getOrCreateUser(firebaseUser);
|
|
523
|
-
|
|
524
|
-
// Check if user has clinic_admin role
|
|
525
|
-
if (!user.roles?.includes(UserRole.CLINIC_ADMIN)) {
|
|
526
|
-
console.error('[AUTH] User is not a clinic admin:', user.uid);
|
|
527
|
-
// Sign out the user immediately for security
|
|
528
|
-
await this.auth.signOut();
|
|
529
|
-
throw AUTH_ERRORS.UNAUTHORIZED_ROLE;
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
// Check and get admin profile
|
|
533
|
-
if (!user.adminProfile) {
|
|
534
|
-
console.error('[AUTH] User has no admin profile:', user.uid);
|
|
535
|
-
throw AUTH_ERRORS.NOT_FOUND;
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
// Get clinic admin profile
|
|
539
|
-
const adminProfile = await clinicAdminService.getClinicAdmin(user.adminProfile);
|
|
540
|
-
if (!adminProfile) {
|
|
541
|
-
console.error('[AUTH] Admin profile not found:', user.adminProfile);
|
|
542
|
-
throw AUTH_ERRORS.NOT_FOUND;
|
|
543
|
-
}
|
|
544
|
-
|
|
545
|
-
// Get clinic group
|
|
546
|
-
const clinicGroup = await clinicGroupService.getClinicGroup(adminProfile.clinicGroupId);
|
|
547
|
-
if (!clinicGroup) {
|
|
548
|
-
console.error('[AUTH] Clinic group not found:', adminProfile.clinicGroupId);
|
|
549
|
-
throw AUTH_ERRORS.NOT_FOUND;
|
|
550
|
-
}
|
|
551
|
-
|
|
552
|
-
return {
|
|
553
|
-
user,
|
|
554
|
-
clinicAdmin: adminProfile,
|
|
555
|
-
clinicGroup,
|
|
556
|
-
};
|
|
557
|
-
} catch (error) {
|
|
558
|
-
console.error('[AUTH] Error in signInClinicAdmin:', error);
|
|
559
|
-
throw error;
|
|
560
|
-
}
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
/**
|
|
564
|
-
* Prijavljuje korisnika anonimno
|
|
565
|
-
*/
|
|
566
|
-
async signInAnonymously(options?: { skipProfileCreation?: boolean }): Promise<User> {
|
|
567
|
-
const { user: firebaseUser } = await firebaseSignInAnonymously(this.auth);
|
|
568
|
-
|
|
569
|
-
if (options?.skipProfileCreation) {
|
|
570
|
-
return this.userService.getOrCreateUser(firebaseUser, UserRole.PATIENT, { skipProfileCreation: true });
|
|
571
|
-
}
|
|
572
|
-
|
|
573
|
-
return this.userService.getOrCreateUser(firebaseUser);
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
/**
|
|
577
|
-
* Odjavljuje trenutnog korisnika
|
|
578
|
-
*/
|
|
579
|
-
async signOut(): Promise<void> {
|
|
580
|
-
await firebaseSignOut(this.auth);
|
|
581
|
-
}
|
|
582
|
-
|
|
583
|
-
/**
|
|
584
|
-
* Vraća trenutno prijavljenog korisnika
|
|
585
|
-
*/
|
|
586
|
-
async getCurrentUser(): Promise<User | null> {
|
|
587
|
-
const firebaseUser = this.auth.currentUser;
|
|
588
|
-
if (!firebaseUser) return null;
|
|
589
|
-
|
|
590
|
-
return this.userService.getUserById(firebaseUser.uid);
|
|
591
|
-
}
|
|
592
|
-
|
|
593
|
-
/**
|
|
594
|
-
* Registruje callback za promene stanja autentifikacije
|
|
595
|
-
*/
|
|
596
|
-
onAuthStateChange(callback: (user: FirebaseUser | null) => void): () => void {
|
|
597
|
-
return onAuthStateChanged(this.auth, callback);
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
async upgradeAnonymousUser(email: string, password: string): Promise<User> {
|
|
601
|
-
try {
|
|
602
|
-
await emailSchema.parseAsync(email);
|
|
603
|
-
await passwordSchema.parseAsync(password);
|
|
604
|
-
|
|
605
|
-
const currentUser = this.auth.currentUser;
|
|
606
|
-
if (!currentUser) {
|
|
607
|
-
throw AUTH_ERRORS.NOT_AUTHENTICATED;
|
|
608
|
-
}
|
|
609
|
-
if (!currentUser.isAnonymous) {
|
|
610
|
-
throw new AuthError('User is not anonymous', 'AUTH/NOT_ANONYMOUS_USER', 400);
|
|
611
|
-
}
|
|
612
|
-
|
|
613
|
-
const credential = EmailAuthProvider.credential(email, password);
|
|
614
|
-
await linkWithCredential(currentUser, credential);
|
|
615
|
-
|
|
616
|
-
return await this.userService.upgradeAnonymousUser(currentUser.uid, email);
|
|
617
|
-
} catch (error) {
|
|
618
|
-
if (error instanceof z.ZodError) {
|
|
619
|
-
throw AUTH_ERRORS.VALIDATION_ERROR;
|
|
620
|
-
}
|
|
621
|
-
const firebaseError = error as FirebaseError;
|
|
622
|
-
if (firebaseError.code === FirebaseErrorCode.EMAIL_ALREADY_IN_USE) {
|
|
623
|
-
throw AUTH_ERRORS.EMAIL_ALREADY_EXISTS;
|
|
624
|
-
}
|
|
625
|
-
throw error;
|
|
626
|
-
}
|
|
627
|
-
}
|
|
628
|
-
|
|
629
|
-
/**
|
|
630
|
-
* Šalje email za resetovanje lozinke korisniku
|
|
631
|
-
* @param email Email adresa korisnika
|
|
632
|
-
* @returns Promise koji se razrešava kada je email poslat
|
|
633
|
-
*/
|
|
634
|
-
async sendPasswordResetEmail(email: string): Promise<void> {
|
|
635
|
-
try {
|
|
636
|
-
await emailSchema.parseAsync(email);
|
|
637
|
-
|
|
638
|
-
// Šaljemo email za resetovanje lozinke
|
|
639
|
-
await sendPasswordResetEmail(this.auth, email);
|
|
640
|
-
} catch (error) {
|
|
641
|
-
if (error instanceof z.ZodError) {
|
|
642
|
-
throw AUTH_ERRORS.VALIDATION_ERROR;
|
|
643
|
-
}
|
|
644
|
-
|
|
645
|
-
const firebaseError = error as FirebaseError;
|
|
646
|
-
|
|
647
|
-
// Handle specific Firebase errors
|
|
648
|
-
switch (firebaseError.code) {
|
|
649
|
-
case FirebaseErrorCode.USER_NOT_FOUND:
|
|
650
|
-
throw AUTH_ERRORS.USER_NOT_FOUND;
|
|
651
|
-
case FirebaseErrorCode.INVALID_EMAIL:
|
|
652
|
-
throw AUTH_ERRORS.INVALID_EMAIL;
|
|
653
|
-
case FirebaseErrorCode.TOO_MANY_REQUESTS:
|
|
654
|
-
throw AUTH_ERRORS.TOO_MANY_REQUESTS;
|
|
655
|
-
case FirebaseErrorCode.NETWORK_ERROR:
|
|
656
|
-
throw AUTH_ERRORS.NETWORK_ERROR;
|
|
657
|
-
case FirebaseErrorCode.OPERATION_NOT_ALLOWED:
|
|
658
|
-
throw AUTH_ERRORS.OPERATION_NOT_ALLOWED;
|
|
659
|
-
default:
|
|
660
|
-
// Re-throw unknown errors as-is
|
|
661
|
-
throw error;
|
|
662
|
-
}
|
|
663
|
-
}
|
|
664
|
-
}
|
|
665
|
-
|
|
666
|
-
/**
|
|
667
|
-
* Verifikuje kod za resetovanje lozinke iz email linka
|
|
668
|
-
* @param oobCode Kod iz URL-a za resetovanje lozinke
|
|
669
|
-
* @returns Promise koji se razrešava sa email adresom korisnika ako je kod validan
|
|
670
|
-
*/
|
|
671
|
-
async verifyPasswordResetCode(oobCode: string): Promise<string> {
|
|
672
|
-
try {
|
|
673
|
-
// Verifikujemo kod i vraćamo email korisnika
|
|
674
|
-
return await verifyPasswordResetCode(this.auth, oobCode);
|
|
675
|
-
} catch (error) {
|
|
676
|
-
const firebaseError = error as FirebaseError;
|
|
677
|
-
if (firebaseError.code === FirebaseErrorCode.EXPIRED_ACTION_CODE) {
|
|
678
|
-
throw AUTH_ERRORS.EXPIRED_ACTION_CODE;
|
|
679
|
-
} else if (firebaseError.code === FirebaseErrorCode.INVALID_ACTION_CODE) {
|
|
680
|
-
throw AUTH_ERRORS.INVALID_ACTION_CODE;
|
|
681
|
-
}
|
|
682
|
-
|
|
683
|
-
throw error;
|
|
684
|
-
}
|
|
685
|
-
}
|
|
686
|
-
|
|
687
|
-
/**
|
|
688
|
-
* Potvrđuje resetovanje lozinke i postavlja novu lozinku
|
|
689
|
-
* @param oobCode Kod iz URL-a za resetovanje lozinke
|
|
690
|
-
* @param newPassword Nova lozinka
|
|
691
|
-
* @returns Promise koji se razrešava kada je lozinka promenjena
|
|
692
|
-
*/
|
|
693
|
-
async confirmPasswordReset(oobCode: string, newPassword: string): Promise<void> {
|
|
694
|
-
try {
|
|
695
|
-
await passwordSchema.parseAsync(newPassword);
|
|
696
|
-
|
|
697
|
-
// Potvrđujemo resetovanje lozinke i postavljamo novu lozinku
|
|
698
|
-
await confirmPasswordReset(this.auth, oobCode, newPassword);
|
|
699
|
-
} catch (error) {
|
|
700
|
-
if (error instanceof z.ZodError) {
|
|
701
|
-
throw AUTH_ERRORS.VALIDATION_ERROR;
|
|
702
|
-
}
|
|
703
|
-
|
|
704
|
-
const firebaseError = error as FirebaseError;
|
|
705
|
-
if (firebaseError.code === FirebaseErrorCode.EXPIRED_ACTION_CODE) {
|
|
706
|
-
throw AUTH_ERRORS.EXPIRED_ACTION_CODE;
|
|
707
|
-
} else if (firebaseError.code === FirebaseErrorCode.INVALID_ACTION_CODE) {
|
|
708
|
-
throw AUTH_ERRORS.INVALID_ACTION_CODE;
|
|
709
|
-
} else if (firebaseError.code === FirebaseErrorCode.WEAK_PASSWORD) {
|
|
710
|
-
throw AUTH_ERRORS.WEAK_PASSWORD;
|
|
711
|
-
}
|
|
712
|
-
|
|
713
|
-
throw error;
|
|
714
|
-
}
|
|
715
|
-
}
|
|
716
|
-
|
|
717
|
-
/**
|
|
718
|
-
* Registers a new practitioner user with email and password (ATOMIC VERSION)
|
|
719
|
-
* Uses Firestore transactions to ensure atomicity and proper rollback on failures
|
|
720
|
-
*
|
|
721
|
-
* @param data - Practitioner signup data containing either new profile details or token for claiming draft profile
|
|
722
|
-
* @returns Object containing the created user and practitioner profile
|
|
723
|
-
*/
|
|
724
|
-
async signUpPractitioner(data: {
|
|
725
|
-
email: string;
|
|
726
|
-
password: string;
|
|
727
|
-
firstName?: string;
|
|
728
|
-
lastName?: string;
|
|
729
|
-
token?: string;
|
|
730
|
-
profileData?: Partial<CreatePractitionerData>;
|
|
731
|
-
}): Promise<{
|
|
732
|
-
user: User;
|
|
733
|
-
practitioner: Practitioner;
|
|
734
|
-
}> {
|
|
735
|
-
let firebaseUser: any = null;
|
|
736
|
-
|
|
737
|
-
try {
|
|
738
|
-
console.log('[AUTH] Starting atomic practitioner signup process', {
|
|
739
|
-
email: data.email,
|
|
740
|
-
hasToken: !!data.token,
|
|
741
|
-
});
|
|
742
|
-
|
|
743
|
-
// Step 1: Pre-validate all data before any mutations
|
|
744
|
-
await this.validateSignupData(data);
|
|
745
|
-
|
|
746
|
-
// Step 2: Create Firebase user (outside transaction - can't be easily rolled back)
|
|
747
|
-
console.log('[AUTH] Creating Firebase user');
|
|
748
|
-
try {
|
|
749
|
-
const result = await createUserWithEmailAndPassword(this.auth, data.email, data.password);
|
|
750
|
-
firebaseUser = result.user;
|
|
751
|
-
console.log('[AUTH] Firebase user created successfully', {
|
|
752
|
-
uid: firebaseUser.uid,
|
|
753
|
-
});
|
|
754
|
-
} catch (firebaseError) {
|
|
755
|
-
console.error('[AUTH] Firebase user creation failed:', firebaseError);
|
|
756
|
-
throw handleFirebaseError(firebaseError);
|
|
757
|
-
}
|
|
758
|
-
|
|
759
|
-
// Step 3: Execute all database operations in a single transaction
|
|
760
|
-
console.log('[AUTH] Starting Firestore transaction');
|
|
761
|
-
const transactionResult = await runTransaction(this.db, async transaction => {
|
|
762
|
-
console.log('[AUTH] Transaction started - creating user and practitioner');
|
|
763
|
-
|
|
764
|
-
// Initialize services
|
|
765
|
-
const practitionerService = new PractitionerService(this.db, this.auth, this.app);
|
|
766
|
-
|
|
767
|
-
// Create user document using existing method (not in transaction for now)
|
|
768
|
-
console.log('[AUTH] Creating user document');
|
|
769
|
-
const user = await this.userService.createUser(firebaseUser, [UserRole.PRACTITIONER], {
|
|
770
|
-
skipProfileCreation: true,
|
|
771
|
-
});
|
|
772
|
-
|
|
773
|
-
let practitioner: Practitioner;
|
|
774
|
-
|
|
775
|
-
// Handle practitioner profile creation/claiming
|
|
776
|
-
if (data.token) {
|
|
777
|
-
console.log('[AUTH] Claiming existing practitioner profile with token');
|
|
778
|
-
const claimedPractitioner = await practitionerService.validateTokenAndClaimProfile(
|
|
779
|
-
data.token,
|
|
780
|
-
firebaseUser.uid,
|
|
781
|
-
);
|
|
782
|
-
if (!claimedPractitioner) {
|
|
783
|
-
throw new Error('Invalid or expired invitation token');
|
|
784
|
-
}
|
|
785
|
-
practitioner = claimedPractitioner;
|
|
786
|
-
} else {
|
|
787
|
-
// Check if a draft profile exists for this email
|
|
788
|
-
console.log('[AUTH] Checking for existing draft practitioner profile', {
|
|
789
|
-
email: data.email,
|
|
790
|
-
});
|
|
791
|
-
const draftPractitioner = await practitionerService.findDraftPractitionerByEmail(
|
|
792
|
-
data.email
|
|
793
|
-
);
|
|
794
|
-
|
|
795
|
-
if (draftPractitioner) {
|
|
796
|
-
console.log('[AUTH] Draft practitioner profile found', {
|
|
797
|
-
practitionerId: draftPractitioner.id,
|
|
798
|
-
email: data.email,
|
|
799
|
-
clinics: draftPractitioner.clinics,
|
|
800
|
-
});
|
|
801
|
-
|
|
802
|
-
// Extract clinic names from clinicsInfo (should be populated when draft is created)
|
|
803
|
-
let clinicNames: string[] = [];
|
|
804
|
-
if (draftPractitioner.clinicsInfo && draftPractitioner.clinicsInfo.length > 0) {
|
|
805
|
-
clinicNames = draftPractitioner.clinicsInfo
|
|
806
|
-
.map((clinic) => clinic.name)
|
|
807
|
-
.filter((name): name is string => !!name);
|
|
808
|
-
} else if (draftPractitioner.clinics && draftPractitioner.clinics.length > 0) {
|
|
809
|
-
// Fallback: fetch clinic names if clinicsInfo is missing
|
|
810
|
-
console.log('[AUTH] clinicsInfo missing, fetching clinic names from clinic IDs');
|
|
811
|
-
const clinicService = practitionerService.getClinicService();
|
|
812
|
-
const clinicNamePromises = draftPractitioner.clinics.map(async (clinicId) => {
|
|
813
|
-
try {
|
|
814
|
-
const clinic = await clinicService.getClinic(clinicId);
|
|
815
|
-
return clinic?.name || null;
|
|
816
|
-
} catch (error) {
|
|
817
|
-
console.error(`[AUTH] Error fetching clinic ${clinicId}:`, error);
|
|
818
|
-
return null;
|
|
819
|
-
}
|
|
820
|
-
});
|
|
821
|
-
const names = await Promise.all(clinicNamePromises);
|
|
822
|
-
clinicNames = names.filter((name): name is string => !!name);
|
|
823
|
-
}
|
|
824
|
-
|
|
825
|
-
// Cleanup Firebase user since we're not creating a profile
|
|
826
|
-
await cleanupFirebaseUser(firebaseUser);
|
|
827
|
-
|
|
828
|
-
// Throw error with clinic information
|
|
829
|
-
throw new AuthError(
|
|
830
|
-
AUTH_ERRORS.DRAFT_PROFILE_EXISTS.message,
|
|
831
|
-
AUTH_ERRORS.DRAFT_PROFILE_EXISTS.code,
|
|
832
|
-
AUTH_ERRORS.DRAFT_PROFILE_EXISTS.status,
|
|
833
|
-
{
|
|
834
|
-
clinicNames,
|
|
835
|
-
clinics: draftPractitioner.clinics,
|
|
836
|
-
clinicsInfo: draftPractitioner.clinicsInfo,
|
|
837
|
-
}
|
|
838
|
-
);
|
|
839
|
-
}
|
|
840
|
-
|
|
841
|
-
console.log('[AUTH] No draft profile found, creating new practitioner profile');
|
|
842
|
-
const practitionerData = buildPractitionerData(data, firebaseUser.uid);
|
|
843
|
-
practitioner = await practitionerService.createPractitioner(practitionerData);
|
|
844
|
-
}
|
|
845
|
-
|
|
846
|
-
// Link practitioner to user
|
|
847
|
-
console.log('[AUTH] Linking practitioner to user');
|
|
848
|
-
await this.userService.updateUser(firebaseUser.uid, {
|
|
849
|
-
practitionerProfile: practitioner.id,
|
|
850
|
-
});
|
|
851
|
-
|
|
852
|
-
console.log('[AUTH] Transaction operations completed successfully');
|
|
853
|
-
return { user, practitioner };
|
|
854
|
-
});
|
|
855
|
-
|
|
856
|
-
console.log('[AUTH] Atomic practitioner signup completed successfully', {
|
|
857
|
-
userId: transactionResult.user.uid,
|
|
858
|
-
practitionerId: transactionResult.practitioner.id,
|
|
859
|
-
});
|
|
860
|
-
|
|
861
|
-
return transactionResult;
|
|
862
|
-
} catch (error) {
|
|
863
|
-
console.error('[AUTH] Atomic signup failed, initiating cleanup...', error);
|
|
864
|
-
|
|
865
|
-
// Cleanup Firebase user if transaction failed
|
|
866
|
-
if (firebaseUser) {
|
|
867
|
-
await cleanupFirebaseUser(firebaseUser);
|
|
868
|
-
}
|
|
869
|
-
|
|
870
|
-
throw handleSignupError(error);
|
|
871
|
-
}
|
|
872
|
-
}
|
|
873
|
-
|
|
874
|
-
/**
|
|
875
|
-
* Claims draft practitioner profiles after Google Sign-In.
|
|
876
|
-
* Uses the current authenticated user (from initial Google Sign-In).
|
|
877
|
-
*
|
|
878
|
-
* @param idToken - The Google ID token (used to re-authenticate if needed)
|
|
879
|
-
* @param practitionerIds - Array of draft practitioner profile IDs to claim
|
|
880
|
-
* @returns Object containing user and claimed practitioner
|
|
881
|
-
*/
|
|
882
|
-
async claimDraftProfilesWithGoogle(
|
|
883
|
-
idToken: string,
|
|
884
|
-
practitionerIds: string[]
|
|
885
|
-
): Promise<{
|
|
886
|
-
user: User;
|
|
887
|
-
practitioner: Practitioner;
|
|
888
|
-
}> {
|
|
889
|
-
try {
|
|
890
|
-
console.log('[AUTH] Starting claim draft profiles with Google', {
|
|
891
|
-
practitionerIdsCount: practitionerIds.length,
|
|
892
|
-
practitionerIds,
|
|
893
|
-
});
|
|
894
|
-
|
|
895
|
-
if (practitionerIds.length === 0) {
|
|
896
|
-
throw new AuthError('No practitioner profiles selected to claim', 'AUTH/NO_PROFILES_SELECTED', 400);
|
|
897
|
-
}
|
|
898
|
-
|
|
899
|
-
const credential = GoogleAuthProvider.credential(idToken);
|
|
900
|
-
const result = await signInWithCredential(this.auth, credential);
|
|
901
|
-
const firebaseUser = result.user;
|
|
902
|
-
|
|
903
|
-
const practitionerService = new PractitionerService(this.db, this.auth, this.app);
|
|
904
|
-
|
|
905
|
-
let user: User;
|
|
906
|
-
try {
|
|
907
|
-
user = await this.userService.getUserById(firebaseUser.uid);
|
|
908
|
-
} catch (userError) {
|
|
909
|
-
throw new AuthError(
|
|
910
|
-
'User account not properly initialized. Please try signing in again.',
|
|
911
|
-
'AUTH/USER_NOT_INITIALIZED',
|
|
912
|
-
500,
|
|
913
|
-
);
|
|
914
|
-
}
|
|
915
|
-
|
|
916
|
-
let practitioner: Practitioner;
|
|
917
|
-
if (practitionerIds.length === 1) {
|
|
918
|
-
practitioner = await practitionerService.claimDraftProfileWithGoogle(
|
|
919
|
-
practitionerIds[0],
|
|
920
|
-
firebaseUser.uid
|
|
921
|
-
);
|
|
922
|
-
} else {
|
|
923
|
-
practitioner = await practitionerService.claimMultipleDraftProfilesWithGoogle(
|
|
924
|
-
practitionerIds,
|
|
925
|
-
firebaseUser.uid
|
|
926
|
-
);
|
|
927
|
-
}
|
|
928
|
-
|
|
929
|
-
if (!user.practitionerProfile || user.practitionerProfile !== practitioner.id) {
|
|
930
|
-
await this.userService.updateUser(firebaseUser.uid, {
|
|
931
|
-
practitionerProfile: practitioner.id,
|
|
932
|
-
});
|
|
933
|
-
}
|
|
934
|
-
|
|
935
|
-
const updatedUser = await this.userService.getUserById(firebaseUser.uid);
|
|
936
|
-
|
|
937
|
-
return {
|
|
938
|
-
user: updatedUser,
|
|
939
|
-
practitioner,
|
|
940
|
-
};
|
|
941
|
-
} catch (error: any) {
|
|
942
|
-
console.error('[AUTH] Error claiming draft profiles with Google:', error);
|
|
943
|
-
throw handleSignupError(error);
|
|
944
|
-
}
|
|
945
|
-
}
|
|
946
|
-
|
|
947
|
-
/**
|
|
948
|
-
* Pre-validate all signup data before any mutations
|
|
949
|
-
* Prevents partial creation by catching issues early
|
|
950
|
-
*/
|
|
951
|
-
private async validateSignupData(data: {
|
|
952
|
-
email: string;
|
|
953
|
-
password: string;
|
|
954
|
-
firstName?: string;
|
|
955
|
-
lastName?: string;
|
|
956
|
-
token?: string;
|
|
957
|
-
profileData?: Partial<CreatePractitionerData>;
|
|
958
|
-
}): Promise<void> {
|
|
959
|
-
console.log('[AUTH] Pre-validating signup data');
|
|
960
|
-
|
|
961
|
-
try {
|
|
962
|
-
// 1. Schema validation
|
|
963
|
-
await practitionerSignupSchema.parseAsync(data);
|
|
964
|
-
console.log('[AUTH] Schema validation passed');
|
|
965
|
-
|
|
966
|
-
// 2. Check if email already exists (before creating Firebase user)
|
|
967
|
-
const emailExists = await checkEmailExists(this.auth, data.email);
|
|
968
|
-
if (emailExists) {
|
|
969
|
-
console.log('[AUTH] Email already exists:', data.email);
|
|
970
|
-
throw AUTH_ERRORS.EMAIL_ALREADY_EXISTS;
|
|
971
|
-
}
|
|
972
|
-
console.log('[AUTH] Email availability confirmed');
|
|
973
|
-
|
|
974
|
-
// 3. Validate token if provided
|
|
975
|
-
if (data.token) {
|
|
976
|
-
const practitionerService = new PractitionerService(this.db, this.auth, this.app);
|
|
977
|
-
const isValidToken = await practitionerService.validateToken(data.token);
|
|
978
|
-
if (!isValidToken) {
|
|
979
|
-
console.log('[AUTH] Invalid token provided:', data.token);
|
|
980
|
-
throw new Error('Invalid or expired invitation token');
|
|
981
|
-
}
|
|
982
|
-
console.log('[AUTH] Token validation passed');
|
|
983
|
-
}
|
|
984
|
-
|
|
985
|
-
// 4. Validate profile data structure if provided
|
|
986
|
-
if (data.profileData) {
|
|
987
|
-
await validatePractitionerProfileData(data.profileData);
|
|
988
|
-
console.log('[AUTH] Profile data validation passed');
|
|
989
|
-
}
|
|
990
|
-
|
|
991
|
-
console.log('[AUTH] All pre-validation checks passed');
|
|
992
|
-
} catch (error) {
|
|
993
|
-
console.error('[AUTH] Pre-validation failed:', error);
|
|
994
|
-
throw error;
|
|
995
|
-
}
|
|
996
|
-
}
|
|
997
|
-
|
|
998
|
-
/**
|
|
999
|
-
* Signs in a user with email and password specifically for practitioner role
|
|
1000
|
-
* @param email - User's email
|
|
1001
|
-
* @param password - User's password
|
|
1002
|
-
* @returns Object containing the user and practitioner profile
|
|
1003
|
-
* @throws {AUTH_ERRORS.INVALID_ROLE} If user doesn't have practitioner role
|
|
1004
|
-
* @throws {AUTH_ERRORS.NOT_FOUND} If practitioner profile is not found
|
|
1005
|
-
*/
|
|
1006
|
-
async signInPractitioner(
|
|
1007
|
-
email: string,
|
|
1008
|
-
password: string,
|
|
1009
|
-
): Promise<{
|
|
1010
|
-
user: User;
|
|
1011
|
-
practitioner: Practitioner;
|
|
1012
|
-
}> {
|
|
1013
|
-
try {
|
|
1014
|
-
console.log('[AUTH] Starting practitioner signin process', {
|
|
1015
|
-
email: email,
|
|
1016
|
-
});
|
|
1017
|
-
|
|
1018
|
-
// Initialize required service
|
|
1019
|
-
const practitionerService = new PractitionerService(this.db, this.auth, this.app);
|
|
1020
|
-
|
|
1021
|
-
// Sign in with email/password
|
|
1022
|
-
const { user: firebaseUser } = await signInWithEmailAndPassword(this.auth, email, password);
|
|
1023
|
-
|
|
1024
|
-
// Get or create user
|
|
1025
|
-
const user = await this.userService.getOrCreateUser(firebaseUser);
|
|
1026
|
-
console.log('[AUTH] User retrieved', { uid: user.uid });
|
|
1027
|
-
|
|
1028
|
-
// Check if user has practitioner role
|
|
1029
|
-
if (!user.roles?.includes(UserRole.PRACTITIONER)) {
|
|
1030
|
-
console.error('[AUTH] User is not a practitioner:', user.uid);
|
|
1031
|
-
throw AUTH_ERRORS.INVALID_ROLE;
|
|
1032
|
-
}
|
|
1033
|
-
|
|
1034
|
-
// Check and get practitioner profile
|
|
1035
|
-
if (!user.practitionerProfile) {
|
|
1036
|
-
console.error('[AUTH] User has no practitioner profile:', user.uid);
|
|
1037
|
-
throw AUTH_ERRORS.NOT_FOUND;
|
|
1038
|
-
}
|
|
1039
|
-
|
|
1040
|
-
// Get practitioner profile
|
|
1041
|
-
const practitioner = await practitionerService.getPractitioner(user.practitionerProfile);
|
|
1042
|
-
if (!practitioner) {
|
|
1043
|
-
console.error('[AUTH] Practitioner profile not found:', user.practitionerProfile);
|
|
1044
|
-
throw AUTH_ERRORS.NOT_FOUND;
|
|
1045
|
-
}
|
|
1046
|
-
|
|
1047
|
-
console.log('[AUTH] Practitioner signin completed successfully', {
|
|
1048
|
-
userId: user.uid,
|
|
1049
|
-
practitionerId: practitioner.id,
|
|
1050
|
-
});
|
|
1051
|
-
|
|
1052
|
-
return {
|
|
1053
|
-
user,
|
|
1054
|
-
practitioner,
|
|
1055
|
-
};
|
|
1056
|
-
} catch (error) {
|
|
1057
|
-
console.error('[AUTH] Error in signInPractitioner:', error);
|
|
1058
|
-
throw error;
|
|
1059
|
-
}
|
|
1060
|
-
}
|
|
1061
|
-
|
|
1062
|
-
/**
|
|
1063
|
-
* Signs in a user with a Google ID token from a mobile client.
|
|
1064
|
-
* If the user does not exist, a new user is created.
|
|
1065
|
-
* @param idToken - The Google ID token obtained from the mobile app.
|
|
1066
|
-
* @param initialRole - The role to assign to the user if they are being created.
|
|
1067
|
-
* @returns The signed-in or newly created user.
|
|
1068
|
-
*/
|
|
1069
|
-
async signInWithGoogleIdToken(
|
|
1070
|
-
idToken: string,
|
|
1071
|
-
initialRole: UserRole = UserRole.PATIENT,
|
|
1072
|
-
): Promise<User> {
|
|
1073
|
-
try {
|
|
1074
|
-
console.log('[AUTH] Signing in with Google ID Token');
|
|
1075
|
-
|
|
1076
|
-
// Sign in with Google credential — auto-creates Firebase Auth user if needed.
|
|
1077
|
-
const credential = GoogleAuthProvider.credential(idToken);
|
|
1078
|
-
const { user: firebaseUser } = await signInWithCredential(this.auth, credential);
|
|
1079
|
-
console.log('[AUTH] Firebase user signed in:', firebaseUser.uid);
|
|
1080
|
-
|
|
1081
|
-
// Load or create domain user document
|
|
1082
|
-
return await this.userService.getOrCreateUser(firebaseUser, UserRole.PATIENT);
|
|
1083
|
-
} catch (error) {
|
|
1084
|
-
console.error('[AUTH] Error in signInWithGoogleIdToken:', error);
|
|
1085
|
-
throw handleFirebaseError(error);
|
|
1086
|
-
}
|
|
1087
|
-
}
|
|
1088
|
-
|
|
1089
|
-
/**
|
|
1090
|
-
* Signs up or signs in a practitioner with Google authentication.
|
|
1091
|
-
* Checks for existing practitioner account or draft profiles.
|
|
1092
|
-
*
|
|
1093
|
-
* @param idToken - The Google ID token obtained from the mobile app
|
|
1094
|
-
* @returns Object containing user, practitioner (if exists), and draft profiles (if any)
|
|
1095
|
-
*/
|
|
1096
|
-
async signUpPractitionerWithGoogle(
|
|
1097
|
-
idToken: string
|
|
1098
|
-
): Promise<{
|
|
1099
|
-
user: User | null;
|
|
1100
|
-
practitioner: Practitioner | null;
|
|
1101
|
-
draftProfiles: Practitioner[];
|
|
1102
|
-
}> {
|
|
1103
|
-
try {
|
|
1104
|
-
console.log('[AUTH] Starting practitioner Google Sign-In/Sign-Up');
|
|
1105
|
-
|
|
1106
|
-
// Extract email from Google ID token
|
|
1107
|
-
let email: string | undefined;
|
|
1108
|
-
try {
|
|
1109
|
-
const payloadBase64 = idToken.split('.')[1];
|
|
1110
|
-
const payloadJson = globalThis.atob
|
|
1111
|
-
? globalThis.atob(payloadBase64)
|
|
1112
|
-
: Buffer.from(payloadBase64, 'base64').toString('utf8');
|
|
1113
|
-
const payload = JSON.parse(payloadJson);
|
|
1114
|
-
email = payload.email as string | undefined;
|
|
1115
|
-
} catch (decodeError) {
|
|
1116
|
-
console.error('[AUTH] Failed to decode email from Google ID token:', decodeError);
|
|
1117
|
-
throw new AuthError(
|
|
1118
|
-
'Unable to read email from Google token. Please try again.',
|
|
1119
|
-
'AUTH/INVALID_GOOGLE_TOKEN',
|
|
1120
|
-
400,
|
|
1121
|
-
);
|
|
1122
|
-
}
|
|
1123
|
-
|
|
1124
|
-
if (!email) {
|
|
1125
|
-
throw new AuthError(
|
|
1126
|
-
'Unable to read email from Google token. Please try again.',
|
|
1127
|
-
'AUTH/INVALID_GOOGLE_TOKEN',
|
|
1128
|
-
400,
|
|
1129
|
-
);
|
|
1130
|
-
}
|
|
1131
|
-
|
|
1132
|
-
const normalizedEmail = email.toLowerCase().trim();
|
|
1133
|
-
|
|
1134
|
-
const methods = await fetchSignInMethodsForEmail(this.auth, normalizedEmail);
|
|
1135
|
-
const hasGoogleMethod = methods.includes(GoogleAuthProvider.GOOGLE_SIGN_IN_METHOD);
|
|
1136
|
-
const hasEmailMethod = methods.includes(EmailAuthProvider.EMAIL_PASSWORD_SIGN_IN_METHOD);
|
|
1137
|
-
|
|
1138
|
-
const practitionerService = new PractitionerService(this.db, this.auth, this.app);
|
|
1139
|
-
|
|
1140
|
-
if (hasGoogleMethod) {
|
|
1141
|
-
const credential = GoogleAuthProvider.credential(idToken);
|
|
1142
|
-
const { user: firebaseUser } = await signInWithCredential(this.auth, credential);
|
|
1143
|
-
|
|
1144
|
-
await this.waitForAuthStateToSettle(firebaseUser.uid);
|
|
1145
|
-
|
|
1146
|
-
let existingUser: User | null = null;
|
|
1147
|
-
try {
|
|
1148
|
-
existingUser = await this.userService.getUserById(firebaseUser.uid);
|
|
1149
|
-
} catch (userError: any) {
|
|
1150
|
-
if (!this.auth.currentUser || this.auth.currentUser.uid !== firebaseUser.uid) {
|
|
1151
|
-
const credential = GoogleAuthProvider.credential(idToken);
|
|
1152
|
-
await signInWithCredential(this.auth, credential);
|
|
1153
|
-
await this.waitForAuthStateToSettle(firebaseUser.uid, 2000);
|
|
1154
|
-
}
|
|
1155
|
-
|
|
1156
|
-
const practitionerService = new PractitionerService(this.db, this.auth, this.app);
|
|
1157
|
-
const draftProfiles = await practitionerService.getDraftProfilesByEmail(normalizedEmail);
|
|
1158
|
-
|
|
1159
|
-
if (draftProfiles.length === 0) {
|
|
1160
|
-
try {
|
|
1161
|
-
await firebaseSignOut(this.auth);
|
|
1162
|
-
} catch (signOutError) {
|
|
1163
|
-
console.warn('[AUTH] Error signing out:', signOutError);
|
|
1164
|
-
}
|
|
1165
|
-
throw new AuthError(
|
|
1166
|
-
'No clinic invitation found for this email. Please contact your clinic administrator to receive an invitation, or use the token provided by your clinic.',
|
|
1167
|
-
'AUTH/NO_DRAFT_PROFILES',
|
|
1168
|
-
404,
|
|
1169
|
-
);
|
|
1170
|
-
}
|
|
1171
|
-
|
|
1172
|
-
try {
|
|
1173
|
-
const newUser = await this.userService.createUser(firebaseUser, [UserRole.PRACTITIONER], {
|
|
1174
|
-
skipProfileCreation: true,
|
|
1175
|
-
});
|
|
1176
|
-
|
|
1177
|
-
return {
|
|
1178
|
-
user: newUser,
|
|
1179
|
-
practitioner: null,
|
|
1180
|
-
draftProfiles: draftProfiles,
|
|
1181
|
-
};
|
|
1182
|
-
} catch (createUserError: any) {
|
|
1183
|
-
try {
|
|
1184
|
-
await firebaseSignOut(this.auth);
|
|
1185
|
-
} catch (signOutError) {
|
|
1186
|
-
console.warn('[AUTH] Error signing out:', signOutError);
|
|
1187
|
-
}
|
|
1188
|
-
throw createUserError;
|
|
1189
|
-
}
|
|
1190
|
-
}
|
|
1191
|
-
|
|
1192
|
-
// User document exists - check for practitioner profile and draft profiles
|
|
1193
|
-
if (!existingUser) {
|
|
1194
|
-
await firebaseSignOut(this.auth);
|
|
1195
|
-
throw new AuthError(
|
|
1196
|
-
'No clinic invitation found for this email. Please contact your clinic administrator to receive an invitation, or use the token provided by your clinic.',
|
|
1197
|
-
'AUTH/NO_DRAFT_PROFILES',
|
|
1198
|
-
404,
|
|
1199
|
-
);
|
|
1200
|
-
}
|
|
1201
|
-
|
|
1202
|
-
// Check if user has practitioner profile
|
|
1203
|
-
let practitioner: Practitioner | null = null;
|
|
1204
|
-
if (existingUser.practitionerProfile) {
|
|
1205
|
-
practitioner = await practitionerService.getPractitioner(existingUser.practitionerProfile);
|
|
1206
|
-
}
|
|
1207
|
-
|
|
1208
|
-
// Check for any new draft profiles
|
|
1209
|
-
const draftProfiles = await practitionerService.getDraftProfilesByEmail(normalizedEmail);
|
|
1210
|
-
|
|
1211
|
-
return {
|
|
1212
|
-
user: existingUser,
|
|
1213
|
-
practitioner,
|
|
1214
|
-
draftProfiles,
|
|
1215
|
-
};
|
|
1216
|
-
}
|
|
1217
|
-
|
|
1218
|
-
if (hasEmailMethod && !hasGoogleMethod) {
|
|
1219
|
-
throw new AuthError(
|
|
1220
|
-
'An account with this email already exists. Please sign in with your email and password, then link your Google account in settings.',
|
|
1221
|
-
'AUTH/EMAIL_ALREADY_EXISTS',
|
|
1222
|
-
409,
|
|
1223
|
-
);
|
|
1224
|
-
}
|
|
1225
|
-
|
|
1226
|
-
const credential = GoogleAuthProvider.credential(idToken);
|
|
1227
|
-
|
|
1228
|
-
let firebaseUser: FirebaseUser;
|
|
1229
|
-
try {
|
|
1230
|
-
const result = await signInWithCredential(this.auth, credential);
|
|
1231
|
-
firebaseUser = result.user;
|
|
1232
|
-
} catch (error: any) {
|
|
1233
|
-
// If sign-in fails because email already exists with different provider
|
|
1234
|
-
if (error.code === 'auth/account-exists-with-different-credential') {
|
|
1235
|
-
throw new AuthError(
|
|
1236
|
-
'An account with this email already exists. Please sign in with your email and password, then link your Google account in settings.',
|
|
1237
|
-
'AUTH/EMAIL_ALREADY_EXISTS',
|
|
1238
|
-
409,
|
|
1239
|
-
);
|
|
1240
|
-
}
|
|
1241
|
-
throw error;
|
|
1242
|
-
}
|
|
1243
|
-
|
|
1244
|
-
await this.waitForAuthStateToSettle(firebaseUser.uid);
|
|
1245
|
-
|
|
1246
|
-
let existingUser: User | null = null;
|
|
1247
|
-
try {
|
|
1248
|
-
const existingUserDoc = await this.userService.getUserById(firebaseUser.uid);
|
|
1249
|
-
if (existingUserDoc) {
|
|
1250
|
-
existingUser = existingUserDoc;
|
|
1251
|
-
}
|
|
1252
|
-
} catch (error) {
|
|
1253
|
-
// Continue with new user creation
|
|
1254
|
-
}
|
|
1255
|
-
|
|
1256
|
-
let draftProfiles: Practitioner[] = [];
|
|
1257
|
-
try {
|
|
1258
|
-
draftProfiles = await practitionerService.getDraftProfilesByEmail(normalizedEmail);
|
|
1259
|
-
} catch (draftCheckError: any) {
|
|
1260
|
-
try {
|
|
1261
|
-
await firebaseSignOut(this.auth);
|
|
1262
|
-
} catch (signOutError) {
|
|
1263
|
-
console.warn('[AUTH] Error signing out:', signOutError);
|
|
1264
|
-
}
|
|
1265
|
-
throw new AuthError(
|
|
1266
|
-
'No clinic invitation found for this email. Please contact your clinic administrator to receive an invitation, or use the token provided by your clinic.',
|
|
1267
|
-
'AUTH/NO_DRAFT_PROFILES',
|
|
1268
|
-
404,
|
|
1269
|
-
);
|
|
1270
|
-
}
|
|
1271
|
-
|
|
1272
|
-
let user: User;
|
|
1273
|
-
if (existingUser) {
|
|
1274
|
-
user = existingUser;
|
|
1275
|
-
} else {
|
|
1276
|
-
if (draftProfiles.length === 0) {
|
|
1277
|
-
try {
|
|
1278
|
-
await firebaseSignOut(this.auth);
|
|
1279
|
-
} catch (signOutError) {
|
|
1280
|
-
console.warn('[AUTH] Error signing out:', signOutError);
|
|
1281
|
-
}
|
|
1282
|
-
throw new AuthError(
|
|
1283
|
-
'No clinic invitation found for this email. Please contact your clinic administrator to receive an invitation, or use the token provided by your clinic.',
|
|
1284
|
-
'AUTH/NO_DRAFT_PROFILES',
|
|
1285
|
-
404,
|
|
1286
|
-
);
|
|
1287
|
-
}
|
|
1288
|
-
|
|
1289
|
-
user = await this.userService.createUser(firebaseUser, [UserRole.PRACTITIONER], {
|
|
1290
|
-
skipProfileCreation: true,
|
|
1291
|
-
});
|
|
1292
|
-
}
|
|
1293
|
-
|
|
1294
|
-
let practitioner: Practitioner | null = null;
|
|
1295
|
-
if (user.practitionerProfile) {
|
|
1296
|
-
practitioner = await practitionerService.getPractitioner(user.practitionerProfile);
|
|
1297
|
-
}
|
|
1298
|
-
|
|
1299
|
-
return {
|
|
1300
|
-
user,
|
|
1301
|
-
practitioner,
|
|
1302
|
-
draftProfiles,
|
|
1303
|
-
};
|
|
1304
|
-
} catch (error: any) {
|
|
1305
|
-
if (error instanceof AuthError) {
|
|
1306
|
-
throw error;
|
|
1307
|
-
}
|
|
1308
|
-
|
|
1309
|
-
const errorMessage = error?.message || error?.toString() || '';
|
|
1310
|
-
if (errorMessage.includes('NO_DRAFT_PROFILES') || errorMessage.includes('clinic invitation')) {
|
|
1311
|
-
throw new AuthError(
|
|
1312
|
-
'No clinic invitation found for this email. Please contact your clinic administrator to receive an invitation, or use the token provided by your clinic.',
|
|
1313
|
-
'AUTH/NO_DRAFT_PROFILES',
|
|
1314
|
-
404,
|
|
1315
|
-
);
|
|
1316
|
-
}
|
|
1317
|
-
|
|
1318
|
-
const wrappedError = handleFirebaseError(error);
|
|
1319
|
-
|
|
1320
|
-
if (wrappedError.message.includes('permissions') || wrappedError.message.includes('Account creation failed')) {
|
|
1321
|
-
throw new AuthError(
|
|
1322
|
-
'No clinic invitation found for this email. Please contact your clinic administrator to receive an invitation, or use the token provided by your clinic.',
|
|
1323
|
-
'AUTH/NO_DRAFT_PROFILES',
|
|
1324
|
-
404,
|
|
1325
|
-
);
|
|
1326
|
-
}
|
|
1327
|
-
|
|
1328
|
-
throw wrappedError;
|
|
1329
|
-
}
|
|
1330
|
-
}
|
|
1331
|
-
|
|
1332
|
-
/**
|
|
1333
|
-
* Links a Google account to the currently signed-in user using an ID token.
|
|
1334
|
-
* This is used to upgrade an anonymous user or to allow an existing user
|
|
1335
|
-
* to sign in with Google in the future.
|
|
1336
|
-
* @param idToken - The Google ID token obtained from the mobile app.
|
|
1337
|
-
* @returns The updated user profile.
|
|
1338
|
-
*/
|
|
1339
|
-
async linkGoogleAccount(idToken: string): Promise<User> {
|
|
1340
|
-
try {
|
|
1341
|
-
console.log('[AUTH] Linking Google account with ID Token');
|
|
1342
|
-
const currentUser = this.auth.currentUser;
|
|
1343
|
-
if (!currentUser) {
|
|
1344
|
-
throw AUTH_ERRORS.NOT_AUTHENTICATED;
|
|
1345
|
-
}
|
|
1346
|
-
|
|
1347
|
-
const wasAnonymous = currentUser.isAnonymous;
|
|
1348
|
-
console.log(`[AUTH] Current user is ${wasAnonymous ? 'anonymous' : 'not anonymous'}`);
|
|
1349
|
-
|
|
1350
|
-
const credential = GoogleAuthProvider.credential(idToken);
|
|
1351
|
-
const userCredential = await linkWithCredential(currentUser, credential);
|
|
1352
|
-
const linkedFirebaseUser = userCredential.user;
|
|
1353
|
-
console.log('[AUTH] Google account linked successfully to user:', linkedFirebaseUser.uid);
|
|
1354
|
-
|
|
1355
|
-
if (wasAnonymous) {
|
|
1356
|
-
console.log('[AUTH] Upgrading anonymous user profile');
|
|
1357
|
-
return await this.userService.upgradeAnonymousUser(
|
|
1358
|
-
linkedFirebaseUser.uid,
|
|
1359
|
-
linkedFirebaseUser.email!,
|
|
1360
|
-
);
|
|
1361
|
-
}
|
|
1362
|
-
|
|
1363
|
-
// If the user was not anonymous, just return their updated profile
|
|
1364
|
-
return (await this.userService.getUserById(linkedFirebaseUser.uid))!;
|
|
1365
|
-
} catch (error) {
|
|
1366
|
-
console.error('[AUTH] Error in linkGoogleAccount:', error);
|
|
1367
|
-
throw handleFirebaseError(error);
|
|
1368
|
-
}
|
|
1369
|
-
}
|
|
1370
|
-
|
|
1371
|
-
/**
|
|
1372
|
-
* Signs in or registers a user with an Apple ID token.
|
|
1373
|
-
* If the user does not exist, a new user is created.
|
|
1374
|
-
*/
|
|
1375
|
-
async signInWithAppleIdToken(
|
|
1376
|
-
idToken: string,
|
|
1377
|
-
rawNonce: string,
|
|
1378
|
-
appleUserInfo?: { fullName?: { givenName?: string; familyName?: string }; email?: string },
|
|
1379
|
-
): Promise<User> {
|
|
1380
|
-
try {
|
|
1381
|
-
console.log('[AUTH] Signing in with Apple ID Token');
|
|
1382
|
-
|
|
1383
|
-
// Build Apple OAuth credential
|
|
1384
|
-
const provider = new OAuthProvider('apple.com');
|
|
1385
|
-
const credential = provider.credential({ idToken, rawNonce });
|
|
1386
|
-
|
|
1387
|
-
// Sign in to Firebase — auto-creates Firebase Auth user if needed
|
|
1388
|
-
const { user: firebaseUser } = await signInWithCredential(this.auth, credential);
|
|
1389
|
-
console.log('[AUTH] Firebase user signed in via Apple:', firebaseUser.uid);
|
|
1390
|
-
|
|
1391
|
-
// Load or create domain user document
|
|
1392
|
-
return await this.userService.getOrCreateUser(firebaseUser, UserRole.PATIENT);
|
|
1393
|
-
} catch (error) {
|
|
1394
|
-
console.error('[AUTH] Error in signInWithAppleIdToken:', error);
|
|
1395
|
-
throw handleFirebaseError(error);
|
|
1396
|
-
}
|
|
1397
|
-
}
|
|
1398
|
-
|
|
1399
|
-
/**
|
|
1400
|
-
* Link an Apple account to the current user (anonymous → full account upgrade).
|
|
1401
|
-
* Mirrors linkGoogleAccount.
|
|
1402
|
-
*/
|
|
1403
|
-
async linkAppleAccount(idToken: string, rawNonce: string): Promise<User> {
|
|
1404
|
-
try {
|
|
1405
|
-
console.log('[AUTH] Linking Apple account with ID Token');
|
|
1406
|
-
const currentUser = this.auth.currentUser;
|
|
1407
|
-
if (!currentUser) {
|
|
1408
|
-
throw AUTH_ERRORS.NOT_AUTHENTICATED;
|
|
1409
|
-
}
|
|
1410
|
-
|
|
1411
|
-
const wasAnonymous = currentUser.isAnonymous;
|
|
1412
|
-
console.log(`[AUTH] Current user is ${wasAnonymous ? 'anonymous' : 'not anonymous'}`);
|
|
1413
|
-
|
|
1414
|
-
const provider = new OAuthProvider('apple.com');
|
|
1415
|
-
const credential = provider.credential({ idToken, rawNonce });
|
|
1416
|
-
const userCredential = await linkWithCredential(currentUser, credential);
|
|
1417
|
-
const linkedFirebaseUser = userCredential.user;
|
|
1418
|
-
console.log('[AUTH] Apple account linked successfully to user:', linkedFirebaseUser.uid);
|
|
1419
|
-
|
|
1420
|
-
if (wasAnonymous) {
|
|
1421
|
-
console.log('[AUTH] Upgrading anonymous user profile');
|
|
1422
|
-
const email = linkedFirebaseUser.email || '';
|
|
1423
|
-
return await this.userService.upgradeAnonymousUser(
|
|
1424
|
-
linkedFirebaseUser.uid,
|
|
1425
|
-
email,
|
|
1426
|
-
);
|
|
1427
|
-
}
|
|
1428
|
-
|
|
1429
|
-
return (await this.userService.getUserById(linkedFirebaseUser.uid))!;
|
|
1430
|
-
} catch (error) {
|
|
1431
|
-
console.error('[AUTH] Error in linkAppleAccount:', error);
|
|
1432
|
-
throw handleFirebaseError(error);
|
|
1433
|
-
}
|
|
1434
|
-
}
|
|
1435
|
-
}
|
|
1
|
+
import {
|
|
2
|
+
Auth,
|
|
3
|
+
User as FirebaseUser,
|
|
4
|
+
signInWithEmailAndPassword,
|
|
5
|
+
createUserWithEmailAndPassword,
|
|
6
|
+
signInAnonymously as firebaseSignInAnonymously,
|
|
7
|
+
signOut as firebaseSignOut,
|
|
8
|
+
GoogleAuthProvider,
|
|
9
|
+
signInWithPopup,
|
|
10
|
+
signInWithRedirect,
|
|
11
|
+
getRedirectResult,
|
|
12
|
+
linkWithCredential,
|
|
13
|
+
EmailAuthProvider,
|
|
14
|
+
onAuthStateChanged,
|
|
15
|
+
sendPasswordResetEmail,
|
|
16
|
+
verifyPasswordResetCode,
|
|
17
|
+
confirmPasswordReset,
|
|
18
|
+
fetchSignInMethodsForEmail,
|
|
19
|
+
signInWithCredential,
|
|
20
|
+
OAuthProvider,
|
|
21
|
+
} from 'firebase/auth';
|
|
22
|
+
import {
|
|
23
|
+
getFirestore,
|
|
24
|
+
collection,
|
|
25
|
+
doc,
|
|
26
|
+
getDoc,
|
|
27
|
+
setDoc,
|
|
28
|
+
updateDoc,
|
|
29
|
+
deleteDoc,
|
|
30
|
+
query,
|
|
31
|
+
where,
|
|
32
|
+
getDocs,
|
|
33
|
+
orderBy,
|
|
34
|
+
limit,
|
|
35
|
+
startAfter,
|
|
36
|
+
Timestamp,
|
|
37
|
+
runTransaction,
|
|
38
|
+
Firestore,
|
|
39
|
+
} from 'firebase/firestore';
|
|
40
|
+
import { FirebaseApp } from 'firebase/app';
|
|
41
|
+
import { User, UserRole, USERS_COLLECTION } from '../../types';
|
|
42
|
+
import { z } from 'zod';
|
|
43
|
+
import { emailSchema, passwordSchema, userRoleSchema } from '../../validations/schemas';
|
|
44
|
+
import { AuthError, AUTH_ERRORS } from '../../errors/auth.errors';
|
|
45
|
+
import { FirebaseErrorCode } from '../../errors/firebase.errors';
|
|
46
|
+
import { FirebaseError } from '../../errors/firebase.errors';
|
|
47
|
+
import { BaseService } from '../base.service';
|
|
48
|
+
import { UserService } from '../user/user.service';
|
|
49
|
+
import {
|
|
50
|
+
ClinicGroup,
|
|
51
|
+
AdminToken,
|
|
52
|
+
AdminTokenStatus,
|
|
53
|
+
CreateClinicGroupData,
|
|
54
|
+
CreateClinicAdminData,
|
|
55
|
+
ContactPerson,
|
|
56
|
+
ClinicAdminSignupData,
|
|
57
|
+
SubscriptionModel,
|
|
58
|
+
CLINIC_GROUPS_COLLECTION,
|
|
59
|
+
ClinicAdmin,
|
|
60
|
+
} from '../../types/clinic';
|
|
61
|
+
import { clinicAdminSignupSchema } from '../../validations/clinic.schema';
|
|
62
|
+
import { ClinicGroupService } from '../clinic/clinic-group.service';
|
|
63
|
+
import { ClinicAdminService } from '../clinic/clinic-admin.service';
|
|
64
|
+
import { ClinicService } from '../clinic/clinic.service';
|
|
65
|
+
import {
|
|
66
|
+
Practitioner,
|
|
67
|
+
CreatePractitionerData,
|
|
68
|
+
PractitionerStatus,
|
|
69
|
+
PractitionerBasicInfo,
|
|
70
|
+
PractitionerCertification,
|
|
71
|
+
} from '../../types/practitioner';
|
|
72
|
+
import { PractitionerService } from '../practitioner/practitioner.service';
|
|
73
|
+
import { practitionerSignupSchema } from '../../validations/practitioner.schema';
|
|
74
|
+
import { CertificationLevel } from '../../backoffice/types/static/certification.types';
|
|
75
|
+
import { MediaService } from '../media/media.service';
|
|
76
|
+
// Import utility functions
|
|
77
|
+
import {
|
|
78
|
+
checkEmailExists,
|
|
79
|
+
cleanupFirebaseUser,
|
|
80
|
+
handleFirebaseError,
|
|
81
|
+
handleSignupError,
|
|
82
|
+
buildPractitionerData,
|
|
83
|
+
validatePractitionerProfileData,
|
|
84
|
+
} from './utils';
|
|
85
|
+
|
|
86
|
+
export class AuthService extends BaseService {
|
|
87
|
+
private googleProvider = new GoogleAuthProvider();
|
|
88
|
+
private userService: UserService;
|
|
89
|
+
|
|
90
|
+
constructor(db: Firestore, auth: Auth, app: FirebaseApp, userService: UserService) {
|
|
91
|
+
super(db, auth, app);
|
|
92
|
+
this.userService = userService || new UserService(db, auth, app);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Waits for Firebase Auth state to settle after sign-in.
|
|
97
|
+
* In React Native with AsyncStorage persistence, auth state may not be immediately available.
|
|
98
|
+
*/
|
|
99
|
+
private async waitForAuthStateToSettle(expectedUid: string, timeoutMs: number = 5000): Promise<void> {
|
|
100
|
+
if (this.auth.currentUser?.uid === expectedUid) {
|
|
101
|
+
await new Promise(resolve => setTimeout(resolve, 200));
|
|
102
|
+
if (this.auth.currentUser?.uid === expectedUid) {
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return new Promise((resolve, reject) => {
|
|
108
|
+
const startTime = Date.now();
|
|
109
|
+
let resolved = false;
|
|
110
|
+
|
|
111
|
+
const unsubscribe = onAuthStateChanged(this.auth, (user) => {
|
|
112
|
+
if (resolved) return;
|
|
113
|
+
|
|
114
|
+
const currentUid = user?.uid || null;
|
|
115
|
+
|
|
116
|
+
if (currentUid === expectedUid) {
|
|
117
|
+
setTimeout(() => {
|
|
118
|
+
if (resolved) return;
|
|
119
|
+
|
|
120
|
+
if (this.auth.currentUser?.uid === expectedUid) {
|
|
121
|
+
resolved = true;
|
|
122
|
+
unsubscribe();
|
|
123
|
+
clearTimeout(timeout);
|
|
124
|
+
resolve();
|
|
125
|
+
}
|
|
126
|
+
}, 300);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const timeout = setTimeout(() => {
|
|
131
|
+
if (resolved) return;
|
|
132
|
+
resolved = true;
|
|
133
|
+
unsubscribe();
|
|
134
|
+
reject(new Error(`Timeout waiting for auth state to settle. Expected: ${expectedUid}, Got: ${this.auth.currentUser?.uid || 'NULL'}`));
|
|
135
|
+
}, timeoutMs);
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Registruje novog korisnika sa email-om i lozinkom
|
|
141
|
+
*/
|
|
142
|
+
async signUp(
|
|
143
|
+
email: string,
|
|
144
|
+
password: string,
|
|
145
|
+
initialRole: UserRole = UserRole.PATIENT,
|
|
146
|
+
options?: {
|
|
147
|
+
patientInviteToken?: string;
|
|
148
|
+
},
|
|
149
|
+
): Promise<User> {
|
|
150
|
+
const { user: firebaseUser } = await createUserWithEmailAndPassword(this.auth, email, password);
|
|
151
|
+
|
|
152
|
+
return this.userService.createUser(firebaseUser, [initialRole], options);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Registers a new clinic admin user with email and password
|
|
157
|
+
* Can either create a new clinic group or join an existing one with a token
|
|
158
|
+
*
|
|
159
|
+
* @param data - Clinic admin signup data
|
|
160
|
+
* @returns Object containing the created user, clinic group, and clinic admin
|
|
161
|
+
*/
|
|
162
|
+
async signUpClinicAdmin(data: ClinicAdminSignupData): Promise<{
|
|
163
|
+
user: User;
|
|
164
|
+
clinicGroup: ClinicGroup;
|
|
165
|
+
clinicAdmin: ClinicAdmin;
|
|
166
|
+
}> {
|
|
167
|
+
try {
|
|
168
|
+
console.log('[AUTH] Starting clinic admin signup process', {
|
|
169
|
+
email: data.email,
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// Validate data
|
|
173
|
+
try {
|
|
174
|
+
await clinicAdminSignupSchema.parseAsync(data);
|
|
175
|
+
console.log('[AUTH] Clinic admin signup data validation passed');
|
|
176
|
+
} catch (validationError) {
|
|
177
|
+
console.error('[AUTH] Validation error in signUpClinicAdmin:', validationError);
|
|
178
|
+
throw validationError;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Create Firebase user
|
|
182
|
+
console.log('[AUTH] Creating Firebase user');
|
|
183
|
+
let firebaseUser;
|
|
184
|
+
try {
|
|
185
|
+
const result = await createUserWithEmailAndPassword(this.auth, data.email, data.password);
|
|
186
|
+
firebaseUser = result.user;
|
|
187
|
+
console.log('[AUTH] Firebase user created successfully', {
|
|
188
|
+
uid: firebaseUser.uid,
|
|
189
|
+
});
|
|
190
|
+
} catch (firebaseError) {
|
|
191
|
+
console.error('[AUTH] Firebase user creation failed:', firebaseError);
|
|
192
|
+
throw handleFirebaseError(firebaseError);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Create user with CLINIC_ADMIN role
|
|
196
|
+
console.log('[AUTH] Creating user with CLINIC_ADMIN role');
|
|
197
|
+
let user;
|
|
198
|
+
try {
|
|
199
|
+
user = await this.userService.createUser(firebaseUser, [UserRole.CLINIC_ADMIN], {
|
|
200
|
+
skipProfileCreation: true,
|
|
201
|
+
});
|
|
202
|
+
console.log('[AUTH] User with CLINIC_ADMIN role created successfully', {
|
|
203
|
+
userId: user.uid,
|
|
204
|
+
});
|
|
205
|
+
} catch (userCreationError) {
|
|
206
|
+
console.error('[AUTH] User creation failed:', userCreationError);
|
|
207
|
+
throw userCreationError;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Create contact person object
|
|
211
|
+
const contactPerson: ContactPerson = {
|
|
212
|
+
firstName: data.firstName,
|
|
213
|
+
lastName: data.lastName,
|
|
214
|
+
title: data.title,
|
|
215
|
+
email: data.email,
|
|
216
|
+
phoneNumber: data.phoneNumber,
|
|
217
|
+
};
|
|
218
|
+
console.log('[AUTH] Contact person object created');
|
|
219
|
+
|
|
220
|
+
// Initialize services
|
|
221
|
+
console.log('[AUTH] Initializing clinic services');
|
|
222
|
+
const clinicAdminService = new ClinicAdminService(this.db, this.auth, this.app);
|
|
223
|
+
const clinicGroupService = new ClinicGroupService(
|
|
224
|
+
this.db,
|
|
225
|
+
this.auth,
|
|
226
|
+
this.app,
|
|
227
|
+
clinicAdminService,
|
|
228
|
+
);
|
|
229
|
+
const mediaService = new MediaService(this.db, this.auth, this.app);
|
|
230
|
+
const clinicService = new ClinicService(
|
|
231
|
+
this.db,
|
|
232
|
+
this.auth,
|
|
233
|
+
this.app,
|
|
234
|
+
clinicGroupService,
|
|
235
|
+
clinicAdminService,
|
|
236
|
+
mediaService,
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
// Set services to resolve circular dependencies
|
|
240
|
+
clinicAdminService.setServices(clinicGroupService, clinicService);
|
|
241
|
+
console.log('[AUTH] Services initialized and circular dependencies resolved');
|
|
242
|
+
|
|
243
|
+
let clinicGroup: ClinicGroup | null = null;
|
|
244
|
+
let adminProfile: ClinicAdmin | null = null;
|
|
245
|
+
|
|
246
|
+
if (data.isCreatingNewGroup) {
|
|
247
|
+
console.log('[AUTH] Creating new clinic group flow');
|
|
248
|
+
// Create new clinic group
|
|
249
|
+
if (!data.clinicGroupData) {
|
|
250
|
+
console.error('[AUTH] Clinic group data is missing');
|
|
251
|
+
throw new Error('Clinic group data is required when creating a new group');
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// First create the clinic admin without a group
|
|
255
|
+
console.log('[AUTH] Creating clinic admin first (without group)');
|
|
256
|
+
const createClinicAdminData: CreateClinicAdminData = {
|
|
257
|
+
userRef: firebaseUser.uid,
|
|
258
|
+
isGroupOwner: true,
|
|
259
|
+
clinicsManaged: [],
|
|
260
|
+
contactInfo: contactPerson,
|
|
261
|
+
roleTitle: data.title,
|
|
262
|
+
isActive: true,
|
|
263
|
+
// No clinicGroupId yet
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
try {
|
|
267
|
+
adminProfile = await clinicAdminService.createClinicAdmin(createClinicAdminData);
|
|
268
|
+
console.log('[AUTH] Clinic admin created successfully', {
|
|
269
|
+
adminId: adminProfile.id,
|
|
270
|
+
});
|
|
271
|
+
} catch (adminCreationError) {
|
|
272
|
+
console.error('[AUTH] Clinic admin creation failed:', adminCreationError);
|
|
273
|
+
throw adminCreationError;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Update user document with admin profile reference
|
|
277
|
+
try {
|
|
278
|
+
console.log('[AUTH] Updating user with admin profile reference');
|
|
279
|
+
user = await this.userService.updateUser(firebaseUser.uid, {
|
|
280
|
+
adminProfile: adminProfile.id,
|
|
281
|
+
});
|
|
282
|
+
console.log('[AUTH] User updated with admin profile reference successfully');
|
|
283
|
+
} catch (userUpdateError) {
|
|
284
|
+
console.error('[AUTH] Failed to update user with admin profile:', userUpdateError);
|
|
285
|
+
throw userUpdateError;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Then create clinic group
|
|
289
|
+
const createClinicGroupData: CreateClinicGroupData = {
|
|
290
|
+
name: data.clinicGroupData.name,
|
|
291
|
+
hqLocation: data.clinicGroupData.hqLocation,
|
|
292
|
+
contactInfo: data.clinicGroupData.contactInfo,
|
|
293
|
+
contactPerson: contactPerson,
|
|
294
|
+
ownerId: adminProfile.id, // Use admin profile ID, not user UID
|
|
295
|
+
isActive: true,
|
|
296
|
+
logo: data.clinicGroupData.logo || null,
|
|
297
|
+
subscriptionModel:
|
|
298
|
+
data.clinicGroupData.subscriptionModel || SubscriptionModel.NO_SUBSCRIPTION,
|
|
299
|
+
onboarding: {
|
|
300
|
+
completed: false,
|
|
301
|
+
step: 1,
|
|
302
|
+
},
|
|
303
|
+
};
|
|
304
|
+
console.log('[AUTH] Clinic group data prepared', {
|
|
305
|
+
groupName: createClinicGroupData.name,
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
// Create clinic group
|
|
309
|
+
try {
|
|
310
|
+
clinicGroup = await clinicGroupService.createClinicGroup(
|
|
311
|
+
createClinicGroupData,
|
|
312
|
+
adminProfile.id, // Use admin profile ID, not user UID
|
|
313
|
+
false, // This is not a default group since we're providing complete data
|
|
314
|
+
);
|
|
315
|
+
console.log('[AUTH] Clinic group created successfully', {
|
|
316
|
+
groupId: clinicGroup.id,
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
// Now update the admin with the group ID
|
|
320
|
+
console.log('[AUTH] Updating admin with clinic group ID');
|
|
321
|
+
await clinicAdminService.updateClinicAdmin(adminProfile.id, {
|
|
322
|
+
// Use admin profile ID, not user UID
|
|
323
|
+
clinicGroupId: clinicGroup.id,
|
|
324
|
+
});
|
|
325
|
+
console.log('[AUTH] Admin updated with clinic group ID successfully');
|
|
326
|
+
|
|
327
|
+
// Get the updated admin profile
|
|
328
|
+
adminProfile = await clinicAdminService.getClinicAdmin(adminProfile.id);
|
|
329
|
+
} catch (groupCreationError) {
|
|
330
|
+
console.error('[AUTH] Clinic group creation failed:', groupCreationError);
|
|
331
|
+
throw groupCreationError;
|
|
332
|
+
}
|
|
333
|
+
} else {
|
|
334
|
+
console.log('[AUTH] Joining existing clinic group flow');
|
|
335
|
+
// Join existing clinic group with token
|
|
336
|
+
if (!data.inviteToken) {
|
|
337
|
+
console.error('[AUTH] Invite token is missing');
|
|
338
|
+
throw new Error('Invite token is required when joining an existing group');
|
|
339
|
+
}
|
|
340
|
+
console.log('[AUTH] Invite token provided', {
|
|
341
|
+
token: data.inviteToken,
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
// Find the token in the database
|
|
345
|
+
console.log('[AUTH] Searching for token in clinic groups');
|
|
346
|
+
const groupsRef = collection(this.db, CLINIC_GROUPS_COLLECTION);
|
|
347
|
+
const q = query(groupsRef);
|
|
348
|
+
const querySnapshot = await getDocs(q);
|
|
349
|
+
|
|
350
|
+
let foundGroup: ClinicGroup | null = null;
|
|
351
|
+
let foundToken: AdminToken | null = null;
|
|
352
|
+
|
|
353
|
+
console.log('[AUTH] Found', querySnapshot.size, 'clinic groups to check');
|
|
354
|
+
for (const docSnapshot of querySnapshot.docs) {
|
|
355
|
+
const group = docSnapshot.data() as ClinicGroup;
|
|
356
|
+
console.log('[AUTH] Checking group', {
|
|
357
|
+
groupId: group.id,
|
|
358
|
+
groupName: group.name,
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
// Find the token in the group's tokens
|
|
362
|
+
const token = group.adminTokens.find(t => {
|
|
363
|
+
const isMatch =
|
|
364
|
+
t.token === data.inviteToken &&
|
|
365
|
+
t.status === AdminTokenStatus.ACTIVE &&
|
|
366
|
+
new Date(t.expiresAt.toDate()) > new Date();
|
|
367
|
+
|
|
368
|
+
console.log('[AUTH] Checking token', {
|
|
369
|
+
tokenId: t.id,
|
|
370
|
+
tokenMatch: t.token === data.inviteToken,
|
|
371
|
+
tokenStatus: t.status,
|
|
372
|
+
tokenActive: t.status === AdminTokenStatus.ACTIVE,
|
|
373
|
+
tokenExpiry: new Date(t.expiresAt.toDate()),
|
|
374
|
+
tokenExpired: new Date(t.expiresAt.toDate()) <= new Date(),
|
|
375
|
+
isMatch,
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
return isMatch;
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
if (token) {
|
|
382
|
+
foundGroup = group;
|
|
383
|
+
foundToken = token;
|
|
384
|
+
console.log('[AUTH] Found matching token in group', {
|
|
385
|
+
groupId: group.id,
|
|
386
|
+
tokenId: token.id,
|
|
387
|
+
});
|
|
388
|
+
break;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
if (!foundGroup || !foundToken) {
|
|
393
|
+
console.error('[AUTH] No valid token found in any clinic group');
|
|
394
|
+
throw new Error('Invalid or expired invite token');
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
clinicGroup = foundGroup;
|
|
398
|
+
|
|
399
|
+
// Create clinic admin
|
|
400
|
+
console.log('[AUTH] Creating clinic admin');
|
|
401
|
+
const createClinicAdminData: CreateClinicAdminData = {
|
|
402
|
+
userRef: firebaseUser.uid,
|
|
403
|
+
clinicGroupId: foundGroup.id,
|
|
404
|
+
isGroupOwner: false,
|
|
405
|
+
clinicsManaged: [],
|
|
406
|
+
contactInfo: contactPerson,
|
|
407
|
+
roleTitle: data.title,
|
|
408
|
+
isActive: true,
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
try {
|
|
412
|
+
adminProfile = await clinicAdminService.createClinicAdmin(createClinicAdminData);
|
|
413
|
+
console.log('[AUTH] Clinic admin created successfully', {
|
|
414
|
+
adminId: adminProfile.id,
|
|
415
|
+
});
|
|
416
|
+
} catch (adminCreationError) {
|
|
417
|
+
console.error('[AUTH] Clinic admin creation failed:', adminCreationError);
|
|
418
|
+
throw adminCreationError;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Mark token as used
|
|
422
|
+
try {
|
|
423
|
+
await clinicGroupService.verifyAndUseAdminToken(
|
|
424
|
+
foundGroup.id,
|
|
425
|
+
data.inviteToken,
|
|
426
|
+
firebaseUser.uid,
|
|
427
|
+
);
|
|
428
|
+
console.log('[AUTH] Token marked as used successfully');
|
|
429
|
+
} catch (tokenUseError) {
|
|
430
|
+
console.error('[AUTH] Failed to mark token as used:', tokenUseError);
|
|
431
|
+
throw tokenUseError;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
console.log('[AUTH] Clinic admin signup completed successfully', {
|
|
436
|
+
userId: user.uid,
|
|
437
|
+
clinicGroupId: clinicGroup.id,
|
|
438
|
+
clinicAdminId: adminProfile?.id || 'unknown',
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
// Ensure we have all required data before returning
|
|
442
|
+
if (!clinicGroup || !adminProfile) {
|
|
443
|
+
throw new Error('Failed to create or retrieve clinic group or admin profile');
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
return {
|
|
447
|
+
user,
|
|
448
|
+
clinicGroup,
|
|
449
|
+
clinicAdmin: adminProfile,
|
|
450
|
+
};
|
|
451
|
+
} catch (error) {
|
|
452
|
+
if (error instanceof z.ZodError) {
|
|
453
|
+
console.error(
|
|
454
|
+
'[AUTH] Zod validation error in signUpClinicAdmin:',
|
|
455
|
+
JSON.stringify(error.errors, null, 2),
|
|
456
|
+
);
|
|
457
|
+
throw AUTH_ERRORS.VALIDATION_ERROR;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const firebaseError = error as FirebaseError;
|
|
461
|
+
if (firebaseError.code === FirebaseErrorCode.EMAIL_ALREADY_IN_USE) {
|
|
462
|
+
console.error('[AUTH] Email already in use:', data.email);
|
|
463
|
+
throw AUTH_ERRORS.EMAIL_ALREADY_EXISTS;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
console.error('[AUTH] Unhandled error in signUpClinicAdmin:', error);
|
|
467
|
+
throw error;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Prijavljuje korisnika sa email-om i lozinkom
|
|
473
|
+
*/
|
|
474
|
+
async signIn(email: string, password: string): Promise<User> {
|
|
475
|
+
const { user: firebaseUser } = await signInWithEmailAndPassword(this.auth, email, password);
|
|
476
|
+
|
|
477
|
+
return this.userService.getOrCreateUser(firebaseUser);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Prijavljuje korisnika sa email-om i lozinkom samo za clinic_admin role
|
|
482
|
+
* @param email - Email korisnika
|
|
483
|
+
* @param password - Lozinka korisnika
|
|
484
|
+
* @returns Objekat koji sadrži korisnika, admin profil i grupu klinika
|
|
485
|
+
* @throws {AUTH_ERRORS.INVALID_ROLE} Ako korisnik nema clinic_admin rolu
|
|
486
|
+
* @throws {AUTH_ERRORS.NOT_FOUND} Ako admin profil nije pronađen
|
|
487
|
+
*/
|
|
488
|
+
async signInClinicAdmin(
|
|
489
|
+
email: string,
|
|
490
|
+
password: string,
|
|
491
|
+
): Promise<{
|
|
492
|
+
user: User;
|
|
493
|
+
clinicAdmin: ClinicAdmin;
|
|
494
|
+
clinicGroup: ClinicGroup;
|
|
495
|
+
}> {
|
|
496
|
+
try {
|
|
497
|
+
// Initialize required services
|
|
498
|
+
const clinicAdminService = new ClinicAdminService(this.db, this.auth, this.app);
|
|
499
|
+
const clinicGroupService = new ClinicGroupService(
|
|
500
|
+
this.db,
|
|
501
|
+
this.auth,
|
|
502
|
+
this.app,
|
|
503
|
+
clinicAdminService,
|
|
504
|
+
);
|
|
505
|
+
const mediaService = new MediaService(this.db, this.auth, this.app);
|
|
506
|
+
const clinicService = new ClinicService(
|
|
507
|
+
this.db,
|
|
508
|
+
this.auth,
|
|
509
|
+
this.app,
|
|
510
|
+
clinicGroupService,
|
|
511
|
+
clinicAdminService,
|
|
512
|
+
mediaService,
|
|
513
|
+
);
|
|
514
|
+
|
|
515
|
+
// Set services to resolve circular dependencies
|
|
516
|
+
clinicAdminService.setServices(clinicGroupService, clinicService);
|
|
517
|
+
|
|
518
|
+
// Sign in with email/password
|
|
519
|
+
const { user: firebaseUser } = await signInWithEmailAndPassword(this.auth, email, password);
|
|
520
|
+
|
|
521
|
+
// Get or create user
|
|
522
|
+
const user = await this.userService.getOrCreateUser(firebaseUser);
|
|
523
|
+
|
|
524
|
+
// Check if user has clinic_admin role
|
|
525
|
+
if (!user.roles?.includes(UserRole.CLINIC_ADMIN)) {
|
|
526
|
+
console.error('[AUTH] User is not a clinic admin:', user.uid);
|
|
527
|
+
// Sign out the user immediately for security
|
|
528
|
+
await this.auth.signOut();
|
|
529
|
+
throw AUTH_ERRORS.UNAUTHORIZED_ROLE;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// Check and get admin profile
|
|
533
|
+
if (!user.adminProfile) {
|
|
534
|
+
console.error('[AUTH] User has no admin profile:', user.uid);
|
|
535
|
+
throw AUTH_ERRORS.NOT_FOUND;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// Get clinic admin profile
|
|
539
|
+
const adminProfile = await clinicAdminService.getClinicAdmin(user.adminProfile);
|
|
540
|
+
if (!adminProfile) {
|
|
541
|
+
console.error('[AUTH] Admin profile not found:', user.adminProfile);
|
|
542
|
+
throw AUTH_ERRORS.NOT_FOUND;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// Get clinic group
|
|
546
|
+
const clinicGroup = await clinicGroupService.getClinicGroup(adminProfile.clinicGroupId);
|
|
547
|
+
if (!clinicGroup) {
|
|
548
|
+
console.error('[AUTH] Clinic group not found:', adminProfile.clinicGroupId);
|
|
549
|
+
throw AUTH_ERRORS.NOT_FOUND;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
return {
|
|
553
|
+
user,
|
|
554
|
+
clinicAdmin: adminProfile,
|
|
555
|
+
clinicGroup,
|
|
556
|
+
};
|
|
557
|
+
} catch (error) {
|
|
558
|
+
console.error('[AUTH] Error in signInClinicAdmin:', error);
|
|
559
|
+
throw error;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* Prijavljuje korisnika anonimno
|
|
565
|
+
*/
|
|
566
|
+
async signInAnonymously(options?: { skipProfileCreation?: boolean }): Promise<User> {
|
|
567
|
+
const { user: firebaseUser } = await firebaseSignInAnonymously(this.auth);
|
|
568
|
+
|
|
569
|
+
if (options?.skipProfileCreation) {
|
|
570
|
+
return this.userService.getOrCreateUser(firebaseUser, UserRole.PATIENT, { skipProfileCreation: true });
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
return this.userService.getOrCreateUser(firebaseUser);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* Odjavljuje trenutnog korisnika
|
|
578
|
+
*/
|
|
579
|
+
async signOut(): Promise<void> {
|
|
580
|
+
await firebaseSignOut(this.auth);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Vraća trenutno prijavljenog korisnika
|
|
585
|
+
*/
|
|
586
|
+
async getCurrentUser(): Promise<User | null> {
|
|
587
|
+
const firebaseUser = this.auth.currentUser;
|
|
588
|
+
if (!firebaseUser) return null;
|
|
589
|
+
|
|
590
|
+
return this.userService.getUserById(firebaseUser.uid);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Registruje callback za promene stanja autentifikacije
|
|
595
|
+
*/
|
|
596
|
+
onAuthStateChange(callback: (user: FirebaseUser | null) => void): () => void {
|
|
597
|
+
return onAuthStateChanged(this.auth, callback);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
async upgradeAnonymousUser(email: string, password: string): Promise<User> {
|
|
601
|
+
try {
|
|
602
|
+
await emailSchema.parseAsync(email);
|
|
603
|
+
await passwordSchema.parseAsync(password);
|
|
604
|
+
|
|
605
|
+
const currentUser = this.auth.currentUser;
|
|
606
|
+
if (!currentUser) {
|
|
607
|
+
throw AUTH_ERRORS.NOT_AUTHENTICATED;
|
|
608
|
+
}
|
|
609
|
+
if (!currentUser.isAnonymous) {
|
|
610
|
+
throw new AuthError('User is not anonymous', 'AUTH/NOT_ANONYMOUS_USER', 400);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
const credential = EmailAuthProvider.credential(email, password);
|
|
614
|
+
await linkWithCredential(currentUser, credential);
|
|
615
|
+
|
|
616
|
+
return await this.userService.upgradeAnonymousUser(currentUser.uid, email);
|
|
617
|
+
} catch (error) {
|
|
618
|
+
if (error instanceof z.ZodError) {
|
|
619
|
+
throw AUTH_ERRORS.VALIDATION_ERROR;
|
|
620
|
+
}
|
|
621
|
+
const firebaseError = error as FirebaseError;
|
|
622
|
+
if (firebaseError.code === FirebaseErrorCode.EMAIL_ALREADY_IN_USE) {
|
|
623
|
+
throw AUTH_ERRORS.EMAIL_ALREADY_EXISTS;
|
|
624
|
+
}
|
|
625
|
+
throw error;
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* Šalje email za resetovanje lozinke korisniku
|
|
631
|
+
* @param email Email adresa korisnika
|
|
632
|
+
* @returns Promise koji se razrešava kada je email poslat
|
|
633
|
+
*/
|
|
634
|
+
async sendPasswordResetEmail(email: string): Promise<void> {
|
|
635
|
+
try {
|
|
636
|
+
await emailSchema.parseAsync(email);
|
|
637
|
+
|
|
638
|
+
// Šaljemo email za resetovanje lozinke
|
|
639
|
+
await sendPasswordResetEmail(this.auth, email);
|
|
640
|
+
} catch (error) {
|
|
641
|
+
if (error instanceof z.ZodError) {
|
|
642
|
+
throw AUTH_ERRORS.VALIDATION_ERROR;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
const firebaseError = error as FirebaseError;
|
|
646
|
+
|
|
647
|
+
// Handle specific Firebase errors
|
|
648
|
+
switch (firebaseError.code) {
|
|
649
|
+
case FirebaseErrorCode.USER_NOT_FOUND:
|
|
650
|
+
throw AUTH_ERRORS.USER_NOT_FOUND;
|
|
651
|
+
case FirebaseErrorCode.INVALID_EMAIL:
|
|
652
|
+
throw AUTH_ERRORS.INVALID_EMAIL;
|
|
653
|
+
case FirebaseErrorCode.TOO_MANY_REQUESTS:
|
|
654
|
+
throw AUTH_ERRORS.TOO_MANY_REQUESTS;
|
|
655
|
+
case FirebaseErrorCode.NETWORK_ERROR:
|
|
656
|
+
throw AUTH_ERRORS.NETWORK_ERROR;
|
|
657
|
+
case FirebaseErrorCode.OPERATION_NOT_ALLOWED:
|
|
658
|
+
throw AUTH_ERRORS.OPERATION_NOT_ALLOWED;
|
|
659
|
+
default:
|
|
660
|
+
// Re-throw unknown errors as-is
|
|
661
|
+
throw error;
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* Verifikuje kod za resetovanje lozinke iz email linka
|
|
668
|
+
* @param oobCode Kod iz URL-a za resetovanje lozinke
|
|
669
|
+
* @returns Promise koji se razrešava sa email adresom korisnika ako je kod validan
|
|
670
|
+
*/
|
|
671
|
+
async verifyPasswordResetCode(oobCode: string): Promise<string> {
|
|
672
|
+
try {
|
|
673
|
+
// Verifikujemo kod i vraćamo email korisnika
|
|
674
|
+
return await verifyPasswordResetCode(this.auth, oobCode);
|
|
675
|
+
} catch (error) {
|
|
676
|
+
const firebaseError = error as FirebaseError;
|
|
677
|
+
if (firebaseError.code === FirebaseErrorCode.EXPIRED_ACTION_CODE) {
|
|
678
|
+
throw AUTH_ERRORS.EXPIRED_ACTION_CODE;
|
|
679
|
+
} else if (firebaseError.code === FirebaseErrorCode.INVALID_ACTION_CODE) {
|
|
680
|
+
throw AUTH_ERRORS.INVALID_ACTION_CODE;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
throw error;
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* Potvrđuje resetovanje lozinke i postavlja novu lozinku
|
|
689
|
+
* @param oobCode Kod iz URL-a za resetovanje lozinke
|
|
690
|
+
* @param newPassword Nova lozinka
|
|
691
|
+
* @returns Promise koji se razrešava kada je lozinka promenjena
|
|
692
|
+
*/
|
|
693
|
+
async confirmPasswordReset(oobCode: string, newPassword: string): Promise<void> {
|
|
694
|
+
try {
|
|
695
|
+
await passwordSchema.parseAsync(newPassword);
|
|
696
|
+
|
|
697
|
+
// Potvrđujemo resetovanje lozinke i postavljamo novu lozinku
|
|
698
|
+
await confirmPasswordReset(this.auth, oobCode, newPassword);
|
|
699
|
+
} catch (error) {
|
|
700
|
+
if (error instanceof z.ZodError) {
|
|
701
|
+
throw AUTH_ERRORS.VALIDATION_ERROR;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
const firebaseError = error as FirebaseError;
|
|
705
|
+
if (firebaseError.code === FirebaseErrorCode.EXPIRED_ACTION_CODE) {
|
|
706
|
+
throw AUTH_ERRORS.EXPIRED_ACTION_CODE;
|
|
707
|
+
} else if (firebaseError.code === FirebaseErrorCode.INVALID_ACTION_CODE) {
|
|
708
|
+
throw AUTH_ERRORS.INVALID_ACTION_CODE;
|
|
709
|
+
} else if (firebaseError.code === FirebaseErrorCode.WEAK_PASSWORD) {
|
|
710
|
+
throw AUTH_ERRORS.WEAK_PASSWORD;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
throw error;
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/**
|
|
718
|
+
* Registers a new practitioner user with email and password (ATOMIC VERSION)
|
|
719
|
+
* Uses Firestore transactions to ensure atomicity and proper rollback on failures
|
|
720
|
+
*
|
|
721
|
+
* @param data - Practitioner signup data containing either new profile details or token for claiming draft profile
|
|
722
|
+
* @returns Object containing the created user and practitioner profile
|
|
723
|
+
*/
|
|
724
|
+
async signUpPractitioner(data: {
|
|
725
|
+
email: string;
|
|
726
|
+
password: string;
|
|
727
|
+
firstName?: string;
|
|
728
|
+
lastName?: string;
|
|
729
|
+
token?: string;
|
|
730
|
+
profileData?: Partial<CreatePractitionerData>;
|
|
731
|
+
}): Promise<{
|
|
732
|
+
user: User;
|
|
733
|
+
practitioner: Practitioner;
|
|
734
|
+
}> {
|
|
735
|
+
let firebaseUser: any = null;
|
|
736
|
+
|
|
737
|
+
try {
|
|
738
|
+
console.log('[AUTH] Starting atomic practitioner signup process', {
|
|
739
|
+
email: data.email,
|
|
740
|
+
hasToken: !!data.token,
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
// Step 1: Pre-validate all data before any mutations
|
|
744
|
+
await this.validateSignupData(data);
|
|
745
|
+
|
|
746
|
+
// Step 2: Create Firebase user (outside transaction - can't be easily rolled back)
|
|
747
|
+
console.log('[AUTH] Creating Firebase user');
|
|
748
|
+
try {
|
|
749
|
+
const result = await createUserWithEmailAndPassword(this.auth, data.email, data.password);
|
|
750
|
+
firebaseUser = result.user;
|
|
751
|
+
console.log('[AUTH] Firebase user created successfully', {
|
|
752
|
+
uid: firebaseUser.uid,
|
|
753
|
+
});
|
|
754
|
+
} catch (firebaseError) {
|
|
755
|
+
console.error('[AUTH] Firebase user creation failed:', firebaseError);
|
|
756
|
+
throw handleFirebaseError(firebaseError);
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// Step 3: Execute all database operations in a single transaction
|
|
760
|
+
console.log('[AUTH] Starting Firestore transaction');
|
|
761
|
+
const transactionResult = await runTransaction(this.db, async transaction => {
|
|
762
|
+
console.log('[AUTH] Transaction started - creating user and practitioner');
|
|
763
|
+
|
|
764
|
+
// Initialize services
|
|
765
|
+
const practitionerService = new PractitionerService(this.db, this.auth, this.app);
|
|
766
|
+
|
|
767
|
+
// Create user document using existing method (not in transaction for now)
|
|
768
|
+
console.log('[AUTH] Creating user document');
|
|
769
|
+
const user = await this.userService.createUser(firebaseUser, [UserRole.PRACTITIONER], {
|
|
770
|
+
skipProfileCreation: true,
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
let practitioner: Practitioner;
|
|
774
|
+
|
|
775
|
+
// Handle practitioner profile creation/claiming
|
|
776
|
+
if (data.token) {
|
|
777
|
+
console.log('[AUTH] Claiming existing practitioner profile with token');
|
|
778
|
+
const claimedPractitioner = await practitionerService.validateTokenAndClaimProfile(
|
|
779
|
+
data.token,
|
|
780
|
+
firebaseUser.uid,
|
|
781
|
+
);
|
|
782
|
+
if (!claimedPractitioner) {
|
|
783
|
+
throw new Error('Invalid or expired invitation token');
|
|
784
|
+
}
|
|
785
|
+
practitioner = claimedPractitioner;
|
|
786
|
+
} else {
|
|
787
|
+
// Check if a draft profile exists for this email
|
|
788
|
+
console.log('[AUTH] Checking for existing draft practitioner profile', {
|
|
789
|
+
email: data.email,
|
|
790
|
+
});
|
|
791
|
+
const draftPractitioner = await practitionerService.findDraftPractitionerByEmail(
|
|
792
|
+
data.email
|
|
793
|
+
);
|
|
794
|
+
|
|
795
|
+
if (draftPractitioner) {
|
|
796
|
+
console.log('[AUTH] Draft practitioner profile found', {
|
|
797
|
+
practitionerId: draftPractitioner.id,
|
|
798
|
+
email: data.email,
|
|
799
|
+
clinics: draftPractitioner.clinics,
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
// Extract clinic names from clinicsInfo (should be populated when draft is created)
|
|
803
|
+
let clinicNames: string[] = [];
|
|
804
|
+
if (draftPractitioner.clinicsInfo && draftPractitioner.clinicsInfo.length > 0) {
|
|
805
|
+
clinicNames = draftPractitioner.clinicsInfo
|
|
806
|
+
.map((clinic) => clinic.name)
|
|
807
|
+
.filter((name): name is string => !!name);
|
|
808
|
+
} else if (draftPractitioner.clinics && draftPractitioner.clinics.length > 0) {
|
|
809
|
+
// Fallback: fetch clinic names if clinicsInfo is missing
|
|
810
|
+
console.log('[AUTH] clinicsInfo missing, fetching clinic names from clinic IDs');
|
|
811
|
+
const clinicService = practitionerService.getClinicService();
|
|
812
|
+
const clinicNamePromises = draftPractitioner.clinics.map(async (clinicId) => {
|
|
813
|
+
try {
|
|
814
|
+
const clinic = await clinicService.getClinic(clinicId);
|
|
815
|
+
return clinic?.name || null;
|
|
816
|
+
} catch (error) {
|
|
817
|
+
console.error(`[AUTH] Error fetching clinic ${clinicId}:`, error);
|
|
818
|
+
return null;
|
|
819
|
+
}
|
|
820
|
+
});
|
|
821
|
+
const names = await Promise.all(clinicNamePromises);
|
|
822
|
+
clinicNames = names.filter((name): name is string => !!name);
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
// Cleanup Firebase user since we're not creating a profile
|
|
826
|
+
await cleanupFirebaseUser(firebaseUser);
|
|
827
|
+
|
|
828
|
+
// Throw error with clinic information
|
|
829
|
+
throw new AuthError(
|
|
830
|
+
AUTH_ERRORS.DRAFT_PROFILE_EXISTS.message,
|
|
831
|
+
AUTH_ERRORS.DRAFT_PROFILE_EXISTS.code,
|
|
832
|
+
AUTH_ERRORS.DRAFT_PROFILE_EXISTS.status,
|
|
833
|
+
{
|
|
834
|
+
clinicNames,
|
|
835
|
+
clinics: draftPractitioner.clinics,
|
|
836
|
+
clinicsInfo: draftPractitioner.clinicsInfo,
|
|
837
|
+
}
|
|
838
|
+
);
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
console.log('[AUTH] No draft profile found, creating new practitioner profile');
|
|
842
|
+
const practitionerData = buildPractitionerData(data, firebaseUser.uid);
|
|
843
|
+
practitioner = await practitionerService.createPractitioner(practitionerData);
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
// Link practitioner to user
|
|
847
|
+
console.log('[AUTH] Linking practitioner to user');
|
|
848
|
+
await this.userService.updateUser(firebaseUser.uid, {
|
|
849
|
+
practitionerProfile: practitioner.id,
|
|
850
|
+
});
|
|
851
|
+
|
|
852
|
+
console.log('[AUTH] Transaction operations completed successfully');
|
|
853
|
+
return { user, practitioner };
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
console.log('[AUTH] Atomic practitioner signup completed successfully', {
|
|
857
|
+
userId: transactionResult.user.uid,
|
|
858
|
+
practitionerId: transactionResult.practitioner.id,
|
|
859
|
+
});
|
|
860
|
+
|
|
861
|
+
return transactionResult;
|
|
862
|
+
} catch (error) {
|
|
863
|
+
console.error('[AUTH] Atomic signup failed, initiating cleanup...', error);
|
|
864
|
+
|
|
865
|
+
// Cleanup Firebase user if transaction failed
|
|
866
|
+
if (firebaseUser) {
|
|
867
|
+
await cleanupFirebaseUser(firebaseUser);
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
throw handleSignupError(error);
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
/**
|
|
875
|
+
* Claims draft practitioner profiles after Google Sign-In.
|
|
876
|
+
* Uses the current authenticated user (from initial Google Sign-In).
|
|
877
|
+
*
|
|
878
|
+
* @param idToken - The Google ID token (used to re-authenticate if needed)
|
|
879
|
+
* @param practitionerIds - Array of draft practitioner profile IDs to claim
|
|
880
|
+
* @returns Object containing user and claimed practitioner
|
|
881
|
+
*/
|
|
882
|
+
async claimDraftProfilesWithGoogle(
|
|
883
|
+
idToken: string,
|
|
884
|
+
practitionerIds: string[]
|
|
885
|
+
): Promise<{
|
|
886
|
+
user: User;
|
|
887
|
+
practitioner: Practitioner;
|
|
888
|
+
}> {
|
|
889
|
+
try {
|
|
890
|
+
console.log('[AUTH] Starting claim draft profiles with Google', {
|
|
891
|
+
practitionerIdsCount: practitionerIds.length,
|
|
892
|
+
practitionerIds,
|
|
893
|
+
});
|
|
894
|
+
|
|
895
|
+
if (practitionerIds.length === 0) {
|
|
896
|
+
throw new AuthError('No practitioner profiles selected to claim', 'AUTH/NO_PROFILES_SELECTED', 400);
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
const credential = GoogleAuthProvider.credential(idToken);
|
|
900
|
+
const result = await signInWithCredential(this.auth, credential);
|
|
901
|
+
const firebaseUser = result.user;
|
|
902
|
+
|
|
903
|
+
const practitionerService = new PractitionerService(this.db, this.auth, this.app);
|
|
904
|
+
|
|
905
|
+
let user: User;
|
|
906
|
+
try {
|
|
907
|
+
user = await this.userService.getUserById(firebaseUser.uid);
|
|
908
|
+
} catch (userError) {
|
|
909
|
+
throw new AuthError(
|
|
910
|
+
'User account not properly initialized. Please try signing in again.',
|
|
911
|
+
'AUTH/USER_NOT_INITIALIZED',
|
|
912
|
+
500,
|
|
913
|
+
);
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
let practitioner: Practitioner;
|
|
917
|
+
if (practitionerIds.length === 1) {
|
|
918
|
+
practitioner = await practitionerService.claimDraftProfileWithGoogle(
|
|
919
|
+
practitionerIds[0],
|
|
920
|
+
firebaseUser.uid
|
|
921
|
+
);
|
|
922
|
+
} else {
|
|
923
|
+
practitioner = await practitionerService.claimMultipleDraftProfilesWithGoogle(
|
|
924
|
+
practitionerIds,
|
|
925
|
+
firebaseUser.uid
|
|
926
|
+
);
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
if (!user.practitionerProfile || user.practitionerProfile !== practitioner.id) {
|
|
930
|
+
await this.userService.updateUser(firebaseUser.uid, {
|
|
931
|
+
practitionerProfile: practitioner.id,
|
|
932
|
+
});
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
const updatedUser = await this.userService.getUserById(firebaseUser.uid);
|
|
936
|
+
|
|
937
|
+
return {
|
|
938
|
+
user: updatedUser,
|
|
939
|
+
practitioner,
|
|
940
|
+
};
|
|
941
|
+
} catch (error: any) {
|
|
942
|
+
console.error('[AUTH] Error claiming draft profiles with Google:', error);
|
|
943
|
+
throw handleSignupError(error);
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
/**
|
|
948
|
+
* Pre-validate all signup data before any mutations
|
|
949
|
+
* Prevents partial creation by catching issues early
|
|
950
|
+
*/
|
|
951
|
+
private async validateSignupData(data: {
|
|
952
|
+
email: string;
|
|
953
|
+
password: string;
|
|
954
|
+
firstName?: string;
|
|
955
|
+
lastName?: string;
|
|
956
|
+
token?: string;
|
|
957
|
+
profileData?: Partial<CreatePractitionerData>;
|
|
958
|
+
}): Promise<void> {
|
|
959
|
+
console.log('[AUTH] Pre-validating signup data');
|
|
960
|
+
|
|
961
|
+
try {
|
|
962
|
+
// 1. Schema validation
|
|
963
|
+
await practitionerSignupSchema.parseAsync(data);
|
|
964
|
+
console.log('[AUTH] Schema validation passed');
|
|
965
|
+
|
|
966
|
+
// 2. Check if email already exists (before creating Firebase user)
|
|
967
|
+
const emailExists = await checkEmailExists(this.auth, data.email);
|
|
968
|
+
if (emailExists) {
|
|
969
|
+
console.log('[AUTH] Email already exists:', data.email);
|
|
970
|
+
throw AUTH_ERRORS.EMAIL_ALREADY_EXISTS;
|
|
971
|
+
}
|
|
972
|
+
console.log('[AUTH] Email availability confirmed');
|
|
973
|
+
|
|
974
|
+
// 3. Validate token if provided
|
|
975
|
+
if (data.token) {
|
|
976
|
+
const practitionerService = new PractitionerService(this.db, this.auth, this.app);
|
|
977
|
+
const isValidToken = await practitionerService.validateToken(data.token);
|
|
978
|
+
if (!isValidToken) {
|
|
979
|
+
console.log('[AUTH] Invalid token provided:', data.token);
|
|
980
|
+
throw new Error('Invalid or expired invitation token');
|
|
981
|
+
}
|
|
982
|
+
console.log('[AUTH] Token validation passed');
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
// 4. Validate profile data structure if provided
|
|
986
|
+
if (data.profileData) {
|
|
987
|
+
await validatePractitionerProfileData(data.profileData);
|
|
988
|
+
console.log('[AUTH] Profile data validation passed');
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
console.log('[AUTH] All pre-validation checks passed');
|
|
992
|
+
} catch (error) {
|
|
993
|
+
console.error('[AUTH] Pre-validation failed:', error);
|
|
994
|
+
throw error;
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
/**
|
|
999
|
+
* Signs in a user with email and password specifically for practitioner role
|
|
1000
|
+
* @param email - User's email
|
|
1001
|
+
* @param password - User's password
|
|
1002
|
+
* @returns Object containing the user and practitioner profile
|
|
1003
|
+
* @throws {AUTH_ERRORS.INVALID_ROLE} If user doesn't have practitioner role
|
|
1004
|
+
* @throws {AUTH_ERRORS.NOT_FOUND} If practitioner profile is not found
|
|
1005
|
+
*/
|
|
1006
|
+
async signInPractitioner(
|
|
1007
|
+
email: string,
|
|
1008
|
+
password: string,
|
|
1009
|
+
): Promise<{
|
|
1010
|
+
user: User;
|
|
1011
|
+
practitioner: Practitioner;
|
|
1012
|
+
}> {
|
|
1013
|
+
try {
|
|
1014
|
+
console.log('[AUTH] Starting practitioner signin process', {
|
|
1015
|
+
email: email,
|
|
1016
|
+
});
|
|
1017
|
+
|
|
1018
|
+
// Initialize required service
|
|
1019
|
+
const practitionerService = new PractitionerService(this.db, this.auth, this.app);
|
|
1020
|
+
|
|
1021
|
+
// Sign in with email/password
|
|
1022
|
+
const { user: firebaseUser } = await signInWithEmailAndPassword(this.auth, email, password);
|
|
1023
|
+
|
|
1024
|
+
// Get or create user
|
|
1025
|
+
const user = await this.userService.getOrCreateUser(firebaseUser);
|
|
1026
|
+
console.log('[AUTH] User retrieved', { uid: user.uid });
|
|
1027
|
+
|
|
1028
|
+
// Check if user has practitioner role
|
|
1029
|
+
if (!user.roles?.includes(UserRole.PRACTITIONER)) {
|
|
1030
|
+
console.error('[AUTH] User is not a practitioner:', user.uid);
|
|
1031
|
+
throw AUTH_ERRORS.INVALID_ROLE;
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
// Check and get practitioner profile
|
|
1035
|
+
if (!user.practitionerProfile) {
|
|
1036
|
+
console.error('[AUTH] User has no practitioner profile:', user.uid);
|
|
1037
|
+
throw AUTH_ERRORS.NOT_FOUND;
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
// Get practitioner profile
|
|
1041
|
+
const practitioner = await practitionerService.getPractitioner(user.practitionerProfile);
|
|
1042
|
+
if (!practitioner) {
|
|
1043
|
+
console.error('[AUTH] Practitioner profile not found:', user.practitionerProfile);
|
|
1044
|
+
throw AUTH_ERRORS.NOT_FOUND;
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
console.log('[AUTH] Practitioner signin completed successfully', {
|
|
1048
|
+
userId: user.uid,
|
|
1049
|
+
practitionerId: practitioner.id,
|
|
1050
|
+
});
|
|
1051
|
+
|
|
1052
|
+
return {
|
|
1053
|
+
user,
|
|
1054
|
+
practitioner,
|
|
1055
|
+
};
|
|
1056
|
+
} catch (error) {
|
|
1057
|
+
console.error('[AUTH] Error in signInPractitioner:', error);
|
|
1058
|
+
throw error;
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
/**
|
|
1063
|
+
* Signs in a user with a Google ID token from a mobile client.
|
|
1064
|
+
* If the user does not exist, a new user is created.
|
|
1065
|
+
* @param idToken - The Google ID token obtained from the mobile app.
|
|
1066
|
+
* @param initialRole - The role to assign to the user if they are being created.
|
|
1067
|
+
* @returns The signed-in or newly created user.
|
|
1068
|
+
*/
|
|
1069
|
+
async signInWithGoogleIdToken(
|
|
1070
|
+
idToken: string,
|
|
1071
|
+
initialRole: UserRole = UserRole.PATIENT,
|
|
1072
|
+
): Promise<User> {
|
|
1073
|
+
try {
|
|
1074
|
+
console.log('[AUTH] Signing in with Google ID Token');
|
|
1075
|
+
|
|
1076
|
+
// Sign in with Google credential — auto-creates Firebase Auth user if needed.
|
|
1077
|
+
const credential = GoogleAuthProvider.credential(idToken);
|
|
1078
|
+
const { user: firebaseUser } = await signInWithCredential(this.auth, credential);
|
|
1079
|
+
console.log('[AUTH] Firebase user signed in:', firebaseUser.uid);
|
|
1080
|
+
|
|
1081
|
+
// Load or create domain user document
|
|
1082
|
+
return await this.userService.getOrCreateUser(firebaseUser, UserRole.PATIENT);
|
|
1083
|
+
} catch (error) {
|
|
1084
|
+
console.error('[AUTH] Error in signInWithGoogleIdToken:', error);
|
|
1085
|
+
throw handleFirebaseError(error);
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
/**
|
|
1090
|
+
* Signs up or signs in a practitioner with Google authentication.
|
|
1091
|
+
* Checks for existing practitioner account or draft profiles.
|
|
1092
|
+
*
|
|
1093
|
+
* @param idToken - The Google ID token obtained from the mobile app
|
|
1094
|
+
* @returns Object containing user, practitioner (if exists), and draft profiles (if any)
|
|
1095
|
+
*/
|
|
1096
|
+
async signUpPractitionerWithGoogle(
|
|
1097
|
+
idToken: string
|
|
1098
|
+
): Promise<{
|
|
1099
|
+
user: User | null;
|
|
1100
|
+
practitioner: Practitioner | null;
|
|
1101
|
+
draftProfiles: Practitioner[];
|
|
1102
|
+
}> {
|
|
1103
|
+
try {
|
|
1104
|
+
console.log('[AUTH] Starting practitioner Google Sign-In/Sign-Up');
|
|
1105
|
+
|
|
1106
|
+
// Extract email from Google ID token
|
|
1107
|
+
let email: string | undefined;
|
|
1108
|
+
try {
|
|
1109
|
+
const payloadBase64 = idToken.split('.')[1];
|
|
1110
|
+
const payloadJson = globalThis.atob
|
|
1111
|
+
? globalThis.atob(payloadBase64)
|
|
1112
|
+
: Buffer.from(payloadBase64, 'base64').toString('utf8');
|
|
1113
|
+
const payload = JSON.parse(payloadJson);
|
|
1114
|
+
email = payload.email as string | undefined;
|
|
1115
|
+
} catch (decodeError) {
|
|
1116
|
+
console.error('[AUTH] Failed to decode email from Google ID token:', decodeError);
|
|
1117
|
+
throw new AuthError(
|
|
1118
|
+
'Unable to read email from Google token. Please try again.',
|
|
1119
|
+
'AUTH/INVALID_GOOGLE_TOKEN',
|
|
1120
|
+
400,
|
|
1121
|
+
);
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
if (!email) {
|
|
1125
|
+
throw new AuthError(
|
|
1126
|
+
'Unable to read email from Google token. Please try again.',
|
|
1127
|
+
'AUTH/INVALID_GOOGLE_TOKEN',
|
|
1128
|
+
400,
|
|
1129
|
+
);
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
const normalizedEmail = email.toLowerCase().trim();
|
|
1133
|
+
|
|
1134
|
+
const methods = await fetchSignInMethodsForEmail(this.auth, normalizedEmail);
|
|
1135
|
+
const hasGoogleMethod = methods.includes(GoogleAuthProvider.GOOGLE_SIGN_IN_METHOD);
|
|
1136
|
+
const hasEmailMethod = methods.includes(EmailAuthProvider.EMAIL_PASSWORD_SIGN_IN_METHOD);
|
|
1137
|
+
|
|
1138
|
+
const practitionerService = new PractitionerService(this.db, this.auth, this.app);
|
|
1139
|
+
|
|
1140
|
+
if (hasGoogleMethod) {
|
|
1141
|
+
const credential = GoogleAuthProvider.credential(idToken);
|
|
1142
|
+
const { user: firebaseUser } = await signInWithCredential(this.auth, credential);
|
|
1143
|
+
|
|
1144
|
+
await this.waitForAuthStateToSettle(firebaseUser.uid);
|
|
1145
|
+
|
|
1146
|
+
let existingUser: User | null = null;
|
|
1147
|
+
try {
|
|
1148
|
+
existingUser = await this.userService.getUserById(firebaseUser.uid);
|
|
1149
|
+
} catch (userError: any) {
|
|
1150
|
+
if (!this.auth.currentUser || this.auth.currentUser.uid !== firebaseUser.uid) {
|
|
1151
|
+
const credential = GoogleAuthProvider.credential(idToken);
|
|
1152
|
+
await signInWithCredential(this.auth, credential);
|
|
1153
|
+
await this.waitForAuthStateToSettle(firebaseUser.uid, 2000);
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
const practitionerService = new PractitionerService(this.db, this.auth, this.app);
|
|
1157
|
+
const draftProfiles = await practitionerService.getDraftProfilesByEmail(normalizedEmail);
|
|
1158
|
+
|
|
1159
|
+
if (draftProfiles.length === 0) {
|
|
1160
|
+
try {
|
|
1161
|
+
await firebaseSignOut(this.auth);
|
|
1162
|
+
} catch (signOutError) {
|
|
1163
|
+
console.warn('[AUTH] Error signing out:', signOutError);
|
|
1164
|
+
}
|
|
1165
|
+
throw new AuthError(
|
|
1166
|
+
'No clinic invitation found for this email. Please contact your clinic administrator to receive an invitation, or use the token provided by your clinic.',
|
|
1167
|
+
'AUTH/NO_DRAFT_PROFILES',
|
|
1168
|
+
404,
|
|
1169
|
+
);
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
try {
|
|
1173
|
+
const newUser = await this.userService.createUser(firebaseUser, [UserRole.PRACTITIONER], {
|
|
1174
|
+
skipProfileCreation: true,
|
|
1175
|
+
});
|
|
1176
|
+
|
|
1177
|
+
return {
|
|
1178
|
+
user: newUser,
|
|
1179
|
+
practitioner: null,
|
|
1180
|
+
draftProfiles: draftProfiles,
|
|
1181
|
+
};
|
|
1182
|
+
} catch (createUserError: any) {
|
|
1183
|
+
try {
|
|
1184
|
+
await firebaseSignOut(this.auth);
|
|
1185
|
+
} catch (signOutError) {
|
|
1186
|
+
console.warn('[AUTH] Error signing out:', signOutError);
|
|
1187
|
+
}
|
|
1188
|
+
throw createUserError;
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
// User document exists - check for practitioner profile and draft profiles
|
|
1193
|
+
if (!existingUser) {
|
|
1194
|
+
await firebaseSignOut(this.auth);
|
|
1195
|
+
throw new AuthError(
|
|
1196
|
+
'No clinic invitation found for this email. Please contact your clinic administrator to receive an invitation, or use the token provided by your clinic.',
|
|
1197
|
+
'AUTH/NO_DRAFT_PROFILES',
|
|
1198
|
+
404,
|
|
1199
|
+
);
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
// Check if user has practitioner profile
|
|
1203
|
+
let practitioner: Practitioner | null = null;
|
|
1204
|
+
if (existingUser.practitionerProfile) {
|
|
1205
|
+
practitioner = await practitionerService.getPractitioner(existingUser.practitionerProfile);
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
// Check for any new draft profiles
|
|
1209
|
+
const draftProfiles = await practitionerService.getDraftProfilesByEmail(normalizedEmail);
|
|
1210
|
+
|
|
1211
|
+
return {
|
|
1212
|
+
user: existingUser,
|
|
1213
|
+
practitioner,
|
|
1214
|
+
draftProfiles,
|
|
1215
|
+
};
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
if (hasEmailMethod && !hasGoogleMethod) {
|
|
1219
|
+
throw new AuthError(
|
|
1220
|
+
'An account with this email already exists. Please sign in with your email and password, then link your Google account in settings.',
|
|
1221
|
+
'AUTH/EMAIL_ALREADY_EXISTS',
|
|
1222
|
+
409,
|
|
1223
|
+
);
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
const credential = GoogleAuthProvider.credential(idToken);
|
|
1227
|
+
|
|
1228
|
+
let firebaseUser: FirebaseUser;
|
|
1229
|
+
try {
|
|
1230
|
+
const result = await signInWithCredential(this.auth, credential);
|
|
1231
|
+
firebaseUser = result.user;
|
|
1232
|
+
} catch (error: any) {
|
|
1233
|
+
// If sign-in fails because email already exists with different provider
|
|
1234
|
+
if (error.code === 'auth/account-exists-with-different-credential') {
|
|
1235
|
+
throw new AuthError(
|
|
1236
|
+
'An account with this email already exists. Please sign in with your email and password, then link your Google account in settings.',
|
|
1237
|
+
'AUTH/EMAIL_ALREADY_EXISTS',
|
|
1238
|
+
409,
|
|
1239
|
+
);
|
|
1240
|
+
}
|
|
1241
|
+
throw error;
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
await this.waitForAuthStateToSettle(firebaseUser.uid);
|
|
1245
|
+
|
|
1246
|
+
let existingUser: User | null = null;
|
|
1247
|
+
try {
|
|
1248
|
+
const existingUserDoc = await this.userService.getUserById(firebaseUser.uid);
|
|
1249
|
+
if (existingUserDoc) {
|
|
1250
|
+
existingUser = existingUserDoc;
|
|
1251
|
+
}
|
|
1252
|
+
} catch (error) {
|
|
1253
|
+
// Continue with new user creation
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
let draftProfiles: Practitioner[] = [];
|
|
1257
|
+
try {
|
|
1258
|
+
draftProfiles = await practitionerService.getDraftProfilesByEmail(normalizedEmail);
|
|
1259
|
+
} catch (draftCheckError: any) {
|
|
1260
|
+
try {
|
|
1261
|
+
await firebaseSignOut(this.auth);
|
|
1262
|
+
} catch (signOutError) {
|
|
1263
|
+
console.warn('[AUTH] Error signing out:', signOutError);
|
|
1264
|
+
}
|
|
1265
|
+
throw new AuthError(
|
|
1266
|
+
'No clinic invitation found for this email. Please contact your clinic administrator to receive an invitation, or use the token provided by your clinic.',
|
|
1267
|
+
'AUTH/NO_DRAFT_PROFILES',
|
|
1268
|
+
404,
|
|
1269
|
+
);
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
let user: User;
|
|
1273
|
+
if (existingUser) {
|
|
1274
|
+
user = existingUser;
|
|
1275
|
+
} else {
|
|
1276
|
+
if (draftProfiles.length === 0) {
|
|
1277
|
+
try {
|
|
1278
|
+
await firebaseSignOut(this.auth);
|
|
1279
|
+
} catch (signOutError) {
|
|
1280
|
+
console.warn('[AUTH] Error signing out:', signOutError);
|
|
1281
|
+
}
|
|
1282
|
+
throw new AuthError(
|
|
1283
|
+
'No clinic invitation found for this email. Please contact your clinic administrator to receive an invitation, or use the token provided by your clinic.',
|
|
1284
|
+
'AUTH/NO_DRAFT_PROFILES',
|
|
1285
|
+
404,
|
|
1286
|
+
);
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
user = await this.userService.createUser(firebaseUser, [UserRole.PRACTITIONER], {
|
|
1290
|
+
skipProfileCreation: true,
|
|
1291
|
+
});
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
let practitioner: Practitioner | null = null;
|
|
1295
|
+
if (user.practitionerProfile) {
|
|
1296
|
+
practitioner = await practitionerService.getPractitioner(user.practitionerProfile);
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
return {
|
|
1300
|
+
user,
|
|
1301
|
+
practitioner,
|
|
1302
|
+
draftProfiles,
|
|
1303
|
+
};
|
|
1304
|
+
} catch (error: any) {
|
|
1305
|
+
if (error instanceof AuthError) {
|
|
1306
|
+
throw error;
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
const errorMessage = error?.message || error?.toString() || '';
|
|
1310
|
+
if (errorMessage.includes('NO_DRAFT_PROFILES') || errorMessage.includes('clinic invitation')) {
|
|
1311
|
+
throw new AuthError(
|
|
1312
|
+
'No clinic invitation found for this email. Please contact your clinic administrator to receive an invitation, or use the token provided by your clinic.',
|
|
1313
|
+
'AUTH/NO_DRAFT_PROFILES',
|
|
1314
|
+
404,
|
|
1315
|
+
);
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
const wrappedError = handleFirebaseError(error);
|
|
1319
|
+
|
|
1320
|
+
if (wrappedError.message.includes('permissions') || wrappedError.message.includes('Account creation failed')) {
|
|
1321
|
+
throw new AuthError(
|
|
1322
|
+
'No clinic invitation found for this email. Please contact your clinic administrator to receive an invitation, or use the token provided by your clinic.',
|
|
1323
|
+
'AUTH/NO_DRAFT_PROFILES',
|
|
1324
|
+
404,
|
|
1325
|
+
);
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
throw wrappedError;
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
/**
|
|
1333
|
+
* Links a Google account to the currently signed-in user using an ID token.
|
|
1334
|
+
* This is used to upgrade an anonymous user or to allow an existing user
|
|
1335
|
+
* to sign in with Google in the future.
|
|
1336
|
+
* @param idToken - The Google ID token obtained from the mobile app.
|
|
1337
|
+
* @returns The updated user profile.
|
|
1338
|
+
*/
|
|
1339
|
+
async linkGoogleAccount(idToken: string): Promise<User> {
|
|
1340
|
+
try {
|
|
1341
|
+
console.log('[AUTH] Linking Google account with ID Token');
|
|
1342
|
+
const currentUser = this.auth.currentUser;
|
|
1343
|
+
if (!currentUser) {
|
|
1344
|
+
throw AUTH_ERRORS.NOT_AUTHENTICATED;
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
const wasAnonymous = currentUser.isAnonymous;
|
|
1348
|
+
console.log(`[AUTH] Current user is ${wasAnonymous ? 'anonymous' : 'not anonymous'}`);
|
|
1349
|
+
|
|
1350
|
+
const credential = GoogleAuthProvider.credential(idToken);
|
|
1351
|
+
const userCredential = await linkWithCredential(currentUser, credential);
|
|
1352
|
+
const linkedFirebaseUser = userCredential.user;
|
|
1353
|
+
console.log('[AUTH] Google account linked successfully to user:', linkedFirebaseUser.uid);
|
|
1354
|
+
|
|
1355
|
+
if (wasAnonymous) {
|
|
1356
|
+
console.log('[AUTH] Upgrading anonymous user profile');
|
|
1357
|
+
return await this.userService.upgradeAnonymousUser(
|
|
1358
|
+
linkedFirebaseUser.uid,
|
|
1359
|
+
linkedFirebaseUser.email!,
|
|
1360
|
+
);
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
// If the user was not anonymous, just return their updated profile
|
|
1364
|
+
return (await this.userService.getUserById(linkedFirebaseUser.uid))!;
|
|
1365
|
+
} catch (error) {
|
|
1366
|
+
console.error('[AUTH] Error in linkGoogleAccount:', error);
|
|
1367
|
+
throw handleFirebaseError(error);
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
/**
|
|
1372
|
+
* Signs in or registers a user with an Apple ID token.
|
|
1373
|
+
* If the user does not exist, a new user is created.
|
|
1374
|
+
*/
|
|
1375
|
+
async signInWithAppleIdToken(
|
|
1376
|
+
idToken: string,
|
|
1377
|
+
rawNonce: string,
|
|
1378
|
+
appleUserInfo?: { fullName?: { givenName?: string; familyName?: string }; email?: string },
|
|
1379
|
+
): Promise<User> {
|
|
1380
|
+
try {
|
|
1381
|
+
console.log('[AUTH] Signing in with Apple ID Token');
|
|
1382
|
+
|
|
1383
|
+
// Build Apple OAuth credential
|
|
1384
|
+
const provider = new OAuthProvider('apple.com');
|
|
1385
|
+
const credential = provider.credential({ idToken, rawNonce });
|
|
1386
|
+
|
|
1387
|
+
// Sign in to Firebase — auto-creates Firebase Auth user if needed
|
|
1388
|
+
const { user: firebaseUser } = await signInWithCredential(this.auth, credential);
|
|
1389
|
+
console.log('[AUTH] Firebase user signed in via Apple:', firebaseUser.uid);
|
|
1390
|
+
|
|
1391
|
+
// Load or create domain user document
|
|
1392
|
+
return await this.userService.getOrCreateUser(firebaseUser, UserRole.PATIENT);
|
|
1393
|
+
} catch (error) {
|
|
1394
|
+
console.error('[AUTH] Error in signInWithAppleIdToken:', error);
|
|
1395
|
+
throw handleFirebaseError(error);
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
/**
|
|
1400
|
+
* Link an Apple account to the current user (anonymous → full account upgrade).
|
|
1401
|
+
* Mirrors linkGoogleAccount.
|
|
1402
|
+
*/
|
|
1403
|
+
async linkAppleAccount(idToken: string, rawNonce: string): Promise<User> {
|
|
1404
|
+
try {
|
|
1405
|
+
console.log('[AUTH] Linking Apple account with ID Token');
|
|
1406
|
+
const currentUser = this.auth.currentUser;
|
|
1407
|
+
if (!currentUser) {
|
|
1408
|
+
throw AUTH_ERRORS.NOT_AUTHENTICATED;
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
const wasAnonymous = currentUser.isAnonymous;
|
|
1412
|
+
console.log(`[AUTH] Current user is ${wasAnonymous ? 'anonymous' : 'not anonymous'}`);
|
|
1413
|
+
|
|
1414
|
+
const provider = new OAuthProvider('apple.com');
|
|
1415
|
+
const credential = provider.credential({ idToken, rawNonce });
|
|
1416
|
+
const userCredential = await linkWithCredential(currentUser, credential);
|
|
1417
|
+
const linkedFirebaseUser = userCredential.user;
|
|
1418
|
+
console.log('[AUTH] Apple account linked successfully to user:', linkedFirebaseUser.uid);
|
|
1419
|
+
|
|
1420
|
+
if (wasAnonymous) {
|
|
1421
|
+
console.log('[AUTH] Upgrading anonymous user profile');
|
|
1422
|
+
const email = linkedFirebaseUser.email || '';
|
|
1423
|
+
return await this.userService.upgradeAnonymousUser(
|
|
1424
|
+
linkedFirebaseUser.uid,
|
|
1425
|
+
email,
|
|
1426
|
+
);
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
return (await this.userService.getUserById(linkedFirebaseUser.uid))!;
|
|
1430
|
+
} catch (error) {
|
|
1431
|
+
console.error('[AUTH] Error in linkAppleAccount:', error);
|
|
1432
|
+
throw handleFirebaseError(error);
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
}
|