@primocaredentgroup/prescriptions-component 0.1.0
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/LICENSE +21 -0
- package/README.md +84 -0
- package/dist/_internal/client/PrescriptionsComponentClient.d.ts +25 -0
- package/dist/_internal/client/PrescriptionsComponentClient.d.ts.map +1 -0
- package/dist/_internal/client/PrescriptionsComponentClient.js +61 -0
- package/dist/_internal/client/PrescriptionsComponentClient.js.map +1 -0
- package/dist/_internal/client/index.d.ts +3 -0
- package/dist/_internal/client/index.d.ts.map +1 -0
- package/dist/_internal/client/index.js +2 -0
- package/dist/_internal/client/index.js.map +1 -0
- package/dist/_internal/client/types.d.ts +47 -0
- package/dist/_internal/client/types.d.ts.map +1 -0
- package/dist/_internal/client/types.js +2 -0
- package/dist/_internal/client/types.js.map +1 -0
- package/dist/_internal/component/convex.config.d.ts +7 -0
- package/dist/_internal/component/convex.config.d.ts.map +1 -0
- package/dist/_internal/component/convex.config.js +8 -0
- package/dist/_internal/component/convex.config.js.map +1 -0
- package/dist/_internal/component/functions.d.ts +9 -0
- package/dist/_internal/component/functions.d.ts.map +1 -0
- package/dist/_internal/component/functions.js +11 -0
- package/dist/_internal/component/functions.js.map +1 -0
- package/dist/_internal/component/index.d.ts +18 -0
- package/dist/_internal/component/index.d.ts.map +1 -0
- package/dist/_internal/component/index.js +20 -0
- package/dist/_internal/component/index.js.map +1 -0
- package/dist/_internal/component/schema.d.ts +2 -0
- package/dist/_internal/component/schema.d.ts.map +1 -0
- package/dist/_internal/component/schema.js +2 -0
- package/dist/_internal/component/schema.js.map +1 -0
- package/dist/_internal/react/PrescriptionsWidget.d.ts +18 -0
- package/dist/_internal/react/PrescriptionsWidget.d.ts.map +1 -0
- package/dist/_internal/react/PrescriptionsWidget.js +137 -0
- package/dist/_internal/react/PrescriptionsWidget.js.map +1 -0
- package/dist/_internal/react/index.d.ts +3 -0
- package/dist/_internal/react/index.d.ts.map +1 -0
- package/dist/_internal/react/index.js +2 -0
- package/dist/_internal/react/index.js.map +1 -0
- package/dist/client/index.d.ts +1 -0
- package/dist/client/index.js +1 -0
- package/dist/component/convex.config.d.ts +2 -0
- package/dist/component/convex.config.js +2 -0
- package/dist/component/index.d.ts +1 -0
- package/dist/component/index.js +1 -0
- package/dist/convex/lib/auth.d.ts +7 -0
- package/dist/convex/lib/auth.d.ts.map +1 -0
- package/dist/convex/lib/auth.js +38 -0
- package/dist/convex/lib/auth.js.map +1 -0
- package/dist/convex/lib/clinicalNormalize.d.ts +13 -0
- package/dist/convex/lib/clinicalNormalize.d.ts.map +1 -0
- package/dist/convex/lib/clinicalNormalize.js +82 -0
- package/dist/convex/lib/clinicalNormalize.js.map +1 -0
- package/dist/convex/lib/dental.d.ts +13 -0
- package/dist/convex/lib/dental.d.ts.map +1 -0
- package/dist/convex/lib/dental.js +79 -0
- package/dist/convex/lib/dental.js.map +1 -0
- package/dist/convex/lib/dynamicFieldsStrict.d.ts +9 -0
- package/dist/convex/lib/dynamicFieldsStrict.d.ts.map +1 -0
- package/dist/convex/lib/dynamicFieldsStrict.js +65 -0
- package/dist/convex/lib/dynamicFieldsStrict.js.map +1 -0
- package/dist/convex/lib/dynamicRules.d.ts +61 -0
- package/dist/convex/lib/dynamicRules.d.ts.map +1 -0
- package/dist/convex/lib/dynamicRules.js +221 -0
- package/dist/convex/lib/dynamicRules.js.map +1 -0
- package/dist/convex/lib/storage.d.ts +59 -0
- package/dist/convex/lib/storage.d.ts.map +1 -0
- package/dist/convex/lib/storage.js +120 -0
- package/dist/convex/lib/storage.js.map +1 -0
- package/dist/convex/lib/utils.d.ts +61 -0
- package/dist/convex/lib/utils.d.ts.map +1 -0
- package/dist/convex/lib/utils.js +135 -0
- package/dist/convex/lib/utils.js.map +1 -0
- package/dist/convex/lib/validation.d.ts +24 -0
- package/dist/convex/lib/validation.d.ts.map +1 -0
- package/dist/convex/lib/validation.js +333 -0
- package/dist/convex/lib/validation.js.map +1 -0
- package/dist/convex/mutations/digitalAssets.d.ts +54 -0
- package/dist/convex/mutations/digitalAssets.d.ts.map +1 -0
- package/dist/convex/mutations/digitalAssets.js +297 -0
- package/dist/convex/mutations/digitalAssets.js.map +1 -0
- package/dist/convex/mutations/operational.d.ts +38 -0
- package/dist/convex/mutations/operational.d.ts.map +1 -0
- package/dist/convex/mutations/operational.js +226 -0
- package/dist/convex/mutations/operational.js.map +1 -0
- package/dist/convex/mutations/phases.d.ts +45 -0
- package/dist/convex/mutations/phases.d.ts.map +1 -0
- package/dist/convex/mutations/phases.js +334 -0
- package/dist/convex/mutations/phases.js.map +1 -0
- package/dist/convex/mutations/prescriptions.d.ts +191 -0
- package/dist/convex/mutations/prescriptions.d.ts.map +1 -0
- package/dist/convex/mutations/prescriptions.js +1263 -0
- package/dist/convex/mutations/prescriptions.js.map +1 -0
- package/dist/convex/mutations/syncJobs.d.ts +37 -0
- package/dist/convex/mutations/syncJobs.d.ts.map +1 -0
- package/dist/convex/mutations/syncJobs.js +238 -0
- package/dist/convex/mutations/syncJobs.js.map +1 -0
- package/dist/convex/prescriptions/fields.d.ts +50 -0
- package/dist/convex/prescriptions/fields.d.ts.map +1 -0
- package/dist/convex/prescriptions/fields.js +242 -0
- package/dist/convex/prescriptions/fields.js.map +1 -0
- package/dist/convex/queries/dynamicFields.d.ts +27 -0
- package/dist/convex/queries/dynamicFields.d.ts.map +1 -0
- package/dist/convex/queries/dynamicFields.js +119 -0
- package/dist/convex/queries/dynamicFields.js.map +1 -0
- package/dist/convex/queries/prescriptions.d.ts +583 -0
- package/dist/convex/queries/prescriptions.d.ts.map +1 -0
- package/dist/convex/queries/prescriptions.js +208 -0
- package/dist/convex/queries/prescriptions.js.map +1 -0
- package/dist/convex/schema.d.ts +962 -0
- package/dist/convex/schema.d.ts.map +1 -0
- package/dist/convex/schema.js +434 -0
- package/dist/convex/schema.js.map +1 -0
- package/dist/convex/types.d.ts +267 -0
- package/dist/convex/types.d.ts.map +1 -0
- package/dist/convex/types.js +2 -0
- package/dist/convex/types.js.map +1 -0
- package/dist/react/index.d.ts +1 -0
- package/dist/react/index.js +1 -0
- package/dist/react/styles.css +54 -0
- package/package.json +82 -0
|
@@ -0,0 +1,1263 @@
|
|
|
1
|
+
import { mutation } from "../_generated/server";
|
|
2
|
+
import { ConvexError, v } from "convex/values";
|
|
3
|
+
import { computeClinicalHash, generateIdempotencyKey } from "../lib/utils";
|
|
4
|
+
import { validateClinicalData, canUserPerformAction, validateDigitalRequirements } from "../lib/validation";
|
|
5
|
+
import { requireIdentityUser, assertPrescriptionAccess } from "../lib/auth";
|
|
6
|
+
import { assertDynamicRequiredComplete } from "../lib/dynamicFieldsStrict";
|
|
7
|
+
import { normalizeClinicalDraftInput } from "../lib/clinicalNormalize";
|
|
8
|
+
// ============================================
|
|
9
|
+
// CALENDAR DATA VALIDATOR (per la mutation)
|
|
10
|
+
// ============================================
|
|
11
|
+
const calendarDataArgsValidator = v.object({
|
|
12
|
+
patientFirstName: v.string(),
|
|
13
|
+
patientLastName: v.string(),
|
|
14
|
+
clinicName: v.string(),
|
|
15
|
+
treatmentPlanId: v.string(),
|
|
16
|
+
treatmentPlanName: v.optional(v.string()),
|
|
17
|
+
treatmentId: v.string(),
|
|
18
|
+
treatmentName: v.string(),
|
|
19
|
+
phaseId: v.string(),
|
|
20
|
+
phaseName: v.string(),
|
|
21
|
+
application: v.string(),
|
|
22
|
+
toothNumber: v.optional(v.string()),
|
|
23
|
+
appointmentId: v.string(),
|
|
24
|
+
appointmentDate: v.number(),
|
|
25
|
+
operatorId: v.string(),
|
|
26
|
+
operatorName: v.string(),
|
|
27
|
+
// Prossimo appuntamento (opzionale)
|
|
28
|
+
nextAppointmentId: v.optional(v.string()),
|
|
29
|
+
nextAppointmentDate: v.optional(v.number()),
|
|
30
|
+
nextAppointmentPhaseId: v.optional(v.string()),
|
|
31
|
+
nextAppointmentPhaseName: v.optional(v.string()),
|
|
32
|
+
});
|
|
33
|
+
async function requireAuthenticatedUserOrThrow(ctx) {
|
|
34
|
+
try {
|
|
35
|
+
return await requireIdentityUser(ctx);
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
if (error instanceof ConvexError) {
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
throw new ConvexError({
|
|
42
|
+
code: "UNAUTHORIZED",
|
|
43
|
+
message: "Accesso non autorizzato",
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function assertPrescriptionAccessOrThrow(user, prescription) {
|
|
48
|
+
try {
|
|
49
|
+
assertPrescriptionAccess(user, prescription);
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
throw new ConvexError({
|
|
53
|
+
code: "FORBIDDEN",
|
|
54
|
+
message: error instanceof Error ? error.message : "Forbidden",
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
// ============================================
|
|
59
|
+
// PRESCRIPTION MUTATIONS
|
|
60
|
+
// ============================================
|
|
61
|
+
/**
|
|
62
|
+
* Crea una nuova bozza di prescrizione.
|
|
63
|
+
*
|
|
64
|
+
* VINCOLO CRITICO: Una sola prescrizione attiva per pdcItemId
|
|
65
|
+
*/
|
|
66
|
+
export const createDraft = mutation({
|
|
67
|
+
args: {
|
|
68
|
+
clinicId: v.string(),
|
|
69
|
+
doctorId: v.string(),
|
|
70
|
+
patientId: v.string(),
|
|
71
|
+
pdcItemId: v.string(),
|
|
72
|
+
listinoId: v.string(),
|
|
73
|
+
flowKey: v.string(),
|
|
74
|
+
},
|
|
75
|
+
handler: async (ctx, args) => {
|
|
76
|
+
const now = Date.now();
|
|
77
|
+
const user = await requireAuthenticatedUserOrThrow(ctx);
|
|
78
|
+
// 1. VINCOLO: Verifica unicità pdcItemId
|
|
79
|
+
const existingPrescriptions = await ctx.db
|
|
80
|
+
.query("prescriptions")
|
|
81
|
+
.withIndex("by_pdcItemId", (q) => q.eq("pdcItemId", args.pdcItemId))
|
|
82
|
+
.collect();
|
|
83
|
+
const activeStatuses = ["DRAFT", "PENDING_DOCTOR", "SIGNED", "SYNCED", "ERROR"];
|
|
84
|
+
const activePrescription = existingPrescriptions.find((p) => activeStatuses.includes(p.status));
|
|
85
|
+
if (activePrescription) {
|
|
86
|
+
throw new Error(`Esiste già una prescrizione attiva per questo PDC Item (ID: ${activePrescription._id})`);
|
|
87
|
+
}
|
|
88
|
+
// 2. Verifica ruolo + ownership clinic
|
|
89
|
+
const canCreate = user.role === "SECRETARY" ||
|
|
90
|
+
user.adminRole === "admin" ||
|
|
91
|
+
user.adminRole === "superadmin";
|
|
92
|
+
if (!canCreate) {
|
|
93
|
+
throw new ConvexError({
|
|
94
|
+
code: "FORBIDDEN",
|
|
95
|
+
message: "Utente non autorizzato a creare prescrizioni",
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
if (user.clinicId !== args.clinicId && user.adminRole !== "admin" && user.adminRole !== "superadmin") {
|
|
99
|
+
throw new ConvexError({
|
|
100
|
+
code: "FORBIDDEN",
|
|
101
|
+
message: "Clinic non autorizzata per l'utente autenticato",
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
// 3. Carica flow attivo
|
|
105
|
+
const flow = await ctx.db
|
|
106
|
+
.query("flows")
|
|
107
|
+
.withIndex("by_flowKey_status", (q) => q.eq("flowKey", args.flowKey).eq("status", "ACTIVE"))
|
|
108
|
+
.first();
|
|
109
|
+
if (!flow) {
|
|
110
|
+
throw new Error(`Flow "${args.flowKey}" non trovato o non attivo`);
|
|
111
|
+
}
|
|
112
|
+
// 3b. Carica rulesetVersion attiva (se presente)
|
|
113
|
+
const activeRuleset = await ctx.db
|
|
114
|
+
.query("activeRulesets")
|
|
115
|
+
.withIndex("by_key", (q) => q.eq("key", "global"))
|
|
116
|
+
.first();
|
|
117
|
+
// 4. Crea prescrizione
|
|
118
|
+
const prescriptionId = await ctx.db.insert("prescriptions", {
|
|
119
|
+
prescriptionId: 0, // Will be backfilled
|
|
120
|
+
clinicId: args.clinicId,
|
|
121
|
+
doctorId: args.doctorId,
|
|
122
|
+
patientId: args.patientId,
|
|
123
|
+
pdcItemId: args.pdcItemId,
|
|
124
|
+
listinoId: args.listinoId,
|
|
125
|
+
flowKey: args.flowKey,
|
|
126
|
+
flowVersion: flow.version,
|
|
127
|
+
status: "DRAFT",
|
|
128
|
+
createdByUserId: user._id,
|
|
129
|
+
createdAt: now,
|
|
130
|
+
updatedAt: now,
|
|
131
|
+
coreRefs: {},
|
|
132
|
+
labRefs: {},
|
|
133
|
+
// Snapshot ruleset attivo al momento della creazione
|
|
134
|
+
rulesetVersionId: activeRuleset?.versionId,
|
|
135
|
+
valuesByFieldId: "{}",
|
|
136
|
+
});
|
|
137
|
+
// 5. Crea revisione clinica iniziale (vuota)
|
|
138
|
+
const initialClinicalData = {
|
|
139
|
+
patient: { corePatientId: args.patientId },
|
|
140
|
+
clinic: { coreClinicId: args.clinicId },
|
|
141
|
+
doctor: { coreDoctorId: args.doctorId },
|
|
142
|
+
prostheticService: {
|
|
143
|
+
pdcItemId: args.pdcItemId,
|
|
144
|
+
listinoId: args.listinoId,
|
|
145
|
+
serviceCode: "",
|
|
146
|
+
serviceLabel: "",
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
const clinicalHash = await computeClinicalHash(initialClinicalData);
|
|
150
|
+
const revisionId = await ctx.db.insert("clinicalRevisions", {
|
|
151
|
+
clinicalRevisionId: 0, // Will be backfilled
|
|
152
|
+
prescriptionRef: prescriptionId,
|
|
153
|
+
revisionNumber: 1,
|
|
154
|
+
clinicalData: initialClinicalData,
|
|
155
|
+
clinicalHash,
|
|
156
|
+
signature: null,
|
|
157
|
+
frozen: false,
|
|
158
|
+
createdAt: now,
|
|
159
|
+
});
|
|
160
|
+
// 6. Aggiorna prescrizione con riferimento alla revisione
|
|
161
|
+
await ctx.db.patch(prescriptionId, {
|
|
162
|
+
latestClinicalRevisionId: revisionId,
|
|
163
|
+
});
|
|
164
|
+
// 7. Inizializza phase instances
|
|
165
|
+
for (let i = 0; i < flow.definition.phases.length; i++) {
|
|
166
|
+
const phase = flow.definition.phases[i];
|
|
167
|
+
await ctx.db.insert("phaseInstances", {
|
|
168
|
+
phaseInstanceId: 0, // Will be backfilled
|
|
169
|
+
prescriptionRef: prescriptionId,
|
|
170
|
+
phaseTypeKey: phase.phaseTypeKey,
|
|
171
|
+
ordinal: i,
|
|
172
|
+
iteration: 1,
|
|
173
|
+
status: "NOT_STARTED",
|
|
174
|
+
createdAt: now,
|
|
175
|
+
updatedAt: now,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
// 8. Crea operational data
|
|
179
|
+
await ctx.db.insert("operationalData", {
|
|
180
|
+
operationalDataId: 0, // Will be backfilled
|
|
181
|
+
prescriptionRef: prescriptionId,
|
|
182
|
+
});
|
|
183
|
+
// 9. Audit
|
|
184
|
+
await ctx.db.insert("auditEvents", {
|
|
185
|
+
auditEventId: 0, // Will be backfilled
|
|
186
|
+
prescriptionRef: prescriptionId,
|
|
187
|
+
at: now,
|
|
188
|
+
actorUserId: user._id,
|
|
189
|
+
actorRole: user.role,
|
|
190
|
+
type: "PRESCRIPTION_CREATED",
|
|
191
|
+
payload: {
|
|
192
|
+
pdcItemId: args.pdcItemId,
|
|
193
|
+
flowKey: args.flowKey,
|
|
194
|
+
flowVersion: flow.version,
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
return { prescriptionId, revisionId };
|
|
198
|
+
},
|
|
199
|
+
});
|
|
200
|
+
/**
|
|
201
|
+
* Aggiorna i dati clinici (bozza).
|
|
202
|
+
* Solo se la revisione non è frozen.
|
|
203
|
+
*/
|
|
204
|
+
export const updateClinicalDraft = mutation({
|
|
205
|
+
args: {
|
|
206
|
+
prescriptionId: v.id("prescriptions"),
|
|
207
|
+
clinicalDataPatch: v.object({
|
|
208
|
+
prostheticService: v.optional(v.object({
|
|
209
|
+
serviceCode: v.optional(v.string()),
|
|
210
|
+
serviceLabel: v.optional(v.string()),
|
|
211
|
+
})),
|
|
212
|
+
application: v.optional(v.union(v.object({ type: v.literal("TOOTH"), toothNumber: v.number() }), v.object({ type: v.literal("MULTI_TOOTH"), teeth: v.array(v.number()) }), v.object({
|
|
213
|
+
type: v.literal("BRIDGE"),
|
|
214
|
+
teeth: v.array(v.object({
|
|
215
|
+
toothNumber: v.number(),
|
|
216
|
+
role: v.union(v.literal("abutment"), v.literal("pontic")),
|
|
217
|
+
})),
|
|
218
|
+
}), v.object({ type: v.literal("QUADRANT"), quadrant: v.number() }), v.object({ type: v.literal("SEXTANT"), sextant: v.number() }), v.object({
|
|
219
|
+
type: v.literal("ARCH"),
|
|
220
|
+
arch: v.union(v.literal("UPPER"), v.literal("LOWER")),
|
|
221
|
+
}), v.object({ type: v.literal("FULL_MOUTH") }), v.null())),
|
|
222
|
+
shade: v.optional(v.union(v.string(), v.null())),
|
|
223
|
+
notePreparazione: v.optional(v.union(v.string(), v.null())),
|
|
224
|
+
lineaMargine: v.optional(v.union(v.string(), v.null())),
|
|
225
|
+
}),
|
|
226
|
+
},
|
|
227
|
+
handler: async (ctx, args) => {
|
|
228
|
+
const now = Date.now();
|
|
229
|
+
// 1. Auth identity + prescrizione
|
|
230
|
+
let user;
|
|
231
|
+
try {
|
|
232
|
+
user = await requireIdentityUser(ctx);
|
|
233
|
+
}
|
|
234
|
+
catch (error) {
|
|
235
|
+
if (error instanceof ConvexError) {
|
|
236
|
+
throw error;
|
|
237
|
+
}
|
|
238
|
+
throw new ConvexError({
|
|
239
|
+
code: "UNAUTHORIZED",
|
|
240
|
+
message: "Accesso non autorizzato",
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
const prescription = await ctx.db.get(args.prescriptionId);
|
|
244
|
+
if (!prescription) {
|
|
245
|
+
throw new Error("Prescrizione non trovata");
|
|
246
|
+
}
|
|
247
|
+
try {
|
|
248
|
+
assertPrescriptionAccess(user, prescription);
|
|
249
|
+
}
|
|
250
|
+
catch (error) {
|
|
251
|
+
throw new ConvexError({
|
|
252
|
+
code: "FORBIDDEN",
|
|
253
|
+
message: error instanceof Error ? error.message : "Forbidden",
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
// 2. Verifica stato
|
|
257
|
+
if (!["DRAFT", "PENDING_DOCTOR"].includes(prescription.status)) {
|
|
258
|
+
throw new Error(`Non è possibile modificare una prescrizione in stato ${prescription.status}`);
|
|
259
|
+
}
|
|
260
|
+
// 3. Verifica permessi azione
|
|
261
|
+
const permission = canUserPerformAction(user.role, "updateDraft", prescription.status);
|
|
262
|
+
if (!permission.allowed) {
|
|
263
|
+
throw new Error(permission.reason);
|
|
264
|
+
}
|
|
265
|
+
// 4. Carica revisione corrente
|
|
266
|
+
if (!prescription.latestClinicalRevisionId) {
|
|
267
|
+
throw new Error("Nessuna revisione clinica trovata");
|
|
268
|
+
}
|
|
269
|
+
const revision = await ctx.db.get(prescription.latestClinicalRevisionId);
|
|
270
|
+
if (!revision) {
|
|
271
|
+
throw new Error("Revisione clinica non trovata");
|
|
272
|
+
}
|
|
273
|
+
if (revision.frozen) {
|
|
274
|
+
throw new Error("La revisione è firmata e non può essere modificata");
|
|
275
|
+
}
|
|
276
|
+
// 5. Normalizza e applica patch ai dati clinici
|
|
277
|
+
const normalizedPatch = normalizeClinicalDraftInput(args.clinicalDataPatch);
|
|
278
|
+
const updatedClinicalData = {
|
|
279
|
+
...revision.clinicalData,
|
|
280
|
+
};
|
|
281
|
+
if (normalizedPatch.prostheticService) {
|
|
282
|
+
updatedClinicalData.prostheticService = {
|
|
283
|
+
...updatedClinicalData.prostheticService,
|
|
284
|
+
...normalizedPatch.prostheticService,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
if (normalizedPatch.application !== undefined) {
|
|
288
|
+
updatedClinicalData.application = normalizedPatch.application ?? undefined;
|
|
289
|
+
}
|
|
290
|
+
if (normalizedPatch.shade !== undefined) {
|
|
291
|
+
updatedClinicalData.shade = normalizedPatch.shade ?? undefined;
|
|
292
|
+
}
|
|
293
|
+
if (normalizedPatch.notePreparazione !== undefined) {
|
|
294
|
+
updatedClinicalData.notePreparazione = normalizedPatch.notePreparazione ?? undefined;
|
|
295
|
+
}
|
|
296
|
+
if (normalizedPatch.lineaMargine !== undefined) {
|
|
297
|
+
updatedClinicalData.lineaMargine = normalizedPatch.lineaMargine ?? undefined;
|
|
298
|
+
}
|
|
299
|
+
const flow = await ctx.db
|
|
300
|
+
.query("flows")
|
|
301
|
+
.withIndex("by_flowKey_version", (q) => q.eq("flowKey", prescription.flowKey).eq("version", prescription.flowVersion))
|
|
302
|
+
.first();
|
|
303
|
+
if (!flow) {
|
|
304
|
+
throw new Error("Flow non trovato");
|
|
305
|
+
}
|
|
306
|
+
// 6. Validazione clinica hard non-blocking in bozza
|
|
307
|
+
const clinicalValidationErrors = validateClinicalData(updatedClinicalData, flow.definition, false);
|
|
308
|
+
// 7. Ricalcola hash
|
|
309
|
+
const newHash = await computeClinicalHash(updatedClinicalData);
|
|
310
|
+
// 8. Aggiorna revisione
|
|
311
|
+
await ctx.db.patch(revision._id, {
|
|
312
|
+
clinicalData: updatedClinicalData,
|
|
313
|
+
clinicalHash: newHash,
|
|
314
|
+
});
|
|
315
|
+
// 9. Aggiorna timestamp prescrizione
|
|
316
|
+
await ctx.db.patch(args.prescriptionId, {
|
|
317
|
+
updatedAt: now,
|
|
318
|
+
});
|
|
319
|
+
const changedKeys = [];
|
|
320
|
+
if (normalizedPatch.prostheticService?.serviceCode !== undefined) {
|
|
321
|
+
changedKeys.push("prostheticService.serviceCode");
|
|
322
|
+
}
|
|
323
|
+
if (normalizedPatch.prostheticService?.serviceLabel !== undefined) {
|
|
324
|
+
changedKeys.push("prostheticService.serviceLabel");
|
|
325
|
+
}
|
|
326
|
+
if (normalizedPatch.application !== undefined) {
|
|
327
|
+
changedKeys.push("application");
|
|
328
|
+
}
|
|
329
|
+
if (normalizedPatch.shade !== undefined) {
|
|
330
|
+
changedKeys.push("shade");
|
|
331
|
+
}
|
|
332
|
+
if (normalizedPatch.notePreparazione !== undefined) {
|
|
333
|
+
changedKeys.push("notePreparazione");
|
|
334
|
+
}
|
|
335
|
+
if (normalizedPatch.lineaMargine !== undefined) {
|
|
336
|
+
changedKeys.push("lineaMargine");
|
|
337
|
+
}
|
|
338
|
+
// 10. Audit
|
|
339
|
+
await ctx.db.insert("auditEvents", {
|
|
340
|
+
auditEventId: 0, // Will be backfilled
|
|
341
|
+
prescriptionRef: args.prescriptionId,
|
|
342
|
+
at: now,
|
|
343
|
+
actorUserId: user._id,
|
|
344
|
+
actorRole: user.role,
|
|
345
|
+
type: user.role === "DOCTOR" ? "DOCTOR_UPDATED_DRAFT" : "CLINICAL_DRAFT_UPDATED",
|
|
346
|
+
payload: {
|
|
347
|
+
changes: normalizedPatch,
|
|
348
|
+
changedKeys,
|
|
349
|
+
clinicalValidationErrorsCount: clinicalValidationErrors.length,
|
|
350
|
+
newHash,
|
|
351
|
+
},
|
|
352
|
+
});
|
|
353
|
+
return { ok: true, success: true, clinicalHash: newHash, clinicalValidationErrors };
|
|
354
|
+
},
|
|
355
|
+
});
|
|
356
|
+
/**
|
|
357
|
+
* Invia prescrizione al medico.
|
|
358
|
+
* Cambia status da DRAFT a PENDING_DOCTOR.
|
|
359
|
+
*/
|
|
360
|
+
export const submitToDoctor = mutation({
|
|
361
|
+
args: {
|
|
362
|
+
prescriptionId: v.id("prescriptions"),
|
|
363
|
+
},
|
|
364
|
+
handler: async (ctx, args) => {
|
|
365
|
+
const now = Date.now();
|
|
366
|
+
const user = await requireAuthenticatedUserOrThrow(ctx);
|
|
367
|
+
// 1. Carica prescrizione
|
|
368
|
+
const prescription = await ctx.db.get(args.prescriptionId);
|
|
369
|
+
if (!prescription) {
|
|
370
|
+
throw new Error("Prescrizione non trovata");
|
|
371
|
+
}
|
|
372
|
+
assertPrescriptionAccessOrThrow(user, prescription);
|
|
373
|
+
// 2. Verifica stato
|
|
374
|
+
if (prescription.status !== "DRAFT") {
|
|
375
|
+
throw new Error(`Non è possibile inviare una prescrizione in stato ${prescription.status}`);
|
|
376
|
+
}
|
|
377
|
+
const permission = canUserPerformAction(user.role, "submit", prescription.status);
|
|
378
|
+
if (!permission.allowed) {
|
|
379
|
+
throw new Error(permission.reason);
|
|
380
|
+
}
|
|
381
|
+
// 4. Valida requisiti per prescrizioni digitali
|
|
382
|
+
const digitalErrors = validateDigitalRequirements(prescription.prescriptionType, prescription.digitalAssets);
|
|
383
|
+
if (digitalErrors.length > 0) {
|
|
384
|
+
throw new Error(digitalErrors.map(e => e.message).join("; "));
|
|
385
|
+
}
|
|
386
|
+
// 5. Aggiorna stato
|
|
387
|
+
await ctx.db.patch(args.prescriptionId, {
|
|
388
|
+
status: "PENDING_DOCTOR",
|
|
389
|
+
updatedAt: now,
|
|
390
|
+
});
|
|
391
|
+
// 6. Audit
|
|
392
|
+
await ctx.db.insert("auditEvents", {
|
|
393
|
+
auditEventId: 0, // Will be backfilled
|
|
394
|
+
prescriptionRef: args.prescriptionId,
|
|
395
|
+
at: now,
|
|
396
|
+
actorUserId: user._id,
|
|
397
|
+
actorRole: user.role,
|
|
398
|
+
type: "SUBMITTED_TO_DOCTOR",
|
|
399
|
+
payload: {},
|
|
400
|
+
});
|
|
401
|
+
return { success: true };
|
|
402
|
+
},
|
|
403
|
+
});
|
|
404
|
+
/**
|
|
405
|
+
* Firma la prescrizione.
|
|
406
|
+
* Solo medico, solo in stato PENDING_DOCTOR.
|
|
407
|
+
*/
|
|
408
|
+
export const signPrescription = mutation({
|
|
409
|
+
args: {
|
|
410
|
+
prescriptionId: v.id("prescriptions"),
|
|
411
|
+
signaturePayload: v.string(), // Base64 della firma "debole"
|
|
412
|
+
},
|
|
413
|
+
handler: async (ctx, args) => {
|
|
414
|
+
const now = Date.now();
|
|
415
|
+
const user = await requireAuthenticatedUserOrThrow(ctx);
|
|
416
|
+
// 1. Carica prescrizione
|
|
417
|
+
const prescription = await ctx.db.get(args.prescriptionId);
|
|
418
|
+
if (!prescription) {
|
|
419
|
+
throw new Error("Prescrizione non trovata");
|
|
420
|
+
}
|
|
421
|
+
assertPrescriptionAccessOrThrow(user, prescription);
|
|
422
|
+
// 2. Verifica stato
|
|
423
|
+
if (prescription.status !== "PENDING_DOCTOR") {
|
|
424
|
+
throw new Error(`Non è possibile firmare una prescrizione in stato ${prescription.status}`);
|
|
425
|
+
}
|
|
426
|
+
if (user.role !== "DOCTOR") {
|
|
427
|
+
throw new ConvexError({
|
|
428
|
+
code: "FORBIDDEN",
|
|
429
|
+
message: "Solo i medici possono firmare le prescrizioni",
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
// 4. Carica flow e revisione
|
|
433
|
+
const flow = await ctx.db
|
|
434
|
+
.query("flows")
|
|
435
|
+
.withIndex("by_flowKey_version", (q) => q.eq("flowKey", prescription.flowKey).eq("version", prescription.flowVersion))
|
|
436
|
+
.first();
|
|
437
|
+
if (!flow) {
|
|
438
|
+
throw new Error("Flow non trovato");
|
|
439
|
+
}
|
|
440
|
+
if (!prescription.latestClinicalRevisionId) {
|
|
441
|
+
throw new Error("Nessuna revisione clinica trovata");
|
|
442
|
+
}
|
|
443
|
+
const revision = await ctx.db.get(prescription.latestClinicalRevisionId);
|
|
444
|
+
if (!revision) {
|
|
445
|
+
throw new Error("Revisione clinica non trovata");
|
|
446
|
+
}
|
|
447
|
+
// 5. Valida i dati clinici per la firma
|
|
448
|
+
const validationErrors = validateClinicalData(revision.clinicalData, flow.definition, true // forSignature
|
|
449
|
+
);
|
|
450
|
+
if (validationErrors.length > 0) {
|
|
451
|
+
throw new ConvexError({
|
|
452
|
+
code: "INVALID_CLINICAL_DATA",
|
|
453
|
+
message: `Validazione fallita: ${validationErrors.map((e) => e.message).join("; ")}`,
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
// 6. Strict dynamic required prima di side effects
|
|
457
|
+
await assertDynamicRequiredComplete(ctx, prescription);
|
|
458
|
+
// 7. Canonicalizza e calcola hash finale
|
|
459
|
+
const finalHash = await computeClinicalHash(revision.clinicalData);
|
|
460
|
+
// 7. Crea firma
|
|
461
|
+
const signature = {
|
|
462
|
+
signedAt: now,
|
|
463
|
+
signedByDoctorId: user.coreDoctorId ?? user._id,
|
|
464
|
+
signatureType: "weak-digital",
|
|
465
|
+
signaturePayload: args.signaturePayload,
|
|
466
|
+
clinicalHash: finalHash,
|
|
467
|
+
};
|
|
468
|
+
// 8. Aggiorna revisione con firma e freeze
|
|
469
|
+
await ctx.db.patch(revision._id, {
|
|
470
|
+
signature,
|
|
471
|
+
frozen: true,
|
|
472
|
+
clinicalHash: finalHash,
|
|
473
|
+
});
|
|
474
|
+
// 9. Crea sync jobs
|
|
475
|
+
const syncJobIds = [];
|
|
476
|
+
// Job per PrimoCore
|
|
477
|
+
const primoCoreJobId = await ctx.db.insert("syncJobs", {
|
|
478
|
+
syncJobId: 0, // Will be backfilled
|
|
479
|
+
prescriptionRef: args.prescriptionId,
|
|
480
|
+
target: "PRIMOCORE",
|
|
481
|
+
kind: "POST_CLINICAL",
|
|
482
|
+
idempotencyKey: generateIdempotencyKey(args.prescriptionId, "PRIMOCORE", "POST_CLINICAL", String(revision.revisionNumber)),
|
|
483
|
+
requestPayload: {
|
|
484
|
+
clinicalData: revision.clinicalData,
|
|
485
|
+
signature,
|
|
486
|
+
prescriptionId: args.prescriptionId,
|
|
487
|
+
},
|
|
488
|
+
status: "PENDING",
|
|
489
|
+
attempts: 0,
|
|
490
|
+
nextRetryAt: now,
|
|
491
|
+
createdAt: now,
|
|
492
|
+
updatedAt: now,
|
|
493
|
+
});
|
|
494
|
+
syncJobIds.push(primoCoreJobId);
|
|
495
|
+
// Job per Lab
|
|
496
|
+
const labJobId = await ctx.db.insert("syncJobs", {
|
|
497
|
+
syncJobId: 0, // Will be backfilled
|
|
498
|
+
prescriptionRef: args.prescriptionId,
|
|
499
|
+
target: "LAB",
|
|
500
|
+
kind: "LAB_REQUEST",
|
|
501
|
+
idempotencyKey: generateIdempotencyKey(args.prescriptionId, "LAB", "LAB_REQUEST", String(revision.revisionNumber)),
|
|
502
|
+
requestPayload: {
|
|
503
|
+
clinicalData: revision.clinicalData,
|
|
504
|
+
prescriptionId: args.prescriptionId,
|
|
505
|
+
},
|
|
506
|
+
status: "PENDING",
|
|
507
|
+
attempts: 0,
|
|
508
|
+
nextRetryAt: now,
|
|
509
|
+
createdAt: now,
|
|
510
|
+
updatedAt: now,
|
|
511
|
+
});
|
|
512
|
+
syncJobIds.push(labJobId);
|
|
513
|
+
// 10. Aggiorna stato prescrizione
|
|
514
|
+
await ctx.db.patch(args.prescriptionId, {
|
|
515
|
+
status: "SIGNED",
|
|
516
|
+
updatedAt: now,
|
|
517
|
+
});
|
|
518
|
+
// 11. Audit
|
|
519
|
+
await ctx.db.insert("auditEvents", {
|
|
520
|
+
auditEventId: 0, // Will be backfilled
|
|
521
|
+
prescriptionRef: args.prescriptionId,
|
|
522
|
+
at: now,
|
|
523
|
+
actorUserId: user._id,
|
|
524
|
+
actorRole: "DOCTOR",
|
|
525
|
+
type: "PRESCRIPTION_SIGNED",
|
|
526
|
+
payload: {
|
|
527
|
+
clinicalHash: finalHash,
|
|
528
|
+
signedAt: now,
|
|
529
|
+
syncJobIds,
|
|
530
|
+
},
|
|
531
|
+
});
|
|
532
|
+
return {
|
|
533
|
+
success: true,
|
|
534
|
+
clinicalHash: finalHash,
|
|
535
|
+
syncJobIds,
|
|
536
|
+
};
|
|
537
|
+
},
|
|
538
|
+
});
|
|
539
|
+
/**
|
|
540
|
+
* Invia la prescrizione al laboratorio.
|
|
541
|
+
* Questa mutation semplifica il flusso: passa direttamente da DRAFT a SIGNED
|
|
542
|
+
* senza richiedere la firma del medico (auto-firma simulata).
|
|
543
|
+
*/
|
|
544
|
+
export const sendToLab = mutation({
|
|
545
|
+
args: {
|
|
546
|
+
prescriptionId: v.id("prescriptions"),
|
|
547
|
+
},
|
|
548
|
+
handler: async (ctx, args) => {
|
|
549
|
+
const now = Date.now();
|
|
550
|
+
// 1. Auth identity + prescrizione
|
|
551
|
+
let user;
|
|
552
|
+
try {
|
|
553
|
+
user = await requireIdentityUser(ctx);
|
|
554
|
+
}
|
|
555
|
+
catch (error) {
|
|
556
|
+
if (error instanceof ConvexError) {
|
|
557
|
+
throw error;
|
|
558
|
+
}
|
|
559
|
+
throw new ConvexError({
|
|
560
|
+
code: "UNAUTHORIZED",
|
|
561
|
+
message: "Accesso non autorizzato",
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
const prescription = await ctx.db.get(args.prescriptionId);
|
|
565
|
+
if (!prescription) {
|
|
566
|
+
throw new Error("Prescrizione non trovata");
|
|
567
|
+
}
|
|
568
|
+
try {
|
|
569
|
+
assertPrescriptionAccess(user, prescription);
|
|
570
|
+
}
|
|
571
|
+
catch (error) {
|
|
572
|
+
throw new ConvexError({
|
|
573
|
+
code: "FORBIDDEN",
|
|
574
|
+
message: error instanceof Error ? error.message : "Forbidden",
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
// 2. Verifica stato (deve essere DRAFT o PENDING_DOCTOR)
|
|
578
|
+
if (prescription.status !== "DRAFT" && prescription.status !== "PENDING_DOCTOR") {
|
|
579
|
+
throw new Error(`Non è possibile inviare al lab una prescrizione in stato ${prescription.status}`);
|
|
580
|
+
}
|
|
581
|
+
// 3. Policy invio vs policy auto-firma:
|
|
582
|
+
// - invio consentito a ruoli autorizzati dal flusso corrente (submit/sign)
|
|
583
|
+
// - auto-firma consentita solo al DOCTOR
|
|
584
|
+
const canSubmit = canUserPerformAction(user.role, "submit", prescription.status).allowed;
|
|
585
|
+
const canSign = canUserPerformAction(user.role, "sign", prescription.status).allowed;
|
|
586
|
+
const canSendToLab = canSubmit || canSign;
|
|
587
|
+
if (!canSendToLab) {
|
|
588
|
+
throw new ConvexError({
|
|
589
|
+
code: "FORBIDDEN",
|
|
590
|
+
message: `Il ruolo ${user.role} non può inviare al laboratorio in stato ${prescription.status}`,
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
const canAutoSign = user.role === "DOCTOR";
|
|
594
|
+
if (!canAutoSign) {
|
|
595
|
+
throw new ConvexError({
|
|
596
|
+
code: "FORBIDDEN",
|
|
597
|
+
message: "Invio al laboratorio non consentito: auto-firma disponibile solo per DOCTOR",
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
// 4. Carica flow e revisione
|
|
601
|
+
const flow = await ctx.db
|
|
602
|
+
.query("flows")
|
|
603
|
+
.withIndex("by_flowKey_version", (q) => q.eq("flowKey", prescription.flowKey).eq("version", prescription.flowVersion))
|
|
604
|
+
.first();
|
|
605
|
+
if (!flow) {
|
|
606
|
+
throw new Error("Flow non trovato");
|
|
607
|
+
}
|
|
608
|
+
if (!prescription.latestClinicalRevisionId) {
|
|
609
|
+
throw new Error("Nessuna revisione clinica trovata");
|
|
610
|
+
}
|
|
611
|
+
const revision = await ctx.db.get(prescription.latestClinicalRevisionId);
|
|
612
|
+
if (!revision) {
|
|
613
|
+
throw new Error("Revisione clinica non trovata");
|
|
614
|
+
}
|
|
615
|
+
// 5. Hard validation clinica (strict)
|
|
616
|
+
const validationErrors = validateClinicalData(revision.clinicalData, flow.definition, true);
|
|
617
|
+
if (validationErrors.length > 0) {
|
|
618
|
+
throw new ConvexError({
|
|
619
|
+
code: "INVALID_CLINICAL_DATA",
|
|
620
|
+
message: "Validazione clinica fallita",
|
|
621
|
+
errors: validationErrors.map((err) => `${err.field}: ${err.message}`),
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
// 6. Hard validation campi dinamici (strict required)
|
|
625
|
+
await assertDynamicRequiredComplete(ctx, prescription);
|
|
626
|
+
// 7. Calcola hash finale
|
|
627
|
+
const finalHash = await computeClinicalHash(revision.clinicalData);
|
|
628
|
+
// 8. Crea firma automatica (simulata)
|
|
629
|
+
const signature = {
|
|
630
|
+
signedAt: now,
|
|
631
|
+
signedByDoctorId: prescription.doctorId, // Usa il medico assegnato alla prescrizione
|
|
632
|
+
signatureType: "auto-lab", // Tipo di firma automatica per invio lab
|
|
633
|
+
signaturePayload: btoa(JSON.stringify({
|
|
634
|
+
autoSigned: true,
|
|
635
|
+
timestamp: now,
|
|
636
|
+
sentBy: user._id,
|
|
637
|
+
sentByRole: user.role,
|
|
638
|
+
})),
|
|
639
|
+
clinicalHash: finalHash,
|
|
640
|
+
};
|
|
641
|
+
// 9. Aggiorna revisione con firma e freeze
|
|
642
|
+
await ctx.db.patch(revision._id, {
|
|
643
|
+
signature,
|
|
644
|
+
frozen: true,
|
|
645
|
+
clinicalHash: finalHash,
|
|
646
|
+
});
|
|
647
|
+
// 10. Crea sync jobs
|
|
648
|
+
const syncJobIds = [];
|
|
649
|
+
// Job per PrimoCore
|
|
650
|
+
const primoCoreJobId = await ctx.db.insert("syncJobs", {
|
|
651
|
+
syncJobId: 0, // Will be backfilled
|
|
652
|
+
prescriptionRef: args.prescriptionId,
|
|
653
|
+
target: "PRIMOCORE",
|
|
654
|
+
kind: "POST_CLINICAL",
|
|
655
|
+
idempotencyKey: generateIdempotencyKey(args.prescriptionId, "PRIMOCORE", "POST_CLINICAL", String(revision.revisionNumber)),
|
|
656
|
+
requestPayload: {
|
|
657
|
+
clinicalData: revision.clinicalData,
|
|
658
|
+
signature,
|
|
659
|
+
prescriptionId: args.prescriptionId,
|
|
660
|
+
},
|
|
661
|
+
status: "PENDING",
|
|
662
|
+
attempts: 0,
|
|
663
|
+
nextRetryAt: now,
|
|
664
|
+
createdAt: now,
|
|
665
|
+
updatedAt: now,
|
|
666
|
+
});
|
|
667
|
+
syncJobIds.push(primoCoreJobId);
|
|
668
|
+
// Job per Lab
|
|
669
|
+
const labJobId = await ctx.db.insert("syncJobs", {
|
|
670
|
+
syncJobId: 0, // Will be backfilled
|
|
671
|
+
prescriptionRef: args.prescriptionId,
|
|
672
|
+
target: "LAB",
|
|
673
|
+
kind: "LAB_REQUEST",
|
|
674
|
+
idempotencyKey: generateIdempotencyKey(args.prescriptionId, "LAB", "LAB_REQUEST", String(revision.revisionNumber)),
|
|
675
|
+
requestPayload: {
|
|
676
|
+
clinicalData: revision.clinicalData,
|
|
677
|
+
prescriptionId: args.prescriptionId,
|
|
678
|
+
},
|
|
679
|
+
status: "PENDING",
|
|
680
|
+
attempts: 0,
|
|
681
|
+
nextRetryAt: now,
|
|
682
|
+
createdAt: now,
|
|
683
|
+
updatedAt: now,
|
|
684
|
+
});
|
|
685
|
+
syncJobIds.push(labJobId);
|
|
686
|
+
// 11. Aggiorna stato prescrizione a SIGNED (= in Lab)
|
|
687
|
+
await ctx.db.patch(args.prescriptionId, {
|
|
688
|
+
status: "SIGNED",
|
|
689
|
+
updatedAt: now,
|
|
690
|
+
});
|
|
691
|
+
// 12. Audit
|
|
692
|
+
await ctx.db.insert("auditEvents", {
|
|
693
|
+
auditEventId: 0, // Will be backfilled
|
|
694
|
+
prescriptionRef: args.prescriptionId,
|
|
695
|
+
at: now,
|
|
696
|
+
actorUserId: user._id,
|
|
697
|
+
actorRole: user.role,
|
|
698
|
+
type: "SENT_TO_LAB",
|
|
699
|
+
payload: {
|
|
700
|
+
clinicalHash: finalHash,
|
|
701
|
+
sentAt: now,
|
|
702
|
+
syncJobIds,
|
|
703
|
+
autoSigned: true,
|
|
704
|
+
},
|
|
705
|
+
});
|
|
706
|
+
return {
|
|
707
|
+
success: true,
|
|
708
|
+
clinicalHash: finalHash,
|
|
709
|
+
syncJobIds,
|
|
710
|
+
};
|
|
711
|
+
},
|
|
712
|
+
});
|
|
713
|
+
/**
|
|
714
|
+
* Crea una nuova revisione dopo la firma.
|
|
715
|
+
* Permette di modificare una prescrizione già firmata.
|
|
716
|
+
*/
|
|
717
|
+
export const reviseAfterSignature = mutation({
|
|
718
|
+
args: {
|
|
719
|
+
prescriptionId: v.id("prescriptions"),
|
|
720
|
+
reason: v.string(),
|
|
721
|
+
},
|
|
722
|
+
handler: async (ctx, args) => {
|
|
723
|
+
const now = Date.now();
|
|
724
|
+
const user = await requireAuthenticatedUserOrThrow(ctx);
|
|
725
|
+
// 1. Carica prescrizione
|
|
726
|
+
const prescription = await ctx.db.get(args.prescriptionId);
|
|
727
|
+
if (!prescription) {
|
|
728
|
+
throw new Error("Prescrizione non trovata");
|
|
729
|
+
}
|
|
730
|
+
assertPrescriptionAccessOrThrow(user, prescription);
|
|
731
|
+
// 2. Verifica stato
|
|
732
|
+
if (!["SIGNED", "ERROR"].includes(prescription.status)) {
|
|
733
|
+
throw new Error(`Non è possibile revisionare una prescrizione in stato ${prescription.status}`);
|
|
734
|
+
}
|
|
735
|
+
if (user.role !== "DOCTOR") {
|
|
736
|
+
throw new ConvexError({
|
|
737
|
+
code: "FORBIDDEN",
|
|
738
|
+
message: "Solo i medici possono creare revisioni",
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
// 4. Carica revisione corrente
|
|
742
|
+
if (!prescription.latestClinicalRevisionId) {
|
|
743
|
+
throw new Error("Nessuna revisione clinica trovata");
|
|
744
|
+
}
|
|
745
|
+
const currentRevision = await ctx.db.get(prescription.latestClinicalRevisionId);
|
|
746
|
+
if (!currentRevision) {
|
|
747
|
+
throw new Error("Revisione clinica non trovata");
|
|
748
|
+
}
|
|
749
|
+
// 5. Crea nuova revisione (copia dei dati, senza firma)
|
|
750
|
+
const newRevisionNumber = currentRevision.revisionNumber + 1;
|
|
751
|
+
const newHash = await computeClinicalHash(currentRevision.clinicalData);
|
|
752
|
+
const newRevisionId = await ctx.db.insert("clinicalRevisions", {
|
|
753
|
+
clinicalRevisionId: 0, // Will be backfilled
|
|
754
|
+
prescriptionRef: args.prescriptionId,
|
|
755
|
+
revisionNumber: newRevisionNumber,
|
|
756
|
+
clinicalData: currentRevision.clinicalData,
|
|
757
|
+
clinicalHash: newHash,
|
|
758
|
+
signature: null,
|
|
759
|
+
frozen: false,
|
|
760
|
+
createdAt: now,
|
|
761
|
+
});
|
|
762
|
+
// 6. Aggiorna prescrizione
|
|
763
|
+
await ctx.db.patch(args.prescriptionId, {
|
|
764
|
+
status: "PENDING_DOCTOR",
|
|
765
|
+
latestClinicalRevisionId: newRevisionId,
|
|
766
|
+
updatedAt: now,
|
|
767
|
+
});
|
|
768
|
+
// 7. Audit
|
|
769
|
+
await ctx.db.insert("auditEvents", {
|
|
770
|
+
auditEventId: 0, // Will be backfilled
|
|
771
|
+
prescriptionRef: args.prescriptionId,
|
|
772
|
+
at: now,
|
|
773
|
+
actorUserId: user._id,
|
|
774
|
+
actorRole: "DOCTOR",
|
|
775
|
+
type: "REVISION_CREATED",
|
|
776
|
+
payload: {
|
|
777
|
+
previousRevisionNumber: currentRevision.revisionNumber,
|
|
778
|
+
newRevisionNumber,
|
|
779
|
+
previousHash: currentRevision.clinicalHash,
|
|
780
|
+
reason: args.reason,
|
|
781
|
+
},
|
|
782
|
+
});
|
|
783
|
+
return {
|
|
784
|
+
success: true,
|
|
785
|
+
newRevisionId,
|
|
786
|
+
newRevisionNumber,
|
|
787
|
+
};
|
|
788
|
+
},
|
|
789
|
+
});
|
|
790
|
+
/**
|
|
791
|
+
* Annulla una prescrizione.
|
|
792
|
+
*/
|
|
793
|
+
export const cancelPrescription = mutation({
|
|
794
|
+
args: {
|
|
795
|
+
prescriptionId: v.id("prescriptions"),
|
|
796
|
+
reason: v.string(),
|
|
797
|
+
},
|
|
798
|
+
handler: async (ctx, args) => {
|
|
799
|
+
const now = Date.now();
|
|
800
|
+
const user = await requireAuthenticatedUserOrThrow(ctx);
|
|
801
|
+
// 1. Carica prescrizione
|
|
802
|
+
const prescription = await ctx.db.get(args.prescriptionId);
|
|
803
|
+
if (!prescription) {
|
|
804
|
+
throw new Error("Prescrizione non trovata");
|
|
805
|
+
}
|
|
806
|
+
assertPrescriptionAccessOrThrow(user, prescription);
|
|
807
|
+
// 2. Verifica stato (non può annullare se già SYNCED)
|
|
808
|
+
if (prescription.status === "SYNCED") {
|
|
809
|
+
throw new Error("Non è possibile annullare una prescrizione già sincronizzata");
|
|
810
|
+
}
|
|
811
|
+
if (prescription.status === "CANCELLED") {
|
|
812
|
+
throw new Error("La prescrizione è già annullata");
|
|
813
|
+
}
|
|
814
|
+
const permission = canUserPerformAction(user.role, "cancel", prescription.status);
|
|
815
|
+
if (!permission.allowed) {
|
|
816
|
+
throw new Error(permission.reason);
|
|
817
|
+
}
|
|
818
|
+
// 4. Aggiorna stato
|
|
819
|
+
await ctx.db.patch(args.prescriptionId, {
|
|
820
|
+
status: "CANCELLED",
|
|
821
|
+
updatedAt: now,
|
|
822
|
+
});
|
|
823
|
+
// 5. Audit
|
|
824
|
+
await ctx.db.insert("auditEvents", {
|
|
825
|
+
auditEventId: 0, // Will be backfilled
|
|
826
|
+
prescriptionRef: args.prescriptionId,
|
|
827
|
+
at: now,
|
|
828
|
+
actorUserId: user._id,
|
|
829
|
+
actorRole: user.role,
|
|
830
|
+
type: "PRESCRIPTION_CANCELLED",
|
|
831
|
+
payload: { reason: args.reason },
|
|
832
|
+
});
|
|
833
|
+
return { success: true };
|
|
834
|
+
},
|
|
835
|
+
});
|
|
836
|
+
/**
|
|
837
|
+
* Marca una prescrizione come spedita al laboratorio.
|
|
838
|
+
* Per prescrizioni analogiche: traccia quando l'impronta fisica viene spedita.
|
|
839
|
+
*/
|
|
840
|
+
export const markAsShipped = mutation({
|
|
841
|
+
args: {
|
|
842
|
+
prescriptionId: v.id("prescriptions"),
|
|
843
|
+
trackingNumber: v.optional(v.string()), // Numero tracking corriere (opzionale)
|
|
844
|
+
},
|
|
845
|
+
handler: async (ctx, args) => {
|
|
846
|
+
const now = Date.now();
|
|
847
|
+
const user = await requireAuthenticatedUserOrThrow(ctx);
|
|
848
|
+
// 1. Carica prescrizione
|
|
849
|
+
const prescription = await ctx.db.get(args.prescriptionId);
|
|
850
|
+
if (!prescription) {
|
|
851
|
+
throw new Error("Prescrizione non trovata");
|
|
852
|
+
}
|
|
853
|
+
assertPrescriptionAccessOrThrow(user, prescription);
|
|
854
|
+
// 2. Verifica stato (deve essere SIGNED o superiore, non CANCELLED)
|
|
855
|
+
const validStatuses = ["SIGNED", "SYNCED", "ERROR"];
|
|
856
|
+
if (!validStatuses.includes(prescription.status)) {
|
|
857
|
+
throw new Error(`Non è possibile marcare come spedita una prescrizione in stato ${prescription.status}`);
|
|
858
|
+
}
|
|
859
|
+
// 3. Verifica se già spedita
|
|
860
|
+
if (prescription.shippedAt) {
|
|
861
|
+
throw new Error("Questa prescrizione è già stata marcata come spedita");
|
|
862
|
+
}
|
|
863
|
+
// 4. Policy ruolo: segreteria/medico e admin builder possono marcare spedito
|
|
864
|
+
const canManageShipment = user.role === "SECRETARY" ||
|
|
865
|
+
user.role === "DOCTOR" ||
|
|
866
|
+
user.adminRole === "admin" ||
|
|
867
|
+
user.adminRole === "superadmin";
|
|
868
|
+
if (!canManageShipment) {
|
|
869
|
+
throw new ConvexError({
|
|
870
|
+
code: "FORBIDDEN",
|
|
871
|
+
message: "Ruolo non autorizzato alla gestione spedizione",
|
|
872
|
+
});
|
|
873
|
+
}
|
|
874
|
+
// 5. Aggiorna prescrizione
|
|
875
|
+
await ctx.db.patch(args.prescriptionId, {
|
|
876
|
+
shippedAt: now,
|
|
877
|
+
shippedBy: user._id,
|
|
878
|
+
trackingNumber: args.trackingNumber,
|
|
879
|
+
updatedAt: now,
|
|
880
|
+
});
|
|
881
|
+
// 6. Audit
|
|
882
|
+
await ctx.db.insert("auditEvents", {
|
|
883
|
+
auditEventId: 0, // Will be backfilled
|
|
884
|
+
prescriptionRef: args.prescriptionId,
|
|
885
|
+
at: now,
|
|
886
|
+
actorUserId: user._id,
|
|
887
|
+
actorRole: user.role,
|
|
888
|
+
type: "PRESCRIPTION_SHIPPED",
|
|
889
|
+
payload: {
|
|
890
|
+
trackingNumber: args.trackingNumber,
|
|
891
|
+
shippedAt: now,
|
|
892
|
+
},
|
|
893
|
+
});
|
|
894
|
+
return { success: true, shippedAt: now };
|
|
895
|
+
},
|
|
896
|
+
});
|
|
897
|
+
/**
|
|
898
|
+
* Rimuove il flag di spedizione (annulla spedizione).
|
|
899
|
+
*/
|
|
900
|
+
export const unmarkAsShipped = mutation({
|
|
901
|
+
args: {
|
|
902
|
+
prescriptionId: v.id("prescriptions"),
|
|
903
|
+
},
|
|
904
|
+
handler: async (ctx, args) => {
|
|
905
|
+
const now = Date.now();
|
|
906
|
+
const user = await requireAuthenticatedUserOrThrow(ctx);
|
|
907
|
+
// 1. Carica prescrizione
|
|
908
|
+
const prescription = await ctx.db.get(args.prescriptionId);
|
|
909
|
+
if (!prescription) {
|
|
910
|
+
throw new Error("Prescrizione non trovata");
|
|
911
|
+
}
|
|
912
|
+
assertPrescriptionAccessOrThrow(user, prescription);
|
|
913
|
+
// 2. Verifica se è spedita
|
|
914
|
+
if (!prescription.shippedAt) {
|
|
915
|
+
throw new Error("Questa prescrizione non è marcata come spedita");
|
|
916
|
+
}
|
|
917
|
+
// 3. Policy ruolo: segreteria/medico e admin builder possono annullare spedizione
|
|
918
|
+
const canManageShipment = user.role === "SECRETARY" ||
|
|
919
|
+
user.role === "DOCTOR" ||
|
|
920
|
+
user.adminRole === "admin" ||
|
|
921
|
+
user.adminRole === "superadmin";
|
|
922
|
+
if (!canManageShipment) {
|
|
923
|
+
throw new ConvexError({
|
|
924
|
+
code: "FORBIDDEN",
|
|
925
|
+
message: "Ruolo non autorizzato alla gestione spedizione",
|
|
926
|
+
});
|
|
927
|
+
}
|
|
928
|
+
// 4. Aggiorna prescrizione
|
|
929
|
+
await ctx.db.patch(args.prescriptionId, {
|
|
930
|
+
shippedAt: undefined,
|
|
931
|
+
shippedBy: undefined,
|
|
932
|
+
trackingNumber: undefined,
|
|
933
|
+
updatedAt: now,
|
|
934
|
+
});
|
|
935
|
+
// 5. Audit
|
|
936
|
+
await ctx.db.insert("auditEvents", {
|
|
937
|
+
auditEventId: 0, // Will be backfilled
|
|
938
|
+
prescriptionRef: args.prescriptionId,
|
|
939
|
+
at: now,
|
|
940
|
+
actorUserId: user._id,
|
|
941
|
+
actorRole: user.role,
|
|
942
|
+
type: "PRESCRIPTION_SHIPMENT_CANCELLED",
|
|
943
|
+
payload: {},
|
|
944
|
+
});
|
|
945
|
+
return { success: true };
|
|
946
|
+
},
|
|
947
|
+
});
|
|
948
|
+
// ============================================
|
|
949
|
+
// CREATE FROM CALENDAR - Creazione automatica dal Calendario
|
|
950
|
+
// ============================================
|
|
951
|
+
/**
|
|
952
|
+
* Crea una prescrizione automaticamente quando il Calendario crea un appuntamento.
|
|
953
|
+
*
|
|
954
|
+
* CARATTERISTICHE:
|
|
955
|
+
* - Usa idempotencyKey per evitare duplicati (se la chiamata viene ritentata)
|
|
956
|
+
* - Salva uno snapshot dei dati dal Calendario (calendarData)
|
|
957
|
+
* - Pre-compila i dati clinici con le info disponibili
|
|
958
|
+
* - Ritorna la prescrizione esistente se idempotencyKey già usata
|
|
959
|
+
*
|
|
960
|
+
* @param idempotencyKey - Chiave univoca (es: "cal-{appointmentId}")
|
|
961
|
+
* @param calendarData - Snapshot dei dati dal Calendario
|
|
962
|
+
* @param clinicId, patientId, etc. - ID per i riferimenti
|
|
963
|
+
*/
|
|
964
|
+
export const createFromCalendar = mutation({
|
|
965
|
+
args: {
|
|
966
|
+
// Chiave per idempotenza - OBBLIGATORIA
|
|
967
|
+
idempotencyKey: v.string(),
|
|
968
|
+
// ID principali (riferimenti alle entità)
|
|
969
|
+
clinicId: v.string(),
|
|
970
|
+
doctorId: v.string(),
|
|
971
|
+
patientId: v.string(),
|
|
972
|
+
pdcItemId: v.string(),
|
|
973
|
+
listinoId: v.string(),
|
|
974
|
+
// Flow da usare (default: prosthetics-standard)
|
|
975
|
+
flowKey: v.optional(v.string()),
|
|
976
|
+
// Snapshot completo dal Calendario
|
|
977
|
+
calendarData: calendarDataArgsValidator,
|
|
978
|
+
},
|
|
979
|
+
handler: async (ctx, args) => {
|
|
980
|
+
const now = Date.now();
|
|
981
|
+
const flowKey = args.flowKey ?? "prosthetics-standard";
|
|
982
|
+
const user = await requireAuthenticatedUserOrThrow(ctx);
|
|
983
|
+
const canCreate = user.role === "SECRETARY" ||
|
|
984
|
+
user.adminRole === "admin" ||
|
|
985
|
+
user.adminRole === "superadmin";
|
|
986
|
+
if (!canCreate) {
|
|
987
|
+
throw new ConvexError({
|
|
988
|
+
code: "FORBIDDEN",
|
|
989
|
+
message: "Utente non autorizzato a creare prescrizioni",
|
|
990
|
+
});
|
|
991
|
+
}
|
|
992
|
+
if (user.clinicId !== args.clinicId && user.adminRole !== "admin" && user.adminRole !== "superadmin") {
|
|
993
|
+
throw new ConvexError({
|
|
994
|
+
code: "FORBIDDEN",
|
|
995
|
+
message: "Clinic non autorizzata per l'utente autenticato",
|
|
996
|
+
});
|
|
997
|
+
}
|
|
998
|
+
// =============================================
|
|
999
|
+
// 1. CONTROLLO IDEMPOTENZA
|
|
1000
|
+
// Se esiste già una prescrizione con questa idempotencyKey,
|
|
1001
|
+
// la ritorniamo invece di crearne una nuova
|
|
1002
|
+
// =============================================
|
|
1003
|
+
const existingByIdempotency = await ctx.db
|
|
1004
|
+
.query("prescriptions")
|
|
1005
|
+
.withIndex("by_idempotencyKey", (q) => q.eq("idempotencyKey", args.idempotencyKey))
|
|
1006
|
+
.first();
|
|
1007
|
+
if (existingByIdempotency) {
|
|
1008
|
+
// Già creata in precedenza, ritorna quella esistente
|
|
1009
|
+
console.log(`[createFromCalendar] Prescrizione già esistente per idempotencyKey: ${args.idempotencyKey}`);
|
|
1010
|
+
return {
|
|
1011
|
+
success: true,
|
|
1012
|
+
prescriptionId: existingByIdempotency._id,
|
|
1013
|
+
alreadyExisted: true,
|
|
1014
|
+
message: "Prescrizione già esistente per questo appuntamento",
|
|
1015
|
+
};
|
|
1016
|
+
}
|
|
1017
|
+
// =============================================
|
|
1018
|
+
// 2. VINCOLO: Una sola prescrizione attiva per pdcItemId
|
|
1019
|
+
// =============================================
|
|
1020
|
+
const existingPrescriptions = await ctx.db
|
|
1021
|
+
.query("prescriptions")
|
|
1022
|
+
.withIndex("by_pdcItemId", (q) => q.eq("pdcItemId", args.pdcItemId))
|
|
1023
|
+
.collect();
|
|
1024
|
+
const activeStatuses = ["DRAFT", "PENDING_DOCTOR", "SIGNED", "SYNCED", "ERROR"];
|
|
1025
|
+
const activePrescription = existingPrescriptions.find((p) => activeStatuses.includes(p.status));
|
|
1026
|
+
if (activePrescription) {
|
|
1027
|
+
// C'è già una prescrizione attiva per questo PDC Item
|
|
1028
|
+
// NON è un errore, ma informiamo il chiamante
|
|
1029
|
+
console.log(`[createFromCalendar] Prescrizione già attiva per pdcItemId: ${args.pdcItemId}`);
|
|
1030
|
+
return {
|
|
1031
|
+
success: false,
|
|
1032
|
+
prescriptionId: activePrescription._id,
|
|
1033
|
+
alreadyExisted: true,
|
|
1034
|
+
message: `Esiste già una prescrizione attiva per questa prestazione (ID: ${activePrescription._id})`,
|
|
1035
|
+
};
|
|
1036
|
+
}
|
|
1037
|
+
// =============================================
|
|
1038
|
+
// 3. CARICA FLOW ATTIVO (con fallback)
|
|
1039
|
+
// =============================================
|
|
1040
|
+
const DEFAULT_FLOW_KEY = "prosthetics-standard";
|
|
1041
|
+
let flow = await ctx.db
|
|
1042
|
+
.query("flows")
|
|
1043
|
+
.withIndex("by_flowKey_status", (q) => q.eq("flowKey", flowKey).eq("status", "ACTIVE"))
|
|
1044
|
+
.first();
|
|
1045
|
+
// Se il flow richiesto non esiste, usa il default
|
|
1046
|
+
let actualFlowKey = flowKey;
|
|
1047
|
+
if (!flow) {
|
|
1048
|
+
console.log(`[createFromCalendar] Flow "${flowKey}" non trovato, uso default "${DEFAULT_FLOW_KEY}"`);
|
|
1049
|
+
flow = await ctx.db
|
|
1050
|
+
.query("flows")
|
|
1051
|
+
.withIndex("by_flowKey_status", (q) => q.eq("flowKey", DEFAULT_FLOW_KEY).eq("status", "ACTIVE"))
|
|
1052
|
+
.first();
|
|
1053
|
+
if (!flow) {
|
|
1054
|
+
throw new Error(`Flow default "${DEFAULT_FLOW_KEY}" non trovato. Eseguire il seed del database.`);
|
|
1055
|
+
}
|
|
1056
|
+
actualFlowKey = DEFAULT_FLOW_KEY;
|
|
1057
|
+
}
|
|
1058
|
+
// =============================================
|
|
1059
|
+
// 4. DETERMINA APPLICAZIONE DAI DATI CALENDARIO
|
|
1060
|
+
// Converte 'mouth', 'tooth', 'upper_arch', 'lower_arch'
|
|
1061
|
+
// nel formato interno
|
|
1062
|
+
// =============================================
|
|
1063
|
+
let application = undefined;
|
|
1064
|
+
switch (args.calendarData.application) {
|
|
1065
|
+
case "tooth":
|
|
1066
|
+
if (args.calendarData.toothNumber) {
|
|
1067
|
+
// Supporta formato multi-dente: "11,12,13" oppure singolo "11"
|
|
1068
|
+
const toothStr = args.calendarData.toothNumber.trim();
|
|
1069
|
+
if (toothStr.includes(",")) {
|
|
1070
|
+
// Multi-dente: "11,12,13"
|
|
1071
|
+
const teeth = toothStr
|
|
1072
|
+
.split(",")
|
|
1073
|
+
.map((s) => parseInt(s.trim(), 10))
|
|
1074
|
+
.filter((n) => !isNaN(n) && n >= 11 && n <= 48);
|
|
1075
|
+
if (teeth.length > 1) {
|
|
1076
|
+
application = { type: "MULTI_TOOTH", teeth };
|
|
1077
|
+
}
|
|
1078
|
+
else if (teeth.length === 1) {
|
|
1079
|
+
application = { type: "TOOTH", toothNumber: teeth[0] };
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
else {
|
|
1083
|
+
// Singolo dente
|
|
1084
|
+
const num = parseInt(toothStr, 10);
|
|
1085
|
+
if (!isNaN(num)) {
|
|
1086
|
+
application = { type: "TOOTH", toothNumber: num };
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
break;
|
|
1091
|
+
case "upper_arch":
|
|
1092
|
+
application = { type: "ARCH", arch: "UPPER" };
|
|
1093
|
+
break;
|
|
1094
|
+
case "lower_arch":
|
|
1095
|
+
application = { type: "ARCH", arch: "LOWER" };
|
|
1096
|
+
break;
|
|
1097
|
+
case "mouth":
|
|
1098
|
+
application = { type: "FULL_MOUTH" };
|
|
1099
|
+
break;
|
|
1100
|
+
// Altri casi possono essere aggiunti
|
|
1101
|
+
}
|
|
1102
|
+
// =============================================
|
|
1103
|
+
// 4b. CARICA RULESET ATTIVO (snapshot)
|
|
1104
|
+
// =============================================
|
|
1105
|
+
const activeRulesetCal = await ctx.db
|
|
1106
|
+
.query("activeRulesets")
|
|
1107
|
+
.withIndex("by_key", (q) => q.eq("key", "global"))
|
|
1108
|
+
.first();
|
|
1109
|
+
// =============================================
|
|
1110
|
+
// 5. CREA PRESCRIZIONE
|
|
1111
|
+
// =============================================
|
|
1112
|
+
const prescriptionId = await ctx.db.insert("prescriptions", {
|
|
1113
|
+
prescriptionId: 0, // Will be backfilled
|
|
1114
|
+
clinicId: args.clinicId,
|
|
1115
|
+
doctorId: args.doctorId,
|
|
1116
|
+
patientId: args.patientId,
|
|
1117
|
+
pdcItemId: args.pdcItemId,
|
|
1118
|
+
listinoId: args.listinoId,
|
|
1119
|
+
flowKey: actualFlowKey, // Usa il flow effettivo (potrebbe essere il default)
|
|
1120
|
+
flowVersion: flow.version,
|
|
1121
|
+
status: "DRAFT",
|
|
1122
|
+
createdByUserId: user._id, // Derivato dall'identità autenticata
|
|
1123
|
+
createdAt: now,
|
|
1124
|
+
updatedAt: now,
|
|
1125
|
+
coreRefs: {
|
|
1126
|
+
coreAppointmentIds: args.calendarData.nextAppointmentId
|
|
1127
|
+
? [args.calendarData.appointmentId, args.calendarData.nextAppointmentId]
|
|
1128
|
+
: [args.calendarData.appointmentId],
|
|
1129
|
+
},
|
|
1130
|
+
labRefs: {},
|
|
1131
|
+
// Nuovi campi
|
|
1132
|
+
idempotencyKey: args.idempotencyKey,
|
|
1133
|
+
calendarData: args.calendarData,
|
|
1134
|
+
// Snapshot ruleset attivo al momento della creazione
|
|
1135
|
+
rulesetVersionId: activeRulesetCal?.versionId,
|
|
1136
|
+
valuesByFieldId: "{}",
|
|
1137
|
+
});
|
|
1138
|
+
// =============================================
|
|
1139
|
+
// 6. CREA REVISIONE CLINICA INIZIALE
|
|
1140
|
+
// Pre-compilata con i dati dal Calendario
|
|
1141
|
+
// =============================================
|
|
1142
|
+
const initialClinicalData = {
|
|
1143
|
+
patient: { corePatientId: args.patientId },
|
|
1144
|
+
clinic: { coreClinicId: args.clinicId },
|
|
1145
|
+
doctor: { coreDoctorId: args.doctorId },
|
|
1146
|
+
prostheticService: {
|
|
1147
|
+
pdcItemId: args.pdcItemId,
|
|
1148
|
+
listinoId: args.listinoId,
|
|
1149
|
+
// Pre-compila con i dati dal Calendario
|
|
1150
|
+
serviceCode: args.calendarData.treatmentId,
|
|
1151
|
+
serviceLabel: args.calendarData.treatmentName,
|
|
1152
|
+
},
|
|
1153
|
+
// Applicazione determinata sopra
|
|
1154
|
+
application: application,
|
|
1155
|
+
};
|
|
1156
|
+
const clinicalHash = await computeClinicalHash(initialClinicalData);
|
|
1157
|
+
const revisionId = await ctx.db.insert("clinicalRevisions", {
|
|
1158
|
+
clinicalRevisionId: 0, // Will be backfilled
|
|
1159
|
+
prescriptionRef: prescriptionId,
|
|
1160
|
+
revisionNumber: 1,
|
|
1161
|
+
clinicalData: initialClinicalData,
|
|
1162
|
+
clinicalHash,
|
|
1163
|
+
signature: null,
|
|
1164
|
+
frozen: false,
|
|
1165
|
+
createdAt: now,
|
|
1166
|
+
});
|
|
1167
|
+
// =============================================
|
|
1168
|
+
// 7. AGGIORNA PRESCRIZIONE CON RIFERIMENTO ALLA REVISIONE
|
|
1169
|
+
// =============================================
|
|
1170
|
+
await ctx.db.patch(prescriptionId, {
|
|
1171
|
+
latestClinicalRevisionId: revisionId,
|
|
1172
|
+
});
|
|
1173
|
+
// =============================================
|
|
1174
|
+
// 8. INIZIALIZZA PHASE INSTANCES
|
|
1175
|
+
// =============================================
|
|
1176
|
+
// Raccogli gli ID degli appuntamenti per il coreRefs
|
|
1177
|
+
const appointmentIds = [args.calendarData.appointmentId];
|
|
1178
|
+
if (args.calendarData.nextAppointmentId) {
|
|
1179
|
+
appointmentIds.push(args.calendarData.nextAppointmentId);
|
|
1180
|
+
}
|
|
1181
|
+
for (let i = 0; i < flow.definition.phases.length; i++) {
|
|
1182
|
+
const phase = flow.definition.phases[i];
|
|
1183
|
+
// Se questa fase corrisponde a quella dell'appuntamento corrente
|
|
1184
|
+
const isCurrentPhase = phase.phaseTypeKey === args.calendarData.phaseId ||
|
|
1185
|
+
phase.label.toLowerCase() === args.calendarData.phaseName.toLowerCase();
|
|
1186
|
+
// Se questa fase corrisponde al prossimo appuntamento (se presente)
|
|
1187
|
+
const isNextPhase = args.calendarData.nextAppointmentId && (phase.phaseTypeKey === args.calendarData.nextAppointmentPhaseId ||
|
|
1188
|
+
(args.calendarData.nextAppointmentPhaseName &&
|
|
1189
|
+
phase.label.toLowerCase() === args.calendarData.nextAppointmentPhaseName.toLowerCase()));
|
|
1190
|
+
// Determina lo status e i dati appuntamento
|
|
1191
|
+
let phaseStatus = "NOT_STARTED";
|
|
1192
|
+
let appointmentData = undefined;
|
|
1193
|
+
if (isCurrentPhase) {
|
|
1194
|
+
phaseStatus = "SCHEDULED";
|
|
1195
|
+
appointmentData = {
|
|
1196
|
+
coreAppointmentId: args.calendarData.appointmentId,
|
|
1197
|
+
startAt: args.calendarData.appointmentDate,
|
|
1198
|
+
doctorId: args.doctorId,
|
|
1199
|
+
};
|
|
1200
|
+
}
|
|
1201
|
+
else if (isNextPhase && args.calendarData.nextAppointmentDate) {
|
|
1202
|
+
phaseStatus = "SCHEDULED";
|
|
1203
|
+
appointmentData = {
|
|
1204
|
+
coreAppointmentId: args.calendarData.nextAppointmentId,
|
|
1205
|
+
startAt: args.calendarData.nextAppointmentDate,
|
|
1206
|
+
doctorId: args.doctorId,
|
|
1207
|
+
};
|
|
1208
|
+
}
|
|
1209
|
+
await ctx.db.insert("phaseInstances", {
|
|
1210
|
+
phaseInstanceId: 0, // Will be backfilled
|
|
1211
|
+
prescriptionRef: prescriptionId,
|
|
1212
|
+
phaseTypeKey: phase.phaseTypeKey,
|
|
1213
|
+
ordinal: i,
|
|
1214
|
+
iteration: 1,
|
|
1215
|
+
status: phaseStatus,
|
|
1216
|
+
appointment: appointmentData,
|
|
1217
|
+
createdAt: now,
|
|
1218
|
+
updatedAt: now,
|
|
1219
|
+
});
|
|
1220
|
+
}
|
|
1221
|
+
// =============================================
|
|
1222
|
+
// 9. CREA OPERATIONAL DATA
|
|
1223
|
+
// =============================================
|
|
1224
|
+
await ctx.db.insert("operationalData", {
|
|
1225
|
+
operationalDataId: 0, // Will be backfilled
|
|
1226
|
+
prescriptionRef: prescriptionId,
|
|
1227
|
+
});
|
|
1228
|
+
// =============================================
|
|
1229
|
+
// 10. AUDIT LOG
|
|
1230
|
+
// =============================================
|
|
1231
|
+
await ctx.db.insert("auditEvents", {
|
|
1232
|
+
auditEventId: 0, // Will be backfilled
|
|
1233
|
+
prescriptionRef: prescriptionId,
|
|
1234
|
+
at: now,
|
|
1235
|
+
actorUserId: user._id,
|
|
1236
|
+
actorRole: "SYSTEM", // Creato automaticamente dal sistema
|
|
1237
|
+
type: "PRESCRIPTION_CREATED_FROM_CALENDAR",
|
|
1238
|
+
payload: {
|
|
1239
|
+
idempotencyKey: args.idempotencyKey,
|
|
1240
|
+
calendarAppointmentId: args.calendarData.appointmentId,
|
|
1241
|
+
pdcItemId: args.pdcItemId,
|
|
1242
|
+
flowKey: actualFlowKey,
|
|
1243
|
+
flowKeyRequested: flowKey !== actualFlowKey ? flowKey : undefined, // Log se era diverso
|
|
1244
|
+
flowVersion: flow.version,
|
|
1245
|
+
patientName: `${args.calendarData.patientFirstName} ${args.calendarData.patientLastName}`,
|
|
1246
|
+
treatmentName: args.calendarData.treatmentName,
|
|
1247
|
+
phaseName: args.calendarData.phaseName,
|
|
1248
|
+
},
|
|
1249
|
+
});
|
|
1250
|
+
// =============================================
|
|
1251
|
+
// 11. RITORNA RISULTATO
|
|
1252
|
+
// =============================================
|
|
1253
|
+
console.log(`[createFromCalendar] Nuova prescrizione creata: ${prescriptionId}`);
|
|
1254
|
+
return {
|
|
1255
|
+
success: true,
|
|
1256
|
+
prescriptionId,
|
|
1257
|
+
revisionId,
|
|
1258
|
+
alreadyExisted: false,
|
|
1259
|
+
message: "Prescrizione creata con successo",
|
|
1260
|
+
};
|
|
1261
|
+
},
|
|
1262
|
+
});
|
|
1263
|
+
//# sourceMappingURL=prescriptions.js.map
|