@blackcode_sa/metaestetics-api 1.14.79 → 1.15.3
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 +8 -0
- package/dist/admin/index.d.ts +8 -0
- package/dist/index.d.mts +321 -1
- package/dist/index.d.ts +321 -1
- package/dist/index.js +8327 -7334
- package/dist/index.mjs +7404 -6415
- package/package.json +1 -1
- package/src/services/appointment/appointment.service.ts +78 -1
- package/src/services/appointment/utils/appointment.utils.ts +22 -13
- package/src/services/appointment/utils/zone-management.utils.ts +62 -9
- package/src/services/patient/patient.service.ts +136 -0
- package/src/services/patient/utils/body-assessment.utils.ts +159 -0
- package/src/services/patient/utils/clinic.utils.ts +5 -5
- package/src/services/patient/utils/hair-scalp-assessment.utils.ts +158 -0
- package/src/services/patient/utils/pre-surgical-assessment.utils.ts +161 -0
- package/src/services/patient/utils/skin-quality-assessment.utils.ts +160 -0
- package/src/types/appointment/index.ts +8 -0
- package/src/types/patient/body-assessment.types.ts +93 -0
- package/src/types/patient/hair-scalp-assessment.types.ts +98 -0
- package/src/types/patient/index.ts +4 -0
- package/src/types/patient/pre-surgical-assessment.types.ts +95 -0
- package/src/types/patient/skin-quality-assessment.types.ts +105 -0
- package/src/validations/patient/body-assessment.schema.ts +82 -0
- package/src/validations/patient/hair-scalp-assessment.schema.ts +70 -0
- package/src/validations/patient/pre-surgical-assessment.schema.ts +78 -0
- package/src/validations/patient/skin-quality-assessment.schema.ts +70 -0
package/package.json
CHANGED
|
@@ -1855,7 +1855,12 @@ export class AppointmentService extends BaseService {
|
|
|
1855
1855
|
throw new Error('No zone data available for billing calculation');
|
|
1856
1856
|
}
|
|
1857
1857
|
|
|
1858
|
-
|
|
1858
|
+
// Preserve existing discount when recalculating
|
|
1859
|
+
const existingDiscount = appointment.metadata?.finalbilling?.discount;
|
|
1860
|
+
const discountParam = existingDiscount
|
|
1861
|
+
? { type: existingDiscount.type, value: existingDiscount.value }
|
|
1862
|
+
: undefined;
|
|
1863
|
+
const finalbilling = calculateFinalBilling(zonesData, taxRate, discountParam);
|
|
1859
1864
|
|
|
1860
1865
|
const currentMetadata = appointment.metadata || {
|
|
1861
1866
|
selectedZones: null,
|
|
@@ -1909,6 +1914,78 @@ export class AppointmentService extends BaseService {
|
|
|
1909
1914
|
}
|
|
1910
1915
|
}
|
|
1911
1916
|
|
|
1917
|
+
/**
|
|
1918
|
+
* Applies or removes an overall discount on the appointment total
|
|
1919
|
+
* @param appointmentId ID of the appointment
|
|
1920
|
+
* @param discount Discount to apply (null to remove)
|
|
1921
|
+
* @param taxRate Optional tax rate override
|
|
1922
|
+
* @returns The updated appointment
|
|
1923
|
+
*/
|
|
1924
|
+
async applyOverallDiscount(
|
|
1925
|
+
appointmentId: string,
|
|
1926
|
+
discount: { type: 'percentage' | 'fixed'; value: number } | null,
|
|
1927
|
+
taxRate?: number,
|
|
1928
|
+
): Promise<Appointment> {
|
|
1929
|
+
try {
|
|
1930
|
+
console.log(
|
|
1931
|
+
`[APPOINTMENT_SERVICE] Applying overall discount for appointment ${appointmentId}`,
|
|
1932
|
+
discount,
|
|
1933
|
+
);
|
|
1934
|
+
|
|
1935
|
+
const appointment = await this.getAppointmentById(appointmentId);
|
|
1936
|
+
if (!appointment) {
|
|
1937
|
+
throw new Error(`Appointment with ID ${appointmentId} not found`);
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1940
|
+
const zonesData = appointment.metadata?.zonesData;
|
|
1941
|
+
if (!zonesData || Object.keys(zonesData).length === 0) {
|
|
1942
|
+
throw new Error('No zone data available for billing calculation');
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
const finalbilling = calculateFinalBilling(zonesData, taxRate, discount);
|
|
1946
|
+
|
|
1947
|
+
const currentMetadata = appointment.metadata || {
|
|
1948
|
+
selectedZones: null,
|
|
1949
|
+
zonePhotos: null,
|
|
1950
|
+
zonesData: null,
|
|
1951
|
+
appointmentProducts: [],
|
|
1952
|
+
extendedProcedures: [],
|
|
1953
|
+
recommendedProcedures: [],
|
|
1954
|
+
finalbilling: null,
|
|
1955
|
+
finalizationNotesShared: null,
|
|
1956
|
+
finalizationNotesInternal: null,
|
|
1957
|
+
finalizationNotes: null,
|
|
1958
|
+
};
|
|
1959
|
+
|
|
1960
|
+
const updateData: UpdateAppointmentData = {
|
|
1961
|
+
metadata: {
|
|
1962
|
+
selectedZones: currentMetadata.selectedZones,
|
|
1963
|
+
zonePhotos: currentMetadata.zonePhotos,
|
|
1964
|
+
zonesData: currentMetadata.zonesData,
|
|
1965
|
+
appointmentProducts: currentMetadata.appointmentProducts || [],
|
|
1966
|
+
extendedProcedures: currentMetadata.extendedProcedures || [],
|
|
1967
|
+
recommendedProcedures: currentMetadata.recommendedProcedures || [],
|
|
1968
|
+
...(currentMetadata.zoneBilling !== undefined && {
|
|
1969
|
+
zoneBilling: currentMetadata.zoneBilling,
|
|
1970
|
+
}),
|
|
1971
|
+
finalbilling,
|
|
1972
|
+
finalizationNotesShared: currentMetadata.finalizationNotesShared ?? null,
|
|
1973
|
+
finalizationNotesInternal: currentMetadata.finalizationNotesInternal ?? null,
|
|
1974
|
+
finalizationNotes:
|
|
1975
|
+
currentMetadata.finalizationNotes ??
|
|
1976
|
+
currentMetadata.finalizationNotesShared ??
|
|
1977
|
+
null,
|
|
1978
|
+
},
|
|
1979
|
+
updatedAt: serverTimestamp(),
|
|
1980
|
+
};
|
|
1981
|
+
|
|
1982
|
+
return await this.updateAppointment(appointmentId, updateData);
|
|
1983
|
+
} catch (error) {
|
|
1984
|
+
console.error(`[APPOINTMENT_SERVICE] Error applying overall discount:`, error);
|
|
1985
|
+
throw error;
|
|
1986
|
+
}
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1912
1989
|
/**
|
|
1913
1990
|
* Adds a recommended procedure to an appointment
|
|
1914
1991
|
* Multiple recommendations of the same procedure are allowed (e.g., touch-up in 2 weeks, full treatment in 3 months)
|
|
@@ -31,6 +31,7 @@ import { ClinicInfo, PatientProfileInfo, PractitionerProfileInfo } from '../../.
|
|
|
31
31
|
import { PRACTITIONERS_COLLECTION } from '../../../types/practitioner';
|
|
32
32
|
import { PATIENTS_COLLECTION } from '../../../types/patient';
|
|
33
33
|
import { CLINICS_COLLECTION } from '../../../types/clinic';
|
|
34
|
+
import { getSensitiveInfoDocRef } from '../../patient/utils/docs.utils';
|
|
34
35
|
import { BlockingCondition } from '../../../backoffice/types/static/blocking-condition.types';
|
|
35
36
|
import { Requirement } from '../../../backoffice/types/requirement.types';
|
|
36
37
|
import { PROCEDURES_COLLECTION } from '../../../types/procedure';
|
|
@@ -64,13 +65,15 @@ export async function fetchAggregatedInfoUtil(
|
|
|
64
65
|
postProcedureRequirements: Requirement[];
|
|
65
66
|
}> {
|
|
66
67
|
try {
|
|
67
|
-
// Fetch all data in parallel for efficiency
|
|
68
|
-
const [clinicDoc, practitionerDoc, patientDoc, procedureDoc] =
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
68
|
+
// Fetch all data in parallel for efficiency (including patient sensitive-info for PII)
|
|
69
|
+
const [clinicDoc, practitionerDoc, patientDoc, patientSensitiveDoc, procedureDoc] =
|
|
70
|
+
await Promise.all([
|
|
71
|
+
getDoc(doc(db, CLINICS_COLLECTION, clinicId)),
|
|
72
|
+
getDoc(doc(db, PRACTITIONERS_COLLECTION, practitionerId)),
|
|
73
|
+
getDoc(doc(db, PATIENTS_COLLECTION, patientId)),
|
|
74
|
+
getDoc(getSensitiveInfoDocRef(db, patientId)),
|
|
75
|
+
getDoc(doc(db, PROCEDURES_COLLECTION, procedureId)),
|
|
76
|
+
]);
|
|
74
77
|
|
|
75
78
|
// Check if all required entities exist
|
|
76
79
|
if (!clinicDoc.exists()) {
|
|
@@ -89,6 +92,9 @@ export async function fetchAggregatedInfoUtil(
|
|
|
89
92
|
const clinicData = clinicDoc.data();
|
|
90
93
|
const practitionerData = practitionerDoc.data();
|
|
91
94
|
const patientData = patientDoc.data();
|
|
95
|
+
const patientSensitiveData = patientSensitiveDoc.exists()
|
|
96
|
+
? (patientSensitiveDoc.data() as { firstName?: string; lastName?: string; email?: string; phoneNumber?: string; dateOfBirth?: any; gender?: string })
|
|
97
|
+
: null;
|
|
92
98
|
const procedureData = procedureDoc.data();
|
|
93
99
|
|
|
94
100
|
// Extract relevant info for ClinicInfo
|
|
@@ -114,14 +120,17 @@ export async function fetchAggregatedInfoUtil(
|
|
|
114
120
|
};
|
|
115
121
|
|
|
116
122
|
// Extract relevant info for PatientProfileInfo
|
|
117
|
-
//
|
|
123
|
+
// Prefer sensitive-info (patient PII) over profile; profile is fallback for displayName from manual creation
|
|
118
124
|
const patientInfo: PatientProfileInfo = {
|
|
119
125
|
id: patientId,
|
|
120
|
-
fullName:
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
126
|
+
fullName:
|
|
127
|
+
patientSensitiveData?.firstName && patientSensitiveData?.lastName
|
|
128
|
+
? `${patientSensitiveData.firstName} ${patientSensitiveData.lastName}`.trim()
|
|
129
|
+
: patientData.displayName || '',
|
|
130
|
+
email: patientSensitiveData?.email ?? patientData.email ?? '',
|
|
131
|
+
phone: patientSensitiveData?.phoneNumber ?? patientData.phoneNumber ?? null,
|
|
132
|
+
dateOfBirth: patientSensitiveData?.dateOfBirth ?? patientData.dateOfBirth ?? Timestamp.now(),
|
|
133
|
+
gender: patientSensitiveData?.gender ?? patientData.gender ?? 'other',
|
|
125
134
|
};
|
|
126
135
|
|
|
127
136
|
// Extract procedureInfo from the procedure document
|
|
@@ -9,6 +9,15 @@ import {
|
|
|
9
9
|
import { getAppointmentByIdUtil } from './appointment.utils';
|
|
10
10
|
import { doc } from 'firebase/firestore';
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Swiss 5-centime rounding (nearest 0.05 CHF)
|
|
14
|
+
* @param value Amount to round
|
|
15
|
+
* @returns Rounded amount
|
|
16
|
+
*/
|
|
17
|
+
export function swissRound(value: number): number {
|
|
18
|
+
return Math.round(value / 0.05) * 0.05;
|
|
19
|
+
}
|
|
20
|
+
|
|
12
21
|
/**
|
|
13
22
|
* Validates that a zone key follows the category.zone format
|
|
14
23
|
* @param zoneKey Zone key to validate
|
|
@@ -49,11 +58,13 @@ export function calculateItemSubtotal(item: Partial<ZoneItemData>): number {
|
|
|
49
58
|
* Recalculates final billing based on all zone items
|
|
50
59
|
* @param zonesData Zone items data
|
|
51
60
|
* @param taxRate Tax rate (e.g., 0.20 for 20%)
|
|
61
|
+
* @param discount Optional overall discount to apply
|
|
52
62
|
* @returns Calculated final billing
|
|
53
63
|
*/
|
|
54
64
|
export function calculateFinalBilling(
|
|
55
65
|
zonesData: Record<string, ZoneItemData[]>,
|
|
56
66
|
taxRate: number = 0.081,
|
|
67
|
+
discount?: { type: 'percentage' | 'fixed'; value: number } | null,
|
|
57
68
|
): FinalBilling {
|
|
58
69
|
let subtotalAll = 0;
|
|
59
70
|
|
|
@@ -66,9 +77,6 @@ export function calculateFinalBilling(
|
|
|
66
77
|
});
|
|
67
78
|
});
|
|
68
79
|
|
|
69
|
-
const taxPrice = subtotalAll * taxRate;
|
|
70
|
-
const finalPrice = subtotalAll + taxPrice;
|
|
71
|
-
|
|
72
80
|
// Get currency from first item (assuming all same currency)
|
|
73
81
|
let currency: any = 'CHF'; // Default
|
|
74
82
|
|
|
@@ -80,6 +88,39 @@ export function calculateFinalBilling(
|
|
|
80
88
|
}
|
|
81
89
|
}
|
|
82
90
|
|
|
91
|
+
// Apply overall discount if provided
|
|
92
|
+
if (discount && discount.value > 0) {
|
|
93
|
+
let discountAmount: number;
|
|
94
|
+
if (discount.type === 'percentage') {
|
|
95
|
+
discountAmount = subtotalAll * (discount.value / 100);
|
|
96
|
+
} else {
|
|
97
|
+
discountAmount = discount.value;
|
|
98
|
+
}
|
|
99
|
+
// Cap discount at subtotal (can't go negative)
|
|
100
|
+
discountAmount = Math.min(discountAmount, subtotalAll);
|
|
101
|
+
|
|
102
|
+
const discountedSubtotal = subtotalAll - discountAmount;
|
|
103
|
+
const taxPrice = discountedSubtotal * taxRate;
|
|
104
|
+
const finalPrice = swissRound(discountedSubtotal + taxPrice);
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
subtotalAll,
|
|
108
|
+
discount: {
|
|
109
|
+
type: discount.type,
|
|
110
|
+
value: discount.value,
|
|
111
|
+
amount: discountAmount,
|
|
112
|
+
},
|
|
113
|
+
discountedSubtotal,
|
|
114
|
+
taxRate,
|
|
115
|
+
taxPrice,
|
|
116
|
+
finalPrice,
|
|
117
|
+
currency,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const taxPrice = subtotalAll * taxRate;
|
|
122
|
+
const finalPrice = swissRound(subtotalAll + taxPrice);
|
|
123
|
+
|
|
83
124
|
return {
|
|
84
125
|
subtotalAll,
|
|
85
126
|
taxRate,
|
|
@@ -183,8 +224,12 @@ export async function addItemToZoneUtil(
|
|
|
183
224
|
// Add item to zone
|
|
184
225
|
zonesData[zoneId].push(itemWithSubtotal);
|
|
185
226
|
|
|
186
|
-
// Recalculate final billing with Swiss tax rate (8.1%)
|
|
187
|
-
const
|
|
227
|
+
// Recalculate final billing with Swiss tax rate (8.1%), preserving existing discount
|
|
228
|
+
const existingDiscount = metadata.finalbilling?.discount;
|
|
229
|
+
const discountParam = existingDiscount
|
|
230
|
+
? { type: existingDiscount.type, value: existingDiscount.value }
|
|
231
|
+
: undefined;
|
|
232
|
+
const finalbilling = calculateFinalBilling(zonesData, 0.081, discountParam);
|
|
188
233
|
|
|
189
234
|
// Update appointment
|
|
190
235
|
const appointmentRef = doc(db, APPOINTMENTS_COLLECTION, appointmentId);
|
|
@@ -234,8 +279,12 @@ export async function removeItemFromZoneUtil(
|
|
|
234
279
|
delete metadata.zonesData[zoneId];
|
|
235
280
|
}
|
|
236
281
|
|
|
237
|
-
// Recalculate final billing with Swiss tax rate (8.1%)
|
|
238
|
-
const
|
|
282
|
+
// Recalculate final billing with Swiss tax rate (8.1%), preserving existing discount
|
|
283
|
+
const existingDiscount = metadata.finalbilling?.discount;
|
|
284
|
+
const discountParam = existingDiscount
|
|
285
|
+
? { type: existingDiscount.type, value: existingDiscount.value }
|
|
286
|
+
: undefined;
|
|
287
|
+
const finalbilling = calculateFinalBilling(metadata.zonesData, 0.081, discountParam);
|
|
239
288
|
|
|
240
289
|
// Update appointment
|
|
241
290
|
const appointmentRef = doc(db, APPOINTMENTS_COLLECTION, appointmentId);
|
|
@@ -327,8 +376,12 @@ export async function updateZoneItemUtil(
|
|
|
327
376
|
newSubtotal: items[itemIndex].subtotal,
|
|
328
377
|
});
|
|
329
378
|
|
|
330
|
-
// Recalculate final billing with Swiss tax rate (8.1%)
|
|
331
|
-
const
|
|
379
|
+
// Recalculate final billing with Swiss tax rate (8.1%), preserving existing discount
|
|
380
|
+
const existingDiscount = metadata.finalbilling?.discount;
|
|
381
|
+
const discountParam = existingDiscount
|
|
382
|
+
? { type: existingDiscount.type, value: existingDiscount.value }
|
|
383
|
+
: undefined;
|
|
384
|
+
const finalbilling = calculateFinalBilling(metadata.zonesData, 0.081, discountParam);
|
|
332
385
|
|
|
333
386
|
// Log what we're about to save to Firestore
|
|
334
387
|
console.log(`[updateZoneItemUtil] Saving to Firestore:`, {
|
|
@@ -103,8 +103,40 @@ import {
|
|
|
103
103
|
AestheticAnalysis,
|
|
104
104
|
CreateAestheticAnalysisData,
|
|
105
105
|
UpdateAestheticAnalysisData,
|
|
106
|
+
SkinQualityAssessment,
|
|
107
|
+
CreateSkinQualityAssessmentData,
|
|
108
|
+
UpdateSkinQualityAssessmentData,
|
|
109
|
+
BodyAssessment,
|
|
110
|
+
CreateBodyAssessmentData,
|
|
111
|
+
UpdateBodyAssessmentData,
|
|
112
|
+
HairScalpAssessment,
|
|
113
|
+
CreateHairScalpAssessmentData,
|
|
114
|
+
UpdateHairScalpAssessmentData,
|
|
115
|
+
PreSurgicalAssessment,
|
|
116
|
+
CreatePreSurgicalAssessmentData,
|
|
117
|
+
UpdatePreSurgicalAssessmentData,
|
|
106
118
|
} from '../../types/patient';
|
|
107
119
|
|
|
120
|
+
import {
|
|
121
|
+
getSkinQualityAssessmentUtil,
|
|
122
|
+
createOrUpdateSkinQualityAssessmentUtil,
|
|
123
|
+
} from './utils/skin-quality-assessment.utils';
|
|
124
|
+
|
|
125
|
+
import {
|
|
126
|
+
getBodyAssessmentUtil,
|
|
127
|
+
createOrUpdateBodyAssessmentUtil,
|
|
128
|
+
} from './utils/body-assessment.utils';
|
|
129
|
+
|
|
130
|
+
import {
|
|
131
|
+
getHairScalpAssessmentUtil,
|
|
132
|
+
createOrUpdateHairScalpAssessmentUtil,
|
|
133
|
+
} from './utils/hair-scalp-assessment.utils';
|
|
134
|
+
|
|
135
|
+
import {
|
|
136
|
+
getPreSurgicalAssessmentUtil,
|
|
137
|
+
createOrUpdatePreSurgicalAssessmentUtil,
|
|
138
|
+
} from './utils/pre-surgical-assessment.utils';
|
|
139
|
+
|
|
108
140
|
import { CreatePatientTokenData, PatientToken } from '../../types/patient/token.types';
|
|
109
141
|
|
|
110
142
|
export class PatientService extends BaseService {
|
|
@@ -882,4 +914,108 @@ export class PatientService extends BaseService {
|
|
|
882
914
|
true
|
|
883
915
|
);
|
|
884
916
|
}
|
|
917
|
+
|
|
918
|
+
// Skin Quality Assessment methods
|
|
919
|
+
async getSkinQualityAssessment(patientId: string): Promise<SkinQualityAssessment | null> {
|
|
920
|
+
const currentUser = await this.getCurrentUser();
|
|
921
|
+
return getSkinQualityAssessmentUtil(this.db, patientId, currentUser.uid, currentUser.roles);
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
async createSkinQualityAssessment(
|
|
925
|
+
patientId: string,
|
|
926
|
+
data: CreateSkinQualityAssessmentData
|
|
927
|
+
): Promise<void> {
|
|
928
|
+
const currentUser = await this.getCurrentUser();
|
|
929
|
+
return createOrUpdateSkinQualityAssessmentUtil(
|
|
930
|
+
this.db, patientId, data, currentUser.uid, currentUser.roles, false
|
|
931
|
+
);
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
async updateSkinQualityAssessment(
|
|
935
|
+
patientId: string,
|
|
936
|
+
data: UpdateSkinQualityAssessmentData
|
|
937
|
+
): Promise<void> {
|
|
938
|
+
const currentUser = await this.getCurrentUser();
|
|
939
|
+
return createOrUpdateSkinQualityAssessmentUtil(
|
|
940
|
+
this.db, patientId, data, currentUser.uid, currentUser.roles, true
|
|
941
|
+
);
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
// Body Assessment methods
|
|
945
|
+
async getBodyAssessment(patientId: string): Promise<BodyAssessment | null> {
|
|
946
|
+
const currentUser = await this.getCurrentUser();
|
|
947
|
+
return getBodyAssessmentUtil(this.db, patientId, currentUser.uid, currentUser.roles);
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
async createBodyAssessment(
|
|
951
|
+
patientId: string,
|
|
952
|
+
data: CreateBodyAssessmentData
|
|
953
|
+
): Promise<void> {
|
|
954
|
+
const currentUser = await this.getCurrentUser();
|
|
955
|
+
return createOrUpdateBodyAssessmentUtil(
|
|
956
|
+
this.db, patientId, data, currentUser.uid, currentUser.roles, false
|
|
957
|
+
);
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
async updateBodyAssessment(
|
|
961
|
+
patientId: string,
|
|
962
|
+
data: UpdateBodyAssessmentData
|
|
963
|
+
): Promise<void> {
|
|
964
|
+
const currentUser = await this.getCurrentUser();
|
|
965
|
+
return createOrUpdateBodyAssessmentUtil(
|
|
966
|
+
this.db, patientId, data, currentUser.uid, currentUser.roles, true
|
|
967
|
+
);
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
// Hair & Scalp Assessment methods
|
|
971
|
+
async getHairScalpAssessment(patientId: string): Promise<HairScalpAssessment | null> {
|
|
972
|
+
const currentUser = await this.getCurrentUser();
|
|
973
|
+
return getHairScalpAssessmentUtil(this.db, patientId, currentUser.uid, currentUser.roles);
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
async createHairScalpAssessment(
|
|
977
|
+
patientId: string,
|
|
978
|
+
data: CreateHairScalpAssessmentData
|
|
979
|
+
): Promise<void> {
|
|
980
|
+
const currentUser = await this.getCurrentUser();
|
|
981
|
+
return createOrUpdateHairScalpAssessmentUtil(
|
|
982
|
+
this.db, patientId, data, currentUser.uid, currentUser.roles, false
|
|
983
|
+
);
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
async updateHairScalpAssessment(
|
|
987
|
+
patientId: string,
|
|
988
|
+
data: UpdateHairScalpAssessmentData
|
|
989
|
+
): Promise<void> {
|
|
990
|
+
const currentUser = await this.getCurrentUser();
|
|
991
|
+
return createOrUpdateHairScalpAssessmentUtil(
|
|
992
|
+
this.db, patientId, data, currentUser.uid, currentUser.roles, true
|
|
993
|
+
);
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
// Pre-Surgical Assessment methods
|
|
997
|
+
async getPreSurgicalAssessment(patientId: string): Promise<PreSurgicalAssessment | null> {
|
|
998
|
+
const currentUser = await this.getCurrentUser();
|
|
999
|
+
return getPreSurgicalAssessmentUtil(this.db, patientId, currentUser.uid, currentUser.roles);
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
async createPreSurgicalAssessment(
|
|
1003
|
+
patientId: string,
|
|
1004
|
+
data: CreatePreSurgicalAssessmentData
|
|
1005
|
+
): Promise<void> {
|
|
1006
|
+
const currentUser = await this.getCurrentUser();
|
|
1007
|
+
return createOrUpdatePreSurgicalAssessmentUtil(
|
|
1008
|
+
this.db, patientId, data, currentUser.uid, currentUser.roles, false
|
|
1009
|
+
);
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
async updatePreSurgicalAssessment(
|
|
1013
|
+
patientId: string,
|
|
1014
|
+
data: UpdatePreSurgicalAssessmentData
|
|
1015
|
+
): Promise<void> {
|
|
1016
|
+
const currentUser = await this.getCurrentUser();
|
|
1017
|
+
return createOrUpdatePreSurgicalAssessmentUtil(
|
|
1018
|
+
this.db, patientId, data, currentUser.uid, currentUser.roles, true
|
|
1019
|
+
);
|
|
1020
|
+
}
|
|
885
1021
|
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { getDoc, updateDoc, setDoc, serverTimestamp, Firestore, doc } from 'firebase/firestore';
|
|
2
|
+
import {
|
|
3
|
+
BodyAssessment,
|
|
4
|
+
CreateBodyAssessmentData,
|
|
5
|
+
UpdateBodyAssessmentData,
|
|
6
|
+
BODY_ASSESSMENT_COLLECTION,
|
|
7
|
+
PATIENTS_COLLECTION,
|
|
8
|
+
BodyAssessmentStatus,
|
|
9
|
+
} from '../../../types/patient';
|
|
10
|
+
import { UserRole } from '../../../types';
|
|
11
|
+
import {
|
|
12
|
+
createBodyAssessmentSchema,
|
|
13
|
+
updateBodyAssessmentSchema,
|
|
14
|
+
bodyAssessmentSchema,
|
|
15
|
+
} from '../../../validations/patient/body-assessment.schema';
|
|
16
|
+
import { getPatientDocRef } from './docs.utils';
|
|
17
|
+
import { AuthError } from '../../../errors/auth.errors';
|
|
18
|
+
import { getPractitionerProfileByUserRef } from './practitioner.utils';
|
|
19
|
+
import { getClinicAdminByUserRef } from '../../clinic/utils/admin.utils';
|
|
20
|
+
|
|
21
|
+
export const getBodyAssessmentDocRef = (db: Firestore, patientId: string) => {
|
|
22
|
+
return doc(db, PATIENTS_COLLECTION, patientId, BODY_ASSESSMENT_COLLECTION, patientId);
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const checkBodyAssessmentAccessUtil = async (
|
|
26
|
+
db: Firestore,
|
|
27
|
+
patientId: string,
|
|
28
|
+
requesterId: string,
|
|
29
|
+
requesterRoles: UserRole[]
|
|
30
|
+
): Promise<void> => {
|
|
31
|
+
const patientDoc = await getDoc(getPatientDocRef(db, patientId));
|
|
32
|
+
if (!patientDoc.exists()) {
|
|
33
|
+
throw new Error('Patient profile not found');
|
|
34
|
+
}
|
|
35
|
+
const patientData = patientDoc.data() as any;
|
|
36
|
+
|
|
37
|
+
if (patientData.userRef && patientData.userRef === requesterId) {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (requesterRoles.includes(UserRole.PRACTITIONER)) {
|
|
42
|
+
const practitionerProfile = await getPractitionerProfileByUserRef(db, requesterId);
|
|
43
|
+
if (practitionerProfile && patientData.doctorIds?.includes(practitionerProfile.id)) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (requesterRoles.includes(UserRole.CLINIC_ADMIN)) {
|
|
49
|
+
const adminProfile = await getClinicAdminByUserRef(db, requesterId);
|
|
50
|
+
if (adminProfile && adminProfile.clinicsManaged) {
|
|
51
|
+
const hasAccess = adminProfile.clinicsManaged.some((managedClinicId) =>
|
|
52
|
+
patientData.clinicIds?.includes(managedClinicId)
|
|
53
|
+
);
|
|
54
|
+
if (hasAccess) {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
throw new AuthError(
|
|
61
|
+
'Unauthorized access to body assessment.',
|
|
62
|
+
'AUTH/UNAUTHORIZED_ACCESS',
|
|
63
|
+
403
|
|
64
|
+
);
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export const calculateBodyAssessmentCompletionPercentage = (data: Partial<BodyAssessment>): number => {
|
|
68
|
+
let completed = 0;
|
|
69
|
+
const total = 4;
|
|
70
|
+
|
|
71
|
+
if (data.selectedZones && data.selectedZones.length > 0) completed++;
|
|
72
|
+
if (data.zoneAssessments && data.zoneAssessments.length > 0) completed++;
|
|
73
|
+
if (data.composition && (data.composition.bmi || data.composition.bodyFatPercentage || data.composition.waistHipRatio)) completed++;
|
|
74
|
+
if ((data.measurements && data.measurements.waist) || (data.symmetry && data.symmetry.overallSymmetry)) completed++;
|
|
75
|
+
|
|
76
|
+
return Math.round((completed / total) * 100);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export const determineBodyAssessmentStatus = (completionPercentage: number): BodyAssessmentStatus => {
|
|
80
|
+
if (completionPercentage < 50) return 'incomplete';
|
|
81
|
+
if (completionPercentage >= 50) return 'ready_for_planning';
|
|
82
|
+
return 'incomplete';
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export const getBodyAssessmentUtil = async (
|
|
86
|
+
db: Firestore,
|
|
87
|
+
patientId: string,
|
|
88
|
+
requesterId: string,
|
|
89
|
+
requesterRoles: UserRole[]
|
|
90
|
+
): Promise<BodyAssessment | null> => {
|
|
91
|
+
await checkBodyAssessmentAccessUtil(db, patientId, requesterId, requesterRoles);
|
|
92
|
+
|
|
93
|
+
const docRef = getBodyAssessmentDocRef(db, patientId);
|
|
94
|
+
const snapshot = await getDoc(docRef);
|
|
95
|
+
|
|
96
|
+
if (!snapshot.exists()) {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const data = snapshot.data();
|
|
101
|
+
return bodyAssessmentSchema.parse({
|
|
102
|
+
...data,
|
|
103
|
+
id: patientId,
|
|
104
|
+
});
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
export const createOrUpdateBodyAssessmentUtil = async (
|
|
108
|
+
db: Firestore,
|
|
109
|
+
patientId: string,
|
|
110
|
+
data: CreateBodyAssessmentData | UpdateBodyAssessmentData,
|
|
111
|
+
requesterId: string,
|
|
112
|
+
requesterRoles: UserRole[],
|
|
113
|
+
isUpdate: boolean = false
|
|
114
|
+
): Promise<void> => {
|
|
115
|
+
await checkBodyAssessmentAccessUtil(db, patientId, requesterId, requesterRoles);
|
|
116
|
+
|
|
117
|
+
const validatedData = isUpdate
|
|
118
|
+
? updateBodyAssessmentSchema.parse(data)
|
|
119
|
+
: createBodyAssessmentSchema.parse(data);
|
|
120
|
+
|
|
121
|
+
const docRef = getBodyAssessmentDocRef(db, patientId);
|
|
122
|
+
const snapshot = await getDoc(docRef);
|
|
123
|
+
|
|
124
|
+
const requesterRole = requesterRoles.includes(UserRole.PRACTITIONER) ? 'PRACTITIONER' : 'PATIENT';
|
|
125
|
+
|
|
126
|
+
const existingData = snapshot.exists() ? snapshot.data() : null;
|
|
127
|
+
const mergedData: any = {
|
|
128
|
+
...(existingData || {}),
|
|
129
|
+
...validatedData,
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const completionPercentage = calculateBodyAssessmentCompletionPercentage(mergedData);
|
|
133
|
+
const status = determineBodyAssessmentStatus(completionPercentage);
|
|
134
|
+
|
|
135
|
+
if (!snapshot.exists()) {
|
|
136
|
+
await setDoc(docRef, {
|
|
137
|
+
id: patientId,
|
|
138
|
+
patientId,
|
|
139
|
+
selectedZones: [],
|
|
140
|
+
zoneAssessments: [],
|
|
141
|
+
...validatedData,
|
|
142
|
+
completionPercentage,
|
|
143
|
+
status,
|
|
144
|
+
lastUpdatedBy: requesterId,
|
|
145
|
+
lastUpdatedByRole: requesterRole,
|
|
146
|
+
createdAt: serverTimestamp(),
|
|
147
|
+
updatedAt: serverTimestamp(),
|
|
148
|
+
});
|
|
149
|
+
} else {
|
|
150
|
+
await updateDoc(docRef, {
|
|
151
|
+
...validatedData,
|
|
152
|
+
completionPercentage,
|
|
153
|
+
status,
|
|
154
|
+
lastUpdatedBy: requesterId,
|
|
155
|
+
lastUpdatedByRole: requesterRole,
|
|
156
|
+
updatedAt: serverTimestamp(),
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
};
|
|
@@ -120,13 +120,13 @@ export const getPatientsByClinicWithDetailsUtil = async (
|
|
|
120
120
|
? (sensitiveInfoDoc.data() as PatientSensitiveInfo)
|
|
121
121
|
: null;
|
|
122
122
|
|
|
123
|
-
// Merge sensitive info into profile (sensitive
|
|
123
|
+
// Merge sensitive info into profile (sensitive-info is source of truth for PII)
|
|
124
124
|
return {
|
|
125
125
|
...profile,
|
|
126
|
-
//
|
|
127
|
-
phoneNumber:
|
|
128
|
-
//
|
|
129
|
-
dateOfBirth:
|
|
126
|
+
// Prefer phoneNumber from sensitive-info (patient self-updated); fallback to profile (manual creation)
|
|
127
|
+
phoneNumber: sensitiveInfo?.phoneNumber ?? profile.phoneNumber ?? null,
|
|
128
|
+
// Prefer dateOfBirth from sensitive-info
|
|
129
|
+
dateOfBirth: sensitiveInfo?.dateOfBirth ?? profile.dateOfBirth ?? null,
|
|
130
130
|
// Merge photoUrl from sensitive info if available
|
|
131
131
|
photoUrl: sensitiveInfo?.photoUrl || null,
|
|
132
132
|
} as PatientProfile & { photoUrl?: string | null };
|