@primocaredentgroup/prescriptions-component 0.1.5 → 0.1.7
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/README.md +16 -0
- package/dist/_internal/component/_generated/component.d.ts +8 -0
- package/dist/_internal/component/_generated/component.d.ts.map +1 -1
- package/dist/_internal/component/functions.d.ts +1 -1
- package/dist/_internal/component/functions.d.ts.map +1 -1
- package/dist/_internal/component/functions.js +1 -1
- package/dist/_internal/component/functions.js.map +1 -1
- package/dist/convex/lib/auth.d.ts +5 -0
- package/dist/convex/lib/auth.d.ts.map +1 -1
- package/dist/convex/lib/auth.js +36 -0
- package/dist/convex/lib/auth.js.map +1 -1
- package/dist/convex/mutations/digitalAssets.d.ts.map +1 -1
- package/dist/convex/mutations/digitalAssets.js +20 -61
- package/dist/convex/mutations/digitalAssets.js.map +1 -1
- package/dist/convex/mutations/operational.d.ts.map +1 -1
- package/dist/convex/mutations/operational.js +19 -54
- package/dist/convex/mutations/operational.js.map +1 -1
- package/dist/convex/mutations/phases.d.ts.map +1 -1
- package/dist/convex/mutations/phases.js +20 -52
- package/dist/convex/mutations/phases.js.map +1 -1
- package/dist/convex/mutations/prescriptions.d.ts +16 -0
- package/dist/convex/mutations/prescriptions.d.ts.map +1 -1
- package/dist/convex/mutations/prescriptions.js +203 -229
- package/dist/convex/mutations/prescriptions.js.map +1 -1
- package/dist/convex/mutations/syncJobs.d.ts.map +1 -1
- package/dist/convex/mutations/syncJobs.js +6 -16
- package/dist/convex/mutations/syncJobs.js.map +1 -1
- package/dist/convex/prescriptions/fields.d.ts.map +1 -1
- package/dist/convex/prescriptions/fields.js +6 -7
- package/dist/convex/prescriptions/fields.js.map +1 -1
- package/dist/convex/queries/dynamicFields.d.ts.map +1 -1
- package/dist/convex/queries/dynamicFields.js +2 -3
- package/dist/convex/queries/dynamicFields.js.map +1 -1
- package/dist/convex/schema.d.ts +2 -2
- package/dist/convex/schema.d.ts.map +1 -1
- package/dist/convex/schema.js +2 -2
- package/dist/convex/schema.js.map +1 -1
- package/dist/convex/types.d.ts +1 -1
- package/dist/convex/types.d.ts.map +1 -1
- package/package.json +1 -1
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { mutation } from "../_generated/server";
|
|
2
2
|
import { ConvexError, v } from "convex/values";
|
|
3
3
|
import { computeClinicalHash, generateIdempotencyKey } from "../lib/utils";
|
|
4
|
-
import { validateClinicalData,
|
|
5
|
-
import {
|
|
4
|
+
import { validateClinicalData, validateDigitalRequirements } from "../lib/validation";
|
|
5
|
+
import { requireIdentityOrThrow } from "../lib/auth";
|
|
6
6
|
import { assertDynamicRequiredComplete } from "../lib/dynamicFieldsStrict";
|
|
7
7
|
import { normalizeClinicalDraftInput } from "../lib/clinicalNormalize";
|
|
8
8
|
// ============================================
|
|
@@ -30,30 +30,11 @@ const calendarDataArgsValidator = v.object({
|
|
|
30
30
|
nextAppointmentPhaseId: v.optional(v.string()),
|
|
31
31
|
nextAppointmentPhaseName: v.optional(v.string()),
|
|
32
32
|
});
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
}
|
|
33
|
+
function getActorFromIdentity(identity) {
|
|
34
|
+
return {
|
|
35
|
+
actorUserId: identity.subject,
|
|
36
|
+
actorRole: "HOST",
|
|
37
|
+
};
|
|
57
38
|
}
|
|
58
39
|
// ============================================
|
|
59
40
|
// PRESCRIPTION MUTATIONS
|
|
@@ -74,7 +55,8 @@ export const createDraft = mutation({
|
|
|
74
55
|
},
|
|
75
56
|
handler: async (ctx, args) => {
|
|
76
57
|
const now = Date.now();
|
|
77
|
-
const
|
|
58
|
+
const identity = await requireIdentityOrThrow(ctx);
|
|
59
|
+
const actor = getActorFromIdentity(identity);
|
|
78
60
|
// 1. VINCOLO: Verifica unicità pdcItemId
|
|
79
61
|
const existingPrescriptions = await ctx.db
|
|
80
62
|
.query("prescriptions")
|
|
@@ -85,23 +67,7 @@ export const createDraft = mutation({
|
|
|
85
67
|
if (activePrescription) {
|
|
86
68
|
throw new Error(`Esiste già una prescrizione attiva per questo PDC Item (ID: ${activePrescription._id})`);
|
|
87
69
|
}
|
|
88
|
-
// 2.
|
|
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
|
|
70
|
+
// 2. Carica flow attivo
|
|
105
71
|
const flow = await ctx.db
|
|
106
72
|
.query("flows")
|
|
107
73
|
.withIndex("by_flowKey_status", (q) => q.eq("flowKey", args.flowKey).eq("status", "ACTIVE"))
|
|
@@ -125,7 +91,7 @@ export const createDraft = mutation({
|
|
|
125
91
|
flowKey: args.flowKey,
|
|
126
92
|
flowVersion: flow.version,
|
|
127
93
|
status: "DRAFT",
|
|
128
|
-
createdByUserId:
|
|
94
|
+
createdByUserId: actor.actorUserId,
|
|
129
95
|
createdAt: now,
|
|
130
96
|
updatedAt: now,
|
|
131
97
|
coreRefs: {},
|
|
@@ -180,18 +146,142 @@ export const createDraft = mutation({
|
|
|
180
146
|
operationalDataId: 0, // Will be backfilled
|
|
181
147
|
prescriptionRef: prescriptionId,
|
|
182
148
|
});
|
|
183
|
-
//
|
|
149
|
+
// 8. Audit
|
|
184
150
|
await ctx.db.insert("auditEvents", {
|
|
185
151
|
auditEventId: 0, // Will be backfilled
|
|
186
152
|
prescriptionRef: prescriptionId,
|
|
187
153
|
at: now,
|
|
188
|
-
actorUserId:
|
|
189
|
-
actorRole:
|
|
154
|
+
actorUserId: actor.actorUserId,
|
|
155
|
+
actorRole: actor.actorRole,
|
|
190
156
|
type: "PRESCRIPTION_CREATED",
|
|
191
157
|
payload: {
|
|
192
158
|
pdcItemId: args.pdcItemId,
|
|
193
159
|
flowKey: args.flowKey,
|
|
194
160
|
flowVersion: flow.version,
|
|
161
|
+
actorEmail: identity.email,
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
return { prescriptionId, revisionId };
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
/**
|
|
168
|
+
* Crea una bozza di prescrizione quando chiamata dal backend (es. da appointments dell'host).
|
|
169
|
+
* NON richiede identity: ctx.runMutation da un'altra mutation non propaga il token utente.
|
|
170
|
+
* Usa actor HOST_BACKEND per audit.
|
|
171
|
+
*/
|
|
172
|
+
export const createDraftFromBackend = mutation({
|
|
173
|
+
args: {
|
|
174
|
+
clinicId: v.string(),
|
|
175
|
+
doctorId: v.string(),
|
|
176
|
+
patientId: v.string(),
|
|
177
|
+
pdcItemId: v.string(),
|
|
178
|
+
listinoId: v.string(),
|
|
179
|
+
flowKey: v.string(),
|
|
180
|
+
},
|
|
181
|
+
handler: async (ctx, args) => {
|
|
182
|
+
const now = Date.now();
|
|
183
|
+
const actor = { actorUserId: "HOST_BACKEND", actorRole: "HOST" };
|
|
184
|
+
// 1. VINCOLO: Verifica unicità pdcItemId
|
|
185
|
+
const existingPrescriptions = await ctx.db
|
|
186
|
+
.query("prescriptions")
|
|
187
|
+
.withIndex("by_pdcItemId", (q) => q.eq("pdcItemId", args.pdcItemId))
|
|
188
|
+
.collect();
|
|
189
|
+
const activeStatuses = ["DRAFT", "PENDING_DOCTOR", "SIGNED", "SYNCED", "ERROR"];
|
|
190
|
+
const activePrescription = existingPrescriptions.find((p) => activeStatuses.includes(p.status));
|
|
191
|
+
if (activePrescription) {
|
|
192
|
+
throw new Error(`Esiste già una prescrizione attiva per questo PDC Item (ID: ${activePrescription._id})`);
|
|
193
|
+
}
|
|
194
|
+
// 2. Carica flow attivo
|
|
195
|
+
const flow = await ctx.db
|
|
196
|
+
.query("flows")
|
|
197
|
+
.withIndex("by_flowKey_status", (q) => q.eq("flowKey", args.flowKey).eq("status", "ACTIVE"))
|
|
198
|
+
.first();
|
|
199
|
+
if (!flow) {
|
|
200
|
+
throw new Error(`Flow "${args.flowKey}" non trovato o non attivo`);
|
|
201
|
+
}
|
|
202
|
+
// 3. Carica ruleset attivo (se presente)
|
|
203
|
+
const activeRuleset = await ctx.db
|
|
204
|
+
.query("activeRulesets")
|
|
205
|
+
.withIndex("by_key", (q) => q.eq("key", "global"))
|
|
206
|
+
.first();
|
|
207
|
+
// 4. Crea prescrizione
|
|
208
|
+
const prescriptionId = await ctx.db.insert("prescriptions", {
|
|
209
|
+
prescriptionId: 0,
|
|
210
|
+
clinicId: args.clinicId,
|
|
211
|
+
doctorId: args.doctorId,
|
|
212
|
+
patientId: args.patientId,
|
|
213
|
+
pdcItemId: args.pdcItemId,
|
|
214
|
+
listinoId: args.listinoId,
|
|
215
|
+
flowKey: args.flowKey,
|
|
216
|
+
flowVersion: flow.version,
|
|
217
|
+
status: "DRAFT",
|
|
218
|
+
createdByUserId: actor.actorUserId,
|
|
219
|
+
createdAt: now,
|
|
220
|
+
updatedAt: now,
|
|
221
|
+
coreRefs: {},
|
|
222
|
+
labRefs: {},
|
|
223
|
+
rulesetVersionId: activeRuleset?.versionId,
|
|
224
|
+
valuesByFieldId: "{}",
|
|
225
|
+
});
|
|
226
|
+
// 5. Crea revisione clinica iniziale (vuota)
|
|
227
|
+
const initialClinicalData = {
|
|
228
|
+
patient: { corePatientId: args.patientId },
|
|
229
|
+
clinic: { coreClinicId: args.clinicId },
|
|
230
|
+
doctor: { coreDoctorId: args.doctorId },
|
|
231
|
+
prostheticService: {
|
|
232
|
+
pdcItemId: args.pdcItemId,
|
|
233
|
+
listinoId: args.listinoId,
|
|
234
|
+
serviceCode: "",
|
|
235
|
+
serviceLabel: "",
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
const clinicalHash = await computeClinicalHash(initialClinicalData);
|
|
239
|
+
const revisionId = await ctx.db.insert("clinicalRevisions", {
|
|
240
|
+
clinicalRevisionId: 0,
|
|
241
|
+
prescriptionRef: prescriptionId,
|
|
242
|
+
revisionNumber: 1,
|
|
243
|
+
clinicalData: initialClinicalData,
|
|
244
|
+
clinicalHash,
|
|
245
|
+
signature: null,
|
|
246
|
+
frozen: false,
|
|
247
|
+
createdAt: now,
|
|
248
|
+
});
|
|
249
|
+
// 6. Aggiorna prescrizione con riferimento alla revisione
|
|
250
|
+
await ctx.db.patch(prescriptionId, {
|
|
251
|
+
latestClinicalRevisionId: revisionId,
|
|
252
|
+
});
|
|
253
|
+
// 7. Inizializza phase instances
|
|
254
|
+
for (let i = 0; i < flow.definition.phases.length; i++) {
|
|
255
|
+
const phase = flow.definition.phases[i];
|
|
256
|
+
await ctx.db.insert("phaseInstances", {
|
|
257
|
+
phaseInstanceId: 0,
|
|
258
|
+
prescriptionRef: prescriptionId,
|
|
259
|
+
phaseTypeKey: phase.phaseTypeKey,
|
|
260
|
+
ordinal: i,
|
|
261
|
+
iteration: 1,
|
|
262
|
+
status: "NOT_STARTED",
|
|
263
|
+
createdAt: now,
|
|
264
|
+
updatedAt: now,
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
// 8. Crea operational data
|
|
268
|
+
await ctx.db.insert("operationalData", {
|
|
269
|
+
operationalDataId: 0,
|
|
270
|
+
prescriptionRef: prescriptionId,
|
|
271
|
+
});
|
|
272
|
+
// 9. Audit
|
|
273
|
+
await ctx.db.insert("auditEvents", {
|
|
274
|
+
auditEventId: 0,
|
|
275
|
+
prescriptionRef: prescriptionId,
|
|
276
|
+
at: now,
|
|
277
|
+
actorUserId: actor.actorUserId,
|
|
278
|
+
actorRole: actor.actorRole,
|
|
279
|
+
type: "PRESCRIPTION_CREATED",
|
|
280
|
+
payload: {
|
|
281
|
+
pdcItemId: args.pdcItemId,
|
|
282
|
+
flowKey: args.flowKey,
|
|
283
|
+
flowVersion: flow.version,
|
|
284
|
+
source: "createDraftFromBackend",
|
|
195
285
|
},
|
|
196
286
|
});
|
|
197
287
|
return { prescriptionId, revisionId };
|
|
@@ -226,43 +316,17 @@ export const updateClinicalDraft = mutation({
|
|
|
226
316
|
},
|
|
227
317
|
handler: async (ctx, args) => {
|
|
228
318
|
const now = Date.now();
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
}
|
|
319
|
+
const identity = await requireIdentityOrThrow(ctx);
|
|
320
|
+
const actor = getActorFromIdentity(identity);
|
|
243
321
|
const prescription = await ctx.db.get(args.prescriptionId);
|
|
244
322
|
if (!prescription) {
|
|
245
323
|
throw new Error("Prescrizione non trovata");
|
|
246
324
|
}
|
|
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
325
|
// 2. Verifica stato
|
|
257
326
|
if (!["DRAFT", "PENDING_DOCTOR"].includes(prescription.status)) {
|
|
258
327
|
throw new Error(`Non è possibile modificare una prescrizione in stato ${prescription.status}`);
|
|
259
328
|
}
|
|
260
|
-
// 3.
|
|
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
|
|
329
|
+
// 3. Carica revisione corrente
|
|
266
330
|
if (!prescription.latestClinicalRevisionId) {
|
|
267
331
|
throw new Error("Nessuna revisione clinica trovata");
|
|
268
332
|
}
|
|
@@ -335,19 +399,20 @@ export const updateClinicalDraft = mutation({
|
|
|
335
399
|
if (normalizedPatch.lineaMargine !== undefined) {
|
|
336
400
|
changedKeys.push("lineaMargine");
|
|
337
401
|
}
|
|
338
|
-
//
|
|
402
|
+
// 9. Audit
|
|
339
403
|
await ctx.db.insert("auditEvents", {
|
|
340
404
|
auditEventId: 0, // Will be backfilled
|
|
341
405
|
prescriptionRef: args.prescriptionId,
|
|
342
406
|
at: now,
|
|
343
|
-
actorUserId:
|
|
344
|
-
actorRole:
|
|
345
|
-
type:
|
|
407
|
+
actorUserId: actor.actorUserId,
|
|
408
|
+
actorRole: actor.actorRole,
|
|
409
|
+
type: "CLINICAL_DRAFT_UPDATED",
|
|
346
410
|
payload: {
|
|
347
411
|
changes: normalizedPatch,
|
|
348
412
|
changedKeys,
|
|
349
413
|
clinicalValidationErrorsCount: clinicalValidationErrors.length,
|
|
350
414
|
newHash,
|
|
415
|
+
actorEmail: identity.email,
|
|
351
416
|
},
|
|
352
417
|
});
|
|
353
418
|
return { ok: true, success: true, clinicalHash: newHash, clinicalValidationErrors };
|
|
@@ -363,22 +428,18 @@ export const submitToDoctor = mutation({
|
|
|
363
428
|
},
|
|
364
429
|
handler: async (ctx, args) => {
|
|
365
430
|
const now = Date.now();
|
|
366
|
-
const
|
|
431
|
+
const identity = await requireIdentityOrThrow(ctx);
|
|
432
|
+
const actor = getActorFromIdentity(identity);
|
|
367
433
|
// 1. Carica prescrizione
|
|
368
434
|
const prescription = await ctx.db.get(args.prescriptionId);
|
|
369
435
|
if (!prescription) {
|
|
370
436
|
throw new Error("Prescrizione non trovata");
|
|
371
437
|
}
|
|
372
|
-
assertPrescriptionAccessOrThrow(user, prescription);
|
|
373
438
|
// 2. Verifica stato
|
|
374
439
|
if (prescription.status !== "DRAFT") {
|
|
375
440
|
throw new Error(`Non è possibile inviare una prescrizione in stato ${prescription.status}`);
|
|
376
441
|
}
|
|
377
|
-
|
|
378
|
-
if (!permission.allowed) {
|
|
379
|
-
throw new Error(permission.reason);
|
|
380
|
-
}
|
|
381
|
-
// 4. Valida requisiti per prescrizioni digitali
|
|
442
|
+
// 3. Valida requisiti per prescrizioni digitali
|
|
382
443
|
const digitalErrors = validateDigitalRequirements(prescription.prescriptionType, prescription.digitalAssets);
|
|
383
444
|
if (digitalErrors.length > 0) {
|
|
384
445
|
throw new Error(digitalErrors.map(e => e.message).join("; "));
|
|
@@ -388,15 +449,15 @@ export const submitToDoctor = mutation({
|
|
|
388
449
|
status: "PENDING_DOCTOR",
|
|
389
450
|
updatedAt: now,
|
|
390
451
|
});
|
|
391
|
-
//
|
|
452
|
+
// 5. Audit
|
|
392
453
|
await ctx.db.insert("auditEvents", {
|
|
393
454
|
auditEventId: 0, // Will be backfilled
|
|
394
455
|
prescriptionRef: args.prescriptionId,
|
|
395
456
|
at: now,
|
|
396
|
-
actorUserId:
|
|
397
|
-
actorRole:
|
|
457
|
+
actorUserId: actor.actorUserId,
|
|
458
|
+
actorRole: actor.actorRole,
|
|
398
459
|
type: "SUBMITTED_TO_DOCTOR",
|
|
399
|
-
payload: {},
|
|
460
|
+
payload: { actorEmail: identity.email },
|
|
400
461
|
});
|
|
401
462
|
return { success: true };
|
|
402
463
|
},
|
|
@@ -412,24 +473,18 @@ export const signPrescription = mutation({
|
|
|
412
473
|
},
|
|
413
474
|
handler: async (ctx, args) => {
|
|
414
475
|
const now = Date.now();
|
|
415
|
-
const
|
|
476
|
+
const identity = await requireIdentityOrThrow(ctx);
|
|
477
|
+
const actor = getActorFromIdentity(identity);
|
|
416
478
|
// 1. Carica prescrizione
|
|
417
479
|
const prescription = await ctx.db.get(args.prescriptionId);
|
|
418
480
|
if (!prescription) {
|
|
419
481
|
throw new Error("Prescrizione non trovata");
|
|
420
482
|
}
|
|
421
|
-
assertPrescriptionAccessOrThrow(user, prescription);
|
|
422
483
|
// 2. Verifica stato
|
|
423
484
|
if (prescription.status !== "PENDING_DOCTOR") {
|
|
424
485
|
throw new Error(`Non è possibile firmare una prescrizione in stato ${prescription.status}`);
|
|
425
486
|
}
|
|
426
|
-
|
|
427
|
-
throw new ConvexError({
|
|
428
|
-
code: "FORBIDDEN",
|
|
429
|
-
message: "Solo i medici possono firmare le prescrizioni",
|
|
430
|
-
});
|
|
431
|
-
}
|
|
432
|
-
// 4. Carica flow e revisione
|
|
487
|
+
// 3. Carica flow e revisione
|
|
433
488
|
const flow = await ctx.db
|
|
434
489
|
.query("flows")
|
|
435
490
|
.withIndex("by_flowKey_version", (q) => q.eq("flowKey", prescription.flowKey).eq("version", prescription.flowVersion))
|
|
@@ -460,7 +515,7 @@ export const signPrescription = mutation({
|
|
|
460
515
|
// 7. Crea firma
|
|
461
516
|
const signature = {
|
|
462
517
|
signedAt: now,
|
|
463
|
-
signedByDoctorId:
|
|
518
|
+
signedByDoctorId: prescription.doctorId,
|
|
464
519
|
signatureType: "weak-digital",
|
|
465
520
|
signaturePayload: args.signaturePayload,
|
|
466
521
|
clinicalHash: finalHash,
|
|
@@ -515,18 +570,19 @@ export const signPrescription = mutation({
|
|
|
515
570
|
status: "SIGNED",
|
|
516
571
|
updatedAt: now,
|
|
517
572
|
});
|
|
518
|
-
//
|
|
573
|
+
// 10. Audit
|
|
519
574
|
await ctx.db.insert("auditEvents", {
|
|
520
575
|
auditEventId: 0, // Will be backfilled
|
|
521
576
|
prescriptionRef: args.prescriptionId,
|
|
522
577
|
at: now,
|
|
523
|
-
actorUserId:
|
|
524
|
-
actorRole:
|
|
578
|
+
actorUserId: actor.actorUserId,
|
|
579
|
+
actorRole: actor.actorRole,
|
|
525
580
|
type: "PRESCRIPTION_SIGNED",
|
|
526
581
|
payload: {
|
|
527
582
|
clinicalHash: finalHash,
|
|
528
583
|
signedAt: now,
|
|
529
584
|
syncJobIds,
|
|
585
|
+
actorEmail: identity.email,
|
|
530
586
|
},
|
|
531
587
|
});
|
|
532
588
|
return {
|
|
@@ -547,57 +603,17 @@ export const sendToLab = mutation({
|
|
|
547
603
|
},
|
|
548
604
|
handler: async (ctx, args) => {
|
|
549
605
|
const now = Date.now();
|
|
550
|
-
|
|
551
|
-
|
|
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
|
-
}
|
|
606
|
+
const identity = await requireIdentityOrThrow(ctx);
|
|
607
|
+
const actor = getActorFromIdentity(identity);
|
|
564
608
|
const prescription = await ctx.db.get(args.prescriptionId);
|
|
565
609
|
if (!prescription) {
|
|
566
610
|
throw new Error("Prescrizione non trovata");
|
|
567
611
|
}
|
|
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
612
|
// 2. Verifica stato (deve essere DRAFT o PENDING_DOCTOR)
|
|
578
613
|
if (prescription.status !== "DRAFT" && prescription.status !== "PENDING_DOCTOR") {
|
|
579
614
|
throw new Error(`Non è possibile inviare al lab una prescrizione in stato ${prescription.status}`);
|
|
580
615
|
}
|
|
581
|
-
// 3.
|
|
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
|
|
616
|
+
// 3. Carica flow e revisione
|
|
601
617
|
const flow = await ctx.db
|
|
602
618
|
.query("flows")
|
|
603
619
|
.withIndex("by_flowKey_version", (q) => q.eq("flowKey", prescription.flowKey).eq("version", prescription.flowVersion))
|
|
@@ -633,8 +649,8 @@ export const sendToLab = mutation({
|
|
|
633
649
|
signaturePayload: btoa(JSON.stringify({
|
|
634
650
|
autoSigned: true,
|
|
635
651
|
timestamp: now,
|
|
636
|
-
sentBy:
|
|
637
|
-
sentByRole:
|
|
652
|
+
sentBy: actor.actorUserId,
|
|
653
|
+
sentByRole: actor.actorRole,
|
|
638
654
|
})),
|
|
639
655
|
clinicalHash: finalHash,
|
|
640
656
|
};
|
|
@@ -693,14 +709,15 @@ export const sendToLab = mutation({
|
|
|
693
709
|
auditEventId: 0, // Will be backfilled
|
|
694
710
|
prescriptionRef: args.prescriptionId,
|
|
695
711
|
at: now,
|
|
696
|
-
actorUserId:
|
|
697
|
-
actorRole:
|
|
712
|
+
actorUserId: actor.actorUserId,
|
|
713
|
+
actorRole: actor.actorRole,
|
|
698
714
|
type: "SENT_TO_LAB",
|
|
699
715
|
payload: {
|
|
700
716
|
clinicalHash: finalHash,
|
|
701
717
|
sentAt: now,
|
|
702
718
|
syncJobIds,
|
|
703
719
|
autoSigned: true,
|
|
720
|
+
actorEmail: identity.email,
|
|
704
721
|
},
|
|
705
722
|
});
|
|
706
723
|
return {
|
|
@@ -721,24 +738,18 @@ export const reviseAfterSignature = mutation({
|
|
|
721
738
|
},
|
|
722
739
|
handler: async (ctx, args) => {
|
|
723
740
|
const now = Date.now();
|
|
724
|
-
const
|
|
741
|
+
const identity = await requireIdentityOrThrow(ctx);
|
|
742
|
+
const actor = getActorFromIdentity(identity);
|
|
725
743
|
// 1. Carica prescrizione
|
|
726
744
|
const prescription = await ctx.db.get(args.prescriptionId);
|
|
727
745
|
if (!prescription) {
|
|
728
746
|
throw new Error("Prescrizione non trovata");
|
|
729
747
|
}
|
|
730
|
-
assertPrescriptionAccessOrThrow(user, prescription);
|
|
731
748
|
// 2. Verifica stato
|
|
732
749
|
if (!["SIGNED", "ERROR"].includes(prescription.status)) {
|
|
733
750
|
throw new Error(`Non è possibile revisionare una prescrizione in stato ${prescription.status}`);
|
|
734
751
|
}
|
|
735
|
-
|
|
736
|
-
throw new ConvexError({
|
|
737
|
-
code: "FORBIDDEN",
|
|
738
|
-
message: "Solo i medici possono creare revisioni",
|
|
739
|
-
});
|
|
740
|
-
}
|
|
741
|
-
// 4. Carica revisione corrente
|
|
752
|
+
// 3. Carica revisione corrente
|
|
742
753
|
if (!prescription.latestClinicalRevisionId) {
|
|
743
754
|
throw new Error("Nessuna revisione clinica trovata");
|
|
744
755
|
}
|
|
@@ -770,14 +781,15 @@ export const reviseAfterSignature = mutation({
|
|
|
770
781
|
auditEventId: 0, // Will be backfilled
|
|
771
782
|
prescriptionRef: args.prescriptionId,
|
|
772
783
|
at: now,
|
|
773
|
-
actorUserId:
|
|
774
|
-
actorRole:
|
|
784
|
+
actorUserId: actor.actorUserId,
|
|
785
|
+
actorRole: actor.actorRole,
|
|
775
786
|
type: "REVISION_CREATED",
|
|
776
787
|
payload: {
|
|
777
788
|
previousRevisionNumber: currentRevision.revisionNumber,
|
|
778
789
|
newRevisionNumber,
|
|
779
790
|
previousHash: currentRevision.clinicalHash,
|
|
780
791
|
reason: args.reason,
|
|
792
|
+
actorEmail: identity.email,
|
|
781
793
|
},
|
|
782
794
|
});
|
|
783
795
|
return {
|
|
@@ -797,13 +809,13 @@ export const cancelPrescription = mutation({
|
|
|
797
809
|
},
|
|
798
810
|
handler: async (ctx, args) => {
|
|
799
811
|
const now = Date.now();
|
|
800
|
-
const
|
|
812
|
+
const identity = await requireIdentityOrThrow(ctx);
|
|
813
|
+
const actor = getActorFromIdentity(identity);
|
|
801
814
|
// 1. Carica prescrizione
|
|
802
815
|
const prescription = await ctx.db.get(args.prescriptionId);
|
|
803
816
|
if (!prescription) {
|
|
804
817
|
throw new Error("Prescrizione non trovata");
|
|
805
818
|
}
|
|
806
|
-
assertPrescriptionAccessOrThrow(user, prescription);
|
|
807
819
|
// 2. Verifica stato (non può annullare se già SYNCED)
|
|
808
820
|
if (prescription.status === "SYNCED") {
|
|
809
821
|
throw new Error("Non è possibile annullare una prescrizione già sincronizzata");
|
|
@@ -811,24 +823,20 @@ export const cancelPrescription = mutation({
|
|
|
811
823
|
if (prescription.status === "CANCELLED") {
|
|
812
824
|
throw new Error("La prescrizione è già annullata");
|
|
813
825
|
}
|
|
814
|
-
|
|
815
|
-
if (!permission.allowed) {
|
|
816
|
-
throw new Error(permission.reason);
|
|
817
|
-
}
|
|
818
|
-
// 4. Aggiorna stato
|
|
826
|
+
// 3. Aggiorna stato
|
|
819
827
|
await ctx.db.patch(args.prescriptionId, {
|
|
820
828
|
status: "CANCELLED",
|
|
821
829
|
updatedAt: now,
|
|
822
830
|
});
|
|
823
|
-
//
|
|
831
|
+
// 4. Audit
|
|
824
832
|
await ctx.db.insert("auditEvents", {
|
|
825
833
|
auditEventId: 0, // Will be backfilled
|
|
826
834
|
prescriptionRef: args.prescriptionId,
|
|
827
835
|
at: now,
|
|
828
|
-
actorUserId:
|
|
829
|
-
actorRole:
|
|
836
|
+
actorUserId: actor.actorUserId,
|
|
837
|
+
actorRole: actor.actorRole,
|
|
830
838
|
type: "PRESCRIPTION_CANCELLED",
|
|
831
|
-
payload: { reason: args.reason },
|
|
839
|
+
payload: { reason: args.reason, actorEmail: identity.email },
|
|
832
840
|
});
|
|
833
841
|
return { success: true };
|
|
834
842
|
},
|
|
@@ -844,13 +852,13 @@ export const markAsShipped = mutation({
|
|
|
844
852
|
},
|
|
845
853
|
handler: async (ctx, args) => {
|
|
846
854
|
const now = Date.now();
|
|
847
|
-
const
|
|
855
|
+
const identity = await requireIdentityOrThrow(ctx);
|
|
856
|
+
const actor = getActorFromIdentity(identity);
|
|
848
857
|
// 1. Carica prescrizione
|
|
849
858
|
const prescription = await ctx.db.get(args.prescriptionId);
|
|
850
859
|
if (!prescription) {
|
|
851
860
|
throw new Error("Prescrizione non trovata");
|
|
852
861
|
}
|
|
853
|
-
assertPrescriptionAccessOrThrow(user, prescription);
|
|
854
862
|
// 2. Verifica stato (deve essere SIGNED o superiore, non CANCELLED)
|
|
855
863
|
const validStatuses = ["SIGNED", "SYNCED", "ERROR"];
|
|
856
864
|
if (!validStatuses.includes(prescription.status)) {
|
|
@@ -860,35 +868,25 @@ export const markAsShipped = mutation({
|
|
|
860
868
|
if (prescription.shippedAt) {
|
|
861
869
|
throw new Error("Questa prescrizione è già stata marcata come spedita");
|
|
862
870
|
}
|
|
863
|
-
//
|
|
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
|
|
871
|
+
// 3. Aggiorna prescrizione
|
|
875
872
|
await ctx.db.patch(args.prescriptionId, {
|
|
876
873
|
shippedAt: now,
|
|
877
|
-
shippedBy:
|
|
874
|
+
shippedBy: actor.actorUserId,
|
|
878
875
|
trackingNumber: args.trackingNumber,
|
|
879
876
|
updatedAt: now,
|
|
880
877
|
});
|
|
881
|
-
//
|
|
878
|
+
// 4. Audit
|
|
882
879
|
await ctx.db.insert("auditEvents", {
|
|
883
880
|
auditEventId: 0, // Will be backfilled
|
|
884
881
|
prescriptionRef: args.prescriptionId,
|
|
885
882
|
at: now,
|
|
886
|
-
actorUserId:
|
|
887
|
-
actorRole:
|
|
883
|
+
actorUserId: actor.actorUserId,
|
|
884
|
+
actorRole: actor.actorRole,
|
|
888
885
|
type: "PRESCRIPTION_SHIPPED",
|
|
889
886
|
payload: {
|
|
890
887
|
trackingNumber: args.trackingNumber,
|
|
891
888
|
shippedAt: now,
|
|
889
|
+
actorEmail: identity.email,
|
|
892
890
|
},
|
|
893
891
|
});
|
|
894
892
|
return { success: true, shippedAt: now };
|
|
@@ -903,44 +901,33 @@ export const unmarkAsShipped = mutation({
|
|
|
903
901
|
},
|
|
904
902
|
handler: async (ctx, args) => {
|
|
905
903
|
const now = Date.now();
|
|
906
|
-
const
|
|
904
|
+
const identity = await requireIdentityOrThrow(ctx);
|
|
905
|
+
const actor = getActorFromIdentity(identity);
|
|
907
906
|
// 1. Carica prescrizione
|
|
908
907
|
const prescription = await ctx.db.get(args.prescriptionId);
|
|
909
908
|
if (!prescription) {
|
|
910
909
|
throw new Error("Prescrizione non trovata");
|
|
911
910
|
}
|
|
912
|
-
assertPrescriptionAccessOrThrow(user, prescription);
|
|
913
911
|
// 2. Verifica se è spedita
|
|
914
912
|
if (!prescription.shippedAt) {
|
|
915
913
|
throw new Error("Questa prescrizione non è marcata come spedita");
|
|
916
914
|
}
|
|
917
|
-
// 3.
|
|
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
|
|
915
|
+
// 3. Aggiorna prescrizione
|
|
929
916
|
await ctx.db.patch(args.prescriptionId, {
|
|
930
917
|
shippedAt: undefined,
|
|
931
918
|
shippedBy: undefined,
|
|
932
919
|
trackingNumber: undefined,
|
|
933
920
|
updatedAt: now,
|
|
934
921
|
});
|
|
935
|
-
//
|
|
922
|
+
// 4. Audit
|
|
936
923
|
await ctx.db.insert("auditEvents", {
|
|
937
924
|
auditEventId: 0, // Will be backfilled
|
|
938
925
|
prescriptionRef: args.prescriptionId,
|
|
939
926
|
at: now,
|
|
940
|
-
actorUserId:
|
|
941
|
-
actorRole:
|
|
927
|
+
actorUserId: actor.actorUserId,
|
|
928
|
+
actorRole: actor.actorRole,
|
|
942
929
|
type: "PRESCRIPTION_SHIPMENT_CANCELLED",
|
|
943
|
-
payload: {},
|
|
930
|
+
payload: { actorEmail: identity.email },
|
|
944
931
|
});
|
|
945
932
|
return { success: true };
|
|
946
933
|
},
|
|
@@ -979,22 +966,8 @@ export const createFromCalendar = mutation({
|
|
|
979
966
|
handler: async (ctx, args) => {
|
|
980
967
|
const now = Date.now();
|
|
981
968
|
const flowKey = args.flowKey ?? "prosthetics-standard";
|
|
982
|
-
const
|
|
983
|
-
const
|
|
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
|
-
}
|
|
969
|
+
const identity = await requireIdentityOrThrow(ctx);
|
|
970
|
+
const actor = getActorFromIdentity(identity);
|
|
998
971
|
// =============================================
|
|
999
972
|
// 1. CONTROLLO IDEMPOTENZA
|
|
1000
973
|
// Se esiste già una prescrizione con questa idempotencyKey,
|
|
@@ -1119,7 +1092,7 @@ export const createFromCalendar = mutation({
|
|
|
1119
1092
|
flowKey: actualFlowKey, // Usa il flow effettivo (potrebbe essere il default)
|
|
1120
1093
|
flowVersion: flow.version,
|
|
1121
1094
|
status: "DRAFT",
|
|
1122
|
-
createdByUserId:
|
|
1095
|
+
createdByUserId: actor.actorUserId, // Derivato dall'identità autenticata
|
|
1123
1096
|
createdAt: now,
|
|
1124
1097
|
updatedAt: now,
|
|
1125
1098
|
coreRefs: {
|
|
@@ -1232,8 +1205,8 @@ export const createFromCalendar = mutation({
|
|
|
1232
1205
|
auditEventId: 0, // Will be backfilled
|
|
1233
1206
|
prescriptionRef: prescriptionId,
|
|
1234
1207
|
at: now,
|
|
1235
|
-
actorUserId:
|
|
1236
|
-
actorRole:
|
|
1208
|
+
actorUserId: actor.actorUserId,
|
|
1209
|
+
actorRole: actor.actorRole,
|
|
1237
1210
|
type: "PRESCRIPTION_CREATED_FROM_CALENDAR",
|
|
1238
1211
|
payload: {
|
|
1239
1212
|
idempotencyKey: args.idempotencyKey,
|
|
@@ -1245,6 +1218,7 @@ export const createFromCalendar = mutation({
|
|
|
1245
1218
|
patientName: `${args.calendarData.patientFirstName} ${args.calendarData.patientLastName}`,
|
|
1246
1219
|
treatmentName: args.calendarData.treatmentName,
|
|
1247
1220
|
phaseName: args.calendarData.phaseName,
|
|
1221
|
+
actorEmail: identity.email,
|
|
1248
1222
|
},
|
|
1249
1223
|
});
|
|
1250
1224
|
// =============================================
|