@blackcode_sa/metaestetics-api 1.7.33 → 1.7.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@blackcode_sa/metaestetics-api",
3
3
  "private": false,
4
- "version": "1.7.33",
4
+ "version": "1.7.35",
5
5
  "description": "Firebase authentication service with anonymous upgrade support",
6
6
  "main": "./dist/index.js",
7
7
  "module": "./dist/index.mjs",
@@ -10,9 +10,15 @@ import {
10
10
  PRACTITIONERS_COLLECTION,
11
11
  PractitionerClinicWorkingHours,
12
12
  } from "../../../types/practitioner";
13
- import { CLINICS_COLLECTION, Clinic } from "../../../types/clinic";
13
+ import {
14
+ CLINICS_COLLECTION,
15
+ Clinic,
16
+ ClinicAdmin,
17
+ CLINIC_ADMINS_COLLECTION,
18
+ } from "../../../types/clinic";
14
19
  import { ClinicInfo } from "../../../types/profile";
15
20
  import { Logger } from "../../logger";
21
+ import { ExistingPractitionerInviteMailingService } from "../../mailing/practitionerInvite/existing-practitioner-invite.mailing";
16
22
 
17
23
  /**
18
24
  * @class PractitionerInviteAggregationService
@@ -22,13 +28,19 @@ import { Logger } from "../../logger";
22
28
  */
