@primocaredentgroup/convex-campaigns-component 0.1.3 → 0.3.1
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/CHANGELOG.md +52 -0
- package/README.md +113 -1
- package/convex/_generated/api.ts +1 -0
- package/convex/campaigns.ts +25 -0
- package/convex/components/campaigns/domain/stateMachine.ts +16 -0
- package/convex/components/campaigns/domain/types.ts +54 -1
- package/convex/components/campaigns/functions/authzInternal.ts +39 -13
- package/convex/components/campaigns/functions/internal.ts +67 -22
- package/convex/components/campaigns/functions/mutations.ts +14 -2
- package/convex/components/campaigns/functions/playground.ts +780 -0
- package/convex/components/campaigns/functions/public.ts +248 -0
- package/convex/components/campaigns/functions/queries.ts +175 -81
- package/convex/components/campaigns/index.ts +4 -0
- package/convex/components/campaigns/lib/helpers.ts +9 -0
- package/convex/components/campaigns/permissions.ts +82 -23
- package/convex/components/campaigns/ports/patientData.ts +3 -3
- package/convex/components/campaigns/schema.ts +124 -2
- package/convex/components/campaigns/v2/callPolicy.ts +67 -0
- package/convex/components/campaigns/v2/dataSource.ts +262 -0
- package/convex/components/campaigns/v2/fieldRegistry.ts +174 -0
- package/convex/components/campaigns/v2/rules.ts +175 -0
- package/convex/components/campaigns/v2/types.ts +62 -0
- package/convex/playground.ts +149 -0
- package/convex.config.ts +11 -4
- package/package.json +17 -3
- package/scripts/smoke-test.mjs +171 -0
- package/scripts/sync-from-repo.mjs +42 -0
|
@@ -0,0 +1,780 @@
|
|
|
1
|
+
import { v } from "convex/values";
|
|
2
|
+
import { mutation, query } from "../../../_generated/server";
|
|
3
|
+
import { assertCanManageCampaigns } from "../permissions";
|
|
4
|
+
import { canTransitionCampaignStatus } from "../domain/stateMachine";
|
|
5
|
+
import { normalizeClinicIds } from "../lib/helpers";
|
|
6
|
+
import { dataSource } from "../v2/dataSource";
|
|
7
|
+
import { getDefaultCallPolicy, validateCallPolicyInput } from "../v2/callPolicy";
|
|
8
|
+
import { fieldRegistry, getFieldRegistryPublic } from "../v2/fieldRegistry";
|
|
9
|
+
import { evaluateRuleGroup, validateRuleDsl } from "../v2/rules";
|
|
10
|
+
import type { RuleGroup } from "../v2/types";
|
|
11
|
+
import { DEFAULT_CALL_POLICY } from "../domain/types";
|
|
12
|
+
|
|
13
|
+
const MAX_PREVIEW = 5000;
|
|
14
|
+
|
|
15
|
+
function hashRules(rules: unknown): string {
|
|
16
|
+
const text = JSON.stringify(rules);
|
|
17
|
+
let h = 2166136261;
|
|
18
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
19
|
+
h ^= text.charCodeAt(i);
|
|
20
|
+
h += (h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24);
|
|
21
|
+
}
|
|
22
|
+
return `rules_${(h >>> 0).toString(16)}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function hashPolicy(policy: unknown): string {
|
|
26
|
+
const text = JSON.stringify(policy);
|
|
27
|
+
let h = 2166136261;
|
|
28
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
29
|
+
h ^= text.charCodeAt(i);
|
|
30
|
+
h += (h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24);
|
|
31
|
+
}
|
|
32
|
+
return `policy_${(h >>> 0).toString(16)}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function safeCampaignsByOrg(ctx: any, orgId: string) {
|
|
36
|
+
try {
|
|
37
|
+
return await ctx.db
|
|
38
|
+
.query("campaigns")
|
|
39
|
+
.withIndex("by_org", (q: any) => q.eq("orgId", orgId))
|
|
40
|
+
.collect();
|
|
41
|
+
} catch {
|
|
42
|
+
return await ctx.db
|
|
43
|
+
.query("campaigns")
|
|
44
|
+
.filter((q: any) => q.eq(q.field("orgId"), orgId))
|
|
45
|
+
.collect();
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function safeCampaignsByOrgStatus(ctx: any, orgId: string, status: string) {
|
|
50
|
+
try {
|
|
51
|
+
return await ctx.db
|
|
52
|
+
.query("campaigns")
|
|
53
|
+
.withIndex("by_org_status", (q: any) => q.eq("orgId", orgId).eq("status", status))
|
|
54
|
+
.collect();
|
|
55
|
+
} catch {
|
|
56
|
+
return await ctx.db
|
|
57
|
+
.query("campaigns")
|
|
58
|
+
.filter((q: any) => q.and(q.eq(q.field("orgId"), orgId), q.eq(q.field("status"), status)))
|
|
59
|
+
.collect();
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function latestContactByPatient(ctx: any, patientId: string, orgId: string): Promise<number | null> {
|
|
64
|
+
try {
|
|
65
|
+
const logs = await ctx.db
|
|
66
|
+
.query("contactLog")
|
|
67
|
+
.withIndex("by_patient_time", (q: any) => q.eq("patientId", patientId))
|
|
68
|
+
.collect();
|
|
69
|
+
const byOrg = logs.filter((l: any) => l.orgId === orgId);
|
|
70
|
+
if (byOrg.length === 0) return null;
|
|
71
|
+
return byOrg.reduce((acc: number, curr: any) => Math.max(acc, curr.createdAt), 0);
|
|
72
|
+
} catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export const getFieldRegistry = query({
|
|
78
|
+
args: {},
|
|
79
|
+
handler: async () => getFieldRegistryPublic(),
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
export const seedDemoData = mutation({
|
|
83
|
+
args: {
|
|
84
|
+
orgId: v.string(),
|
|
85
|
+
clinicIds: v.array(v.string()),
|
|
86
|
+
nPatients: v.number(),
|
|
87
|
+
},
|
|
88
|
+
handler: async (ctx, args) => {
|
|
89
|
+
await assertCanManageCampaigns(ctx);
|
|
90
|
+
const clinicIds = normalizeClinicIds(args.clinicIds);
|
|
91
|
+
if (clinicIds.length === 0) throw new Error("Almeno una clinicId è obbligatoria.");
|
|
92
|
+
|
|
93
|
+
const firstNames = ["Mario", "Luca", "Anna", "Giulia", "Sara", "Marco", "Paolo", "Elena"];
|
|
94
|
+
const lastNames = ["Rossi", "Bianchi", "Verdi", "Conti", "Neri", "Marino", "Gallo", "Costa"];
|
|
95
|
+
const cities = ["Milano", "Roma", "Torino", "Bologna", "Napoli", "Brescia"];
|
|
96
|
+
const now = Date.now();
|
|
97
|
+
let inserted = 0;
|
|
98
|
+
|
|
99
|
+
for (let i = 0; i < args.nPatients; i += 1) {
|
|
100
|
+
const clinicId = clinicIds[i % clinicIds.length];
|
|
101
|
+
const patientId = `demo-${args.orgId}-${clinicId}-${i + 1}`;
|
|
102
|
+
const birthYear = 1950 + Math.floor(Math.random() * 55);
|
|
103
|
+
const birthDate = `${birthYear}-${String(1 + (i % 12)).padStart(2, "0")}-${String(
|
|
104
|
+
1 + (i % 28),
|
|
105
|
+
).padStart(2, "0")}`;
|
|
106
|
+
const firstName = firstNames[i % firstNames.length];
|
|
107
|
+
const lastName = lastNames[(i * 3) % lastNames.length];
|
|
108
|
+
const city = cities[(i * 5) % cities.length];
|
|
109
|
+
const hasCallConsent = i % 7 !== 0;
|
|
110
|
+
const phoneMasked = i % 9 === 0 ? undefined : `+39*******${String(100 + i).slice(-3)}`;
|
|
111
|
+
|
|
112
|
+
const existing = await ctx.db
|
|
113
|
+
.query("demoPatients")
|
|
114
|
+
.withIndex("by_org_patient", (q: any) => q.eq("orgId", args.orgId).eq("patientId", patientId))
|
|
115
|
+
.first();
|
|
116
|
+
if (!existing) {
|
|
117
|
+
await ctx.db.insert("demoPatients", {
|
|
118
|
+
orgId: args.orgId,
|
|
119
|
+
clinicId,
|
|
120
|
+
patientId,
|
|
121
|
+
firstName,
|
|
122
|
+
lastName,
|
|
123
|
+
city,
|
|
124
|
+
birthDate,
|
|
125
|
+
hasCallConsent,
|
|
126
|
+
phoneMasked,
|
|
127
|
+
});
|
|
128
|
+
inserted += 1;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const daysAgo = 10 + (i % 220);
|
|
132
|
+
await ctx.db.insert("demoAppointments", {
|
|
133
|
+
orgId: args.orgId,
|
|
134
|
+
clinicId,
|
|
135
|
+
patientId,
|
|
136
|
+
date: now - daysAgo * 24 * 60 * 60 * 1000,
|
|
137
|
+
status: i % 8 === 0 ? "missed" : "done",
|
|
138
|
+
});
|
|
139
|
+
if (i % 5 === 0) {
|
|
140
|
+
await ctx.db.insert("demoAppointments", {
|
|
141
|
+
orgId: args.orgId,
|
|
142
|
+
clinicId,
|
|
143
|
+
patientId,
|
|
144
|
+
date: now + (3 + (i % 20)) * 24 * 60 * 60 * 1000,
|
|
145
|
+
status: "booked",
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const estStatus = i % 6 === 0 ? "expired" : i % 4 === 0 ? "accepted" : "open";
|
|
150
|
+
await ctx.db.insert("demoEstimates", {
|
|
151
|
+
orgId: args.orgId,
|
|
152
|
+
clinicId,
|
|
153
|
+
patientId,
|
|
154
|
+
status: estStatus,
|
|
155
|
+
amount: 400 + (i % 15) * 120,
|
|
156
|
+
createdAt: now - (i % 120) * 24 * 60 * 60 * 1000,
|
|
157
|
+
expiredAt: estStatus === "expired" ? now - (5 + (i % 30)) * 24 * 60 * 60 * 1000 : undefined,
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
await ctx.db.insert("demoTreatmentPlans", {
|
|
161
|
+
orgId: args.orgId,
|
|
162
|
+
clinicId,
|
|
163
|
+
patientId,
|
|
164
|
+
status: i % 3 === 0 ? "open" : "closed",
|
|
165
|
+
lastActivityAt: now - (i % 90) * 24 * 60 * 60 * 1000,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const clinicIdsUsed = [...new Set(clinicIds)];
|
|
170
|
+
const samplePatientIds = Array.from(
|
|
171
|
+
{ length: Math.min(5, args.nPatients) },
|
|
172
|
+
(_, i) => {
|
|
173
|
+
const c = clinicIdsUsed[i % clinicIdsUsed.length];
|
|
174
|
+
return `demo-${args.orgId}-${c}-${i + 1}`;
|
|
175
|
+
},
|
|
176
|
+
);
|
|
177
|
+
return {
|
|
178
|
+
insertedPatients: inserted,
|
|
179
|
+
requested: args.nPatients,
|
|
180
|
+
clinicIds: clinicIdsUsed,
|
|
181
|
+
samplePatientIds,
|
|
182
|
+
};
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
export const updateCampaignCallPolicy = mutation({
|
|
187
|
+
args: {
|
|
188
|
+
campaignId: v.id("campaigns"),
|
|
189
|
+
callPolicy: v.any(),
|
|
190
|
+
},
|
|
191
|
+
handler: async (ctx, args) => {
|
|
192
|
+
await assertCanManageCampaigns(ctx);
|
|
193
|
+
const validation = validateCallPolicyInput(args.callPolicy);
|
|
194
|
+
if (!validation.ok) {
|
|
195
|
+
throw new Error(`CALL_POLICY_VALIDATION_ERROR: ${validation.errors.join(" | ")}`);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const campaign = await ctx.db.get(args.campaignId);
|
|
199
|
+
if (!campaign) throw new Error("Campaign non trovata.");
|
|
200
|
+
|
|
201
|
+
const identity = await ctx.auth.getUserIdentity();
|
|
202
|
+
const actor = identity?.email ?? identity?.subject ?? "unknown";
|
|
203
|
+
const now = Date.now();
|
|
204
|
+
|
|
205
|
+
await ctx.db.patch(args.campaignId, {
|
|
206
|
+
callPolicy: validation.data,
|
|
207
|
+
policyUpdatedAt: now,
|
|
208
|
+
policyUpdatedBy: actor,
|
|
209
|
+
updatedAt: now,
|
|
210
|
+
updatedBy: actor,
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
return await ctx.db.get(args.campaignId);
|
|
214
|
+
},
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
export const getCampaignCallPolicy = query({
|
|
218
|
+
args: { campaignId: v.id("campaigns") },
|
|
219
|
+
handler: async (ctx, args) => {
|
|
220
|
+
try {
|
|
221
|
+
await assertCanManageCampaigns(ctx);
|
|
222
|
+
const campaign = await ctx.db.get(args.campaignId);
|
|
223
|
+
if (!campaign) return null;
|
|
224
|
+
const policy = campaign.callPolicy ?? getDefaultCallPolicy();
|
|
225
|
+
return { callPolicy: policy, defaults: DEFAULT_CALL_POLICY };
|
|
226
|
+
} catch (error) {
|
|
227
|
+
console.error("[getCampaignCallPolicy]", error);
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
},
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
export const upsertCampaign = mutation({
|
|
234
|
+
args: {
|
|
235
|
+
id: v.optional(v.id("campaigns")),
|
|
236
|
+
orgId: v.string(),
|
|
237
|
+
name: v.string(),
|
|
238
|
+
description: v.optional(v.string()),
|
|
239
|
+
priority: v.optional(v.number()),
|
|
240
|
+
status: v.optional(v.union(v.literal("draft"), v.literal("ready"), v.literal("archived"))),
|
|
241
|
+
clinicIds: v.optional(v.array(v.string())),
|
|
242
|
+
rules: v.any(),
|
|
243
|
+
frequencyCapDays: v.optional(v.number()),
|
|
244
|
+
},
|
|
245
|
+
handler: async (ctx, args) => {
|
|
246
|
+
await assertCanManageCampaigns(ctx);
|
|
247
|
+
|
|
248
|
+
const rulesValidation = validateRuleDsl(args.rules);
|
|
249
|
+
if (!rulesValidation.ok) {
|
|
250
|
+
throw new Error(`RULES_VALIDATION_ERROR: ${rulesValidation.errors.join(" | ")}`);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const now = Date.now();
|
|
254
|
+
const identity = await ctx.auth.getUserIdentity();
|
|
255
|
+
const actor = identity?.email ?? identity?.subject ?? "unknown";
|
|
256
|
+
const clinicIds = normalizeClinicIds(args.clinicIds as (string | unknown)[] | undefined);
|
|
257
|
+
const status = args.status ?? "draft";
|
|
258
|
+
const rawPriority = args.priority ?? 100;
|
|
259
|
+
const priority = Math.max(1, rawPriority);
|
|
260
|
+
if (rawPriority < 1) {
|
|
261
|
+
throw new Error("PRIORITY_VALIDATION: priority deve essere >= 1");
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const payload = {
|
|
265
|
+
orgId: args.orgId,
|
|
266
|
+
name: args.name,
|
|
267
|
+
description: args.description,
|
|
268
|
+
priority,
|
|
269
|
+
status,
|
|
270
|
+
scope: { orgId: args.orgId, clinicIds },
|
|
271
|
+
rules: rulesValidation.data,
|
|
272
|
+
frequencyCapDays: args.frequencyCapDays,
|
|
273
|
+
scopeType: clinicIds.length > 0 ? "CLINIC" : "HQ",
|
|
274
|
+
scopeClinicIds: clinicIds,
|
|
275
|
+
createdBy: actor,
|
|
276
|
+
updatedBy: actor,
|
|
277
|
+
updatedAt: now,
|
|
278
|
+
createdAt: now,
|
|
279
|
+
defaultRunConfig: { capWindowDays: 7, capMaxContacts: 1, lockDays: 7 },
|
|
280
|
+
} as any;
|
|
281
|
+
|
|
282
|
+
if (!args.id) {
|
|
283
|
+
const id = await ctx.db.insert("campaigns", payload);
|
|
284
|
+
return { id, created: true };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const existing = await ctx.db.get(args.id);
|
|
288
|
+
if (!existing) throw new Error("Campaign non trovata.");
|
|
289
|
+
await ctx.db.patch(args.id, {
|
|
290
|
+
...payload,
|
|
291
|
+
createdAt: existing.createdAt ?? now,
|
|
292
|
+
createdBy: existing.createdBy ?? actor,
|
|
293
|
+
});
|
|
294
|
+
return { id: args.id, created: false };
|
|
295
|
+
},
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
export const setCampaignLifecycleStatus = mutation({
|
|
299
|
+
args: {
|
|
300
|
+
campaignId: v.id("campaigns"),
|
|
301
|
+
status: v.union(v.literal("draft"), v.literal("ready"), v.literal("archived")),
|
|
302
|
+
},
|
|
303
|
+
handler: async (ctx, args) => {
|
|
304
|
+
await assertCanManageCampaigns(ctx);
|
|
305
|
+
const campaign = await ctx.db.get(args.campaignId);
|
|
306
|
+
if (!campaign) throw new Error("Campaign non trovata.");
|
|
307
|
+
const statusToV2: Record<string, "draft" | "ready" | "archived"> = {
|
|
308
|
+
DRAFT: "draft",
|
|
309
|
+
draft: "draft",
|
|
310
|
+
RUNNING: "ready",
|
|
311
|
+
ready: "ready",
|
|
312
|
+
PAUSED: "ready",
|
|
313
|
+
COMPLETED: "archived",
|
|
314
|
+
ARCHIVED: "archived",
|
|
315
|
+
archived: "archived",
|
|
316
|
+
};
|
|
317
|
+
const current = statusToV2[campaign.status ?? "draft"] ?? "draft";
|
|
318
|
+
const next = args.status as "draft" | "ready" | "archived";
|
|
319
|
+
if (!canTransitionCampaignStatus(current, next)) {
|
|
320
|
+
throw new Error(
|
|
321
|
+
`LIFECYCLE_INVALID: transizione da "${current}" a "${next}" non consentita.`,
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
const identity = await ctx.auth.getUserIdentity();
|
|
325
|
+
await ctx.db.patch(args.campaignId, {
|
|
326
|
+
status: args.status,
|
|
327
|
+
updatedAt: Date.now(),
|
|
328
|
+
updatedBy: identity?.email ?? identity?.subject ?? "unknown",
|
|
329
|
+
});
|
|
330
|
+
return { success: true };
|
|
331
|
+
},
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
export const listCampaignsV2 = query({
|
|
335
|
+
args: {
|
|
336
|
+
orgId: v.string(),
|
|
337
|
+
status: v.optional(v.union(v.literal("draft"), v.literal("ready"), v.literal("archived"))),
|
|
338
|
+
},
|
|
339
|
+
handler: async (ctx, args) => {
|
|
340
|
+
try {
|
|
341
|
+
await assertCanManageCampaigns(ctx);
|
|
342
|
+
const rows = args.status
|
|
343
|
+
? await safeCampaignsByOrgStatus(ctx, args.orgId, args.status)
|
|
344
|
+
: await safeCampaignsByOrg(ctx, args.orgId);
|
|
345
|
+
return rows.sort((a: any, b: any) => (a.priority ?? 100) - (b.priority ?? 100));
|
|
346
|
+
} catch (error) {
|
|
347
|
+
console.error("[listCampaignsV2]", error);
|
|
348
|
+
return [];
|
|
349
|
+
}
|
|
350
|
+
},
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
export const getCampaignV2 = query({
|
|
354
|
+
args: { id: v.id("campaigns") },
|
|
355
|
+
handler: async (ctx, args) => {
|
|
356
|
+
try {
|
|
357
|
+
await assertCanManageCampaigns(ctx);
|
|
358
|
+
return await ctx.db.get(args.id);
|
|
359
|
+
} catch (error) {
|
|
360
|
+
console.error("[getCampaignV2]", error);
|
|
361
|
+
return null;
|
|
362
|
+
}
|
|
363
|
+
},
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
export const listCampaignVersions = query({
|
|
367
|
+
args: { campaignId: v.id("campaigns") },
|
|
368
|
+
handler: async (ctx, args) => {
|
|
369
|
+
try {
|
|
370
|
+
await assertCanManageCampaigns(ctx);
|
|
371
|
+
const versions = await ctx.db
|
|
372
|
+
.query("campaignVersions")
|
|
373
|
+
.withIndex("by_campaign", (q: any) => q.eq("campaignId", args.campaignId))
|
|
374
|
+
.collect();
|
|
375
|
+
return versions.sort((a: any, b: any) => b.version - a.version);
|
|
376
|
+
} catch (error) {
|
|
377
|
+
console.error("[listCampaignVersions]", error);
|
|
378
|
+
return [];
|
|
379
|
+
}
|
|
380
|
+
},
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
export const previewCampaignAudience = query({
|
|
384
|
+
args: {
|
|
385
|
+
campaignId: v.optional(v.id("campaigns")),
|
|
386
|
+
orgId: v.optional(v.string()),
|
|
387
|
+
clinicIds: v.optional(v.array(v.string())),
|
|
388
|
+
rules: v.optional(v.any()),
|
|
389
|
+
frequencyCapDays: v.optional(v.number()),
|
|
390
|
+
},
|
|
391
|
+
handler: async (ctx, args) => {
|
|
392
|
+
await assertCanManageCampaigns(ctx);
|
|
393
|
+
|
|
394
|
+
let orgId = args.orgId ?? "";
|
|
395
|
+
let clinicIds = normalizeClinicIds(args.clinicIds);
|
|
396
|
+
let rules: RuleGroup | null = null;
|
|
397
|
+
let frequencyCapDays = args.frequencyCapDays;
|
|
398
|
+
|
|
399
|
+
if (args.campaignId) {
|
|
400
|
+
const campaign = await ctx.db.get(args.campaignId);
|
|
401
|
+
if (!campaign) throw new Error("Campaign non trovata");
|
|
402
|
+
orgId = campaign.orgId ?? campaign.scope?.orgId;
|
|
403
|
+
clinicIds = normalizeClinicIds((campaign.scope?.clinicIds as string[]) ?? campaign.scopeClinicIds ?? []);
|
|
404
|
+
rules = (campaign.rules ?? null) as RuleGroup | null;
|
|
405
|
+
frequencyCapDays = campaign.frequencyCapDays;
|
|
406
|
+
} else {
|
|
407
|
+
orgId = args.orgId ?? "";
|
|
408
|
+
const parsed = validateRuleDsl(args.rules);
|
|
409
|
+
if (!parsed.ok) throw new Error(`RULES_VALIDATION_ERROR: ${parsed.errors.join(" | ")}`);
|
|
410
|
+
rules = parsed.data;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (!orgId) throw new Error("orgId obbligatorio.");
|
|
414
|
+
if (!rules) throw new Error("Regole mancanti.");
|
|
415
|
+
|
|
416
|
+
let cursor: string | null = null;
|
|
417
|
+
let total = 0;
|
|
418
|
+
let isTruncated = false;
|
|
419
|
+
const byClinicMap = new Map<string, number>();
|
|
420
|
+
const sample: any[] = [];
|
|
421
|
+
|
|
422
|
+
while (true) {
|
|
423
|
+
const page = await dataSource.listPatients(ctx, orgId, clinicIds, cursor, 200);
|
|
424
|
+
if (page.page.length === 0) break;
|
|
425
|
+
for (const row of page.page) {
|
|
426
|
+
const patientCtx = await dataSource.getPatientContext(ctx, row.orgId, row.clinicId, row.patientId);
|
|
427
|
+
if (!patientCtx) continue;
|
|
428
|
+
|
|
429
|
+
const evalResult = evaluateRuleGroup(rules, patientCtx);
|
|
430
|
+
if (!evalResult.match) continue;
|
|
431
|
+
|
|
432
|
+
if (frequencyCapDays && patientCtx.contact.lastContactAt) {
|
|
433
|
+
const inWindow = patientCtx.contact.lastContactAt >= Date.now() - frequencyCapDays * 24 * 60 * 60 * 1000;
|
|
434
|
+
if (inWindow) continue;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
total += 1;
|
|
438
|
+
byClinicMap.set(row.clinicId, (byClinicMap.get(row.clinicId) ?? 0) + 1);
|
|
439
|
+
if (sample.length < 20) {
|
|
440
|
+
sample.push({
|
|
441
|
+
patientId: row.patientId,
|
|
442
|
+
clinicId: row.clinicId,
|
|
443
|
+
reasons: evalResult.reasons,
|
|
444
|
+
masked: {
|
|
445
|
+
displayName: `${patientCtx.firstName} ${patientCtx.lastName.charAt(0)}.`,
|
|
446
|
+
city: patientCtx.city,
|
|
447
|
+
lastAppointmentDate: patientCtx.appointment.lastDate ?? undefined,
|
|
448
|
+
},
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
if (total >= MAX_PREVIEW) {
|
|
452
|
+
isTruncated = true;
|
|
453
|
+
break;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
if (isTruncated) break;
|
|
458
|
+
cursor = page.nextCursor;
|
|
459
|
+
if (!cursor) break;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
return {
|
|
463
|
+
total,
|
|
464
|
+
byClinic: [...byClinicMap.entries()].map(([clinicId, count]) => ({ clinicId, count })),
|
|
465
|
+
sample,
|
|
466
|
+
isTruncated,
|
|
467
|
+
};
|
|
468
|
+
},
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
export const publishCampaignVersion = mutation({
|
|
472
|
+
args: {
|
|
473
|
+
campaignId: v.id("campaigns"),
|
|
474
|
+
label: v.optional(v.string()),
|
|
475
|
+
},
|
|
476
|
+
handler: async (ctx, args) => {
|
|
477
|
+
await assertCanManageCampaigns(ctx);
|
|
478
|
+
const campaign = await ctx.db.get(args.campaignId);
|
|
479
|
+
if (!campaign) throw new Error("Campaign non trovata.");
|
|
480
|
+
if (campaign.status !== "ready") {
|
|
481
|
+
throw new Error("Campaign deve essere in stato 'ready' per pubblicare una versione.");
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const orgId = campaign.orgId ?? campaign.scope?.orgId;
|
|
485
|
+
if (!orgId) throw new Error("Campaign senza orgId.");
|
|
486
|
+
const clinicIds = normalizeClinicIds((campaign.scope?.clinicIds as string[]) ?? campaign.scopeClinicIds ?? []);
|
|
487
|
+
const rulesCheck = validateRuleDsl(campaign.rules);
|
|
488
|
+
if (!rulesCheck.ok) throw new Error(`RULES_VALIDATION_ERROR: ${rulesCheck.errors.join(" | ")}`);
|
|
489
|
+
|
|
490
|
+
const identity = await ctx.auth.getUserIdentity();
|
|
491
|
+
const actor = identity?.email ?? identity?.subject ?? "unknown";
|
|
492
|
+
const existing = await ctx.db
|
|
493
|
+
.query("campaignVersions")
|
|
494
|
+
.withIndex("by_campaign", (q: any) => q.eq("campaignId", args.campaignId))
|
|
495
|
+
.collect();
|
|
496
|
+
const version = existing.length === 0 ? 1 : Math.max(...existing.map((v: any) => v.version)) + 1;
|
|
497
|
+
|
|
498
|
+
const callPolicySnapshot =
|
|
499
|
+
campaign.callPolicy ?? getDefaultCallPolicy();
|
|
500
|
+
const policyHash = hashPolicy(callPolicySnapshot);
|
|
501
|
+
|
|
502
|
+
const now = Date.now();
|
|
503
|
+
const versionId = await ctx.db.insert("campaignVersions", {
|
|
504
|
+
campaignId: args.campaignId,
|
|
505
|
+
version,
|
|
506
|
+
label: args.label,
|
|
507
|
+
scopeSnapshot: { orgId, clinicIds },
|
|
508
|
+
rulesSnapshot: rulesCheck.data,
|
|
509
|
+
rulesHash: hashRules(rulesCheck.data),
|
|
510
|
+
callPolicySnapshot,
|
|
511
|
+
policyHash,
|
|
512
|
+
createdAt: now,
|
|
513
|
+
createdBy: actor,
|
|
514
|
+
publishedAt: now,
|
|
515
|
+
publishedBy: actor,
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
let cursor: string | null = null;
|
|
519
|
+
let inserted = 0;
|
|
520
|
+
const dedupe = new Set<string>();
|
|
521
|
+
while (true) {
|
|
522
|
+
const page = await dataSource.listPatients(ctx, orgId, clinicIds, cursor, 100);
|
|
523
|
+
for (const row of page.page) {
|
|
524
|
+
const dedupeKey = `${versionId}:${row.patientId}`;
|
|
525
|
+
if (dedupe.has(dedupeKey)) continue;
|
|
526
|
+
dedupe.add(dedupeKey);
|
|
527
|
+
|
|
528
|
+
const already = await ctx.db
|
|
529
|
+
.query("audienceMembers")
|
|
530
|
+
.withIndex("by_patient_version", (q: any) =>
|
|
531
|
+
q.eq("patientId", row.patientId).eq("versionId", versionId),
|
|
532
|
+
)
|
|
533
|
+
.first();
|
|
534
|
+
if (already) continue;
|
|
535
|
+
|
|
536
|
+
const patientCtx = await dataSource.getPatientContext(ctx, row.orgId, row.clinicId, row.patientId);
|
|
537
|
+
if (!patientCtx) continue;
|
|
538
|
+
const evalResult = evaluateRuleGroup(rulesCheck.data, patientCtx);
|
|
539
|
+
if (!evalResult.match) continue;
|
|
540
|
+
|
|
541
|
+
if (campaign.frequencyCapDays) {
|
|
542
|
+
const lastContactAt = await latestContactByPatient(ctx, row.patientId, orgId);
|
|
543
|
+
if (
|
|
544
|
+
lastContactAt &&
|
|
545
|
+
lastContactAt >= now - campaign.frequencyCapDays * 24 * 60 * 60 * 1000
|
|
546
|
+
) {
|
|
547
|
+
continue;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
await ctx.db.insert("audienceMembers", {
|
|
552
|
+
orgId,
|
|
553
|
+
clinicId: row.clinicId,
|
|
554
|
+
campaignId: args.campaignId,
|
|
555
|
+
versionId,
|
|
556
|
+
patientId: row.patientId,
|
|
557
|
+
reasons: evalResult.reasons,
|
|
558
|
+
createdAt: now,
|
|
559
|
+
});
|
|
560
|
+
inserted += 1;
|
|
561
|
+
}
|
|
562
|
+
cursor = page.nextCursor;
|
|
563
|
+
if (!cursor) break;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
return { versionId, version, inserted };
|
|
567
|
+
},
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
async function safeAudienceMembersByOrgPatient(ctx: any, orgId: string, patientId: string) {
|
|
571
|
+
try {
|
|
572
|
+
return await ctx.db
|
|
573
|
+
.query("audienceMembers")
|
|
574
|
+
.withIndex("by_org_patient", (q: any) =>
|
|
575
|
+
q.eq("orgId", orgId).eq("patientId", patientId),
|
|
576
|
+
)
|
|
577
|
+
.collect();
|
|
578
|
+
} catch {
|
|
579
|
+
return await ctx.db
|
|
580
|
+
.query("audienceMembers")
|
|
581
|
+
.filter(
|
|
582
|
+
(q: any) =>
|
|
583
|
+
q.eq(q.field("orgId"), orgId) && q.eq(q.field("patientId"), patientId),
|
|
584
|
+
)
|
|
585
|
+
.collect();
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
export const getPatientCampaignMemberships = query({
|
|
590
|
+
args: {
|
|
591
|
+
orgId: v.string(),
|
|
592
|
+
patientId: v.string(),
|
|
593
|
+
},
|
|
594
|
+
handler: async (ctx, args) => {
|
|
595
|
+
try {
|
|
596
|
+
await assertCanManageCampaigns(ctx);
|
|
597
|
+
const memberships = await safeAudienceMembersByOrgPatient(
|
|
598
|
+
ctx,
|
|
599
|
+
args.orgId,
|
|
600
|
+
args.patientId,
|
|
601
|
+
);
|
|
602
|
+
if (memberships.length === 0) return [];
|
|
603
|
+
|
|
604
|
+
const versionIds = [...new Set(memberships.map((m: any) => m.versionId))];
|
|
605
|
+
const versionsMap = new Map<string, any>();
|
|
606
|
+
for (const vid of versionIds) {
|
|
607
|
+
const ver = await ctx.db.get(vid);
|
|
608
|
+
if (ver) versionsMap.set(vid, ver);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
const campaignIds = [
|
|
612
|
+
...new Set(
|
|
613
|
+
[...versionsMap.values()].map((v: any) => v.campaignId as string),
|
|
614
|
+
),
|
|
615
|
+
];
|
|
616
|
+
const campaignsMap = new Map<string, any>();
|
|
617
|
+
for (const cid of campaignIds) {
|
|
618
|
+
const camp = await ctx.db.get(cid);
|
|
619
|
+
if (camp && camp.status === "ready") {
|
|
620
|
+
campaignsMap.set(cid, camp);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
const byCampaign = new Map<
|
|
625
|
+
string,
|
|
626
|
+
{ versionId: string; version: number; versionNumber: number }
|
|
627
|
+
>();
|
|
628
|
+
for (const m of memberships) {
|
|
629
|
+
const ver = versionsMap.get(m.versionId);
|
|
630
|
+
const camp = campaignsMap.get(ver?.campaignId);
|
|
631
|
+
if (!ver || !camp) continue;
|
|
632
|
+
|
|
633
|
+
const prev = byCampaign.get(ver.campaignId);
|
|
634
|
+
if (!prev || ver.version > prev.versionNumber) {
|
|
635
|
+
byCampaign.set(ver.campaignId, {
|
|
636
|
+
versionId: m.versionId,
|
|
637
|
+
version: m.versionId,
|
|
638
|
+
versionNumber: ver.version,
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
const result = [...byCampaign.entries()]
|
|
644
|
+
.map(([campaignId, v]) => {
|
|
645
|
+
const campaign = campaignsMap.get(campaignId);
|
|
646
|
+
if (!campaign) return null;
|
|
647
|
+
return {
|
|
648
|
+
campaignId,
|
|
649
|
+
campaignName: campaign.name,
|
|
650
|
+
priority: campaign.priority ?? 100,
|
|
651
|
+
status: campaign.status,
|
|
652
|
+
latestPublishedVersionId: v.versionId,
|
|
653
|
+
latestPublishedVersionNumber: v.versionNumber,
|
|
654
|
+
};
|
|
655
|
+
})
|
|
656
|
+
.filter(Boolean) as any[];
|
|
657
|
+
|
|
658
|
+
return result.sort((a, b) => a.priority - b.priority);
|
|
659
|
+
} catch (error) {
|
|
660
|
+
console.error("[getPatientCampaignMemberships]", error);
|
|
661
|
+
return [];
|
|
662
|
+
}
|
|
663
|
+
},
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
export const getAudiencePage = query({
|
|
667
|
+
args: {
|
|
668
|
+
versionId: v.id("campaignVersions"),
|
|
669
|
+
clinicId: v.optional(v.string()),
|
|
670
|
+
cursor: v.optional(v.string()),
|
|
671
|
+
limit: v.optional(v.number()),
|
|
672
|
+
},
|
|
673
|
+
handler: async (ctx, args) => {
|
|
674
|
+
await assertCanManageCampaigns(ctx);
|
|
675
|
+
const limit = Math.min(200, Math.max(1, args.limit ?? 50));
|
|
676
|
+
if (args.clinicId) {
|
|
677
|
+
const page = await ctx.db
|
|
678
|
+
.query("audienceMembers")
|
|
679
|
+
.withIndex("by_version_clinic", (q: any) =>
|
|
680
|
+
q.eq("versionId", args.versionId).eq("clinicId", args.clinicId!),
|
|
681
|
+
)
|
|
682
|
+
.paginate({ cursor: args.cursor ?? null, numItems: limit });
|
|
683
|
+
return {
|
|
684
|
+
page: page.page,
|
|
685
|
+
isDone: page.isDone,
|
|
686
|
+
continueCursor: page.continueCursor,
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
const page = await ctx.db
|
|
691
|
+
.query("audienceMembers")
|
|
692
|
+
.withIndex("by_version", (q: any) => q.eq("versionId", args.versionId))
|
|
693
|
+
.paginate({ cursor: args.cursor ?? null, numItems: limit });
|
|
694
|
+
return {
|
|
695
|
+
page: page.page,
|
|
696
|
+
isDone: page.isDone,
|
|
697
|
+
continueCursor: page.continueCursor,
|
|
698
|
+
};
|
|
699
|
+
},
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
export const recordContactAttempt = mutation({
|
|
703
|
+
args: {
|
|
704
|
+
orgId: v.string(),
|
|
705
|
+
clinicId: v.string(),
|
|
706
|
+
patientId: v.string(),
|
|
707
|
+
campaignId: v.id("campaigns"),
|
|
708
|
+
versionId: v.id("campaignVersions"),
|
|
709
|
+
channel: v.union(v.literal("call"), v.literal("sms"), v.literal("email")),
|
|
710
|
+
outcome: v.union(
|
|
711
|
+
v.literal("answered"),
|
|
712
|
+
v.literal("no_answer"),
|
|
713
|
+
v.literal("busy"),
|
|
714
|
+
v.literal("wrong_number"),
|
|
715
|
+
v.literal("opt_out"),
|
|
716
|
+
v.literal("success"),
|
|
717
|
+
v.literal("failed"),
|
|
718
|
+
),
|
|
719
|
+
},
|
|
720
|
+
handler: async (ctx, args) => {
|
|
721
|
+
await assertCanManageCampaigns(ctx);
|
|
722
|
+
const id = await ctx.db.insert("contactLog", {
|
|
723
|
+
...args,
|
|
724
|
+
createdAt: Date.now(),
|
|
725
|
+
});
|
|
726
|
+
return { id };
|
|
727
|
+
},
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
export const getPlaygroundBootstrap = query({
|
|
731
|
+
args: {
|
|
732
|
+
orgId: v.string(),
|
|
733
|
+
},
|
|
734
|
+
handler: async (ctx, args) => {
|
|
735
|
+
try {
|
|
736
|
+
const sample = await dataSource.getSamplePatients(ctx, args.orgId, [], 20);
|
|
737
|
+
const clinicIds = [...new Set(sample.map((s) => s.clinicId))];
|
|
738
|
+
const fieldRegistryPublic = fieldRegistry.map((f) => ({
|
|
739
|
+
key: f.key,
|
|
740
|
+
label: f.label,
|
|
741
|
+
operators: f.operators,
|
|
742
|
+
type: f.type,
|
|
743
|
+
category: f.category,
|
|
744
|
+
enumValues: f.enumValues ?? [],
|
|
745
|
+
}));
|
|
746
|
+
return {
|
|
747
|
+
clinicIds,
|
|
748
|
+
clinics: clinicIds,
|
|
749
|
+
fields: fieldRegistryPublic,
|
|
750
|
+
fieldRegistry: fieldRegistryPublic,
|
|
751
|
+
defaults: {
|
|
752
|
+
callPolicy: DEFAULT_CALL_POLICY,
|
|
753
|
+
},
|
|
754
|
+
};
|
|
755
|
+
} catch (error) {
|
|
756
|
+
console.error("[getPlaygroundBootstrap]", error);
|
|
757
|
+
return {
|
|
758
|
+
clinicIds: [],
|
|
759
|
+
clinics: [],
|
|
760
|
+
fields: fieldRegistry.map((f) => ({
|
|
761
|
+
key: f.key,
|
|
762
|
+
label: f.label,
|
|
763
|
+
operators: f.operators,
|
|
764
|
+
type: f.type,
|
|
765
|
+
category: f.category,
|
|
766
|
+
enumValues: f.enumValues ?? [],
|
|
767
|
+
})),
|
|
768
|
+
fieldRegistry: fieldRegistry.map((f) => ({
|
|
769
|
+
key: f.key,
|
|
770
|
+
label: f.label,
|
|
771
|
+
operators: f.operators,
|
|
772
|
+
type: f.type,
|
|
773
|
+
category: f.category,
|
|
774
|
+
enumValues: f.enumValues ?? [],
|
|
775
|
+
})),
|
|
776
|
+
defaults: { callPolicy: DEFAULT_CALL_POLICY },
|
|
777
|
+
};
|
|
778
|
+
}
|
|
779
|
+
},
|
|
780
|
+
});
|