@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.
Files changed (120) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +84 -0
  3. package/dist/_internal/client/PrescriptionsComponentClient.d.ts +25 -0
  4. package/dist/_internal/client/PrescriptionsComponentClient.d.ts.map +1 -0
  5. package/dist/_internal/client/PrescriptionsComponentClient.js +61 -0
  6. package/dist/_internal/client/PrescriptionsComponentClient.js.map +1 -0
  7. package/dist/_internal/client/index.d.ts +3 -0
  8. package/dist/_internal/client/index.d.ts.map +1 -0
  9. package/dist/_internal/client/index.js +2 -0
  10. package/dist/_internal/client/index.js.map +1 -0
  11. package/dist/_internal/client/types.d.ts +47 -0
  12. package/dist/_internal/client/types.d.ts.map +1 -0
  13. package/dist/_internal/client/types.js +2 -0
  14. package/dist/_internal/client/types.js.map +1 -0
  15. package/dist/_internal/component/convex.config.d.ts +7 -0
  16. package/dist/_internal/component/convex.config.d.ts.map +1 -0
  17. package/dist/_internal/component/convex.config.js +8 -0
  18. package/dist/_internal/component/convex.config.js.map +1 -0
  19. package/dist/_internal/component/functions.d.ts +9 -0
  20. package/dist/_internal/component/functions.d.ts.map +1 -0
  21. package/dist/_internal/component/functions.js +11 -0
  22. package/dist/_internal/component/functions.js.map +1 -0
  23. package/dist/_internal/component/index.d.ts +18 -0
  24. package/dist/_internal/component/index.d.ts.map +1 -0
  25. package/dist/_internal/component/index.js +20 -0
  26. package/dist/_internal/component/index.js.map +1 -0
  27. package/dist/_internal/component/schema.d.ts +2 -0
  28. package/dist/_internal/component/schema.d.ts.map +1 -0
  29. package/dist/_internal/component/schema.js +2 -0
  30. package/dist/_internal/component/schema.js.map +1 -0
  31. package/dist/_internal/react/PrescriptionsWidget.d.ts +18 -0
  32. package/dist/_internal/react/PrescriptionsWidget.d.ts.map +1 -0
  33. package/dist/_internal/react/PrescriptionsWidget.js +137 -0
  34. package/dist/_internal/react/PrescriptionsWidget.js.map +1 -0
  35. package/dist/_internal/react/index.d.ts +3 -0
  36. package/dist/_internal/react/index.d.ts.map +1 -0
  37. package/dist/_internal/react/index.js +2 -0
  38. package/dist/_internal/react/index.js.map +1 -0
  39. package/dist/client/index.d.ts +1 -0
  40. package/dist/client/index.js +1 -0
  41. package/dist/component/convex.config.d.ts +2 -0
  42. package/dist/component/convex.config.js +2 -0
  43. package/dist/component/index.d.ts +1 -0
  44. package/dist/component/index.js +1 -0
  45. package/dist/convex/lib/auth.d.ts +7 -0
  46. package/dist/convex/lib/auth.d.ts.map +1 -0
  47. package/dist/convex/lib/auth.js +38 -0
  48. package/dist/convex/lib/auth.js.map +1 -0
  49. package/dist/convex/lib/clinicalNormalize.d.ts +13 -0
  50. package/dist/convex/lib/clinicalNormalize.d.ts.map +1 -0
  51. package/dist/convex/lib/clinicalNormalize.js +82 -0
  52. package/dist/convex/lib/clinicalNormalize.js.map +1 -0
  53. package/dist/convex/lib/dental.d.ts +13 -0
  54. package/dist/convex/lib/dental.d.ts.map +1 -0
  55. package/dist/convex/lib/dental.js +79 -0
  56. package/dist/convex/lib/dental.js.map +1 -0
  57. package/dist/convex/lib/dynamicFieldsStrict.d.ts +9 -0
  58. package/dist/convex/lib/dynamicFieldsStrict.d.ts.map +1 -0
  59. package/dist/convex/lib/dynamicFieldsStrict.js +65 -0
  60. package/dist/convex/lib/dynamicFieldsStrict.js.map +1 -0
  61. package/dist/convex/lib/dynamicRules.d.ts +61 -0
  62. package/dist/convex/lib/dynamicRules.d.ts.map +1 -0
  63. package/dist/convex/lib/dynamicRules.js +221 -0
  64. package/dist/convex/lib/dynamicRules.js.map +1 -0
  65. package/dist/convex/lib/storage.d.ts +59 -0
  66. package/dist/convex/lib/storage.d.ts.map +1 -0
  67. package/dist/convex/lib/storage.js +120 -0
  68. package/dist/convex/lib/storage.js.map +1 -0
  69. package/dist/convex/lib/utils.d.ts +61 -0
  70. package/dist/convex/lib/utils.d.ts.map +1 -0
  71. package/dist/convex/lib/utils.js +135 -0
  72. package/dist/convex/lib/utils.js.map +1 -0
  73. package/dist/convex/lib/validation.d.ts +24 -0
  74. package/dist/convex/lib/validation.d.ts.map +1 -0
  75. package/dist/convex/lib/validation.js +333 -0
  76. package/dist/convex/lib/validation.js.map +1 -0
  77. package/dist/convex/mutations/digitalAssets.d.ts +54 -0
  78. package/dist/convex/mutations/digitalAssets.d.ts.map +1 -0
  79. package/dist/convex/mutations/digitalAssets.js +297 -0
  80. package/dist/convex/mutations/digitalAssets.js.map +1 -0
  81. package/dist/convex/mutations/operational.d.ts +38 -0
  82. package/dist/convex/mutations/operational.d.ts.map +1 -0
  83. package/dist/convex/mutations/operational.js +226 -0
  84. package/dist/convex/mutations/operational.js.map +1 -0
  85. package/dist/convex/mutations/phases.d.ts +45 -0
  86. package/dist/convex/mutations/phases.d.ts.map +1 -0
  87. package/dist/convex/mutations/phases.js +334 -0
  88. package/dist/convex/mutations/phases.js.map +1 -0
  89. package/dist/convex/mutations/prescriptions.d.ts +191 -0
  90. package/dist/convex/mutations/prescriptions.d.ts.map +1 -0
  91. package/dist/convex/mutations/prescriptions.js +1263 -0
  92. package/dist/convex/mutations/prescriptions.js.map +1 -0
  93. package/dist/convex/mutations/syncJobs.d.ts +37 -0
  94. package/dist/convex/mutations/syncJobs.d.ts.map +1 -0
  95. package/dist/convex/mutations/syncJobs.js +238 -0
  96. package/dist/convex/mutations/syncJobs.js.map +1 -0
  97. package/dist/convex/prescriptions/fields.d.ts +50 -0
  98. package/dist/convex/prescriptions/fields.d.ts.map +1 -0
  99. package/dist/convex/prescriptions/fields.js +242 -0
  100. package/dist/convex/prescriptions/fields.js.map +1 -0
  101. package/dist/convex/queries/dynamicFields.d.ts +27 -0
  102. package/dist/convex/queries/dynamicFields.d.ts.map +1 -0
  103. package/dist/convex/queries/dynamicFields.js +119 -0
  104. package/dist/convex/queries/dynamicFields.js.map +1 -0
  105. package/dist/convex/queries/prescriptions.d.ts +583 -0
  106. package/dist/convex/queries/prescriptions.d.ts.map +1 -0
  107. package/dist/convex/queries/prescriptions.js +208 -0
  108. package/dist/convex/queries/prescriptions.js.map +1 -0
  109. package/dist/convex/schema.d.ts +962 -0
  110. package/dist/convex/schema.d.ts.map +1 -0
  111. package/dist/convex/schema.js +434 -0
  112. package/dist/convex/schema.js.map +1 -0
  113. package/dist/convex/types.d.ts +267 -0
  114. package/dist/convex/types.d.ts.map +1 -0
  115. package/dist/convex/types.js +2 -0
  116. package/dist/convex/types.js.map +1 -0
  117. package/dist/react/index.d.ts +1 -0
  118. package/dist/react/index.js +1 -0
  119. package/dist/react/styles.css +54 -0
  120. 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