23
29
  export class PractitionerInviteAggregationService {
24
30
  private db: admin.firestore.Firestore;
31
+ private mailingService?: ExistingPractitionerInviteMailingService;
25
32
 
26
33
  /**
27
34
  * Constructor for PractitionerInviteAggregationService.
28
35
  * @param firestore Optional Firestore instance. If not provided, it uses the default admin SDK instance.
36
+ * @param mailingService Optional mailing service for sending emails
29
37
  */
30
- constructor(firestore?: admin.firestore.Firestore) {
38
+ constructor(
39
+ firestore?: admin.firestore.Firestore,
40
+ mailingService?: ExistingPractitionerInviteMailingService
41
+ ) {
31
42
  this.db = firestore || admin.firestore();
43
+ this.mailingService = mailingService;
32
44
  Logger.info("[PractitionerInviteAggregationService] Initialized.");
33
45
  }
34
46
 
@@ -36,17 +48,49 @@ export class PractitionerInviteAggregationService {
36
48
  * Handles side effects when a practitioner invite is first created.
37
49
  * This function would typically be called by a Firestore onCreate trigger.
38
50
  * @param {PractitionerInvite} invite - The newly created PractitionerInvite object.
51
+ * @param {object} emailConfig - Optional email configuration for sending invite emails
39
52
  * @returns {Promise<void>}
40
53
  */
41
- async handleInviteCreate(invite: PractitionerInvite): Promise<void> {
54
+ async handleInviteCreate(
55
+ invite: PractitionerInvite,
56
+ emailConfig?: {
57
+ fromAddress: string;
58
+ domain: string;
59
+ acceptUrl: string;
60
+ rejectUrl: string;
61
+ }
62
+ ): Promise<void> {
42
63
  Logger.info(
43
64
  `[PractitionerInviteAggService] Handling CREATE for invite: ${invite.id}, practitioner: ${invite.practitionerId}, clinic: ${invite.clinicId}, status: ${invite.status}`
44
65
  );
45
66
 
46
67
  try {
47
- // TODO: Add any side effects needed when an invite is created
48
- // For example: Send notification emails, update counters, etc.
49
- // Currently, the main email sending is handled by the mailing service
68
+ // Send invitation email to practitioner if mailing service is available
69
+ if (
70
+ this.mailingService &&
71
+ emailConfig &&
72
+ invite.status === PractitionerInviteStatus.PENDING
73
+ ) {
74
+ Logger.info(
75
+ `[PractitionerInviteAggService] Sending invitation email for invite: ${invite.id}`
76
+ );
77
+
78
+ try {
79
+ await this.mailingService.handleInviteCreationEvent(
80
+ invite,
81
+ emailConfig
82
+ );
83
+ Logger.info(
84
+ `[PractitionerInviteAggService] Successfully sent invitation email for invite: ${invite.id}`
85
+ );
86
+ } catch (emailError) {
87
+ Logger.error(
88
+ `[PractitionerInviteAggService] Error sending invitation email for invite ${invite.id}:`,
89
+ emailError
90
+ );
91
+ // Don't throw - email failure shouldn't break the invite creation
92
+ }
93
+ }
50
94
 
51
95
  Logger.info(
52
96
  `[PractitionerInviteAggService] Successfully processed CREATE for invite: ${invite.id}`
@@ -65,11 +109,19 @@ export class PractitionerInviteAggregationService {
65
109
  * This function would typically be called by a Firestore onUpdate trigger.
66
110
  * @param {PractitionerInvite} before - The PractitionerInvite object before the update.
67
111
  * @param {PractitionerInvite} after - The PractitionerInvite object after the update.
112
+ * @param {object} emailConfig - Optional email configuration for sending notification emails
68
113
  * @returns {Promise<void>}
69
114
  */
70
115
  async handleInviteUpdate(
71
116
  before: PractitionerInvite,
72
- after: PractitionerInvite
117
+ after: PractitionerInvite,
118
+ emailConfig?: {
119
+ fromAddress: string;
120
+ domain: string;
121
+ clinicDashboardUrl: string;
122
+ practitionerProfileUrl?: string;
123
+ findPractitionersUrl?: string;
124
+ }
73
125
  ): Promise<void> {
74
126
  Logger.info(
75
127
  `[PractitionerInviteAggService] Handling UPDATE for invite: ${after.id}. Status ${before.status} -> ${after.status}`
@@ -91,7 +143,7 @@ export class PractitionerInviteAggregationService {
91
143
  Logger.info(
92
144
  `[PractitionerInviteAggService] Invite ${after.id} PENDING -> ACCEPTED. Adding practitioner to clinic.`
93
145
  );
94
- await this.handleInviteAccepted(after);
146
+ await this.handleInviteAccepted(after, emailConfig);
95
147
  }
96
148
 
97
149
  // Handle PENDING -> REJECTED
@@ -102,7 +154,7 @@ export class PractitionerInviteAggregationService {
102
154
  Logger.info(
103
155
  `[PractitionerInviteAggService] Invite ${after.id} PENDING -> REJECTED.`
104
156
  );
105
- await this.handleInviteRejected(after);
157
+ await this.handleInviteRejected(after, emailConfig);
106
158
  }
107
159
 
108
160
  // Handle PENDING -> CANCELLED
@@ -162,10 +214,17 @@ export class PractitionerInviteAggregationService {
162
214
  * Handles the business logic when a practitioner accepts an invite.
163
215
  * This includes adding the practitioner to the clinic and the clinic to the practitioner.
164
216
  * @param {PractitionerInvite} invite - The accepted invite
217
+ * @param {object} emailConfig - Optional email configuration for sending notification emails
165
218
  * @returns {Promise<void>}
166
219
  */
167
220
  private async handleInviteAccepted(
168
- invite: PractitionerInvite
221
+ invite: PractitionerInvite,
222
+ emailConfig?: {
223
+ fromAddress: string;
224
+ domain: string;
225
+ clinicDashboardUrl: string;
226
+ practitionerProfileUrl?: string;
227
+ }
169
228
  ): Promise<void> {
170
229
  Logger.info(
171
230
  `[PractitionerInviteAggService] Processing accepted invite ${invite.id} for practitioner ${invite.practitionerId} and clinic ${invite.clinicId}`
@@ -251,6 +310,31 @@ export class PractitionerInviteAggregationService {
251
310
  await this.updatePractitionerWorkingHours(practitioner.id, invite);
252
311
  }
253
312
 
313
+ // Send acceptance notification email to clinic admin if mailing service is available
314
+ if (this.mailingService && emailConfig) {
315
+ Logger.info(
316
+ `[PractitionerInviteAggService] Sending acceptance notification email for invite: ${invite.id}`
317
+ );
318
+
319
+ try {
320
+ await this.sendAcceptanceNotificationEmail(
321
+ invite,
322
+ practitioner,
323
+ clinic,
324
+ emailConfig
325
+ );
326
+ Logger.info(
327
+ `[PractitionerInviteAggService] Successfully sent acceptance notification email for invite: ${invite.id}`
328
+ );
329
+ } catch (emailError) {
330
+ Logger.error(
331
+ `[PractitionerInviteAggService] Error sending acceptance notification email for invite ${invite.id}:`,
332
+ emailError
333
+ );
334
+ // Don't throw - email failure shouldn't break the acceptance logic
335
+ }
336
+ }
337
+
254
338
  Logger.info(
255
339
  `[PractitionerInviteAggService] Successfully processed accepted invite ${invite.id}`
256
340
  );
@@ -266,18 +350,54 @@ export class PractitionerInviteAggregationService {
266
350
  /**
267
351
  * Handles the business logic when a practitioner rejects an invite.
268
352
  * @param {PractitionerInvite} invite - The rejected invite
353
+ * @param {object} emailConfig - Optional email configuration for sending notification emails
269
354
  * @returns {Promise<void>}
270
355
  */
271
356
  private async handleInviteRejected(
272
- invite: PractitionerInvite
357
+ invite: PractitionerInvite,
358
+ emailConfig?: {
359
+ fromAddress: string;
360
+ domain: string;
361
+ clinicDashboardUrl: string;
362
+ findPractitionersUrl?: string;
363
+ }
273
364
  ): Promise<void> {
274
365
  Logger.info(
275
366
  `[PractitionerInviteAggService] Processing rejected invite ${invite.id}`
276
367
  );
277
368
 
278
369
  try {
279
- // TODO: Add any side effects for rejected invites
280
- // For example: Update counters, send notifications, etc.
370
+ // Send rejection notification email to clinic admin if mailing service is available
371
+ if (this.mailingService && emailConfig) {
372
+ Logger.info(
373
+ `[PractitionerInviteAggService] Sending rejection notification email for invite: ${invite.id}`
374
+ );
375
+
376
+ try {
377
+ const [practitioner, clinic] = await Promise.all([
378
+ this.fetchPractitionerById(invite.practitionerId),
379
+ this.fetchClinicById(invite.clinicId),
380
+ ]);
381
+
382
+ if (practitioner && clinic) {
383
+ await this.sendRejectionNotificationEmail(
384
+ invite,
385
+ practitioner,
386
+ clinic,
387
+ emailConfig
388
+ );
389
+ Logger.info(
390
+ `[PractitionerInviteAggService] Successfully sent rejection notification email for invite: ${invite.id}`
391
+ );
392
+ }
393
+ } catch (emailError) {
394
+ Logger.error(
395
+ `[PractitionerInviteAggService] Error sending rejection notification email for invite ${invite.id}:`,
396
+ emailError
397
+ );
398
+ // Don't throw - email failure shouldn't break the rejection logic
399
+ }
400
+ }
281
401
 
282
402
  Logger.info(
283
403
  `[PractitionerInviteAggService] Successfully processed rejected invite ${invite.id}`
@@ -530,6 +650,29 @@ export class PractitionerInviteAggregationService {
530
650
 
531
651
  // --- Data Fetching Helpers ---
532
652
 
653
+ /**
654
+ * Fetches a clinic admin by ID
655
+ * @param adminId The clinic admin ID
656
+ * @returns The clinic admin or null if not found
657
+ */
658
+ private async fetchClinicAdminById(
659
+ adminId: string
660
+ ): Promise<ClinicAdmin | null> {
661
+ try {
662
+ const doc = await this.db
663
+ .collection(CLINIC_ADMINS_COLLECTION)
664
+ .doc(adminId)
665
+ .get();
666
+ return doc.exists ? (doc.data() as ClinicAdmin) : null;
667
+ } catch (error) {
668
+ Logger.error(
669
+ `[PractitionerInviteAggService] Error fetching clinic admin ${adminId}:`,
670
+ error
671
+ );
672
+ return null;
673
+ }
674
+ }
675
+
533
676
  /**
534
677
  * Fetches a practitioner by ID.
535
678
  * @param practitionerId The practitioner ID.
@@ -573,4 +716,246 @@ export class PractitionerInviteAggregationService {
573
716
  return null;
574
717
  }
575
718
  }
719
+
720
+ // --- Email Helper Methods ---
721
+
722
+ /**
723
+ * Sends acceptance notification email to clinic admin
724
+ * @param invite The accepted invite
725
+ * @param practitioner The practitioner who accepted
726
+ * @param clinic The clinic that sent the invite
727
+ * @param emailConfig Email configuration
728
+ */
729
+ private async sendAcceptanceNotificationEmail(
730
+ invite: PractitionerInvite,
731
+ practitioner: Practitioner,
732
+ clinic: Clinic,
733
+ emailConfig: {
734
+ fromAddress: string;
735
+ domain: string;
736
+ clinicDashboardUrl: string;
737
+ practitionerProfileUrl?: string;
738
+ }
739
+ ): Promise<void> {
740
+ if (!this.mailingService) return;
741
+
742
+ try {
743
+ // Fetch the admin who created the invite
744
+ const admin = await this.fetchClinicAdminById(invite.invitedBy);
745
+ if (!admin) {
746
+ Logger.warn(
747
+ `[PractitionerInviteAggService] Admin ${invite.invitedBy} not found, using clinic contact email as fallback`
748
+ );
749
+
750
+ // Fallback to clinic contact email
751
+ const notificationData = {
752
+ invite,
753
+ practitioner: {
754
+ firstName: practitioner.basicInfo.firstName || "",
755
+ lastName: practitioner.basicInfo.lastName || "",
756
+ specialties:
757
+ practitioner.certification?.specialties?.map(
758
+ (s: any) => s.name || s
759
+ ) || [],
760
+ profileImageUrl:
761
+ typeof practitioner.basicInfo.profileImageUrl === "string"
762
+ ? practitioner.basicInfo.profileImageUrl
763
+ : null,
764
+ experienceYears: undefined,
765
+ },
766
+ clinic: {
767
+ name: clinic.name,
768
+ adminName: "Admin",
769
+ adminEmail: clinic.contactInfo.email,
770
+ },
771
+ context: {
772
+ invitationDate: invite.createdAt.toDate().toLocaleDateString(),
773
+ responseDate:
774
+ invite.acceptedAt?.toDate().toLocaleDateString() ||
775
+ new Date().toLocaleDateString(),
776
+ },
777
+ urls: {
778
+ clinicDashboardUrl: emailConfig.clinicDashboardUrl,
779
+ practitionerProfileUrl: emailConfig.practitionerProfileUrl,
780
+ },
781
+ options: {
782
+ fromAddress: emailConfig.fromAddress,
783
+ mailgunDomain: emailConfig.domain,
784
+ },
785
+ };
786
+
787
+ await this.mailingService.sendAcceptedNotificationEmail(
788
+ notificationData
789
+ );
790
+ return;
791
+ }
792
+
793
+ // Use specific admin details
794
+ const adminName = `${admin.contactInfo.firstName} ${admin.contactInfo.lastName}`;
795
+
796
+ const notificationData = {
797
+ invite,
798
+ practitioner: {
799
+ firstName: practitioner.basicInfo.firstName || "",
800
+ lastName: practitioner.basicInfo.lastName || "",
801
+ specialties:
802
+ practitioner.certification?.specialties?.map(
803
+ (s: any) => s.name || s
804
+ ) || [],
805
+ profileImageUrl:
806
+ typeof practitioner.basicInfo.profileImageUrl === "string"
807
+ ? practitioner.basicInfo.profileImageUrl
808
+ : null,
809
+ experienceYears: undefined, // This would need to be calculated or stored in practitioner data
810
+ },
811
+ clinic: {
812
+ name: clinic.name,
813
+ adminName: adminName,
814
+ adminEmail: admin.contactInfo.email, // Use the specific admin's email
815
+ },
816
+ context: {
817
+ invitationDate: invite.createdAt.toDate().toLocaleDateString(),
818
+ responseDate:
819
+ invite.acceptedAt?.toDate().toLocaleDateString() ||
820
+ new Date().toLocaleDateString(),
821
+ },
822
+ urls: {
823
+ clinicDashboardUrl: emailConfig.clinicDashboardUrl,
824
+ practitionerProfileUrl: emailConfig.practitionerProfileUrl,
825
+ },
826
+ options: {
827
+ fromAddress: emailConfig.fromAddress,
828
+ mailgunDomain: emailConfig.domain,
829
+ },
830
+ };
831
+
832
+ await this.mailingService.sendAcceptedNotificationEmail(notificationData);
833
+ } catch (error) {
834
+ Logger.error(
835
+ `[PractitionerInviteAggService] Error sending acceptance notification email:`,
836
+ error
837
+ );
838
+ throw error;
839
+ }
840
+ }
841
+
842
+ /**
843
+ * Sends rejection notification email to clinic admin
844
+ * @param invite The rejected invite
845
+ * @param practitioner The practitioner who rejected
846
+ * @param clinic The clinic that sent the invite
847
+ * @param emailConfig Email configuration
848
+ */
849
+ private async sendRejectionNotificationEmail(
850
+ invite: PractitionerInvite,
851
+ practitioner: Practitioner,
852
+ clinic: Clinic,
853
+ emailConfig: {
854
+ fromAddress: string;
855
+ domain: string;
856
+ clinicDashboardUrl: string;
857
+ findPractitionersUrl?: string;
858
+ }
859
+ ): Promise<void> {
860
+ if (!this.mailingService) return;
861
+
862
+ try {
863
+ // Fetch the admin who created the invite
864
+ const admin = await this.fetchClinicAdminById(invite.invitedBy);
865
+ if (!admin) {
866
+ Logger.warn(
867
+ `[PractitionerInviteAggService] Admin ${invite.invitedBy} not found, using clinic contact email as fallback`
868
+ );
869
+
870
+ // Fallback to clinic contact email
871
+ const notificationData = {
872
+ invite,
873
+ practitioner: {
874
+ firstName: practitioner.basicInfo.firstName || "",
875
+ lastName: practitioner.basicInfo.lastName || "",
876
+ specialties:
877
+ practitioner.certification?.specialties?.map(
878
+ (s: any) => s.name || s
879
+ ) || [],
880
+ profileImageUrl:
881
+ typeof practitioner.basicInfo.profileImageUrl === "string"
882
+ ? practitioner.basicInfo.profileImageUrl
883
+ : null,
884
+ },
885
+ clinic: {
886
+ name: clinic.name,
887
+ adminName: "Admin",
888
+ adminEmail: clinic.contactInfo.email,
889
+ },
890
+ context: {
891
+ invitationDate: invite.createdAt.toDate().toLocaleDateString(),
892
+ responseDate:
893
+ invite.rejectedAt?.toDate().toLocaleDateString() ||
894
+ new Date().toLocaleDateString(),
895
+ rejectionReason: invite.rejectionReason || undefined,
896
+ },
897
+ urls: {
898
+ clinicDashboardUrl: emailConfig.clinicDashboardUrl,
899
+ findPractitionersUrl: emailConfig.findPractitionersUrl,
900
+ },
901
+ options: {
902
+ fromAddress: emailConfig.fromAddress,
903
+ mailgunDomain: emailConfig.domain,
904
+ },
905
+ };
906
+
907
+ await this.mailingService.sendRejectedNotificationEmail(
908
+ notificationData
909
+ );
910
+ return;
911
+ }
912
+
913
+ // Use specific admin details
914
+ const adminName = `${admin.contactInfo.firstName} ${admin.contactInfo.lastName}`;
915
+
916
+ const notificationData = {
917
+ invite,
918
+ practitioner: {
919
+ firstName: practitioner.basicInfo.firstName || "",
920
+ lastName: practitioner.basicInfo.lastName || "",
921
+ specialties:
922
+ practitioner.certification?.specialties?.map(
923
+ (s: any) => s.name || s
924
+ ) || [],
925
+ profileImageUrl:
926
+ typeof practitioner.basicInfo.profileImageUrl === "string"
927
+ ? practitioner.basicInfo.profileImageUrl
928
+ : null,
929
+ },
930
+ clinic: {
931
+ name: clinic.name,
932
+ adminName: adminName,
933
+ adminEmail: admin.contactInfo.email, // Use the specific admin's email
934
+ },
935
+ context: {
936
+ invitationDate: invite.createdAt.toDate().toLocaleDateString(),
937
+ responseDate:
938
+ invite.rejectedAt?.toDate().toLocaleDateString() ||
939
+ new Date().toLocaleDateString(),
940
+ rejectionReason: invite.rejectionReason || undefined,
941
+ },
942
+ urls: {
943
+ clinicDashboardUrl: emailConfig.clinicDashboardUrl,
944
+ findPractitionersUrl: emailConfig.findPractitionersUrl,
945
+ },
946
+ options: {
947
+ fromAddress: emailConfig.fromAddress,
948
+ mailgunDomain: emailConfig.domain,
949
+ },
950
+ };
951
+
952
+ await this.mailingService.sendRejectedNotificationEmail(notificationData);
953
+ } catch (error) {
954
+ Logger.error(
955
+ `[PractitionerInviteAggService] Error sending rejection notification email:`,
956
+ error
957
+ );
958
+ throw error;
959
+ }
960
+ }
576
961
  }
@@ -33,6 +33,7 @@ import { ReviewsAggregationService } from "./aggregation/reviews/reviews.aggrega
33
33
  // Import mailing services
34
34
  import { BaseMailingService } from "./mailing/base.mailing.service";
35
35
  import { PractitionerInviteMailingService } from "./mailing/practitionerInvite/practitionerInvite.mailing";
36
+ import { ExistingPractitionerInviteMailingService } from "./mailing/practitionerInvite/existing-practitioner-invite.mailing";
36
37
 
37
38
  // Import booking services
38
39
  import { BookingAdmin } from "./booking/booking.admin";
@@ -87,7 +88,17 @@ export {
87
88
  };
88
89
 
89
90
  // Export mailing services
90
- export { BaseMailingService, PractitionerInviteMailingService };
91
+ export {
92
+ BaseMailingService,
93
+ PractitionerInviteMailingService,
94
+ ExistingPractitionerInviteMailingService,
95
+ };
96
+
97
+ // Export mailing service interfaces
98
+ export type {
99
+ ExistingPractitionerInviteEmailData,
100
+ ClinicAdminNotificationData,
101
+ } from "./mailing/practitionerInvite/existing-practitioner-invite.mailing";
91
102
 
92
103
  // Export booking services
93
104
  export { BookingAdmin };