@blackcode_sa/metaestetics-api 1.7.23 → 1.7.24
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/index.d.mts +189 -1
- package/dist/index.d.ts +189 -1
- package/dist/index.js +983 -576
- package/dist/index.mjs +1110 -692
- package/package.json +1 -1
- package/src/index.ts +8 -0
- package/src/services/clinic/README.md +117 -0
- package/src/services/clinic/practitioner-invite.service.ts +519 -0
- package/src/types/clinic/index.ts +3 -0
- package/src/types/clinic/practitioner-invite.types.ts +91 -0
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -25,6 +25,7 @@ export { ProcedureService } from "./services/procedure/procedure.service";
|
|
|
25
25
|
export { ClinicService } from "./services/clinic/clinic.service";
|
|
26
26
|
export { ClinicAdminService } from "./services/clinic/clinic-admin.service";
|
|
27
27
|
export { ClinicGroupService } from "./services/clinic/clinic-group.service";
|
|
28
|
+
export { PractitionerInviteService } from "./services/clinic/practitioner-invite.service";
|
|
28
29
|
export {
|
|
29
30
|
DocumentationTemplateService,
|
|
30
31
|
FilledDocumentService,
|
|
@@ -197,17 +198,24 @@ export type {
|
|
|
197
198
|
CreateDefaultClinicGroupData,
|
|
198
199
|
ClinicGroupSetupData,
|
|
199
200
|
ClinicBranchSetupData,
|
|
201
|
+
PractitionerInvite,
|
|
202
|
+
CreatePractitionerInviteData,
|
|
203
|
+
UpdatePractitionerInviteData,
|
|
204
|
+
PractitionerInviteFilters,
|
|
205
|
+
ProposedWorkingHours,
|
|
200
206
|
} from "./types/clinic";
|
|
201
207
|
export {
|
|
202
208
|
CLINICS_COLLECTION,
|
|
203
209
|
CLINIC_GROUPS_COLLECTION,
|
|
204
210
|
CLINIC_ADMINS_COLLECTION,
|
|
211
|
+
PRACTITIONER_INVITES_COLLECTION,
|
|
205
212
|
PracticeType,
|
|
206
213
|
Language,
|
|
207
214
|
ClinicTag,
|
|
208
215
|
ClinicPhotoTag,
|
|
209
216
|
AdminTokenStatus,
|
|
210
217
|
SubscriptionModel,
|
|
218
|
+
PractitionerInviteStatus,
|
|
211
219
|
} from "./types/clinic";
|
|
212
220
|
|
|
213
221
|
// Profile info types
|
|
@@ -85,3 +85,120 @@ Initializes the service with Firestore, Auth, App instances, and required depend
|
|
|
85
85
|
- **`photos.utils.ts`**: Firebase Storage interactions (`uploadPhoto`, `uploadMultiplePhotos`, `deletePhoto`).
|
|
86
86
|
- **`admin.utils.ts`**: Helpers for clinic admin interactions (used by `ClinicAdminService`).
|
|
87
87
|
- **`search.utils.ts`**: Geo-radius search logic (`findClinicsInRadius`).
|
|
88
|
+
|
|
89
|
+
# Clinic Services
|
|
90
|
+
|
|
91
|
+
This directory contains services related to clinic operations.
|
|
92
|
+
|
|
93
|
+
## Services
|
|
94
|
+
|
|
95
|
+
### ClinicService
|
|
96
|
+
|
|
97
|
+
Handles clinic CRUD operations, searching, and management.
|
|
98
|
+
|
|
99
|
+
### ClinicGroupService
|
|
100
|
+
|
|
101
|
+
Manages clinic groups and their operations.
|
|
102
|
+
|
|
103
|
+
### ClinicAdminService
|
|
104
|
+
|
|
105
|
+
Handles clinic administrator operations.
|
|
106
|
+
|
|
107
|
+
### PractitionerInviteService
|
|
108
|
+
|
|
109
|
+
Manages the practitioner invitation system for clinics.
|
|
110
|
+
|
|
111
|
+
## PractitionerInviteService
|
|
112
|
+
|
|
113
|
+
The `PractitionerInviteService` handles the complete flow of inviting practitioners to join clinics.
|
|
114
|
+
|
|
115
|
+
### Flow
|
|
116
|
+
|
|
117
|
+
1. Clinic searches for active practitioners by name or email (using existing practitioner search services)
|
|
118
|
+
2. Clinic creates an invite request with proposed working hours
|
|
119
|
+
3. Practitioner receives the invite and can accept or reject it
|
|
120
|
+
4. Admins can cancel pending invites if needed
|
|
121
|
+
5. Connection between practitioner and clinic is handled by aggregation side-effects
|
|
122
|
+
|
|
123
|
+
### Methods
|
|
124
|
+
|
|
125
|
+
#### Core Invite Methods
|
|
126
|
+
|
|
127
|
+
- `createInviteAdmin(practitionerId, clinicId, proposedWorkingHours, invitedBy, message?)` - Create a new invite
|
|
128
|
+
- `acceptInviteDoctor(inviteId)` - Doctor accepts an invite
|
|
129
|
+
- `rejectInviteDoctor(inviteId, rejectionReason?)` - Doctor rejects an invite
|
|
130
|
+
- `cancelInviteAdmin(inviteId, cancelReason?)` - Admin cancels a pending invite
|
|
131
|
+
|
|
132
|
+
#### Retrieval Methods
|
|
133
|
+
|
|
134
|
+
- `getAllInvitesDoctor(practitionerId, statusFilter?)` - Get all invites for a practitioner
|
|
135
|
+
- `getAllInvitesClinic(clinicId, statusFilter?)` - Get all invites for a clinic
|
|
136
|
+
- `getInviteById(inviteId)` - Get a specific invite
|
|
137
|
+
- `getInvitesWithFilters(filters)` - Advanced filtering
|
|
138
|
+
|
|
139
|
+
#### Admin Methods
|
|
140
|
+
|
|
141
|
+
- `deleteInvite(inviteId)` - Delete an invite (admin only)
|
|
142
|
+
|
|
143
|
+
### Status Filtering
|
|
144
|
+
|
|
145
|
+
All retrieval methods support filtering by invite status:
|
|
146
|
+
|
|
147
|
+
- `PENDING` - Invite has been sent but not responded to
|
|
148
|
+
- `ACCEPTED` - Practitioner has accepted the invite
|
|
149
|
+
- `REJECTED` - Practitioner has rejected the invite
|
|
150
|
+
- `CANCELLED` - Admin has cancelled the invite
|
|
151
|
+
|
|
152
|
+
### Usage Example
|
|
153
|
+
|
|
154
|
+
```typescript
|
|
155
|
+
// Create service instance
|
|
156
|
+
const practitionerInviteService = new PractitionerInviteService(db, auth, app);
|
|
157
|
+
|
|
158
|
+
// Create an invite
|
|
159
|
+
const invite = await practitionerInviteService.createInviteAdmin(
|
|
160
|
+
"practitioner123",
|
|
161
|
+
"clinic456",
|
|
162
|
+
proposedHours,
|
|
163
|
+
"admin789",
|
|
164
|
+
"We'd love to have you join our team!"
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
// Get clinic's invites
|
|
168
|
+
const clinicInvites = await practitionerInviteService.getAllInvitesClinic(
|
|
169
|
+
"clinic456",
|
|
170
|
+
[PractitionerInviteStatus.PENDING]
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
// Doctor accepts invite
|
|
174
|
+
const acceptedInvite = await practitionerInviteService.acceptInviteDoctor(
|
|
175
|
+
invite.id
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
// Admin cancels pending invite
|
|
179
|
+
const cancelledInvite = await practitionerInviteService.cancelInviteAdmin(
|
|
180
|
+
invite.id,
|
|
181
|
+
"Position no longer available"
|
|
182
|
+
);
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
### Data Structure
|
|
186
|
+
|
|
187
|
+
The service uses existing aggregation types from the profile module:
|
|
188
|
+
|
|
189
|
+
- `PractitionerProfileInfo` - Complete practitioner information including certification
|
|
190
|
+
- `ClinicInfo` - Complete clinic information including location and contact details
|
|
191
|
+
|
|
192
|
+
### Error Handling
|
|
193
|
+
|
|
194
|
+
All methods include proper error handling and logging. Common errors:
|
|
195
|
+
|
|
196
|
+
- Practitioner/Clinic not found
|
|
197
|
+
- Duplicate pending invites
|
|
198
|
+
- Invalid status transitions (only pending invites can be accepted/rejected/cancelled)
|
|
199
|
+
- Permission issues
|
|
200
|
+
|
|
201
|
+
### Firestore Collections
|
|
202
|
+
|
|
203
|
+
- Collection: `practitioner-invites`
|
|
204
|
+
- Documents contain complete invite information with embedded practitioner and clinic details
|
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
import {
|
|
2
|
+
collection,
|
|
3
|
+
doc,
|
|
4
|
+
getDoc,
|
|
5
|
+
getDocs,
|
|
6
|
+
query,
|
|
7
|
+
where,
|
|
8
|
+
updateDoc,
|
|
9
|
+
setDoc,
|
|
10
|
+
deleteDoc,
|
|
11
|
+
Timestamp,
|
|
12
|
+
serverTimestamp,
|
|
13
|
+
QueryConstraint,
|
|
14
|
+
orderBy,
|
|
15
|
+
limit,
|
|
16
|
+
and,
|
|
17
|
+
or,
|
|
18
|
+
} from "firebase/firestore";
|
|
19
|
+
import { BaseService } from "../base.service";
|
|
20
|
+
import {
|
|
21
|
+
PractitionerInvite,
|
|
22
|
+
CreatePractitionerInviteData,
|
|
23
|
+
UpdatePractitionerInviteData,
|
|
24
|
+
PractitionerInviteFilters,
|
|
25
|
+
PractitionerInviteStatus,
|
|
26
|
+
PRACTITIONER_INVITES_COLLECTION,
|
|
27
|
+
} from "../../types/clinic/practitioner-invite.types";
|
|
28
|
+
import { ClinicInfo, PractitionerProfileInfo } from "../../types/profile";
|
|
29
|
+
import {
|
|
30
|
+
PRACTITIONERS_COLLECTION,
|
|
31
|
+
Practitioner,
|
|
32
|
+
} from "../../types/practitioner";
|
|
33
|
+
import { CLINICS_COLLECTION, Clinic } from "../../types/clinic";
|
|
34
|
+
import { Auth } from "firebase/auth";
|
|
35
|
+
import { Firestore } from "firebase/firestore";
|
|
36
|
+
import { FirebaseApp } from "firebase/app";
|
|
37
|
+
|
|
38
|
+
export class PractitionerInviteService extends BaseService {
|
|
39
|
+
constructor(db: Firestore, auth: Auth, app: FirebaseApp) {
|
|
40
|
+
super(db, auth, app);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Creates a new practitioner invite
|
|
45
|
+
* @param practitionerId - Practitioner ID
|
|
46
|
+
* @param clinicId - Clinic ID
|
|
47
|
+
* @param proposedWorkingHours - Proposed working hours
|
|
48
|
+
* @param invitedBy - Admin ID who creates the invite
|
|
49
|
+
* @param message - Optional message
|
|
50
|
+
* @returns Created invite
|
|
51
|
+
*/
|
|
52
|
+
async createInviteAdmin(
|
|
53
|
+
practitionerId: string,
|
|
54
|
+
clinicId: string,
|
|
55
|
+
proposedWorkingHours: any,
|
|
56
|
+
invitedBy: string,
|
|
57
|
+
message?: string
|
|
58
|
+
): Promise<PractitionerInvite> {
|
|
59
|
+
try {
|
|
60
|
+
const inviteId = this.generateId();
|
|
61
|
+
|
|
62
|
+
// Fetch practitioner and clinic information
|
|
63
|
+
const [practitioner, clinic] = await Promise.all([
|
|
64
|
+
this.getPractitionerById(practitionerId),
|
|
65
|
+
this.getClinicById(clinicId),
|
|
66
|
+
]);
|
|
67
|
+
|
|
68
|
+
if (!practitioner) {
|
|
69
|
+
throw new Error(`Practitioner with ID ${practitionerId} not found`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (!clinic) {
|
|
73
|
+
throw new Error(`Clinic with ID ${clinicId} not found`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Check if there's already a pending invite for this practitioner-clinic pair
|
|
77
|
+
const existingInvite = await this.findExistingInvite(
|
|
78
|
+
practitionerId,
|
|
79
|
+
clinicId
|
|
80
|
+
);
|
|
81
|
+
if (
|
|
82
|
+
existingInvite &&
|
|
83
|
+
existingInvite.status === PractitionerInviteStatus.PENDING
|
|
84
|
+
) {
|
|
85
|
+
throw new Error(
|
|
86
|
+
"There's already a pending invite for this practitioner at this clinic"
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Build practitioner info using existing aggregation type
|
|
91
|
+
const practitionerInfo: PractitionerProfileInfo = {
|
|
92
|
+
id: practitioner.id,
|
|
93
|
+
practitionerPhoto:
|
|
94
|
+
typeof practitioner.basicInfo.profileImageUrl === "string"
|
|
95
|
+
? practitioner.basicInfo.profileImageUrl
|
|
96
|
+
: null,
|
|
97
|
+
name: `${practitioner.basicInfo.firstName} ${practitioner.basicInfo.lastName}`,
|
|
98
|
+
email: practitioner.basicInfo.email,
|
|
99
|
+
phone: practitioner.basicInfo.phoneNumber || null,
|
|
100
|
+
certification: practitioner.certification,
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
// Build clinic info using existing aggregation type
|
|
104
|
+
const clinicInfo: ClinicInfo = {
|
|
105
|
+
id: clinic.id,
|
|
106
|
+
featuredPhoto:
|
|
107
|
+
typeof clinic.coverPhoto === "string" ? clinic.coverPhoto : "",
|
|
108
|
+
name: clinic.name,
|
|
109
|
+
description: clinic.description || null,
|
|
110
|
+
location: clinic.location,
|
|
111
|
+
contactInfo: clinic.contactInfo,
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const inviteData: CreatePractitionerInviteData = {
|
|
115
|
+
practitionerId,
|
|
116
|
+
clinicId,
|
|
117
|
+
practitionerInfo,
|
|
118
|
+
clinicInfo,
|
|
119
|
+
proposedWorkingHours,
|
|
120
|
+
invitedBy,
|
|
121
|
+
message: message || null,
|
|
122
|
+
status: PractitionerInviteStatus.PENDING,
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const now = Timestamp.now();
|
|
126
|
+
const invite: PractitionerInvite = {
|
|
127
|
+
id: inviteId,
|
|
128
|
+
...inviteData,
|
|
129
|
+
status: PractitionerInviteStatus.PENDING,
|
|
130
|
+
createdAt: now,
|
|
131
|
+
updatedAt: now,
|
|
132
|
+
acceptedAt: null,
|
|
133
|
+
rejectedAt: null,
|
|
134
|
+
cancelledAt: null,
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const docRef = doc(this.db, PRACTITIONER_INVITES_COLLECTION, inviteId);
|
|
138
|
+
await setDoc(docRef, invite);
|
|
139
|
+
|
|
140
|
+
return invite;
|
|
141
|
+
} catch (error) {
|
|
142
|
+
console.error(
|
|
143
|
+
"[PractitionerInviteService] Error creating invite:",
|
|
144
|
+
error
|
|
145
|
+
);
|
|
146
|
+
throw error;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Gets all invites for a specific doctor/practitioner
|
|
152
|
+
* @param practitionerId - Practitioner ID
|
|
153
|
+
* @param statusFilter - Optional status filter
|
|
154
|
+
* @returns Array of invites
|
|
155
|
+
*/
|
|
156
|
+
async getAllInvitesDoctor(
|
|
157
|
+
practitionerId: string,
|
|
158
|
+
statusFilter?: PractitionerInviteStatus[]
|
|
159
|
+
): Promise<PractitionerInvite[]> {
|
|
160
|
+
try {
|
|
161
|
+
const constraints: QueryConstraint[] = [
|
|
162
|
+
where("practitionerId", "==", practitionerId),
|
|
163
|
+
orderBy("createdAt", "desc"),
|
|
164
|
+
];
|
|
165
|
+
|
|
166
|
+
if (statusFilter && statusFilter.length > 0) {
|
|
167
|
+
constraints.push(where("status", "in", statusFilter));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const q = query(
|
|
171
|
+
collection(this.db, PRACTITIONER_INVITES_COLLECTION),
|
|
172
|
+
...constraints
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
const querySnapshot = await getDocs(q);
|
|
176
|
+
return querySnapshot.docs.map((doc) => doc.data() as PractitionerInvite);
|
|
177
|
+
} catch (error) {
|
|
178
|
+
console.error(
|
|
179
|
+
"[PractitionerInviteService] Error getting doctor invites:",
|
|
180
|
+
error
|
|
181
|
+
);
|
|
182
|
+
throw error;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Gets all invites for a specific clinic
|
|
188
|
+
* @param clinicId - Clinic ID
|
|
189
|
+
* @param statusFilter - Optional status filter
|
|
190
|
+
* @returns Array of invites
|
|
191
|
+
*/
|
|
192
|
+
async getAllInvitesClinic(
|
|
193
|
+
clinicId: string,
|
|
194
|
+
statusFilter?: PractitionerInviteStatus[]
|
|
195
|
+
): Promise<PractitionerInvite[]> {
|
|
196
|
+
try {
|
|
197
|
+
const constraints: QueryConstraint[] = [
|
|
198
|
+
where("clinicId", "==", clinicId),
|
|
199
|
+
orderBy("createdAt", "desc"),
|
|
200
|
+
];
|
|
201
|
+
|
|
202
|
+
if (statusFilter && statusFilter.length > 0) {
|
|
203
|
+
constraints.push(where("status", "in", statusFilter));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const q = query(
|
|
207
|
+
collection(this.db, PRACTITIONER_INVITES_COLLECTION),
|
|
208
|
+
...constraints
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
const querySnapshot = await getDocs(q);
|
|
212
|
+
return querySnapshot.docs.map((doc) => doc.data() as PractitionerInvite);
|
|
213
|
+
} catch (error) {
|
|
214
|
+
console.error(
|
|
215
|
+
"[PractitionerInviteService] Error getting clinic invites:",
|
|
216
|
+
error
|
|
217
|
+
);
|
|
218
|
+
throw error;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Doctor accepts an invite
|
|
224
|
+
* @param inviteId - Invite ID
|
|
225
|
+
* @returns Updated invite
|
|
226
|
+
*/
|
|
227
|
+
async acceptInviteDoctor(inviteId: string): Promise<PractitionerInvite> {
|
|
228
|
+
try {
|
|
229
|
+
const invite = await this.getInviteById(inviteId);
|
|
230
|
+
if (!invite) {
|
|
231
|
+
throw new Error(`Invite with ID ${inviteId} not found`);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (invite.status !== PractitionerInviteStatus.PENDING) {
|
|
235
|
+
throw new Error("Only pending invites can be accepted");
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const updateData = {
|
|
239
|
+
status: PractitionerInviteStatus.ACCEPTED,
|
|
240
|
+
acceptedAt: Timestamp.now(),
|
|
241
|
+
updatedAt: serverTimestamp(),
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
const docRef = doc(this.db, PRACTITIONER_INVITES_COLLECTION, inviteId);
|
|
245
|
+
await updateDoc(docRef, updateData);
|
|
246
|
+
|
|
247
|
+
// Return updated invite
|
|
248
|
+
return (await this.getInviteById(inviteId)) as PractitionerInvite;
|
|
249
|
+
} catch (error) {
|
|
250
|
+
console.error(
|
|
251
|
+
"[PractitionerInviteService] Error accepting invite:",
|
|
252
|
+
error
|
|
253
|
+
);
|
|
254
|
+
throw error;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Doctor rejects an invite
|
|
260
|
+
* @param inviteId - Invite ID
|
|
261
|
+
* @param rejectionReason - Optional reason for rejection
|
|
262
|
+
* @returns Updated invite
|
|
263
|
+
*/
|
|
264
|
+
async rejectInviteDoctor(
|
|
265
|
+
inviteId: string,
|
|
266
|
+
rejectionReason?: string
|
|
267
|
+
): Promise<PractitionerInvite> {
|
|
268
|
+
try {
|
|
269
|
+
const invite = await this.getInviteById(inviteId);
|
|
270
|
+
if (!invite) {
|
|
271
|
+
throw new Error(`Invite with ID ${inviteId} not found`);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (invite.status !== PractitionerInviteStatus.PENDING) {
|
|
275
|
+
throw new Error("Only pending invites can be rejected");
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const updateData = {
|
|
279
|
+
status: PractitionerInviteStatus.REJECTED,
|
|
280
|
+
rejectionReason: rejectionReason || null,
|
|
281
|
+
rejectedAt: Timestamp.now(),
|
|
282
|
+
updatedAt: serverTimestamp(),
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
const docRef = doc(this.db, PRACTITIONER_INVITES_COLLECTION, inviteId);
|
|
286
|
+
await updateDoc(docRef, updateData);
|
|
287
|
+
|
|
288
|
+
// Return updated invite
|
|
289
|
+
return (await this.getInviteById(inviteId)) as PractitionerInvite;
|
|
290
|
+
} catch (error) {
|
|
291
|
+
console.error(
|
|
292
|
+
"[PractitionerInviteService] Error rejecting invite:",
|
|
293
|
+
error
|
|
294
|
+
);
|
|
295
|
+
throw error;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Admin cancels an invite
|
|
301
|
+
* @param inviteId - Invite ID
|
|
302
|
+
* @param cancelReason - Optional reason for cancellation
|
|
303
|
+
* @returns Updated invite
|
|
304
|
+
*/
|
|
305
|
+
async cancelInviteAdmin(
|
|
306
|
+
inviteId: string,
|
|
307
|
+
cancelReason?: string
|
|
308
|
+
): Promise<PractitionerInvite> {
|
|
309
|
+
try {
|
|
310
|
+
const invite = await this.getInviteById(inviteId);
|
|
311
|
+
if (!invite) {
|
|
312
|
+
throw new Error(`Invite with ID ${inviteId} not found`);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (invite.status !== PractitionerInviteStatus.PENDING) {
|
|
316
|
+
throw new Error("Only pending invites can be cancelled");
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const updateData = {
|
|
320
|
+
status: PractitionerInviteStatus.CANCELLED,
|
|
321
|
+
cancelReason: cancelReason || null,
|
|
322
|
+
cancelledAt: Timestamp.now(),
|
|
323
|
+
updatedAt: serverTimestamp(),
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
const docRef = doc(this.db, PRACTITIONER_INVITES_COLLECTION, inviteId);
|
|
327
|
+
await updateDoc(docRef, updateData);
|
|
328
|
+
|
|
329
|
+
// Return updated invite
|
|
330
|
+
return (await this.getInviteById(inviteId)) as PractitionerInvite;
|
|
331
|
+
} catch (error) {
|
|
332
|
+
console.error(
|
|
333
|
+
"[PractitionerInviteService] Error cancelling invite:",
|
|
334
|
+
error
|
|
335
|
+
);
|
|
336
|
+
throw error;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Gets an invite by ID
|
|
342
|
+
* @param inviteId - Invite ID
|
|
343
|
+
* @returns Invite or null if not found
|
|
344
|
+
*/
|
|
345
|
+
async getInviteById(inviteId: string): Promise<PractitionerInvite | null> {
|
|
346
|
+
try {
|
|
347
|
+
const docRef = doc(this.db, PRACTITIONER_INVITES_COLLECTION, inviteId);
|
|
348
|
+
const docSnap = await getDoc(docRef);
|
|
349
|
+
|
|
350
|
+
if (docSnap.exists()) {
|
|
351
|
+
return docSnap.data() as PractitionerInvite;
|
|
352
|
+
}
|
|
353
|
+
return null;
|
|
354
|
+
} catch (error) {
|
|
355
|
+
console.error(
|
|
356
|
+
"[PractitionerInviteService] Error getting invite by ID:",
|
|
357
|
+
error
|
|
358
|
+
);
|
|
359
|
+
throw error;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Gets invites with advanced filtering options
|
|
365
|
+
* @param filters - Filter options
|
|
366
|
+
* @returns Array of filtered invites
|
|
367
|
+
*/
|
|
368
|
+
async getInvitesWithFilters(
|
|
369
|
+
filters: PractitionerInviteFilters
|
|
370
|
+
): Promise<PractitionerInvite[]> {
|
|
371
|
+
try {
|
|
372
|
+
const constraints: QueryConstraint[] = [];
|
|
373
|
+
|
|
374
|
+
// Add filters
|
|
375
|
+
if (filters.practitionerId) {
|
|
376
|
+
constraints.push(where("practitionerId", "==", filters.practitionerId));
|
|
377
|
+
}
|
|
378
|
+
if (filters.clinicId) {
|
|
379
|
+
constraints.push(where("clinicId", "==", filters.clinicId));
|
|
380
|
+
}
|
|
381
|
+
if (filters.invitedBy) {
|
|
382
|
+
constraints.push(where("invitedBy", "==", filters.invitedBy));
|
|
383
|
+
}
|
|
384
|
+
if (filters.status && filters.status.length > 0) {
|
|
385
|
+
constraints.push(where("status", "in", filters.status));
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Add ordering
|
|
389
|
+
const orderField = filters.orderBy || "createdAt";
|
|
390
|
+
const orderDirection = filters.orderDirection || "desc";
|
|
391
|
+
constraints.push(orderBy(orderField, orderDirection));
|
|
392
|
+
|
|
393
|
+
// Add limit
|
|
394
|
+
if (filters.limit) {
|
|
395
|
+
constraints.push(limit(filters.limit));
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const q = query(
|
|
399
|
+
collection(this.db, PRACTITIONER_INVITES_COLLECTION),
|
|
400
|
+
...constraints
|
|
401
|
+
);
|
|
402
|
+
|
|
403
|
+
const querySnapshot = await getDocs(q);
|
|
404
|
+
let invites = querySnapshot.docs.map(
|
|
405
|
+
(doc) => doc.data() as PractitionerInvite
|
|
406
|
+
);
|
|
407
|
+
|
|
408
|
+
// Apply date filters (client-side filtering due to Firestore limitations)
|
|
409
|
+
if (filters.fromDate) {
|
|
410
|
+
invites = invites.filter(
|
|
411
|
+
(invite) => invite.createdAt >= filters.fromDate!
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
if (filters.toDate) {
|
|
415
|
+
invites = invites.filter(
|
|
416
|
+
(invite) => invite.createdAt <= filters.toDate!
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
return invites;
|
|
421
|
+
} catch (error) {
|
|
422
|
+
console.error(
|
|
423
|
+
"[PractitionerInviteService] Error getting invites with filters:",
|
|
424
|
+
error
|
|
425
|
+
);
|
|
426
|
+
throw error;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Deletes an invite (admin only)
|
|
432
|
+
* @param inviteId - Invite ID
|
|
433
|
+
*/
|
|
434
|
+
async deleteInvite(inviteId: string): Promise<void> {
|
|
435
|
+
try {
|
|
436
|
+
const docRef = doc(this.db, PRACTITIONER_INVITES_COLLECTION, inviteId);
|
|
437
|
+
await deleteDoc(docRef);
|
|
438
|
+
} catch (error) {
|
|
439
|
+
console.error(
|
|
440
|
+
"[PractitionerInviteService] Error deleting invite:",
|
|
441
|
+
error
|
|
442
|
+
);
|
|
443
|
+
throw error;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Private helper methods
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Gets practitioner by ID
|
|
451
|
+
* @param practitionerId - Practitioner ID
|
|
452
|
+
* @returns Practitioner or null
|
|
453
|
+
*/
|
|
454
|
+
private async getPractitionerById(
|
|
455
|
+
practitionerId: string
|
|
456
|
+
): Promise<Practitioner | null> {
|
|
457
|
+
try {
|
|
458
|
+
const docRef = doc(this.db, PRACTITIONERS_COLLECTION, practitionerId);
|
|
459
|
+
const docSnap = await getDoc(docRef);
|
|
460
|
+
return docSnap.exists() ? (docSnap.data() as Practitioner) : null;
|
|
461
|
+
} catch (error) {
|
|
462
|
+
console.error(
|
|
463
|
+
"[PractitionerInviteService] Error getting practitioner:",
|
|
464
|
+
error
|
|
465
|
+
);
|
|
466
|
+
return null;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Gets clinic by ID
|
|
472
|
+
* @param clinicId - Clinic ID
|
|
473
|
+
* @returns Clinic or null
|
|
474
|
+
*/
|
|
475
|
+
private async getClinicById(clinicId: string): Promise<Clinic | null> {
|
|
476
|
+
try {
|
|
477
|
+
const docRef = doc(this.db, CLINICS_COLLECTION, clinicId);
|
|
478
|
+
const docSnap = await getDoc(docRef);
|
|
479
|
+
return docSnap.exists() ? (docSnap.data() as Clinic) : null;
|
|
480
|
+
} catch (error) {
|
|
481
|
+
console.error("[PractitionerInviteService] Error getting clinic:", error);
|
|
482
|
+
return null;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* Finds existing invite between practitioner and clinic
|
|
488
|
+
* @param practitionerId - Practitioner ID
|
|
489
|
+
* @param clinicId - Clinic ID
|
|
490
|
+
* @returns Existing invite or null
|
|
491
|
+
*/
|
|
492
|
+
private async findExistingInvite(
|
|
493
|
+
practitionerId: string,
|
|
494
|
+
clinicId: string
|
|
495
|
+
): Promise<PractitionerInvite | null> {
|
|
496
|
+
try {
|
|
497
|
+
const q = query(
|
|
498
|
+
collection(this.db, PRACTITIONER_INVITES_COLLECTION),
|
|
499
|
+
where("practitionerId", "==", practitionerId),
|
|
500
|
+
where("clinicId", "==", clinicId),
|
|
501
|
+
orderBy("createdAt", "desc"),
|
|
502
|
+
limit(1)
|
|
503
|
+
);
|
|
504
|
+
|
|
505
|
+
const querySnapshot = await getDocs(q);
|
|
506
|
+
if (querySnapshot.empty) {
|
|
507
|
+
return null;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
return querySnapshot.docs[0].data() as PractitionerInvite;
|
|
511
|
+
} catch (error) {
|
|
512
|
+
console.error(
|
|
513
|
+
"[PractitionerInviteService] Error finding existing invite:",
|
|
514
|
+
error
|
|
515
|
+
);
|
|
516
|
+
return null;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|