@blackcode_sa/metaestetics-api 1.5.29 → 1.5.30

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.
@@ -0,0 +1,256 @@
1
+ import * as admin from "firebase-admin";
2
+ import * as mailgun from "mailgun-js";
3
+ import { BaseMailingService } from "../base.mailing.service";
4
+ import { practitionerInvitationTemplate } from "./templates/invitation.template";
5
+
6
+ // Import specific types and collection constants
7
+ import {
8
+ Practitioner,
9
+ PractitionerToken,
10
+ PRACTITIONERS_COLLECTION,
11
+ } from "../../../types/practitioner";
12
+ import { Clinic, CLINICS_COLLECTION } from "../../../types/clinic";
13
+
14
+ /**
15
+ * Interface for the data required to send a practitioner invitation email
16
+ */
17
+ export interface PractitionerInviteEmailData {
18
+ /** The token object from the practitioner service */
19
+ token: {
20
+ id: string;
21
+ token: string;
22
+ practitionerId: string;
23
+ email: string;
24
+ clinicId: string;
25
+ expiresAt: admin.firestore.Timestamp;
26
+ };
27
+
28
+ /** Practitioner basic info */
29
+ practitioner: {
30
+ firstName: string;
31
+ lastName: string;
32
+ };
33
+
34
+ /** Clinic info */
35
+ clinic: {
36
+ name: string;
37
+ contactEmail: string;
38
+ contactName?: string;
39
+ };
40
+
41
+ /** Config options */
42
+ options?: {
43
+ registrationUrl?: string;
44
+ customSubject?: string;
45
+ fromAddress?: string;
46
+ };
47
+ }
48
+
49
+ /**
50
+ * Service for sending practitioner invitation emails
51
+ */
52
+ export class PractitionerInviteMailingService extends BaseMailingService {
53
+ private readonly DEFAULT_REGISTRATION_URL =
54
+ "https://app.medclinic.com/register";
55
+ private readonly DEFAULT_SUBJECT =
56
+ "You've Been Invited to Join as a Practitioner";
57
+ private readonly DEFAULT_FROM_ADDRESS =
58
+ "MedClinic <no-reply@your-domain.com>";
59
+
60
+ /**
61
+ * Constructor for PractitionerInviteMailingService
62
+ * @param firestore Firestore instance provided by the caller
63
+ * @param mailgunClient Mailgun client instance provided by the caller
64
+ */
65
+ constructor(
66
+ firestore: FirebaseFirestore.Firestore,
67
+ mailgunClient: mailgun.Mailgun
68
+ ) {
69
+ super(firestore, mailgunClient);
70
+ }
71
+
72
+ /**
73
+ * Sends a practitioner invitation email
74
+ * @param data The practitioner invitation data
75
+ * @returns Promise resolved when email is sent
76
+ */
77
+ async sendInvitationEmail(
78
+ data: PractitionerInviteEmailData
79
+ ): Promise<mailgun.messages.SendResponse> {
80
+ try {
81
+ console.log(
82
+ "[PractitionerInviteMailingService] Sending invitation email to",
83
+ data.token.email
84
+ );
85
+
86
+ // Format expiration date
87
+ const expirationDate = data.token.expiresAt
88
+ .toDate()
89
+ .toLocaleDateString("en-US", {
90
+ weekday: "long",
91
+ year: "numeric",
92
+ month: "long",
93
+ day: "numeric",
94
+ });
95
+
96
+ // Registration URL
97
+ const registrationUrl =
98
+ data.options?.registrationUrl || this.DEFAULT_REGISTRATION_URL;
99
+
100
+ // Contact information
101
+ const contactName = data.clinic.contactName || "Clinic Administrator";
102
+ const contactEmail = data.clinic.contactEmail;
103
+
104
+ // Subject line
105
+ const subject = data.options?.customSubject || this.DEFAULT_SUBJECT;
106
+
107
+ // Determine 'from' address
108
+ const fromAddress =
109
+ data.options?.fromAddress || this.DEFAULT_FROM_ADDRESS;
110
+
111
+ // Current year for copyright
112
+ const currentYear = new Date().getFullYear().toString();
113
+
114
+ // Practitioner full name
115
+ const practitionerName = `${data.practitioner.firstName} ${data.practitioner.lastName}`;
116
+
117
+ // Prepare template variables
118
+ const templateVariables = {
119
+ clinicName: data.clinic.name,
120
+ practitionerName,
121
+ inviteToken: data.token.token,
122
+ expirationDate,
123
+ registrationUrl,
124
+ contactName,
125
+ contactEmail,
126
+ currentYear,
127
+ };
128
+
129
+ // Render HTML email
130
+ const html = this.renderTemplate(
131
+ practitionerInvitationTemplate,
132
+ templateVariables
133
+ );
134
+
135
+ // Send email - ensure 'from' is included
136
+ const emailData: mailgun.messages.SendData = {
137
+ to: data.token.email,
138
+ from: fromAddress,
139
+ subject,
140
+ html,
141
+ };
142
+
143
+ const result = await this.sendEmail(emailData);
144
+
145
+ // Log success
146
+ await this.logEmailAttempt(
147
+ {
148
+ to: data.token.email,
149
+ subject,
150
+ templateName: "practitioner_invitation",
151
+ },
152
+ true
153
+ );
154
+
155
+ return result;
156
+ } catch (error) {
157
+ console.error(
158
+ "[PractitionerInviteMailingService] Error sending invitation email:",
159
+ error
160
+ );
161
+
162
+ // Log failure
163
+ await this.logEmailAttempt(
164
+ {
165
+ to: data.token.email,
166
+ subject: data.options?.customSubject || this.DEFAULT_SUBJECT,
167
+ templateName: "practitioner_invitation",
168
+ },
169
+ false,
170
+ error
171
+ );
172
+
173
+ throw error;
174
+ }
175
+ }
176
+
177
+ /**
178
+ * Handles the practitioner token creation event from Cloud Functions
179
+ * Fetches necessary data using defined types and collection constants,
180
+ * and sends the invitation email.
181
+ * @param tokenData The fully typed token object including its id
182
+ * @param fromAddress The 'from' email address to use, obtained from config
183
+ * @returns Promise resolved when the email is sent
184
+ */
185
+ async handleTokenCreationEvent(
186
+ tokenData: PractitionerToken,
187
+ fromAddress: string
188
+ ): Promise<void> {
189
+ try {
190
+ console.log(
191
+ "[PractitionerInviteMailingService] Handling token creation event for token:",
192
+ tokenData.id
193
+ );
194
+
195
+ // Get practitioner data using constant and type
196
+ const practitionerRef = this.db
197
+ .collection(PRACTITIONERS_COLLECTION)
198
+ .doc(tokenData.practitionerId);
199
+ const practitionerDoc = await practitionerRef.get();
200
+
201
+ if (!practitionerDoc.exists) {
202
+ throw new Error(`Practitioner ${tokenData.practitionerId} not found`);
203
+ }
204
+
205
+ const practitionerData = practitionerDoc.data() as Practitioner;
206
+
207
+ // Get clinic data using constant and type
208
+ const clinicRef = this.db
209
+ .collection(CLINICS_COLLECTION)
210
+ .doc(tokenData.clinicId);
211
+ const clinicDoc = await clinicRef.get();
212
+
213
+ if (!clinicDoc.exists) {
214
+ throw new Error(`Clinic ${tokenData.clinicId} not found`);
215
+ }
216
+
217
+ const clinicData = clinicDoc.data() as Clinic;
218
+
219
+ // Prepare email data using typed data
220
+ const emailData: PractitionerInviteEmailData = {
221
+ token: {
222
+ id: tokenData.id,
223
+ token: tokenData.token,
224
+ practitionerId: tokenData.practitionerId,
225
+ email: tokenData.email,
226
+ clinicId: tokenData.clinicId,
227
+ expiresAt: tokenData.expiresAt,
228
+ },
229
+ practitioner: {
230
+ firstName: practitionerData.basicInfo.firstName || "",
231
+ lastName: practitionerData.basicInfo.lastName || "",
232
+ },
233
+ clinic: {
234
+ name: clinicData.name || "Medical Clinic",
235
+ contactEmail: clinicData.contactInfo.email || "contact@medclinic.com",
236
+ },
237
+ options: {
238
+ fromAddress: fromAddress,
239
+ },
240
+ };
241
+
242
+ // Send the invitation email
243
+ await this.sendInvitationEmail(emailData);
244
+
245
+ console.log(
246
+ "[PractitionerInviteMailingService] Invitation email sent successfully"
247
+ );
248
+ } catch (error) {
249
+ console.error(
250
+ "[PractitionerInviteMailingService] Error handling token creation event:",
251
+ error
252
+ );
253
+ throw error;
254
+ }
255
+ }
256
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * HTML email template for practitioner invitation
3
+ */
4
+ export const practitionerInvitationTemplate = `
5
+ <!DOCTYPE html>
6
+ <html>
7
+ <head>
8
+ <meta charset="UTF-8">
9
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
10
+ <title>Join {{clinicName}} as a Practitioner</title>
11
+ <style>
12
+ body {
13
+ font-family: Arial, sans-serif;
14
+ line-height: 1.6;
15
+ color: #333;
16
+ margin: 0;
17
+ padding: 0;
18
+ }
19
+ .container {
20
+ max-width: 600px;
21
+ margin: 0 auto;
22
+ padding: 20px;
23
+ }
24
+ .header {
25
+ background-color: #4A90E2;
26
+ padding: 20px;
27
+ text-align: center;
28
+ color: white;
29
+ }
30
+ .content {
31
+ padding: 20px;
32
+ background-color: #f9f9f9;
33
+ }
34
+ .footer {
35
+ padding: 20px;
36
+ text-align: center;
37
+ font-size: 12px;
38
+ color: #888;
39
+ }
40
+ .button {
41
+ display: inline-block;
42
+ background-color: #4A90E2;
43
+ color: white;
44
+ text-decoration: none;
45
+ padding: 12px 24px;
46
+ border-radius: 4px;
47
+ margin: 20px 0;
48
+ font-weight: bold;
49
+ }
50
+ .token {
51
+ font-size: 24px;
52
+ font-weight: bold;
53
+ color: #4A90E2;
54
+ padding: 10px;
55
+ background-color: #e9f0f9;
56
+ border-radius: 4px;
57
+ display: inline-block;
58
+ letter-spacing: 2px;
59
+ margin: 10px 0;
60
+ }
61
+ </style>
62
+ </head>
63
+ <body>
64
+ <div class="container">
65
+ <div class="header">
66
+ <h1>You've Been Invited</h1>
67
+ </div>
68
+ <div class="content">
69
+ <p>Hello {{practitionerName}},</p>
70
+
71
+ <p>You have been invited to join <strong>{{clinicName}}</strong> as a healthcare practitioner.</p>
72
+
73
+ <p>Your profile has been created and is ready for you to claim. Please use the following token to register:</p>
74
+
75
+ <div style="text-align: center;">
76
+ <span class="token">{{inviteToken}}</span>
77
+ </div>
78
+
79
+ <p>This token will expire on <strong>{{expirationDate}}</strong>.</p>
80
+
81
+ <p>To create your account:</p>
82
+ <ol>
83
+ <li>Visit {{registrationUrl}}</li>
84
+ <li>Enter your email and create a password</li>
85
+ <li>When prompted, enter the token above</li>
86
+ </ol>
87
+
88
+ <div style="text-align: center;">
89
+ <a href="{{registrationUrl}}" class="button">Create Your Account</a>
90
+ </div>
91
+
92
+ <p>If you have any questions, please contact {{contactName}} at {{contactEmail}}.</p>
93
+ </div>
94
+ <div class="footer">
95
+ <p>This is an automated message from {{clinicName}}. Please do not reply to this email.</p>
96
+ <p>&copy; {{currentYear}} {{clinicName}}. All rights reserved.</p>
97
+ </div>
98
+ </div>
99
+ </body>
100
+ </html>
101
+ `;
@@ -0,0 +1,106 @@
1
+ # Services
2
+
3
+ This directory contains service modules that implement the business logic of the application. Services act as an intermediary layer between the API handlers and data models, encapsulating complex operations and enforcing business rules.
4
+
5
+ ## Service Responsibilities
6
+
7
+ Services handle:
8
+
9
+ 1. **Business Logic**: Implementing complex business rules and workflows
10
+ 2. **Data Validation**: Ensuring data integrity before persistence
11
+ 3. **Transaction Management**: Handling atomic operations across multiple entities
12
+ 4. **Error Handling**: Providing consistent error responses for business logic failures
13
+ 5. **Integration**: Coordinating interactions with external services and APIs
14
+
15
+ ## Service Structure
16
+
17
+ Each service typically follows this pattern:
18
+
19
+ ```typescript
20
+ // Service function signature
21
+ export const someServiceFunction = async (
22
+ params: SomeParamsType,
23
+ options?: SomeOptionsType
24
+ ): Promise<SomeReturnType> => {
25
+ try {
26
+ // 1. Input validation
27
+ const validatedData = someSchema.parse(params);
28
+
29
+ // 2. Business logic implementation
30
+ // ...
31
+
32
+ // 3. Data persistence
33
+ const result = await saveToDatabase(processedData);
34
+
35
+ // 4. Return formatted response
36
+ return result;
37
+ } catch (error) {
38
+ // Error handling and transformation
39
+ if (error instanceof z.ZodError) {
40
+ throw new ValidationError("Invalid input data", error);
41
+ }
42
+ // Other error handling...
43
+ throw error;
44
+ }
45
+ };
46
+ ```
47
+
48
+ ## Core Services
49
+
50
+ The application includes the following service modules:
51
+
52
+ - **auth**: User authentication and authorization
53
+ - **user**: User profile management
54
+ - **practitioner**: Practitioner profile and availability management
55
+ - **clinic**: Clinic/facility management
56
+ - **procedure**: Medical procedure and service management
57
+ - **appointment**: Appointment scheduling and management
58
+ - **review**: Practitioner review and rating
59
+ - **search**: Search functionality across entities
60
+ - **notification**: User notification delivery
61
+ - **file**: File upload and management
62
+
63
+ ## Error Handling
64
+
65
+ Services use a consistent error handling approach:
66
+
67
+ - Custom error types for different failure scenarios
68
+ - Error transformation to provide meaningful context
69
+ - Detailed error information for debugging while maintaining security
70
+
71
+ ## Transaction Management
72
+
73
+ For operations affecting multiple entities, services implement transaction patterns to ensure data consistency:
74
+
75
+ ```typescript
76
+ // Example transaction pattern
77
+ export const complexOperation = async (data: SomeType): Promise<ResultType> => {
78
+ // Begin transaction context
79
+ try {
80
+ // Multiple database operations...
81
+
82
+ // If all succeed, return result
83
+ return result;
84
+ } catch (error) {
85
+ // Handle error and ensure rollback if needed
86
+ throw error;
87
+ }
88
+ };
89
+ ```
90
+
91
+ ## Service Dependencies
92
+
93
+ Services may depend on other services to complete their operations. Dependencies are typically:
94
+
95
+ - Explicitly imported at the module level
96
+ - Passed as parameters to functions when needed for testing
97
+ - Designed to avoid circular dependencies
98
+
99
+ ## Testing
100
+
101
+ Services are designed to be easily testable:
102
+
103
+ - Pure functions where possible
104
+ - External dependencies injectable for mocking
105
+ - Clear input/output contracts
106
+ - Isolated business logic
@@ -0,0 +1,87 @@
1
+ # Clinic Service
2
+
3
+ This service manages clinic data within the Firestore database. It provides methods for creating, reading, updating, and managing clinics, their branches, and associated data like tags and administrators.
4
+
5
+ **Note:** This service relies on helper functions defined in the `./utils` directory for specific operations like photo uploads, geo-queries, and data fetching. Cloud Functions handle data aggregation into related entities (Practitioners, Procedures, ClinicGroups).
6
+
7
+ ## `ClinicService` Class
8
+
9
+ Extends `BaseService`.
10
+
11
+ ### Constructor
12
+
13
+ ```typescript
14
+ constructor(
15
+ db: Firestore,
16
+ auth: Auth,
17
+ app: FirebaseApp,
18
+ clinicGroupService: ClinicGroupService,
19
+ clinicAdminService: ClinicAdminService
20
+ )
21
+ ```
22
+
23
+ Initializes the service with Firestore, Auth, App instances, and required dependency services (`ClinicGroupService`, `ClinicAdminService`).
24
+
25
+ ### Core Methods
26
+
27
+ - **`createClinic(data: CreateClinicData, creatorAdminId: string): Promise<Clinic>`**
28
+ - Creates a new clinic document in Firestore.
29
+ - Validates input data using `createClinicSchema`.
30
+ - Verifies the `creatorAdminId` and their association with the specified `clinicGroupId`.
31
+ - Calculates the geohash for the clinic's location.
32
+ - Initializes default review information.
33
+ - Handles photo uploads for logo, cover photo, featured photos, and photos with tags using utility functions.
34
+ - Updates the creator admin's `clinicsManaged` list.
35
+ - **Aggregation Note:** Adding the clinic to the `ClinicGroup` is handled by Cloud Functions.
36
+ - **`updateClinic(clinicId: string, data: Partial<Omit<Clinic, \"id\" | \"createdAt\" | \"clinicGroupId\">>, adminId: string): Promise<Clinic>`**
37
+ - Updates an existing clinic document.
38
+ - Fetches the current clinic data.
39
+ - Validates the partial update data against the `clinicSchema` after merging with existing data.
40
+ - Updates the geohash if the location is changed.
41
+ - Handles photo updates/uploads if necessary.
42
+ - **Aggregation Note:** Aggregation updates in related entities (Practitioners, Procedures, ClinicGroup) are handled by Cloud Functions.
43
+ - **`deactivateClinic(clinicId: string, adminId: string): Promise<void>`**
44
+ - Sets the `isActive` flag of a clinic to `false`.
45
+ - Requires admin permission (verification logic might be in the calling function or assumed).
46
+ - **Aggregation Note:** Aggregation updates (e.g., removing from active lists, updating related entities) are handled by Cloud Functions.
47
+ - **`getClinic(clinicId: string): Promise<Clinic | null>`**
48
+ - Retrieves a single clinic document by its ID using `ClinicUtils.getClinic`.
49
+ - **`getClinicsByGroup(groupId: string): Promise<Clinic[]>`**
50
+ - Retrieves all clinic documents belonging to a specific `clinicGroupId` using `ClinicUtils.getClinicsByGroup`.
51
+ - **`findClinicsInRadius(center: { latitude: number; longitude: number }, radiusInKm: number, filters?: { procedures?: string[]; tags?: ClinicTag[] }): Promise<Clinic[]>`**
52
+ - Finds active clinics within a specified radius using geohash queries.
53
+ - Leverages `SearchUtils.findClinicsInRadius`.
54
+ - Optionally filters results by procedure IDs (services) and tags.
55
+ - **`addTags(clinicId: string, adminId: string, newTags: { tags?: ClinicTag[] }): Promise<Clinic>`**
56
+ - Adds specified tags to a clinic's `tags` array using `TagUtils.addTags`.
57
+ - Ensures the admin has permission. Prevents duplicates.
58
+ - **`removeTags(clinicId: string, adminId: string, tagsToRemove: { tags?: ClinicTag[] }): Promise<Clinic>`**
59
+ - Removes specified tags from a clinic's `tags` array using `TagUtils.removeTags`.
60
+ - Ensures the admin has permission.
61
+ - **`getClinicsByAdmin(adminId: string, options?: { isActive?: boolean; includeGroupClinics?: boolean }): Promise<Clinic[]>`**
62
+ - Retrieves clinics associated with a specific admin using `ClinicUtils.getClinicsByAdmin`.
63
+ - Handles options for filtering by `isActive` and including all group clinics for owners.
64
+ - **`getActiveClinicsByAdmin(adminId: string): Promise<Clinic[]>`**
65
+ - Retrieves only the active clinics associated with a specific admin using `ClinicUtils.getActiveClinicsByAdmin`.
66
+ - **`createClinicBranch(clinicGroupId: string, setupData: ClinicBranchSetupData, adminId: string): Promise<Clinic>`**
67
+ - Creates a new clinic (branch) within an existing `clinicGroupId`.
68
+ - Validates group existence. Uses `createClinic` internally.
69
+ - **`getClinicById(clinicId: string): Promise<Clinic | null>`**
70
+ - Retrieves a single clinic document by its ID using `ClinicUtils.getClinicById`.
71
+ - **`getAllClinics(pagination?: number, lastDoc?: any): Promise<{ clinics: Clinic[]; lastDoc: any }>`**
72
+ - Retrieves all clinics, ordered by ID, optionally with pagination, using `ClinicUtils.getAllClinics`.
73
+ - **`getAllClinicsInRange(center: { latitude: number; longitude: number }, rangeInKm: number, pagination?: number, lastDoc?: any): Promise<{ clinics: (Clinic & { distance: number })[]; lastDoc: any }>`**
74
+ - Retrieves all clinics within a range, sorted by distance, optionally with pagination, using `ClinicUtils.getAllClinicsInRange`.
75
+ - **`getClinicsByFilters(filters: { ... }): Promise<{ clinics: (Clinic & { distance?: number })[]; lastDoc: any }>`**
76
+ - Retrieves clinics based on complex filters (location, tags, procedures, rating, etc.).
77
+ - Uses `FilterUtils.getClinicsByFilters` for combined query and in-memory filtering.
78
+
79
+ ### Utility Functions (`./utils`)
80
+
81
+ - **`clinic.utils.ts`**: Core fetching (`getClinic`, `getClinicsByGroup`, etc.), create/update/deactivate logic wrappers, admin checks.
82
+ - **`filter.utils.ts`**: Complex filtering logic (`getClinicsByFilters`), including geo-queries.
83
+ - **`clinic-group.utils.ts`**: Helpers for clinic group interactions (used by `ClinicGroupService`).
84
+ - **`tag.utils.ts`**: Logic for adding/removing tags (`addTags`, `removeTags`).
85
+ - **`photos.utils.ts`**: Firebase Storage interactions (`uploadPhoto`, `uploadMultiplePhotos`, `deletePhoto`).
86
+ - **`admin.utils.ts`**: Helpers for clinic admin interactions (used by `ClinicAdminService`).
87
+ - **`search.utils.ts`**: Geo-radius search logic (`findClinicsInRadius`).