@blackcode_sa/metaestetics-api 1.12.26 → 1.12.28
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 +2 -0
- package/dist/admin/index.d.ts +2 -0
- package/dist/admin/index.js +31 -14
- package/dist/admin/index.mjs +31 -14
- package/dist/index.d.mts +3 -2
- package/dist/index.d.ts +3 -2
- package/dist/index.js +64 -140
- package/dist/index.mjs +65 -146
- package/package.json +1 -1
- package/src/admin/booking/booking.admin.ts +34 -12
- package/src/admin/booking/booking.calculator.ts +14 -9
- package/src/admin/booking/timezones-problem.md +49 -5
- package/src/services/clinic/clinic.service.ts +1 -1
- package/src/services/patient/patient.service.ts +117 -282
- package/src/services/patient/utils/sensitive.utils.ts +81 -67
- package/src/services/user/user.service.ts +9 -3
package/dist/index.mjs
CHANGED
|
@@ -2905,12 +2905,7 @@ import {
|
|
|
2905
2905
|
} from "firebase/firestore";
|
|
2906
2906
|
|
|
2907
2907
|
// src/services/patient/utils/sensitive.utils.ts
|
|
2908
|
-
import {
|
|
2909
|
-
getDoc as getDoc6,
|
|
2910
|
-
updateDoc as updateDoc4,
|
|
2911
|
-
setDoc as setDoc4,
|
|
2912
|
-
serverTimestamp as serverTimestamp4
|
|
2913
|
-
} from "firebase/firestore";
|
|
2908
|
+
import { getDoc as getDoc6, updateDoc as updateDoc4, setDoc as setDoc4, serverTimestamp as serverTimestamp4 } from "firebase/firestore";
|
|
2914
2909
|
|
|
2915
2910
|
// src/validations/patient.schema.ts
|
|
2916
2911
|
import { z as z7 } from "zod";
|
|
@@ -4211,10 +4206,7 @@ var checkSensitiveAccessUtil = async (db, patientId, requesterId, requesterRoles
|
|
|
4211
4206
|
return;
|
|
4212
4207
|
}
|
|
4213
4208
|
if (requesterRoles.includes("practitioner" /* PRACTITIONER */)) {
|
|
4214
|
-
const practitionerProfile = await getPractitionerProfileByUserRef(
|
|
4215
|
-
db,
|
|
4216
|
-
requesterId
|
|
4217
|
-
);
|
|
4209
|
+
const practitionerProfile = await getPractitionerProfileByUserRef(db, requesterId);
|
|
4218
4210
|
if (practitionerProfile && ((_a = patientData.doctorIds) == null ? void 0 : _a.includes(practitionerProfile.id))) {
|
|
4219
4211
|
return;
|
|
4220
4212
|
}
|
|
@@ -4262,16 +4254,9 @@ var handlePhotoUrlUpload = async (photoUrl, patientId, mediaService) => {
|
|
|
4262
4254
|
};
|
|
4263
4255
|
var createSensitiveInfoUtil = async (db, data, requesterId, requesterRoles, mediaService) => {
|
|
4264
4256
|
try {
|
|
4265
|
-
await checkSensitiveAccessUtil(
|
|
4266
|
-
db,
|
|
4267
|
-
data.patientId,
|
|
4268
|
-
requesterId,
|
|
4269
|
-
requesterRoles
|
|
4270
|
-
);
|
|
4257
|
+
await checkSensitiveAccessUtil(db, data.patientId, requesterId, requesterRoles);
|
|
4271
4258
|
const validatedData = createPatientSensitiveInfoSchema.parse(data);
|
|
4272
|
-
const sensitiveDoc = await getDoc6(
|
|
4273
|
-
getSensitiveInfoDocRef(db, data.patientId)
|
|
4274
|
-
);
|
|
4259
|
+
const sensitiveDoc = await getDoc6(getSensitiveInfoDocRef(db, data.patientId));
|
|
4275
4260
|
if (sensitiveDoc.exists()) {
|
|
4276
4261
|
throw new Error("Sensitive information already exists for this patient");
|
|
4277
4262
|
}
|
|
@@ -4316,11 +4301,7 @@ var updateSensitiveInfoUtil = async (db, patientId, data, requesterId, requester
|
|
|
4316
4301
|
let processedPhotoUrl = void 0;
|
|
4317
4302
|
if (data.photoUrl !== void 0) {
|
|
4318
4303
|
if (mediaService) {
|
|
4319
|
-
processedPhotoUrl = await handlePhotoUrlUpload(
|
|
4320
|
-
data.photoUrl,
|
|
4321
|
-
patientId,
|
|
4322
|
-
mediaService
|
|
4323
|
-
);
|
|
4304
|
+
processedPhotoUrl = await handlePhotoUrlUpload(data.photoUrl, patientId, mediaService);
|
|
4324
4305
|
} else if (typeof data.photoUrl === "string" || data.photoUrl === null) {
|
|
4325
4306
|
processedPhotoUrl = data.photoUrl;
|
|
4326
4307
|
} else {
|
|
@@ -4339,6 +4320,36 @@ var updateSensitiveInfoUtil = async (db, patientId, data, requesterId, requester
|
|
|
4339
4320
|
}
|
|
4340
4321
|
return updatedDoc.data();
|
|
4341
4322
|
};
|
|
4323
|
+
var claimPatientSensitiveInfoUtil = async (db, patientId, userId) => {
|
|
4324
|
+
const patientDoc = await getDoc6(getPatientDocRef(db, patientId));
|
|
4325
|
+
if (!patientDoc.exists()) {
|
|
4326
|
+
throw new Error("Patient profile not found");
|
|
4327
|
+
}
|
|
4328
|
+
const patientData = patientDoc.data();
|
|
4329
|
+
if (!patientData.isManual) {
|
|
4330
|
+
throw new Error("Only manually created patient profiles can be claimed");
|
|
4331
|
+
}
|
|
4332
|
+
if (patientData.userRef) {
|
|
4333
|
+
throw new Error("Patient profile has already been claimed");
|
|
4334
|
+
}
|
|
4335
|
+
const sensitiveDoc = await getDoc6(getSensitiveInfoDocRef(db, patientId));
|
|
4336
|
+
if (!sensitiveDoc.exists()) {
|
|
4337
|
+
throw new Error("Patient sensitive information not found");
|
|
4338
|
+
}
|
|
4339
|
+
const sensitiveData = sensitiveDoc.data();
|
|
4340
|
+
if (sensitiveData.userRef) {
|
|
4341
|
+
throw new Error("Patient sensitive information has already been claimed");
|
|
4342
|
+
}
|
|
4343
|
+
await updateDoc4(getSensitiveInfoDocRef(db, patientId), {
|
|
4344
|
+
userRef: userId,
|
|
4345
|
+
updatedAt: serverTimestamp4()
|
|
4346
|
+
});
|
|
4347
|
+
const updatedDoc = await getDoc6(getSensitiveInfoDocRef(db, patientId));
|
|
4348
|
+
if (!updatedDoc.exists()) {
|
|
4349
|
+
throw new Error("Failed to retrieve updated sensitive information");
|
|
4350
|
+
}
|
|
4351
|
+
return updatedDoc.data();
|
|
4352
|
+
};
|
|
4342
4353
|
|
|
4343
4354
|
// src/services/patient/utils/docs.utils.ts
|
|
4344
4355
|
var getPatientDocRef = (db, patientId) => {
|
|
@@ -5524,12 +5535,7 @@ var PatientService = class extends BaseService {
|
|
|
5524
5535
|
const currentUser = await this.getCurrentUser();
|
|
5525
5536
|
if (currentUser.uid !== requesterUserId) {
|
|
5526
5537
|
}
|
|
5527
|
-
return getSensitiveInfoUtil(
|
|
5528
|
-
this.db,
|
|
5529
|
-
patientId,
|
|
5530
|
-
requesterUserId,
|
|
5531
|
-
currentUser.roles
|
|
5532
|
-
);
|
|
5538
|
+
return getSensitiveInfoUtil(this.db, patientId, requesterUserId, currentUser.roles);
|
|
5533
5539
|
}
|
|
5534
5540
|
async getSensitiveInfoByUserRef(userRef, requesterUserId) {
|
|
5535
5541
|
const profile = await this.getPatientProfileByUserRef(userRef);
|
|
@@ -5550,25 +5556,17 @@ var PatientService = class extends BaseService {
|
|
|
5550
5556
|
this.mediaService
|
|
5551
5557
|
);
|
|
5552
5558
|
}
|
|
5559
|
+
async claimPatientSensitiveInfo(patientId, userId) {
|
|
5560
|
+
return claimPatientSensitiveInfoUtil(this.db, patientId, userId);
|
|
5561
|
+
}
|
|
5553
5562
|
// Metode za rad sa medicinskim informacijama
|
|
5554
5563
|
async createMedicalInfo(patientId, data) {
|
|
5555
5564
|
const currentUser = await this.getCurrentUser();
|
|
5556
|
-
await createMedicalInfoUtil(
|
|
5557
|
-
this.db,
|
|
5558
|
-
patientId,
|
|
5559
|
-
data,
|
|
5560
|
-
currentUser.uid,
|
|
5561
|
-
currentUser.roles
|
|
5562
|
-
);
|
|
5565
|
+
await createMedicalInfoUtil(this.db, patientId, data, currentUser.uid, currentUser.roles);
|
|
5563
5566
|
}
|
|
5564
5567
|
async getMedicalInfo(patientId) {
|
|
5565
5568
|
const currentUser = await this.getCurrentUser();
|
|
5566
|
-
return getMedicalInfoUtil(
|
|
5567
|
-
this.db,
|
|
5568
|
-
patientId,
|
|
5569
|
-
currentUser.uid,
|
|
5570
|
-
currentUser.roles
|
|
5571
|
-
);
|
|
5569
|
+
return getMedicalInfoUtil(this.db, patientId, currentUser.uid, currentUser.roles);
|
|
5572
5570
|
}
|
|
5573
5571
|
async getMedicalInfoByUserRef(userRef) {
|
|
5574
5572
|
const profile = await this.getPatientProfileByUserRef(userRef);
|
|
@@ -5578,65 +5576,29 @@ var PatientService = class extends BaseService {
|
|
|
5578
5576
|
// Metode za rad sa vitalnim statistikama
|
|
5579
5577
|
async updateVitalStats(patientId, data) {
|
|
5580
5578
|
const currentUser = await this.getCurrentUser();
|
|
5581
|
-
await updateVitalStatsUtil(
|
|
5582
|
-
this.db,
|
|
5583
|
-
patientId,
|
|
5584
|
-
data,
|
|
5585
|
-
currentUser.uid,
|
|
5586
|
-
currentUser.roles
|
|
5587
|
-
);
|
|
5579
|
+
await updateVitalStatsUtil(this.db, patientId, data, currentUser.uid, currentUser.roles);
|
|
5588
5580
|
}
|
|
5589
5581
|
// Metode za rad sa alergijama
|
|
5590
5582
|
async addAllergy(patientId, data) {
|
|
5591
5583
|
const currentUser = await this.getCurrentUser();
|
|
5592
|
-
await addAllergyUtil(
|
|
5593
|
-
this.db,
|
|
5594
|
-
patientId,
|
|
5595
|
-
data,
|
|
5596
|
-
currentUser.uid,
|
|
5597
|
-
currentUser.roles
|
|
5598
|
-
);
|
|
5584
|
+
await addAllergyUtil(this.db, patientId, data, currentUser.uid, currentUser.roles);
|
|
5599
5585
|
}
|
|
5600
5586
|
async updateAllergy(patientId, data) {
|
|
5601
5587
|
const currentUser = await this.getCurrentUser();
|
|
5602
|
-
await updateAllergyUtil(
|
|
5603
|
-
this.db,
|
|
5604
|
-
patientId,
|
|
5605
|
-
data,
|
|
5606
|
-
currentUser.uid,
|
|
5607
|
-
currentUser.roles
|
|
5608
|
-
);
|
|
5588
|
+
await updateAllergyUtil(this.db, patientId, data, currentUser.uid, currentUser.roles);
|
|
5609
5589
|
}
|
|
5610
5590
|
async removeAllergy(patientId, allergyIndex) {
|
|
5611
5591
|
const currentUser = await this.getCurrentUser();
|
|
5612
|
-
await removeAllergyUtil(
|
|
5613
|
-
this.db,
|
|
5614
|
-
patientId,
|
|
5615
|
-
allergyIndex,
|
|
5616
|
-
currentUser.uid,
|
|
5617
|
-
currentUser.roles
|
|
5618
|
-
);
|
|
5592
|
+
await removeAllergyUtil(this.db, patientId, allergyIndex, currentUser.uid, currentUser.roles);
|
|
5619
5593
|
}
|
|
5620
5594
|
// Metode za rad sa blocking conditions
|
|
5621
5595
|
async addBlockingCondition(patientId, data) {
|
|
5622
5596
|
const currentUser = await this.getCurrentUser();
|
|
5623
|
-
await addBlockingConditionUtil(
|
|
5624
|
-
this.db,
|
|
5625
|
-
patientId,
|
|
5626
|
-
data,
|
|
5627
|
-
currentUser.uid,
|
|
5628
|
-
currentUser.roles
|
|
5629
|
-
);
|
|
5597
|
+
await addBlockingConditionUtil(this.db, patientId, data, currentUser.uid, currentUser.roles);
|
|
5630
5598
|
}
|
|
5631
5599
|
async updateBlockingCondition(patientId, data) {
|
|
5632
5600
|
const currentUser = await this.getCurrentUser();
|
|
5633
|
-
await updateBlockingConditionUtil(
|
|
5634
|
-
this.db,
|
|
5635
|
-
patientId,
|
|
5636
|
-
data,
|
|
5637
|
-
currentUser.uid,
|
|
5638
|
-
currentUser.roles
|
|
5639
|
-
);
|
|
5601
|
+
await updateBlockingConditionUtil(this.db, patientId, data, currentUser.uid, currentUser.roles);
|
|
5640
5602
|
}
|
|
5641
5603
|
async removeBlockingCondition(patientId, conditionIndex) {
|
|
5642
5604
|
const currentUser = await this.getCurrentUser();
|
|
@@ -5651,23 +5613,11 @@ var PatientService = class extends BaseService {
|
|
|
5651
5613
|
// Metode za rad sa kontraindikacijama
|
|
5652
5614
|
async addContraindication(patientId, data) {
|
|
5653
5615
|
const currentUser = await this.getCurrentUser();
|
|
5654
|
-
await addContraindicationUtil(
|
|
5655
|
-
this.db,
|
|
5656
|
-
patientId,
|
|
5657
|
-
data,
|
|
5658
|
-
currentUser.uid,
|
|
5659
|
-
currentUser.roles
|
|
5660
|
-
);
|
|
5616
|
+
await addContraindicationUtil(this.db, patientId, data, currentUser.uid, currentUser.roles);
|
|
5661
5617
|
}
|
|
5662
5618
|
async updateContraindication(patientId, data) {
|
|
5663
5619
|
const currentUser = await this.getCurrentUser();
|
|
5664
|
-
await updateContraindicationUtil(
|
|
5665
|
-
this.db,
|
|
5666
|
-
patientId,
|
|
5667
|
-
data,
|
|
5668
|
-
currentUser.uid,
|
|
5669
|
-
currentUser.roles
|
|
5670
|
-
);
|
|
5620
|
+
await updateContraindicationUtil(this.db, patientId, data, currentUser.uid, currentUser.roles);
|
|
5671
5621
|
}
|
|
5672
5622
|
async removeContraindication(patientId, contraindicationIndex) {
|
|
5673
5623
|
const currentUser = await this.getCurrentUser();
|
|
@@ -5682,23 +5632,11 @@ var PatientService = class extends BaseService {
|
|
|
5682
5632
|
// Metode za rad sa medikacijama
|
|
5683
5633
|
async addMedication(patientId, data) {
|
|
5684
5634
|
const currentUser = await this.getCurrentUser();
|
|
5685
|
-
await addMedicationUtil(
|
|
5686
|
-
this.db,
|
|
5687
|
-
patientId,
|
|
5688
|
-
data,
|
|
5689
|
-
currentUser.uid,
|
|
5690
|
-
currentUser.roles
|
|
5691
|
-
);
|
|
5635
|
+
await addMedicationUtil(this.db, patientId, data, currentUser.uid, currentUser.roles);
|
|
5692
5636
|
}
|
|
5693
5637
|
async updateMedication(patientId, data) {
|
|
5694
5638
|
const currentUser = await this.getCurrentUser();
|
|
5695
|
-
await updateMedicationUtil(
|
|
5696
|
-
this.db,
|
|
5697
|
-
patientId,
|
|
5698
|
-
data,
|
|
5699
|
-
currentUser.uid,
|
|
5700
|
-
currentUser.roles
|
|
5701
|
-
);
|
|
5639
|
+
await updateMedicationUtil(this.db, patientId, data, currentUser.uid, currentUser.roles);
|
|
5702
5640
|
}
|
|
5703
5641
|
async removeMedication(patientId, medicationIndex) {
|
|
5704
5642
|
const currentUser = await this.getCurrentUser();
|
|
@@ -5739,9 +5677,7 @@ var PatientService = class extends BaseService {
|
|
|
5739
5677
|
if (!this.auth.currentUser) {
|
|
5740
5678
|
throw new Error("No authenticated user");
|
|
5741
5679
|
}
|
|
5742
|
-
const userDoc = await getDoc13(
|
|
5743
|
-
doc10(this.db, "users", this.auth.currentUser.uid)
|
|
5744
|
-
);
|
|
5680
|
+
const userDoc = await getDoc13(doc10(this.db, "users", this.auth.currentUser.uid));
|
|
5745
5681
|
if (!userDoc.exists()) {
|
|
5746
5682
|
throw new Error("User not found");
|
|
5747
5683
|
}
|
|
@@ -5782,9 +5718,7 @@ var PatientService = class extends BaseService {
|
|
|
5782
5718
|
* @returns URL of the uploaded photo
|
|
5783
5719
|
*/
|
|
5784
5720
|
async uploadProfilePhoto(patientId, file) {
|
|
5785
|
-
console.log(
|
|
5786
|
-
`[PatientService] Uploading profile photo for patient ${patientId}`
|
|
5787
|
-
);
|
|
5721
|
+
console.log(`[PatientService] Uploading profile photo for patient ${patientId}`);
|
|
5788
5722
|
const mediaMetadata = await this.mediaService.uploadMedia(
|
|
5789
5723
|
file,
|
|
5790
5724
|
patientId,
|
|
@@ -5807,14 +5741,9 @@ var PatientService = class extends BaseService {
|
|
|
5807
5741
|
* @returns URL of the new uploaded photo
|
|
5808
5742
|
*/
|
|
5809
5743
|
async updateProfilePhoto(patientId, file) {
|
|
5810
|
-
console.log(
|
|
5811
|
-
`[PatientService] Updating profile photo for patient ${patientId}`
|
|
5812
|
-
);
|
|
5744
|
+
console.log(`[PatientService] Updating profile photo for patient ${patientId}`);
|
|
5813
5745
|
const currentUser = await this.getCurrentUser();
|
|
5814
|
-
const currentSensitiveInfo = await this.getSensitiveInfo(
|
|
5815
|
-
patientId,
|
|
5816
|
-
currentUser.uid
|
|
5817
|
-
);
|
|
5746
|
+
const currentSensitiveInfo = await this.getSensitiveInfo(patientId, currentUser.uid);
|
|
5818
5747
|
if ((currentSensitiveInfo == null ? void 0 : currentSensitiveInfo.photoUrl) && typeof currentSensitiveInfo.photoUrl === "string") {
|
|
5819
5748
|
try {
|
|
5820
5749
|
const existingMediaMetadata = await this.mediaService.getMediaMetadataByUrl(
|
|
@@ -5837,14 +5766,9 @@ var PatientService = class extends BaseService {
|
|
|
5837
5766
|
* @param patientId - ID of the patient
|
|
5838
5767
|
*/
|
|
5839
5768
|
async deleteProfilePhoto(patientId) {
|
|
5840
|
-
console.log(
|
|
5841
|
-
`[PatientService] Deleting profile photo for patient ${patientId}`
|
|
5842
|
-
);
|
|
5769
|
+
console.log(`[PatientService] Deleting profile photo for patient ${patientId}`);
|
|
5843
5770
|
const currentUser = await this.getCurrentUser();
|
|
5844
|
-
const currentSensitiveInfo = await this.getSensitiveInfo(
|
|
5845
|
-
patientId,
|
|
5846
|
-
currentUser.uid
|
|
5847
|
-
);
|
|
5771
|
+
const currentSensitiveInfo = await this.getSensitiveInfo(patientId, currentUser.uid);
|
|
5848
5772
|
if ((currentSensitiveInfo == null ? void 0 : currentSensitiveInfo.photoUrl) && typeof currentSensitiveInfo.photoUrl === "string") {
|
|
5849
5773
|
try {
|
|
5850
5774
|
const existingMediaMetadata = await this.mediaService.getMediaMetadataByUrl(
|
|
@@ -5916,10 +5840,7 @@ var PatientService = class extends BaseService {
|
|
|
5916
5840
|
* @returns {Promise<PatientProfile[]>} A promise resolving to an array of all patient profiles.
|
|
5917
5841
|
*/
|
|
5918
5842
|
async getAllPatients(options) {
|
|
5919
|
-
console.log(
|
|
5920
|
-
`[PatientService.getAllPatients] Fetching patients with options:`,
|
|
5921
|
-
options
|
|
5922
|
-
);
|
|
5843
|
+
console.log(`[PatientService.getAllPatients] Fetching patients with options:`, options);
|
|
5923
5844
|
return getAllPatientsUtil(this.db, options);
|
|
5924
5845
|
}
|
|
5925
5846
|
/**
|
|
@@ -5950,11 +5871,7 @@ var PatientService = class extends BaseService {
|
|
|
5950
5871
|
console.log(
|
|
5951
5872
|
`[PatientService.getPatientsByPractitionerWithDetails] Fetching detailed patient profiles for practitioner: ${practitionerId}`
|
|
5952
5873
|
);
|
|
5953
|
-
return getPatientsByPractitionerWithDetailsUtil(
|
|
5954
|
-
this.db,
|
|
5955
|
-
practitionerId,
|
|
5956
|
-
options
|
|
5957
|
-
);
|
|
5874
|
+
return getPatientsByPractitionerWithDetailsUtil(this.db, practitionerId, options);
|
|
5958
5875
|
}
|
|
5959
5876
|
/**
|
|
5960
5877
|
* Gets all patients associated with a specific clinic.
|
|
@@ -5966,9 +5883,7 @@ var PatientService = class extends BaseService {
|
|
|
5966
5883
|
* @returns {Promise<PatientProfile[]>} A promise resolving to an array of patient profiles
|
|
5967
5884
|
*/
|
|
5968
5885
|
async getPatientsByClinic(clinicId, options) {
|
|
5969
|
-
console.log(
|
|
5970
|
-
`[PatientService.getPatientsByClinic] Fetching patients for clinic: ${clinicId}`
|
|
5971
|
-
);
|
|
5886
|
+
console.log(`[PatientService.getPatientsByClinic] Fetching patients for clinic: ${clinicId}`);
|
|
5972
5887
|
return getPatientsByClinicUtil(this.db, clinicId, options);
|
|
5973
5888
|
}
|
|
5974
5889
|
/**
|
|
@@ -7680,11 +7595,15 @@ var UserService = class extends BaseService {
|
|
|
7680
7595
|
if ((await this.getUserById(userId)).patientProfile || patientProfile2.userRef) {
|
|
7681
7596
|
throw new Error("User already has a patient profile.");
|
|
7682
7597
|
}
|
|
7683
|
-
const sensitiveInfo = await patientService.
|
|
7598
|
+
const sensitiveInfo = await patientService.claimPatientSensitiveInfo(
|
|
7599
|
+
patientProfile2.id,
|
|
7600
|
+
userId
|
|
7601
|
+
);
|
|
7684
7602
|
const fullDisplayName = sensitiveInfo ? `${sensitiveInfo.firstName} ${sensitiveInfo.lastName}` : patientProfile2.displayName;
|
|
7685
7603
|
await patientService.updatePatientProfile(patientProfile2.id, {
|
|
7686
7604
|
userRef: userId,
|
|
7687
7605
|
isManual: false,
|
|
7606
|
+
isVerified: true,
|
|
7688
7607
|
displayName: fullDisplayName
|
|
7689
7608
|
});
|
|
7690
7609
|
await patientService.markPatientTokenAsUsed(token.id, token.patientId, userId);
|
package/package.json
CHANGED
|
@@ -339,22 +339,33 @@ export class BookingAdmin {
|
|
|
339
339
|
endTime: end.toDate().toISOString(),
|
|
340
340
|
});
|
|
341
341
|
|
|
342
|
+
const MAX_EVENT_DURATION_MS = 24 * 60 * 60 * 1000;
|
|
343
|
+
const queryStart = admin.firestore.Timestamp.fromMillis(
|
|
344
|
+
start.toMillis() - MAX_EVENT_DURATION_MS
|
|
345
|
+
);
|
|
346
|
+
|
|
342
347
|
const eventsRef = this.db
|
|
343
348
|
.collection(`clinics/${clinicId}/calendar`)
|
|
344
|
-
.where("eventTime.start", ">=",
|
|
345
|
-
.where("eventTime.start", "
|
|
349
|
+
.where("eventTime.start", ">=", queryStart)
|
|
350
|
+
.where("eventTime.start", "<", end)
|
|
351
|
+
.orderBy("eventTime.start");
|
|
346
352
|
|
|
347
353
|
const snapshot = await eventsRef.get();
|
|
348
354
|
|
|
349
|
-
const events = snapshot.docs
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
355
|
+
const events = snapshot.docs
|
|
356
|
+
.map((doc) => ({
|
|
357
|
+
...doc.data(),
|
|
358
|
+
id: doc.id,
|
|
359
|
+
}))
|
|
360
|
+
.filter((event: any) => {
|
|
361
|
+
return event.eventTime.end.toMillis() > start.toMillis();
|
|
362
|
+
});
|
|
353
363
|
|
|
354
364
|
Logger.debug("[BookingAdmin] Retrieved clinic calendar events", {
|
|
355
365
|
clinicId,
|
|
356
366
|
eventsCount: events.length,
|
|
357
367
|
eventsTypes: this.summarizeEventTypes(events),
|
|
368
|
+
queryStartTime: queryStart.toDate().toISOString(),
|
|
358
369
|
});
|
|
359
370
|
|
|
360
371
|
return events;
|
|
@@ -392,22 +403,33 @@ export class BookingAdmin {
|
|
|
392
403
|
endTime: end.toDate().toISOString(),
|
|
393
404
|
});
|
|
394
405
|
|
|
406
|
+
const MAX_EVENT_DURATION_MS = 24 * 60 * 60 * 1000;
|
|
407
|
+
const queryStart = admin.firestore.Timestamp.fromMillis(
|
|
408
|
+
start.toMillis() - MAX_EVENT_DURATION_MS
|
|
409
|
+
);
|
|
410
|
+
|
|
395
411
|
const eventsRef = this.db
|
|
396
412
|
.collection(`practitioners/${practitionerId}/calendar`)
|
|
397
|
-
.where("eventTime.start", ">=",
|
|
398
|
-
.where("eventTime.start", "
|
|
413
|
+
.where("eventTime.start", ">=", queryStart)
|
|
414
|
+
.where("eventTime.start", "<", end)
|
|
415
|
+
.orderBy("eventTime.start");
|
|
399
416
|
|
|
400
417
|
const snapshot = await eventsRef.get();
|
|
401
418
|
|
|
402
|
-
const events = snapshot.docs
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
419
|
+
const events = snapshot.docs
|
|
420
|
+
.map((doc) => ({
|
|
421
|
+
...doc.data(),
|
|
422
|
+
id: doc.id,
|
|
423
|
+
}))
|
|
424
|
+
.filter((event: any) => {
|
|
425
|
+
return event.eventTime.end.toMillis() > start.toMillis();
|
|
426
|
+
});
|
|
406
427
|
|
|
407
428
|
Logger.debug("[BookingAdmin] Retrieved practitioner calendar events", {
|
|
408
429
|
practitionerId,
|
|
409
430
|
eventsCount: events.length,
|
|
410
431
|
eventsTypes: this.summarizeEventTypes(events),
|
|
432
|
+
queryStartTime: queryStart.toDate().toISOString(),
|
|
411
433
|
});
|
|
412
434
|
|
|
413
435
|
return events;
|
|
@@ -96,7 +96,8 @@ export class BookingAvailabilityCalculator {
|
|
|
96
96
|
const availableSlots = this.generateAvailableSlots(
|
|
97
97
|
availableIntervals,
|
|
98
98
|
schedulingIntervalMinutes,
|
|
99
|
-
procedureDurationMinutes
|
|
99
|
+
procedureDurationMinutes,
|
|
100
|
+
tz
|
|
100
101
|
);
|
|
101
102
|
|
|
102
103
|
return { availableSlots };
|
|
@@ -469,16 +470,18 @@ export class BookingAvailabilityCalculator {
|
|
|
469
470
|
* @param intervals - Final available intervals
|
|
470
471
|
* @param intervalMinutes - Scheduling interval in minutes
|
|
471
472
|
* @param durationMinutes - Procedure duration in minutes
|
|
473
|
+
* @param tz - IANA timezone of the clinic
|
|
472
474
|
* @returns Array of available booking slots
|
|
473
475
|
*/
|
|
474
476
|
private static generateAvailableSlots(
|
|
475
477
|
intervals: TimeInterval[],
|
|
476
478
|
intervalMinutes: number,
|
|
477
|
-
durationMinutes: number
|
|
479
|
+
durationMinutes: number,
|
|
480
|
+
tz: string
|
|
478
481
|
): AvailableSlot[] {
|
|
479
482
|
const slots: AvailableSlot[] = [];
|
|
480
483
|
console.log(
|
|
481
|
-
`Generating slots with ${intervalMinutes}min intervals for ${durationMinutes}min procedure`
|
|
484
|
+
`Generating slots with ${intervalMinutes}min intervals for ${durationMinutes}min procedure in timezone ${tz}`
|
|
482
485
|
);
|
|
483
486
|
|
|
484
487
|
// Convert duration to milliseconds
|
|
@@ -492,8 +495,8 @@ export class BookingAvailabilityCalculator {
|
|
|
492
495
|
const intervalStart = interval.start.toDate();
|
|
493
496
|
const intervalEnd = interval.end.toDate();
|
|
494
497
|
|
|
495
|
-
// Start at the beginning of the interval
|
|
496
|
-
let slotStart = DateTime.
|
|
498
|
+
// Start at the beginning of the interval IN CLINIC TIMEZONE
|
|
499
|
+
let slotStart = DateTime.fromMillis(intervalStart.getTime(), { zone: tz });
|
|
497
500
|
|
|
498
501
|
// Adjust slotStart to the nearest interval boundary if needed
|
|
499
502
|
const minutesIntoDay = slotStart.hour * 60 + slotStart.minute;
|
|
@@ -511,7 +514,7 @@ export class BookingAvailabilityCalculator {
|
|
|
511
514
|
const slotEnd = slotStart.plus({ minutes: durationMinutes });
|
|
512
515
|
|
|
513
516
|
// Check if this slot fits entirely within one of our available intervals
|
|
514
|
-
if (this.isSlotFullyAvailable(slotStart, slotEnd, intervals)) {
|
|
517
|
+
if (this.isSlotFullyAvailable(slotStart, slotEnd, intervals, tz)) {
|
|
515
518
|
slots.push({
|
|
516
519
|
start: Timestamp.fromMillis(slotStart.toMillis()),
|
|
517
520
|
});
|
|
@@ -532,17 +535,19 @@ export class BookingAvailabilityCalculator {
|
|
|
532
535
|
* @param slotStart - Start time of the slot
|
|
533
536
|
* @param slotEnd - End time of the slot
|
|
534
537
|
* @param intervals - Available intervals
|
|
538
|
+
* @param tz - IANA timezone of the clinic
|
|
535
539
|
* @returns True if the slot is fully contained within an available interval
|
|
536
540
|
*/
|
|
537
541
|
private static isSlotFullyAvailable(
|
|
538
542
|
slotStart: DateTime,
|
|
539
543
|
slotEnd: DateTime,
|
|
540
|
-
intervals: TimeInterval[]
|
|
544
|
+
intervals: TimeInterval[],
|
|
545
|
+
tz: string
|
|
541
546
|
): boolean {
|
|
542
547
|
// Check if the slot is fully contained in any of the available intervals
|
|
543
548
|
return intervals.some((interval) => {
|
|
544
|
-
const intervalStart = DateTime.fromMillis(interval.start.toMillis());
|
|
545
|
-
const intervalEnd = DateTime.fromMillis(interval.end.toMillis());
|
|
549
|
+
const intervalStart = DateTime.fromMillis(interval.start.toMillis(), { zone: tz });
|
|
550
|
+
const intervalEnd = DateTime.fromMillis(interval.end.toMillis(), { zone: tz });
|
|
546
551
|
|
|
547
552
|
return slotStart >= intervalStart && slotEnd <= intervalEnd;
|
|
548
553
|
});
|
|
@@ -122,11 +122,55 @@ Alignment expectation:
|
|
|
122
122
|
- Interval sizes that don’t divide 60 (e.g., 20 min), and variable procedure durations.
|
|
123
123
|
|
|
124
124
|
### Actionable checklist
|
|
125
|
-
- Make all DateTime constructions in slot generation and containment checks use `{ zone: tz }`.
|
|
126
|
-
- Replace `fromJSDate` with `fromMillis` in calculator paths.
|
|
127
|
-
- Update event fetching to include overlaps with the timeframe; union queries or adjust model.
|
|
128
|
-
- Re-assert the type contract: calculator receives only client `Timestamp` + `tz`.
|
|
129
|
-
- Document UI responsibility to convert wall-clock (clinic `tz`) → UTC Timestamp exactly once.
|
|
125
|
+
- ✅ Make all DateTime constructions in slot generation and containment checks use `{ zone: tz }`.
|
|
126
|
+
- ✅ Replace `fromJSDate` with `fromMillis` in calculator paths.
|
|
127
|
+
- ✅ Update event fetching to include overlaps with the timeframe; union queries or adjust model.
|
|
128
|
+
- ⏳ Re-assert the type contract: calculator receives only client `Timestamp` + `tz`.
|
|
129
|
+
- ⏳ Document UI responsibility to convert wall-clock (clinic `tz`) → UTC Timestamp exactly once.
|
|
130
|
+
|
|
131
|
+
### PHASE 1 COMPLETED: Backend Calculator Fixes ✅
|
|
132
|
+
|
|
133
|
+
**Date Completed:** October 1, 2025
|
|
134
|
+
|
|
135
|
+
**Changes Made:**
|
|
136
|
+
|
|
137
|
+
1. **booking.calculator.ts - generateAvailableSlots()**
|
|
138
|
+
- Added `tz: string` parameter
|
|
139
|
+
- Changed `DateTime.fromJSDate(intervalStart)` → `DateTime.fromMillis(intervalStart.getTime(), { zone: tz })`
|
|
140
|
+
- Now explicitly creates DateTime in clinic timezone for all slot calculations
|
|
141
|
+
|
|
142
|
+
2. **booking.calculator.ts - isSlotFullyAvailable()**
|
|
143
|
+
- Added `tz: string` parameter
|
|
144
|
+
- Changed `DateTime.fromMillis(interval.start.toMillis())` → `DateTime.fromMillis(interval.start.toMillis(), { zone: tz })`
|
|
145
|
+
- Interval boundary checks now use clinic timezone context
|
|
146
|
+
|
|
147
|
+
3. **booking.calculator.ts - calculateSlots()**
|
|
148
|
+
- Updated call to `generateAvailableSlots()` to pass `tz` parameter
|
|
149
|
+
- Updated call to `isSlotFullyAvailable()` to pass `tz` parameter
|
|
150
|
+
|
|
151
|
+
4. **booking.admin.ts - getClinicCalendarEvents()**
|
|
152
|
+
- Fixed event overlap logic with bounded query
|
|
153
|
+
- Added lower bound: `queryStart = start - 24 hours` to prevent querying all historical events
|
|
154
|
+
- Query: `eventTime.start >= queryStart AND eventTime.start < end`
|
|
155
|
+
- Added post-filter: `eventTime.end > start` to catch all overlapping events
|
|
156
|
+
- Prevents missing events that start before window but overlap into it
|
|
157
|
+
- Performance optimized: only queries ~24-48 hours of events instead of entire history
|
|
158
|
+
|
|
159
|
+
5. **booking.admin.ts - getPractitionerCalendarEvents()**
|
|
160
|
+
- Applied same overlap fix with 24-hour lookback window
|
|
161
|
+
- Ensures busy time subtraction includes all conflicting events
|
|
162
|
+
- Efficient: assumes no appointments longer than 24 hours
|
|
163
|
+
|
|
164
|
+
**Impact:**
|
|
165
|
+
- Slot generation now correctly happens in clinic timezone
|
|
166
|
+
- Slots are properly converted to UTC for storage/transmission
|
|
167
|
+
- Event blocking now catches all overlapping events, not just those starting within window
|
|
168
|
+
- Fixes timezone display issues for users in different timezones
|
|
169
|
+
|
|
170
|
+
**Next Steps:**
|
|
171
|
+
- Phase 2: Mobile app fixes (use original UTC timestamp from slots)
|
|
172
|
+
- Phase 3: ClinicApp fixes (same as Mobile)
|
|
173
|
+
- Phase 4: Testing across multiple timezones and DST boundaries
|
|
130
174
|
|
|
131
175
|
### Known risks
|
|
132
176
|
- DST transitions can produce days with 23/25 hours; rounding/iteration must not assume 24h.
|
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
arrayUnion,
|
|
18
18
|
arrayRemove,
|
|
19
19
|
} from "firebase/firestore";
|
|
20
|
-
import { getFunctions, httpsCallable
|
|
20
|
+
import { getFunctions, httpsCallable } from "firebase/functions";
|
|
21
21
|
import { BaseService } from "../base.service";
|
|
22
22
|
import {
|
|
23
23
|
Clinic,
|