@primocaredentgroup/convex-campaigns-component 0.3.17 → 0.3.22
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/convex/campaigns.ts +2 -0
- package/convex/components/campaigns/functions/actions.ts +3 -3
- package/convex/components/campaigns/functions/ingestionInternal.ts +3 -3
- package/convex/components/campaigns/functions/mutations.ts +287 -66
- package/convex/components/campaigns/functions/queries.ts +32 -21
- package/convex/components/campaigns/functions/tickProcessor.ts +0 -345
- package/convex/components/campaigns/permissions.ts +1 -2
- package/convex/components/campaigns/ports/callCenter.ts +2 -2
- package/convex/components/campaigns/ports/consent.ts +15 -23
- package/convex/components/campaigns/ports/messaging.ts +2 -2
- package/convex/components/campaigns/ports/patientData.ts +60 -36
- package/convex/components/campaigns/schema.ts +11 -11
- package/package.json +1 -1
package/convex/campaigns.ts
CHANGED
|
@@ -19,7 +19,7 @@ export const ingestCallOutcome: any = action({
|
|
|
19
19
|
idempotencyKey: v.string(),
|
|
20
20
|
campaignId: v.id("campaigns"),
|
|
21
21
|
snapshotId: v.id("audience_snapshots"),
|
|
22
|
-
patientId: v.
|
|
22
|
+
patientId: v.string(),
|
|
23
23
|
stepId: v.id("campaign_steps"),
|
|
24
24
|
outcome: v.union(v.literal("COMPLETED"), v.literal("FAILED")),
|
|
25
25
|
notes: v.optional(v.string()),
|
|
@@ -44,7 +44,7 @@ export const ingestAppointmentBooked: any = action({
|
|
|
44
44
|
appointmentId: v.optional(v.string()),
|
|
45
45
|
campaignId: v.optional(v.id("campaigns")),
|
|
46
46
|
snapshotId: v.optional(v.id("audience_snapshots")),
|
|
47
|
-
patientId: v.
|
|
47
|
+
patientId: v.string(),
|
|
48
48
|
releaseLock: v.optional(v.boolean()),
|
|
49
49
|
}),
|
|
50
50
|
},
|
|
@@ -66,7 +66,7 @@ export const ingestDeliveryStatus: any = action({
|
|
|
66
66
|
idempotencyKey: v.string(),
|
|
67
67
|
campaignId: v.id("campaigns"),
|
|
68
68
|
snapshotId: v.id("audience_snapshots"),
|
|
69
|
-
patientId: v.
|
|
69
|
+
patientId: v.string(),
|
|
70
70
|
stepId: v.id("campaign_steps"),
|
|
71
71
|
status: v.union(v.literal("DELIVERED"), v.literal("FAILED")),
|
|
72
72
|
providerMessageId: v.optional(v.string()),
|
|
@@ -17,7 +17,7 @@ export const ingestCallOutcomeInternal = internalMutation({
|
|
|
17
17
|
idempotencyKey: v.string(),
|
|
18
18
|
campaignId: v.id("campaigns"),
|
|
19
19
|
snapshotId: v.id("audience_snapshots"),
|
|
20
|
-
patientId: v.
|
|
20
|
+
patientId: v.string(),
|
|
21
21
|
stepId: v.id("campaign_steps"),
|
|
22
22
|
outcome: v.union(v.literal("COMPLETED"), v.literal("FAILED")),
|
|
23
23
|
notes: v.optional(v.string()),
|
|
@@ -64,7 +64,7 @@ export const ingestAppointmentBookedInternal = internalMutation({
|
|
|
64
64
|
appointmentId: v.optional(v.string()),
|
|
65
65
|
campaignId: v.optional(v.id("campaigns")),
|
|
66
66
|
snapshotId: v.optional(v.id("audience_snapshots")),
|
|
67
|
-
patientId: v.
|
|
67
|
+
patientId: v.string(),
|
|
68
68
|
releaseLock: v.optional(v.boolean()),
|
|
69
69
|
}),
|
|
70
70
|
},
|
|
@@ -117,7 +117,7 @@ export const ingestDeliveryStatusInternal = internalMutation({
|
|
|
117
117
|
idempotencyKey: v.string(),
|
|
118
118
|
campaignId: v.id("campaigns"),
|
|
119
119
|
snapshotId: v.id("audience_snapshots"),
|
|
120
|
-
patientId: v.
|
|
120
|
+
patientId: v.string(),
|
|
121
121
|
stepId: v.id("campaign_steps"),
|
|
122
122
|
status: v.union(v.literal("DELIVERED"), v.literal("FAILED")),
|
|
123
123
|
providerMessageId: v.optional(v.string()),
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { v } from "convex/values";
|
|
2
2
|
import { mutation } from "../../../_generated/server";
|
|
3
|
+
import type { Doc } from "../../../_generated/dataModel";
|
|
3
4
|
import { assertAuthorized } from "../permissions";
|
|
4
5
|
import {
|
|
5
6
|
campaignStatusValidator,
|
|
@@ -9,10 +10,8 @@ import {
|
|
|
9
10
|
stepTypeValidator,
|
|
10
11
|
DEFAULT_RUN_CONFIG,
|
|
11
12
|
} from "../domain/types";
|
|
12
|
-
import * as consentPort from "../ports/consent";
|
|
13
|
-
import * as patientDataPort from "../ports/patientData";
|
|
14
13
|
|
|
15
|
-
const clinicScopeIdValidator = v.
|
|
14
|
+
const clinicScopeIdValidator = v.string();
|
|
16
15
|
|
|
17
16
|
function withDefaultRunConfig(input?: {
|
|
18
17
|
capWindowDays?: number;
|
|
@@ -42,7 +41,7 @@ export const createCampaign = mutation({
|
|
|
42
41
|
input: v.object({
|
|
43
42
|
name: v.string(),
|
|
44
43
|
description: v.optional(v.string()),
|
|
45
|
-
ownerUserId: v.optional(v.
|
|
44
|
+
ownerUserId: v.optional(v.string()),
|
|
46
45
|
scopeType: v.string(),
|
|
47
46
|
scopeClinicIds: v.array(clinicScopeIdValidator),
|
|
48
47
|
priority: v.number(),
|
|
@@ -70,7 +69,7 @@ export const updateCampaign = mutation({
|
|
|
70
69
|
patch: v.object({
|
|
71
70
|
name: v.optional(v.string()),
|
|
72
71
|
description: v.optional(v.string()),
|
|
73
|
-
ownerUserId: v.optional(v.
|
|
72
|
+
ownerUserId: v.optional(v.string()),
|
|
74
73
|
scopeType: v.optional(v.string()),
|
|
75
74
|
scopeClinicIds: v.optional(v.array(clinicScopeIdValidator)),
|
|
76
75
|
priority: v.optional(v.number()),
|
|
@@ -168,68 +167,10 @@ export const previewAudience = mutation({
|
|
|
168
167
|
},
|
|
169
168
|
handler: async (ctx, args) => {
|
|
170
169
|
await assertAuthorized(ctx, ["Admin", "Marketing"], { trustedUserId: args._trustedUserId });
|
|
171
|
-
const campaign = await ctx.db.get(args.campaignId);
|
|
172
|
-
if (!campaign) throw new Error("Campaign not found");
|
|
173
|
-
|
|
174
|
-
const sampleSize = Math.min(Math.max(1, args.sampleSize ?? 10), 50);
|
|
175
|
-
const sample: Array<{
|
|
176
|
-
patientId: string;
|
|
177
|
-
firstName?: string;
|
|
178
|
-
lastName?: string;
|
|
179
|
-
birthDate?: string;
|
|
180
|
-
gender?: string;
|
|
181
|
-
residenceCityName?: string;
|
|
182
|
-
email?: string;
|
|
183
|
-
phone?: string;
|
|
184
|
-
}> = [];
|
|
185
|
-
|
|
186
|
-
let cursor: string | null = null;
|
|
187
|
-
let total = 0;
|
|
188
|
-
let withEmail = 0;
|
|
189
|
-
let withPhone = 0;
|
|
190
|
-
let blacklisted = 0;
|
|
191
|
-
const maxPages = 3;
|
|
192
|
-
let pages = 0;
|
|
193
|
-
|
|
194
|
-
while (pages < maxPages && sample.length < sampleSize) {
|
|
195
|
-
const page = await patientDataPort.queryCandidates(
|
|
196
|
-
ctx,
|
|
197
|
-
{ scopeType: campaign.scopeType, scopeClinicIds: campaign.scopeClinicIds },
|
|
198
|
-
args.segmentationRules as any,
|
|
199
|
-
cursor,
|
|
200
|
-
{ includeAnagrafica: true },
|
|
201
|
-
);
|
|
202
|
-
for (const p of page.patients) {
|
|
203
|
-
total += 1;
|
|
204
|
-
const consent = await consentPort.getConsent(ctx, p.patientId);
|
|
205
|
-
if (p.email && consent.email) withEmail += 1;
|
|
206
|
-
if (p.phone && consent.phone) withPhone += 1;
|
|
207
|
-
if (consent.blacklisted) blacklisted += 1;
|
|
208
|
-
|
|
209
|
-
if (sample.length < sampleSize) {
|
|
210
|
-
sample.push({
|
|
211
|
-
patientId: p.patientId,
|
|
212
|
-
firstName: p.firstName,
|
|
213
|
-
lastName: p.lastName,
|
|
214
|
-
birthDate: p.birthDate,
|
|
215
|
-
gender: p.gender,
|
|
216
|
-
residenceCityName: p.residenceCityName,
|
|
217
|
-
email: p.email,
|
|
218
|
-
phone: p.phone,
|
|
219
|
-
});
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
cursor = page.nextCursor;
|
|
223
|
-
pages += 1;
|
|
224
|
-
if (!cursor) break;
|
|
225
|
-
}
|
|
226
|
-
|
|
227
170
|
return {
|
|
228
|
-
counts: { total, withEmail, withPhone, blacklisted },
|
|
229
|
-
warnings:
|
|
230
|
-
|
|
231
|
-
: [],
|
|
232
|
-
sample,
|
|
171
|
+
counts: { total: 0, withEmail: 0, withPhone: 0, blacklisted: 0 },
|
|
172
|
+
warnings: ["Preview is now handled by the host application. This endpoint is deprecated."],
|
|
173
|
+
sample: [],
|
|
233
174
|
};
|
|
234
175
|
},
|
|
235
176
|
});
|
|
@@ -271,3 +212,283 @@ export const buildAudienceSnapshot = mutation({
|
|
|
271
212
|
return { snapshotId };
|
|
272
213
|
},
|
|
273
214
|
});
|
|
215
|
+
|
|
216
|
+
export const ingestAudienceMembers = mutation({
|
|
217
|
+
args: {
|
|
218
|
+
...trustedUserIdArg,
|
|
219
|
+
snapshotId: v.id("audience_snapshots"),
|
|
220
|
+
members: v.array(
|
|
221
|
+
v.object({
|
|
222
|
+
patientId: v.string(),
|
|
223
|
+
clinicId: v.optional(v.string()),
|
|
224
|
+
email: v.optional(v.string()),
|
|
225
|
+
phone: v.optional(v.string()),
|
|
226
|
+
consent: v.object({
|
|
227
|
+
email: v.boolean(),
|
|
228
|
+
sms: v.boolean(),
|
|
229
|
+
phone: v.boolean(),
|
|
230
|
+
blacklisted: v.boolean(),
|
|
231
|
+
}),
|
|
232
|
+
}),
|
|
233
|
+
),
|
|
234
|
+
},
|
|
235
|
+
handler: async (ctx, args) => {
|
|
236
|
+
await assertAuthorized(ctx, ["Admin", "Marketing", "System"], {
|
|
237
|
+
trustedUserId: args._trustedUserId,
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
const snapshot = await ctx.db.get(args.snapshotId);
|
|
241
|
+
if (!snapshot || snapshot.status !== "BUILDING") {
|
|
242
|
+
throw new Error("Snapshot not found or not in BUILDING status");
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const campaign = await ctx.db.get(snapshot.campaignId);
|
|
246
|
+
if (!campaign) throw new Error("Campaign not found");
|
|
247
|
+
|
|
248
|
+
let steps: Doc<"campaign_steps">[];
|
|
249
|
+
try {
|
|
250
|
+
steps = await ctx.db
|
|
251
|
+
.query("campaign_steps")
|
|
252
|
+
.withIndex("by_campaign", (q: any) => q.eq("campaignId", campaign._id))
|
|
253
|
+
.collect();
|
|
254
|
+
} catch {
|
|
255
|
+
steps = (await ctx.db.query("campaign_steps").collect()).filter(
|
|
256
|
+
(s: any) => s.campaignId === campaign._id,
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
const orderedSteps = [...steps].sort((a, b) => a.order - b.order);
|
|
260
|
+
|
|
261
|
+
const runConfig = withDefaultRunConfig(snapshot.runConfig);
|
|
262
|
+
const now = Date.now();
|
|
263
|
+
|
|
264
|
+
let deltaTotal = 0;
|
|
265
|
+
let deltaEligible = 0;
|
|
266
|
+
let deltaQueued = 0;
|
|
267
|
+
const suppressedByReason: Record<string, number> = {};
|
|
268
|
+
|
|
269
|
+
for (const member of args.members) {
|
|
270
|
+
deltaTotal += 1;
|
|
271
|
+
|
|
272
|
+
let existingMember;
|
|
273
|
+
try {
|
|
274
|
+
existingMember = await ctx.db
|
|
275
|
+
.query("audience_members")
|
|
276
|
+
.withIndex("by_snapshot_patient", (q: any) =>
|
|
277
|
+
q.eq("snapshotId", snapshot._id).eq("patientId", member.patientId),
|
|
278
|
+
)
|
|
279
|
+
.first();
|
|
280
|
+
} catch {
|
|
281
|
+
existingMember = (await ctx.db.query("audience_members").collect()).find(
|
|
282
|
+
(m: any) => m.snapshotId === snapshot._id && m.patientId === member.patientId,
|
|
283
|
+
) ?? null;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (!existingMember) {
|
|
287
|
+
await ctx.db.insert("audience_members", {
|
|
288
|
+
snapshotId: snapshot._id,
|
|
289
|
+
campaignId: campaign._id,
|
|
290
|
+
patientId: member.patientId,
|
|
291
|
+
clinicId: member.clinicId,
|
|
292
|
+
contactRefs: { email: member.email, phone: member.phone },
|
|
293
|
+
eligibility: member.consent,
|
|
294
|
+
createdAt: now,
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
let activeLock;
|
|
299
|
+
try {
|
|
300
|
+
activeLock = await ctx.db
|
|
301
|
+
.query("contact_locks")
|
|
302
|
+
.withIndex("by_patient", (q: any) => q.eq("patientId", member.patientId))
|
|
303
|
+
.first();
|
|
304
|
+
} catch {
|
|
305
|
+
activeLock = (await ctx.db.query("contact_locks").collect()).find(
|
|
306
|
+
(l: any) => l.patientId === member.patientId,
|
|
307
|
+
) ?? null;
|
|
308
|
+
}
|
|
309
|
+
const lockedByOther =
|
|
310
|
+
Boolean(activeLock) &&
|
|
311
|
+
(activeLock?.until ?? 0) > now &&
|
|
312
|
+
activeLock?.lockedByCampaignId !== campaign._id;
|
|
313
|
+
|
|
314
|
+
let events;
|
|
315
|
+
try {
|
|
316
|
+
events = await ctx.db
|
|
317
|
+
.query("event_log")
|
|
318
|
+
.withIndex("by_patient_createdAt", (q: any) =>
|
|
319
|
+
q.eq("patientId", member.patientId).gte("createdAt", now - runConfig.capWindowDays * 86_400_000),
|
|
320
|
+
)
|
|
321
|
+
.collect();
|
|
322
|
+
} catch {
|
|
323
|
+
events = (await ctx.db.query("event_log").collect()).filter(
|
|
324
|
+
(e: any) => e.patientId === member.patientId && e.createdAt >= now - runConfig.capWindowDays * 86_400_000,
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
const contactCount = events.filter(
|
|
328
|
+
(e: any) =>
|
|
329
|
+
e.type === "message_sent" ||
|
|
330
|
+
e.type === "message_queued" ||
|
|
331
|
+
e.type === "call_task_created",
|
|
332
|
+
).length;
|
|
333
|
+
const capExceeded = contactCount >= runConfig.capMaxContacts;
|
|
334
|
+
|
|
335
|
+
let hasEligibleStep = false;
|
|
336
|
+
for (const step of orderedSteps) {
|
|
337
|
+
let suppression: string | null = null;
|
|
338
|
+
if (lockedByOther) suppression = "LOCKED";
|
|
339
|
+
else if (capExceeded) suppression = "CAP";
|
|
340
|
+
else if (member.consent.blacklisted) suppression = "BLACKLIST";
|
|
341
|
+
else if (step.type === "EMAIL" && !member.consent.email) suppression = "NO_CONSENT";
|
|
342
|
+
else if (step.type === "EMAIL" && !member.email) suppression = "INVALID_CONTACT";
|
|
343
|
+
else if (step.type === "SMS" && !member.consent.sms) suppression = "NO_CONSENT";
|
|
344
|
+
else if (step.type === "SMS" && !member.phone) suppression = "INVALID_CONTACT";
|
|
345
|
+
else if ((step.type === "CALL_CENTER" || step.type === "CALL_CLINIC") && !member.consent.phone)
|
|
346
|
+
suppression = "NO_CONSENT";
|
|
347
|
+
else if ((step.type === "CALL_CENTER" || step.type === "CALL_CLINIC") && !member.phone)
|
|
348
|
+
suppression = "INVALID_CONTACT";
|
|
349
|
+
|
|
350
|
+
if (suppression) {
|
|
351
|
+
suppressedByReason[suppression] = (suppressedByReason[suppression] ?? 0) + 1;
|
|
352
|
+
|
|
353
|
+
let existingRSSup;
|
|
354
|
+
try {
|
|
355
|
+
existingRSSup = await ctx.db
|
|
356
|
+
.query("recipient_states")
|
|
357
|
+
.withIndex("by_snapshot_patient_step", (q: any) =>
|
|
358
|
+
q
|
|
359
|
+
.eq("snapshotId", snapshot._id)
|
|
360
|
+
.eq("patientId", member.patientId)
|
|
361
|
+
.eq("stepId", step._id),
|
|
362
|
+
)
|
|
363
|
+
.first();
|
|
364
|
+
} catch {
|
|
365
|
+
existingRSSup = (await ctx.db.query("recipient_states").collect()).find(
|
|
366
|
+
(r: any) => r.snapshotId === snapshot._id && r.patientId === member.patientId && r.stepId === step._id,
|
|
367
|
+
) ?? null;
|
|
368
|
+
}
|
|
369
|
+
if (!existingRSSup) {
|
|
370
|
+
await ctx.db.insert("recipient_states", {
|
|
371
|
+
snapshotId: snapshot._id,
|
|
372
|
+
campaignId: campaign._id,
|
|
373
|
+
patientId: member.patientId,
|
|
374
|
+
stepId: step._id,
|
|
375
|
+
state: "SUPPRESSED",
|
|
376
|
+
reason: suppression,
|
|
377
|
+
createdAt: now,
|
|
378
|
+
updatedAt: now,
|
|
379
|
+
lastEventAt: now,
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const dueAt =
|
|
386
|
+
step.scheduleSpec.mode === "AT" && step.scheduleSpec.at
|
|
387
|
+
? step.scheduleSpec.at
|
|
388
|
+
: step.scheduleSpec.mode === "DELAY"
|
|
389
|
+
? snapshot.createdAt + (step.scheduleSpec.delayMinutes ?? 0) * 60_000
|
|
390
|
+
: now;
|
|
391
|
+
const nextState = dueAt <= now ? "QUEUED" : "ELIGIBLE";
|
|
392
|
+
if (nextState === "QUEUED") deltaQueued += 1;
|
|
393
|
+
hasEligibleStep = true;
|
|
394
|
+
|
|
395
|
+
let existingRSElig;
|
|
396
|
+
try {
|
|
397
|
+
existingRSElig = await ctx.db
|
|
398
|
+
.query("recipient_states")
|
|
399
|
+
.withIndex("by_snapshot_patient_step", (q: any) =>
|
|
400
|
+
q
|
|
401
|
+
.eq("snapshotId", snapshot._id)
|
|
402
|
+
.eq("patientId", member.patientId)
|
|
403
|
+
.eq("stepId", step._id),
|
|
404
|
+
)
|
|
405
|
+
.first();
|
|
406
|
+
} catch {
|
|
407
|
+
existingRSElig = (await ctx.db.query("recipient_states").collect()).find(
|
|
408
|
+
(r: any) => r.snapshotId === snapshot._id && r.patientId === member.patientId && r.stepId === step._id,
|
|
409
|
+
) ?? null;
|
|
410
|
+
}
|
|
411
|
+
if (!existingRSElig) {
|
|
412
|
+
await ctx.db.insert("recipient_states", {
|
|
413
|
+
snapshotId: snapshot._id,
|
|
414
|
+
campaignId: campaign._id,
|
|
415
|
+
patientId: member.patientId,
|
|
416
|
+
stepId: step._id,
|
|
417
|
+
state: nextState,
|
|
418
|
+
dueAt,
|
|
419
|
+
createdAt: now,
|
|
420
|
+
updatedAt: now,
|
|
421
|
+
lastEventAt: now,
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
if (hasEligibleStep && !lockedByOther) {
|
|
427
|
+
deltaEligible += 1;
|
|
428
|
+
const until = now + runConfig.lockDays * 86_400_000;
|
|
429
|
+
if (!activeLock) {
|
|
430
|
+
await ctx.db.insert("contact_locks", {
|
|
431
|
+
patientId: member.patientId,
|
|
432
|
+
lockedByCampaignId: campaign._id,
|
|
433
|
+
until,
|
|
434
|
+
reason: "campaign_snapshot",
|
|
435
|
+
createdAt: now,
|
|
436
|
+
updatedAt: now,
|
|
437
|
+
});
|
|
438
|
+
} else if (activeLock.lockedByCampaignId === campaign._id) {
|
|
439
|
+
await ctx.db.patch(activeLock._id, { until, updatedAt: now });
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const mergedSuppressed: Record<string, number> = { ...(snapshot.stats?.suppressedByReason ?? {}) };
|
|
445
|
+
for (const [reason, count] of Object.entries(suppressedByReason)) {
|
|
446
|
+
mergedSuppressed[reason] = (mergedSuppressed[reason] ?? 0) + count;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
await ctx.db.patch(snapshot._id, {
|
|
450
|
+
updatedAt: now,
|
|
451
|
+
stats: {
|
|
452
|
+
...snapshot.stats,
|
|
453
|
+
totalCandidates: (snapshot.stats?.totalCandidates ?? 0) + deltaTotal,
|
|
454
|
+
eligible: (snapshot.stats?.eligible ?? 0) + deltaEligible,
|
|
455
|
+
queued: (snapshot.stats?.queued ?? 0) + deltaQueued,
|
|
456
|
+
suppressedByReason: mergedSuppressed,
|
|
457
|
+
},
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
return { ingested: deltaTotal, eligible: deltaEligible, queued: deltaQueued };
|
|
461
|
+
},
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
export const sealSnapshot = mutation({
|
|
465
|
+
args: {
|
|
466
|
+
...trustedUserIdArg,
|
|
467
|
+
snapshotId: v.id("audience_snapshots"),
|
|
468
|
+
},
|
|
469
|
+
handler: async (ctx, args) => {
|
|
470
|
+
await assertAuthorized(ctx, ["Admin", "Marketing", "System"], {
|
|
471
|
+
trustedUserId: args._trustedUserId,
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
const snapshot = await ctx.db.get(args.snapshotId);
|
|
475
|
+
if (!snapshot) throw new Error("Snapshot not found");
|
|
476
|
+
if (snapshot.status !== "BUILDING") throw new Error("Snapshot not in BUILDING status");
|
|
477
|
+
|
|
478
|
+
const now = Date.now();
|
|
479
|
+
await ctx.db.patch(args.snapshotId, {
|
|
480
|
+
status: "READY",
|
|
481
|
+
updatedAt: now,
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
await ctx.db.insert("event_log", {
|
|
485
|
+
campaignId: snapshot.campaignId,
|
|
486
|
+
snapshotId: args.snapshotId,
|
|
487
|
+
type: "audience_snapshot_built",
|
|
488
|
+
payload: { totalCandidates: snapshot.stats?.totalCandidates ?? 0 },
|
|
489
|
+
createdAt: now,
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
return { ok: true };
|
|
493
|
+
},
|
|
494
|
+
});
|
|
@@ -3,7 +3,7 @@ import { query } from "../../../_generated/server";
|
|
|
3
3
|
import { assertAuthorized } from "../permissions";
|
|
4
4
|
import { campaignStatusValidator, scopeTypeValidator } from "../domain/types";
|
|
5
5
|
|
|
6
|
-
const clinicIdFilterValidator = v.
|
|
6
|
+
const clinicIdFilterValidator = v.string();
|
|
7
7
|
const trustedUserIdArg = { _trustedUserId: v.optional(v.string()) };
|
|
8
8
|
|
|
9
9
|
function isRecoverableQueryError(error: unknown): boolean {
|
|
@@ -188,28 +188,39 @@ export const getSnapshotMemberSample = query({
|
|
|
188
188
|
}> = [];
|
|
189
189
|
|
|
190
190
|
for (const m of members) {
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
191
|
+
let patient: any = null;
|
|
192
|
+
try {
|
|
193
|
+
patient = await ctx.db.get(m.patientId);
|
|
194
|
+
} catch {
|
|
195
|
+
// patientId belongs to host DB, not component DB
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (patient) {
|
|
199
|
+
const p = patient as any;
|
|
200
|
+
let residenceCityName: string | undefined;
|
|
201
|
+
if (p.residenceCityId) {
|
|
202
|
+
try {
|
|
203
|
+
const city = await ctx.db.get(p.residenceCityId);
|
|
204
|
+
residenceCityName = (city as any)?.name;
|
|
205
|
+
} catch {}
|
|
201
206
|
}
|
|
207
|
+
sample.push({
|
|
208
|
+
patientId: m.patientId,
|
|
209
|
+
firstName: p.firstName,
|
|
210
|
+
lastName: p.lastName,
|
|
211
|
+
birthDate: p.birthDate,
|
|
212
|
+
gender: p.gender,
|
|
213
|
+
residenceCityName,
|
|
214
|
+
email: m.contactRefs?.email ?? p.email,
|
|
215
|
+
phone: m.contactRefs?.phone ?? p.phone ?? p.mobilePhone,
|
|
216
|
+
});
|
|
217
|
+
} else {
|
|
218
|
+
sample.push({
|
|
219
|
+
patientId: m.patientId,
|
|
220
|
+
email: m.contactRefs?.email,
|
|
221
|
+
phone: m.contactRefs?.phone,
|
|
222
|
+
});
|
|
202
223
|
}
|
|
203
|
-
sample.push({
|
|
204
|
-
patientId: m.patientId,
|
|
205
|
-
firstName: p.firstName,
|
|
206
|
-
lastName: p.lastName,
|
|
207
|
-
birthDate: p.birthDate,
|
|
208
|
-
gender: p.gender,
|
|
209
|
-
residenceCityName,
|
|
210
|
-
email: m.contactRefs?.email ?? p.email,
|
|
211
|
-
phone: m.contactRefs?.phone ?? p.phone ?? p.mobilePhone,
|
|
212
|
-
});
|
|
213
224
|
}
|
|
214
225
|
|
|
215
226
|
return { sample };
|
|
@@ -2,22 +2,9 @@ import { v } from "convex/values";
|
|
|
2
2
|
import { internalMutation, mutation } from "../../../_generated/server";
|
|
3
3
|
import type { Id, Doc } from "../../../_generated/dataModel";
|
|
4
4
|
import { assertAuthorized } from "../permissions";
|
|
5
|
-
import { canTransitionRecipientState } from "../domain/stateMachine";
|
|
6
|
-
import { DEFAULT_RUN_CONFIG, type SuppressionReason } from "../domain/types";
|
|
7
|
-
import * as consentPort from "../ports/consent";
|
|
8
|
-
import * as patientDataPort from "../ports/patientData";
|
|
9
5
|
import * as messagingPort from "../ports/messaging";
|
|
10
6
|
import * as callCenterPort from "../ports/callCenter";
|
|
11
7
|
|
|
12
|
-
function resolveRunConfig(input?: Doc<"audience_snapshots">["runConfig"]) {
|
|
13
|
-
return {
|
|
14
|
-
capWindowDays: input?.capWindowDays ?? DEFAULT_RUN_CONFIG.capWindowDays,
|
|
15
|
-
capMaxContacts: input?.capMaxContacts ?? DEFAULT_RUN_CONFIG.capMaxContacts,
|
|
16
|
-
lockDays: input?.lockDays ?? DEFAULT_RUN_CONFIG.lockDays,
|
|
17
|
-
quietHours: input?.quietHours,
|
|
18
|
-
};
|
|
19
|
-
}
|
|
20
|
-
|
|
21
8
|
async function safeCollectCampaignSteps(ctx: any, campaignId: Id<"campaigns">) {
|
|
22
9
|
try {
|
|
23
10
|
return await ctx.db
|
|
@@ -46,21 +33,6 @@ async function safeCollectSnapshotsByCampaign(ctx: any, campaignId: Id<"campaign
|
|
|
46
33
|
}
|
|
47
34
|
}
|
|
48
35
|
|
|
49
|
-
async function safeFirstSnapshotByStatus(ctx: any, status: "BUILDING" | "READY" | "FAILED") {
|
|
50
|
-
try {
|
|
51
|
-
return await ctx.db
|
|
52
|
-
.query("audience_snapshots")
|
|
53
|
-
.withIndex("by_status", (q: any) => q.eq("status", status))
|
|
54
|
-
.first();
|
|
55
|
-
} catch {
|
|
56
|
-
const docs = await ctx.db
|
|
57
|
-
.query("audience_snapshots")
|
|
58
|
-
.filter((q: any) => q.eq(q.field("status"), status))
|
|
59
|
-
.collect();
|
|
60
|
-
return docs[0] ?? null;
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
36
|
async function safeCollectCampaignsByStatus(
|
|
65
37
|
ctx: any,
|
|
66
38
|
status: "DRAFT" | "RUNNING" | "PAUSED" | "COMPLETED" | "ARCHIVED",
|
|
@@ -78,302 +50,6 @@ async function safeCollectCampaignsByStatus(
|
|
|
78
50
|
}
|
|
79
51
|
}
|
|
80
52
|
|
|
81
|
-
function computeDueAt(
|
|
82
|
-
schedule: Doc<"campaign_steps">["scheduleSpec"],
|
|
83
|
-
baseline: number,
|
|
84
|
-
now: number,
|
|
85
|
-
): number {
|
|
86
|
-
if (schedule.mode === "AT" && schedule.at) return schedule.at;
|
|
87
|
-
if (schedule.mode === "DELAY") {
|
|
88
|
-
return baseline + (schedule.delayMinutes ?? 0) * 60_000;
|
|
89
|
-
}
|
|
90
|
-
return now;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
async function upsertRecipientState(
|
|
94
|
-
ctx: any,
|
|
95
|
-
input: {
|
|
96
|
-
snapshotId: Id<"audience_snapshots">;
|
|
97
|
-
campaignId: Id<"campaigns">;
|
|
98
|
-
patientId: Id<"patients">;
|
|
99
|
-
stepId: Id<"campaign_steps">;
|
|
100
|
-
state: Doc<"recipient_states">["state"];
|
|
101
|
-
reason?: string;
|
|
102
|
-
dueAt?: number;
|
|
103
|
-
},
|
|
104
|
-
) {
|
|
105
|
-
const now = Date.now();
|
|
106
|
-
const existing = await ctx.db
|
|
107
|
-
.query("recipient_states")
|
|
108
|
-
.withIndex("by_snapshot_patient_step", (q: any) =>
|
|
109
|
-
q
|
|
110
|
-
.eq("snapshotId", input.snapshotId)
|
|
111
|
-
.eq("patientId", input.patientId)
|
|
112
|
-
.eq("stepId", input.stepId),
|
|
113
|
-
)
|
|
114
|
-
.first();
|
|
115
|
-
|
|
116
|
-
if (!existing) {
|
|
117
|
-
await ctx.db.insert("recipient_states", {
|
|
118
|
-
...input,
|
|
119
|
-
createdAt: now,
|
|
120
|
-
updatedAt: now,
|
|
121
|
-
lastEventAt: now,
|
|
122
|
-
});
|
|
123
|
-
return;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
if (!canTransitionRecipientState(existing.state as any, input.state as any)) {
|
|
127
|
-
return;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
if (
|
|
131
|
-
existing.state !== input.state ||
|
|
132
|
-
existing.reason !== input.reason ||
|
|
133
|
-
existing.dueAt !== input.dueAt
|
|
134
|
-
) {
|
|
135
|
-
await ctx.db.patch(existing._id, {
|
|
136
|
-
state: input.state,
|
|
137
|
-
reason: input.reason,
|
|
138
|
-
dueAt: input.dueAt,
|
|
139
|
-
updatedAt: now,
|
|
140
|
-
lastEventAt: now,
|
|
141
|
-
});
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
async function countRecentContacts(
|
|
146
|
-
ctx: any,
|
|
147
|
-
patientId: Id<"patients">,
|
|
148
|
-
windowStart: number,
|
|
149
|
-
) {
|
|
150
|
-
const events = await ctx.db
|
|
151
|
-
.query("event_log")
|
|
152
|
-
.withIndex("by_patient_createdAt", (q: any) =>
|
|
153
|
-
q.eq("patientId", patientId).gte("createdAt", windowStart),
|
|
154
|
-
)
|
|
155
|
-
.collect();
|
|
156
|
-
|
|
157
|
-
return events.filter(
|
|
158
|
-
(e: any) =>
|
|
159
|
-
e.type === "message_sent" ||
|
|
160
|
-
e.type === "message_queued" ||
|
|
161
|
-
e.type === "call_task_created",
|
|
162
|
-
).length;
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
async function resolveSuppressionReason(input: {
|
|
166
|
-
lockedByOther: boolean;
|
|
167
|
-
capExceeded: boolean;
|
|
168
|
-
consent: Awaited<ReturnType<typeof consentPort.getConsent>>;
|
|
169
|
-
stepType: Doc<"campaign_steps">["type"];
|
|
170
|
-
hasEmail: boolean;
|
|
171
|
-
hasPhone: boolean;
|
|
172
|
-
}): Promise<SuppressionReason | null> {
|
|
173
|
-
if (input.lockedByOther) return "LOCKED";
|
|
174
|
-
if (input.capExceeded) return "CAP";
|
|
175
|
-
if (input.consent.blacklisted) return "BLACKLIST";
|
|
176
|
-
|
|
177
|
-
if (input.stepType === "EMAIL") {
|
|
178
|
-
if (!input.consent.email) return "NO_CONSENT";
|
|
179
|
-
if (!input.hasEmail) return "INVALID_CONTACT";
|
|
180
|
-
}
|
|
181
|
-
if (input.stepType === "SMS") {
|
|
182
|
-
if (!input.consent.sms) return "NO_CONSENT";
|
|
183
|
-
if (!input.hasPhone) return "INVALID_CONTACT";
|
|
184
|
-
}
|
|
185
|
-
if (input.stepType === "CALL_CENTER" || input.stepType === "CALL_CLINIC") {
|
|
186
|
-
if (!input.consent.phone) return "NO_CONSENT";
|
|
187
|
-
if (!input.hasPhone) return "INVALID_CONTACT";
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
return null;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
async function buildSnapshotChunkCore(
|
|
194
|
-
ctx: any,
|
|
195
|
-
snapshotId: Id<"audience_snapshots">,
|
|
196
|
-
forcedCursor?: string,
|
|
197
|
-
) {
|
|
198
|
-
const snapshot = await ctx.db.get(snapshotId);
|
|
199
|
-
if (!snapshot || snapshot.status !== "BUILDING") return { done: true, nextCursor: null as string | null };
|
|
200
|
-
|
|
201
|
-
const campaign = await ctx.db.get(snapshot.campaignId);
|
|
202
|
-
if (!campaign) {
|
|
203
|
-
await ctx.db.patch(snapshotId, { status: "FAILED", updatedAt: Date.now() });
|
|
204
|
-
return { done: true, nextCursor: null as string | null };
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
const steps = await safeCollectCampaignSteps(ctx, campaign._id);
|
|
208
|
-
const orderedSteps = [...steps].sort((a, b) => a.order - b.order);
|
|
209
|
-
if (orderedSteps.length === 0) {
|
|
210
|
-
await ctx.db.patch(snapshotId, { status: "FAILED", updatedAt: Date.now() });
|
|
211
|
-
return { done: true, nextCursor: null as string | null };
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
const runConfig = resolveRunConfig(snapshot.runConfig);
|
|
215
|
-
const cursor = forcedCursor ?? snapshot.nextCursor ?? null;
|
|
216
|
-
const { patients, nextCursor } = await patientDataPort.queryCandidates(
|
|
217
|
-
ctx,
|
|
218
|
-
{ scopeType: campaign.scopeType, scopeClinicIds: campaign.scopeClinicIds },
|
|
219
|
-
snapshot.segmentationRules as any,
|
|
220
|
-
cursor,
|
|
221
|
-
);
|
|
222
|
-
|
|
223
|
-
const now = Date.now();
|
|
224
|
-
let deltaTotal = 0;
|
|
225
|
-
let deltaEligible = 0;
|
|
226
|
-
let deltaQueued = 0;
|
|
227
|
-
const suppressedByReason: Record<string, number> = {};
|
|
228
|
-
|
|
229
|
-
for (const p of patients) {
|
|
230
|
-
deltaTotal += 1;
|
|
231
|
-
const consent = await consentPort.getConsent(ctx, p.patientId);
|
|
232
|
-
const existingMember = await ctx.db
|
|
233
|
-
.query("audience_members")
|
|
234
|
-
.withIndex("by_snapshot_patient", (q: any) =>
|
|
235
|
-
q.eq("snapshotId", snapshot._id).eq("patientId", p.patientId),
|
|
236
|
-
)
|
|
237
|
-
.first();
|
|
238
|
-
|
|
239
|
-
if (!existingMember) {
|
|
240
|
-
await ctx.db.insert("audience_members", {
|
|
241
|
-
snapshotId: snapshot._id,
|
|
242
|
-
campaignId: campaign._id,
|
|
243
|
-
patientId: p.patientId,
|
|
244
|
-
clinicId: p.clinicId,
|
|
245
|
-
contactRefs: { email: p.email, phone: p.phone },
|
|
246
|
-
eligibility: {
|
|
247
|
-
email: consent.email,
|
|
248
|
-
sms: consent.sms,
|
|
249
|
-
phone: consent.phone,
|
|
250
|
-
blacklisted: consent.blacklisted,
|
|
251
|
-
},
|
|
252
|
-
createdAt: now,
|
|
253
|
-
});
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
const activeLock = await ctx.db
|
|
257
|
-
.query("contact_locks")
|
|
258
|
-
.withIndex("by_patient", (q: any) => q.eq("patientId", p.patientId))
|
|
259
|
-
.first();
|
|
260
|
-
const lockedByOther =
|
|
261
|
-
Boolean(activeLock) &&
|
|
262
|
-
(activeLock?.until ?? 0) > now &&
|
|
263
|
-
activeLock?.lockedByCampaignId !== campaign._id;
|
|
264
|
-
|
|
265
|
-
const contactCount = await countRecentContacts(
|
|
266
|
-
ctx,
|
|
267
|
-
p.patientId,
|
|
268
|
-
now - runConfig.capWindowDays * 24 * 60 * 60 * 1000,
|
|
269
|
-
);
|
|
270
|
-
const capExceeded = contactCount >= runConfig.capMaxContacts;
|
|
271
|
-
|
|
272
|
-
let patientHasAtLeastOneEligibleStep = false;
|
|
273
|
-
for (const step of orderedSteps) {
|
|
274
|
-
const suppression = await resolveSuppressionReason({
|
|
275
|
-
lockedByOther,
|
|
276
|
-
capExceeded,
|
|
277
|
-
consent,
|
|
278
|
-
stepType: step.type,
|
|
279
|
-
hasEmail: Boolean(p.email),
|
|
280
|
-
hasPhone: Boolean(p.phone),
|
|
281
|
-
});
|
|
282
|
-
|
|
283
|
-
if (suppression) {
|
|
284
|
-
suppressedByReason[suppression] = (suppressedByReason[suppression] ?? 0) + 1;
|
|
285
|
-
await upsertRecipientState(ctx, {
|
|
286
|
-
snapshotId: snapshot._id,
|
|
287
|
-
campaignId: campaign._id,
|
|
288
|
-
patientId: p.patientId,
|
|
289
|
-
stepId: step._id,
|
|
290
|
-
state: "SUPPRESSED",
|
|
291
|
-
reason: suppression,
|
|
292
|
-
});
|
|
293
|
-
continue;
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
const dueAt = computeDueAt(step.scheduleSpec, snapshot.createdAt, now);
|
|
297
|
-
const nextState = dueAt <= now ? "QUEUED" : "ELIGIBLE";
|
|
298
|
-
if (nextState === "QUEUED") deltaQueued += 1;
|
|
299
|
-
patientHasAtLeastOneEligibleStep = true;
|
|
300
|
-
|
|
301
|
-
await upsertRecipientState(ctx, {
|
|
302
|
-
snapshotId: snapshot._id,
|
|
303
|
-
campaignId: campaign._id,
|
|
304
|
-
patientId: p.patientId,
|
|
305
|
-
stepId: step._id,
|
|
306
|
-
state: nextState,
|
|
307
|
-
dueAt,
|
|
308
|
-
});
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
if (patientHasAtLeastOneEligibleStep && !lockedByOther) {
|
|
312
|
-
deltaEligible += 1;
|
|
313
|
-
if (!activeLock || activeLock.lockedByCampaignId !== campaign._id) {
|
|
314
|
-
const until = now + runConfig.lockDays * 24 * 60 * 60 * 1000;
|
|
315
|
-
if (!activeLock) {
|
|
316
|
-
await ctx.db.insert("contact_locks", {
|
|
317
|
-
patientId: p.patientId,
|
|
318
|
-
lockedByCampaignId: campaign._id,
|
|
319
|
-
until,
|
|
320
|
-
reason: "campaign_snapshot",
|
|
321
|
-
createdAt: now,
|
|
322
|
-
updatedAt: now,
|
|
323
|
-
});
|
|
324
|
-
} else {
|
|
325
|
-
await ctx.db.patch(activeLock._id, {
|
|
326
|
-
lockedByCampaignId: campaign._id,
|
|
327
|
-
until,
|
|
328
|
-
reason: "campaign_snapshot",
|
|
329
|
-
updatedAt: now,
|
|
330
|
-
});
|
|
331
|
-
}
|
|
332
|
-
}
|
|
333
|
-
} else if (lockedByOther) {
|
|
334
|
-
await ctx.db.insert("event_log", {
|
|
335
|
-
campaignId: campaign._id,
|
|
336
|
-
snapshotId: snapshot._id,
|
|
337
|
-
patientId: p.patientId,
|
|
338
|
-
type: "recipient_suppressed",
|
|
339
|
-
payload: { reason: "LOCKED" },
|
|
340
|
-
createdAt: now,
|
|
341
|
-
});
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
const mergedSuppressed = { ...snapshot.stats.suppressedByReason };
|
|
346
|
-
for (const [reason, count] of Object.entries(suppressedByReason)) {
|
|
347
|
-
mergedSuppressed[reason] = (mergedSuppressed[reason] ?? 0) + count;
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
const finished = nextCursor === null;
|
|
351
|
-
await ctx.db.patch(snapshot._id, {
|
|
352
|
-
nextCursor: nextCursor ?? undefined,
|
|
353
|
-
status: finished ? "READY" : "BUILDING",
|
|
354
|
-
updatedAt: now,
|
|
355
|
-
stats: {
|
|
356
|
-
...snapshot.stats,
|
|
357
|
-
totalCandidates: snapshot.stats.totalCandidates + deltaTotal,
|
|
358
|
-
eligible: snapshot.stats.eligible + deltaEligible,
|
|
359
|
-
queued: snapshot.stats.queued + deltaQueued,
|
|
360
|
-
suppressedByReason: mergedSuppressed,
|
|
361
|
-
},
|
|
362
|
-
});
|
|
363
|
-
|
|
364
|
-
if (finished) {
|
|
365
|
-
await ctx.db.insert("event_log", {
|
|
366
|
-
campaignId: campaign._id,
|
|
367
|
-
snapshotId: snapshot._id,
|
|
368
|
-
type: "audience_snapshot_built",
|
|
369
|
-
payload: { totalCandidates: deltaTotal },
|
|
370
|
-
createdAt: now,
|
|
371
|
-
});
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
return { done: finished, nextCursor };
|
|
375
|
-
}
|
|
376
|
-
|
|
377
53
|
async function enqueueStepJobsCore(
|
|
378
54
|
ctx: any,
|
|
379
55
|
snapshotId: Id<"audience_snapshots">,
|
|
@@ -498,17 +174,6 @@ async function enqueueStepJobsCore(
|
|
|
498
174
|
return { nextCursor: next < due.length ? String(next) : null, processed: batch.length };
|
|
499
175
|
}
|
|
500
176
|
|
|
501
|
-
export const _buildSnapshotChunk = internalMutation({
|
|
502
|
-
args: {
|
|
503
|
-
snapshotId: v.id("audience_snapshots"),
|
|
504
|
-
cursor: v.optional(v.string()),
|
|
505
|
-
},
|
|
506
|
-
handler: async (ctx, args) => {
|
|
507
|
-
await assertAuthorized(ctx, ["Admin", "Marketing", "System"]);
|
|
508
|
-
return buildSnapshotChunkCore(ctx as any, args.snapshotId, args.cursor);
|
|
509
|
-
},
|
|
510
|
-
});
|
|
511
|
-
|
|
512
177
|
export const _enqueueStepJobs = internalMutation({
|
|
513
178
|
args: {
|
|
514
179
|
snapshotId: v.id("audience_snapshots"),
|
|
@@ -521,13 +186,7 @@ export const _enqueueStepJobs = internalMutation({
|
|
|
521
186
|
},
|
|
522
187
|
});
|
|
523
188
|
|
|
524
|
-
/** Logica condivisa del tick, usata da _runTickInternal e runTickMutation. */
|
|
525
189
|
async function runTickCore(ctx: any) {
|
|
526
|
-
const buildingSnapshot = await safeFirstSnapshotByStatus(ctx, "BUILDING");
|
|
527
|
-
if (buildingSnapshot) {
|
|
528
|
-
await buildSnapshotChunkCore(ctx, buildingSnapshot._id);
|
|
529
|
-
}
|
|
530
|
-
|
|
531
190
|
const runningCampaigns = await safeCollectCampaignsByStatus(ctx, "RUNNING");
|
|
532
191
|
|
|
533
192
|
for (const campaign of runningCampaigns.slice(0, 10)) {
|
|
@@ -556,10 +215,6 @@ export const _runTickInternal = internalMutation({
|
|
|
556
215
|
},
|
|
557
216
|
});
|
|
558
217
|
|
|
559
|
-
/**
|
|
560
|
-
* Mutation pubblica per eseguire il tick. Rinominato da internal.ts a tickProcessor.ts
|
|
561
|
-
* per evitare conflitto con il namespace "internal" riservato di Convex.
|
|
562
|
-
*/
|
|
563
218
|
export const runTickMutation = mutation({
|
|
564
219
|
args: {},
|
|
565
220
|
handler: async (ctx) => {
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import type { Id } from "../../_generated/dataModel";
|
|
2
1
|
import { internal } from "../../_generated/api";
|
|
3
2
|
|
|
4
3
|
type Role = "Admin" | "Marketing" | "System";
|
|
@@ -82,7 +81,7 @@ async function resolveCurrentUserWithDb(ctx: AnyCtx) {
|
|
|
82
81
|
return null;
|
|
83
82
|
}
|
|
84
83
|
|
|
85
|
-
async function getUserRoleCodes(ctx: AnyCtx, userId:
|
|
84
|
+
async function getUserRoleCodes(ctx: AnyCtx, userId: string): Promise<Set<string>> {
|
|
86
85
|
if (!ctx.db) return new Set<string>();
|
|
87
86
|
const roles = new Set<string>();
|
|
88
87
|
|
|
@@ -4,8 +4,8 @@ import type { Id } from "../../../_generated/dataModel";
|
|
|
4
4
|
export async function createOutboxTasks(
|
|
5
5
|
ctx: MutationCtx,
|
|
6
6
|
tasks: Array<{
|
|
7
|
-
patientId:
|
|
8
|
-
clinicId?:
|
|
7
|
+
patientId: string;
|
|
8
|
+
clinicId?: string;
|
|
9
9
|
campaignId: Id<"campaigns">;
|
|
10
10
|
snapshotId: Id<"audience_snapshots">;
|
|
11
11
|
stepId: Id<"campaign_steps">;
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import type { QueryCtx, MutationCtx } from "../../../_generated/server";
|
|
2
|
-
import type { Id } from "../../../_generated/dataModel";
|
|
3
2
|
|
|
4
3
|
export type ConsentResult = {
|
|
5
4
|
email: boolean;
|
|
@@ -10,28 +9,21 @@ export type ConsentResult = {
|
|
|
10
9
|
|
|
11
10
|
export async function getConsent(
|
|
12
11
|
ctx: QueryCtx | MutationCtx,
|
|
13
|
-
patientId:
|
|
12
|
+
patientId: string,
|
|
14
13
|
): Promise<ConsentResult> {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
return { email:
|
|
14
|
+
try {
|
|
15
|
+
const patient = await ctx.db.get(patientId as any);
|
|
16
|
+
if (!patient) {
|
|
17
|
+
return { email: true, sms: true, phone: true, blacklisted: false };
|
|
18
|
+
}
|
|
19
|
+
const p = patient as any;
|
|
20
|
+
return {
|
|
21
|
+
email: p?.consentEmail ?? Boolean(patient.email),
|
|
22
|
+
sms: p?.consentSms ?? Boolean(patient.phone),
|
|
23
|
+
phone: p?.consentPhone ?? Boolean(patient.phone),
|
|
24
|
+
blacklisted: p?.blacklisted ?? false,
|
|
25
|
+
};
|
|
26
|
+
} catch {
|
|
27
|
+
return { email: true, sms: true, phone: true, blacklisted: false };
|
|
29
28
|
}
|
|
30
|
-
|
|
31
|
-
return {
|
|
32
|
-
email: p?.consentEmail ?? Boolean(patient.email),
|
|
33
|
-
sms: p?.consentSms ?? Boolean(patient.phone),
|
|
34
|
-
phone: p?.consentPhone ?? Boolean(patient.phone),
|
|
35
|
-
blacklisted: p?.blacklisted ?? false,
|
|
36
|
-
};
|
|
37
29
|
}
|
|
@@ -6,7 +6,7 @@ export async function enqueueEmail(
|
|
|
6
6
|
input: {
|
|
7
7
|
campaignId: Id<"campaigns">;
|
|
8
8
|
snapshotId: Id<"audience_snapshots">;
|
|
9
|
-
patientId:
|
|
9
|
+
patientId: string;
|
|
10
10
|
stepId: Id<"campaign_steps">;
|
|
11
11
|
dedupeKey: string;
|
|
12
12
|
payload: Record<string, any>;
|
|
@@ -37,7 +37,7 @@ export async function enqueueSms(
|
|
|
37
37
|
input: {
|
|
38
38
|
campaignId: Id<"campaigns">;
|
|
39
39
|
snapshotId: Id<"audience_snapshots">;
|
|
40
|
-
patientId:
|
|
40
|
+
patientId: string;
|
|
41
41
|
stepId: Id<"campaign_steps">;
|
|
42
42
|
dedupeKey: string;
|
|
43
43
|
payload: Record<string, any>;
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import type { QueryCtx, MutationCtx } from "../../../_generated/server";
|
|
2
|
-
import type { Doc, Id } from "../../../_generated/dataModel";
|
|
3
2
|
|
|
4
3
|
export type CampaignScope = {
|
|
5
4
|
scopeType: string;
|
|
6
|
-
scopeClinicIds:
|
|
5
|
+
scopeClinicIds: string[];
|
|
7
6
|
};
|
|
8
7
|
|
|
9
8
|
export type SegmentationRules = {
|
|
@@ -12,8 +11,8 @@ export type SegmentationRules = {
|
|
|
12
11
|
};
|
|
13
12
|
|
|
14
13
|
export type CandidatePatient = {
|
|
15
|
-
patientId:
|
|
16
|
-
clinicId?:
|
|
14
|
+
patientId: string;
|
|
15
|
+
clinicId?: string;
|
|
17
16
|
email?: string;
|
|
18
17
|
phone?: string;
|
|
19
18
|
firstName?: string;
|
|
@@ -26,7 +25,7 @@ export type CandidatePatient = {
|
|
|
26
25
|
const DEFAULT_PAGE_SIZE = 200;
|
|
27
26
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
28
27
|
|
|
29
|
-
type AppointmentDoc =
|
|
28
|
+
type AppointmentDoc = any;
|
|
30
29
|
|
|
31
30
|
/** Calcola età in anni da birthDate (formato "YYYY-MM-DD" o simile) */
|
|
32
31
|
function ageFromBirthDate(birthDate: string | undefined): number | null {
|
|
@@ -93,7 +92,7 @@ async function resolveResidenceCityName(
|
|
|
93
92
|
|
|
94
93
|
function buildCandidatePatient(
|
|
95
94
|
patient: any,
|
|
96
|
-
clinicId:
|
|
95
|
+
clinicId: string | undefined,
|
|
97
96
|
residenceCityName?: string,
|
|
98
97
|
): CandidatePatient {
|
|
99
98
|
return {
|
|
@@ -112,7 +111,7 @@ function buildCandidatePatient(
|
|
|
112
111
|
async function buildCandidateWithAnagrafica(
|
|
113
112
|
ctx: QueryCtx | MutationCtx,
|
|
114
113
|
patient: any,
|
|
115
|
-
clinicId:
|
|
114
|
+
clinicId: string | undefined,
|
|
116
115
|
): Promise<CandidatePatient> {
|
|
117
116
|
const residenceCityName = patient.residenceCityId
|
|
118
117
|
? await resolveResidenceCityName(ctx, patient.residenceCityId)
|
|
@@ -122,9 +121,9 @@ async function buildCandidateWithAnagrafica(
|
|
|
122
121
|
|
|
123
122
|
async function deriveClinicIdFromAppointments(
|
|
124
123
|
ctx: QueryCtx | MutationCtx,
|
|
125
|
-
patient:
|
|
124
|
+
patient: any,
|
|
126
125
|
scope: CampaignScope,
|
|
127
|
-
): Promise<
|
|
126
|
+
): Promise<string | undefined> {
|
|
128
127
|
const appointments = await ctx.db
|
|
129
128
|
.query("appointments")
|
|
130
129
|
.withIndex("by_patient", (q) => q.eq("patientId", patient._id))
|
|
@@ -143,7 +142,7 @@ function isAppointmentActive(appointment: AppointmentDoc): boolean {
|
|
|
143
142
|
|
|
144
143
|
function isInScope(
|
|
145
144
|
scope: CampaignScope,
|
|
146
|
-
clinicId:
|
|
145
|
+
clinicId: string | undefined,
|
|
147
146
|
): boolean {
|
|
148
147
|
if (scope.scopeType === "HQ") return true;
|
|
149
148
|
if (!clinicId) return false;
|
|
@@ -153,8 +152,8 @@ function isInScope(
|
|
|
153
152
|
async function resolveAppointmentTypeIdsByCodes(
|
|
154
153
|
ctx: QueryCtx | MutationCtx,
|
|
155
154
|
codes: string[],
|
|
156
|
-
): Promise<Set<
|
|
157
|
-
const ids = new Set<
|
|
155
|
+
): Promise<Set<string>> {
|
|
156
|
+
const ids = new Set<string>();
|
|
158
157
|
for (const code of codes) {
|
|
159
158
|
const typeDoc = await ctx.db
|
|
160
159
|
.query("appointment_types")
|
|
@@ -174,16 +173,23 @@ async function queryNoShowCandidates(
|
|
|
174
173
|
): Promise<{ patients: CandidatePatient[]; nextCursor: string | null }> {
|
|
175
174
|
const daysBack = typeof params?.daysBack === "number" ? params.daysBack : 90;
|
|
176
175
|
const cutoff = Date.now() - daysBack * DAY_MS;
|
|
177
|
-
|
|
176
|
+
|
|
177
|
+
let q = ctx.db
|
|
178
178
|
.query("patients")
|
|
179
|
-
.withIndex("by_status", (q) => q.eq("status", "active"))
|
|
180
|
-
|
|
179
|
+
.withIndex("by_status", (q: any) => q.eq("status", "active"));
|
|
180
|
+
if (paginationCursor) {
|
|
181
|
+
const cursorTime = Number(paginationCursor);
|
|
182
|
+
q = q.filter((q: any) => q.gt(q.field("_creationTime"), cursorTime));
|
|
183
|
+
}
|
|
184
|
+
const allItems = await q.take(DEFAULT_PAGE_SIZE + 1);
|
|
185
|
+
const hasMore = allItems.length > DEFAULT_PAGE_SIZE;
|
|
186
|
+
const pageItems = hasMore ? allItems.slice(0, DEFAULT_PAGE_SIZE) : allItems;
|
|
181
187
|
|
|
182
188
|
const patients: CandidatePatient[] = [];
|
|
183
|
-
for (const patient of
|
|
189
|
+
for (const patient of pageItems) {
|
|
184
190
|
const appointments = await ctx.db
|
|
185
191
|
.query("appointments")
|
|
186
|
-
.withIndex("by_patient", (q) => q.eq("patientId", patient._id))
|
|
192
|
+
.withIndex("by_patient", (q: any) => q.eq("patientId", patient._id))
|
|
187
193
|
.collect();
|
|
188
194
|
|
|
189
195
|
const hasNoShow = appointments.some(
|
|
@@ -205,10 +211,10 @@ async function queryNoShowCandidates(
|
|
|
205
211
|
);
|
|
206
212
|
}
|
|
207
213
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
};
|
|
214
|
+
const nextCursor = hasMore && pageItems.length > 0
|
|
215
|
+
? String(pageItems[pageItems.length - 1]._creationTime)
|
|
216
|
+
: null;
|
|
217
|
+
return { patients, nextCursor };
|
|
212
218
|
}
|
|
213
219
|
|
|
214
220
|
function hasFutureAppointment(appointments: AppointmentDoc[], now: number): boolean {
|
|
@@ -228,10 +234,17 @@ async function queryRecallOrQuoteFollowupCandidates(
|
|
|
228
234
|
options?: { includeAnagrafica?: boolean },
|
|
229
235
|
): Promise<{ patients: CandidatePatient[]; nextCursor: string | null }> {
|
|
230
236
|
const now = Date.now();
|
|
231
|
-
|
|
237
|
+
|
|
238
|
+
let q = ctx.db
|
|
232
239
|
.query("patients")
|
|
233
|
-
.withIndex("by_status", (q) => q.eq("status", "active"))
|
|
234
|
-
|
|
240
|
+
.withIndex("by_status", (q: any) => q.eq("status", "active"));
|
|
241
|
+
if (paginationCursor) {
|
|
242
|
+
const cursorTime = Number(paginationCursor);
|
|
243
|
+
q = q.filter((q: any) => q.gt(q.field("_creationTime"), cursorTime));
|
|
244
|
+
}
|
|
245
|
+
const allItems = await q.take(DEFAULT_PAGE_SIZE + 1);
|
|
246
|
+
const hasMore = allItems.length > DEFAULT_PAGE_SIZE;
|
|
247
|
+
const pageItems = hasMore ? allItems.slice(0, DEFAULT_PAGE_SIZE) : allItems;
|
|
235
248
|
|
|
236
249
|
const isRecall = segmentationRules.segmentType === "RECALL";
|
|
237
250
|
const monthsSince = typeof segmentationRules.params?.monthsSince === "number"
|
|
@@ -253,20 +266,19 @@ async function queryRecallOrQuoteFollowupCandidates(
|
|
|
253
266
|
const typeCodes = isRecall ? recallCodes : quoteCodes;
|
|
254
267
|
let typeIds = typeCodes.length
|
|
255
268
|
? await resolveAppointmentTypeIdsByCodes(ctx, typeCodes)
|
|
256
|
-
: new Set<
|
|
257
|
-
// If requested quote codes are not configured yet, fallback to all completed appointments.
|
|
269
|
+
: new Set<string>();
|
|
258
270
|
if (!isRecall && typeCodes.length > 0 && typeIds.size === 0) {
|
|
259
|
-
typeIds = new Set<
|
|
271
|
+
typeIds = new Set<string>();
|
|
260
272
|
}
|
|
261
273
|
|
|
262
274
|
const patients: CandidatePatient[] = [];
|
|
263
|
-
for (const patient of
|
|
275
|
+
for (const patient of pageItems) {
|
|
264
276
|
const clinicId = await deriveClinicIdFromAppointments(ctx, patient, campaignScope);
|
|
265
277
|
if (campaignScope.scopeType !== "HQ" && !clinicId) continue;
|
|
266
278
|
|
|
267
279
|
const appointments = await ctx.db
|
|
268
280
|
.query("appointments")
|
|
269
|
-
.withIndex("by_patient", (q) => q.eq("patientId", patient._id))
|
|
281
|
+
.withIndex("by_patient", (q: any) => q.eq("patientId", patient._id))
|
|
270
282
|
.collect();
|
|
271
283
|
const activeAppointments = appointments.filter(isAppointmentActive);
|
|
272
284
|
if (activeAppointments.length === 0) continue;
|
|
@@ -296,7 +308,10 @@ async function queryRecallOrQuoteFollowupCandidates(
|
|
|
296
308
|
);
|
|
297
309
|
}
|
|
298
310
|
|
|
299
|
-
|
|
311
|
+
const nextCursor = hasMore && pageItems.length > 0
|
|
312
|
+
? String(pageItems[pageItems.length - 1]._creationTime)
|
|
313
|
+
: null;
|
|
314
|
+
return { patients, nextCursor };
|
|
300
315
|
}
|
|
301
316
|
|
|
302
317
|
export async function queryCandidates(
|
|
@@ -308,7 +323,7 @@ export async function queryCandidates(
|
|
|
308
323
|
): Promise<{ patients: CandidatePatient[]; nextCursor: string | null }> {
|
|
309
324
|
if (segmentationRules.segmentType === "CUSTOM_IDS") {
|
|
310
325
|
const patientIds = Array.isArray(segmentationRules.params?.patientIds)
|
|
311
|
-
? (segmentationRules.params.patientIds as
|
|
326
|
+
? (segmentationRules.params.patientIds as string[])
|
|
312
327
|
: [];
|
|
313
328
|
const offset = paginationCursor ? Number(paginationCursor) : 0;
|
|
314
329
|
const chunk = patientIds.slice(offset, offset + DEFAULT_PAGE_SIZE);
|
|
@@ -354,13 +369,19 @@ export async function queryCandidates(
|
|
|
354
369
|
);
|
|
355
370
|
}
|
|
356
371
|
|
|
357
|
-
|
|
372
|
+
let q = ctx.db
|
|
358
373
|
.query("patients")
|
|
359
|
-
.withIndex("by_status", (q) => q.eq("status", "active"))
|
|
360
|
-
|
|
374
|
+
.withIndex("by_status", (q: any) => q.eq("status", "active"));
|
|
375
|
+
if (paginationCursor) {
|
|
376
|
+
const cursorTime = Number(paginationCursor);
|
|
377
|
+
q = q.filter((q: any) => q.gt(q.field("_creationTime"), cursorTime));
|
|
378
|
+
}
|
|
379
|
+
const allItems = await q.take(DEFAULT_PAGE_SIZE + 1);
|
|
380
|
+
const hasMore = allItems.length > DEFAULT_PAGE_SIZE;
|
|
381
|
+
const pageItems = hasMore ? allItems.slice(0, DEFAULT_PAGE_SIZE) : allItems;
|
|
361
382
|
|
|
362
383
|
const patients: CandidatePatient[] = [];
|
|
363
|
-
for (const patient of
|
|
384
|
+
for (const patient of pageItems) {
|
|
364
385
|
if (!matchesDemographicFilters(patient as any, segmentationRules.params)) continue;
|
|
365
386
|
const clinicId = await deriveClinicIdFromAppointments(ctx, patient, campaignScope);
|
|
366
387
|
if (campaignScope.scopeType !== "HQ" && !clinicId) continue;
|
|
@@ -371,5 +392,8 @@ export async function queryCandidates(
|
|
|
371
392
|
);
|
|
372
393
|
}
|
|
373
394
|
|
|
374
|
-
|
|
395
|
+
const nextCursor = hasMore && pageItems.length > 0
|
|
396
|
+
? String(pageItems[pageItems.length - 1]._creationTime)
|
|
397
|
+
: null;
|
|
398
|
+
return { patients, nextCursor };
|
|
375
399
|
}
|
|
@@ -17,10 +17,10 @@ export const campaignsTables = {
|
|
|
17
17
|
orgId: v.optional(v.string()),
|
|
18
18
|
name: v.string(),
|
|
19
19
|
description: v.optional(v.string()),
|
|
20
|
-
ownerUserId: v.optional(v.
|
|
20
|
+
ownerUserId: v.optional(v.string()),
|
|
21
21
|
scopeType: scopeTypeValidator,
|
|
22
22
|
// Accept both Convex IDs and plain string clinic identifiers for cross-host compatibility.
|
|
23
|
-
scopeClinicIds: v.array(v.
|
|
23
|
+
scopeClinicIds: v.array(v.string()),
|
|
24
24
|
priority: v.number(),
|
|
25
25
|
status: campaignStatusValidator,
|
|
26
26
|
scope: v.optional(
|
|
@@ -60,7 +60,7 @@ export const campaignsTables = {
|
|
|
60
60
|
audience_snapshots: defineTable({
|
|
61
61
|
campaignId: v.id("campaigns"),
|
|
62
62
|
createdAt: v.number(),
|
|
63
|
-
createdBy: v.optional(v.
|
|
63
|
+
createdBy: v.optional(v.string()),
|
|
64
64
|
status: snapshotStatusValidator,
|
|
65
65
|
segmentationRules: segmentationRulesValidator,
|
|
66
66
|
runConfig: runConfigValidator,
|
|
@@ -84,8 +84,8 @@ export const campaignsTables = {
|
|
|
84
84
|
audience_members: defineTable({
|
|
85
85
|
snapshotId: v.id("audience_snapshots"),
|
|
86
86
|
campaignId: v.id("campaigns"),
|
|
87
|
-
patientId: v.
|
|
88
|
-
clinicId: v.optional(v.
|
|
87
|
+
patientId: v.string(),
|
|
88
|
+
clinicId: v.optional(v.string()),
|
|
89
89
|
contactRefs: v.object({
|
|
90
90
|
email: v.optional(v.string()),
|
|
91
91
|
phone: v.optional(v.string()),
|
|
@@ -104,7 +104,7 @@ export const campaignsTables = {
|
|
|
104
104
|
recipient_states: defineTable({
|
|
105
105
|
campaignId: v.id("campaigns"),
|
|
106
106
|
snapshotId: v.id("audience_snapshots"),
|
|
107
|
-
patientId: v.
|
|
107
|
+
patientId: v.string(),
|
|
108
108
|
stepId: v.id("campaign_steps"),
|
|
109
109
|
state: recipientStateValidator,
|
|
110
110
|
reason: v.optional(v.string()),
|
|
@@ -118,7 +118,7 @@ export const campaignsTables = {
|
|
|
118
118
|
.index("by_snapshot_patient_step", ["snapshotId", "patientId", "stepId"]),
|
|
119
119
|
|
|
120
120
|
contact_locks: defineTable({
|
|
121
|
-
patientId: v.
|
|
121
|
+
patientId: v.string(),
|
|
122
122
|
lockedByCampaignId: v.id("campaigns"),
|
|
123
123
|
until: v.number(),
|
|
124
124
|
reason: v.optional(v.string()),
|
|
@@ -129,7 +129,7 @@ export const campaignsTables = {
|
|
|
129
129
|
event_log: defineTable({
|
|
130
130
|
campaignId: v.optional(v.id("campaigns")),
|
|
131
131
|
snapshotId: v.optional(v.id("audience_snapshots")),
|
|
132
|
-
patientId: v.optional(v.
|
|
132
|
+
patientId: v.optional(v.string()),
|
|
133
133
|
type: v.string(),
|
|
134
134
|
payload: v.optional(v.any()),
|
|
135
135
|
idempotencyKey: v.optional(v.string()),
|
|
@@ -143,7 +143,7 @@ export const campaignsTables = {
|
|
|
143
143
|
channel: v.union(v.literal("EMAIL"), v.literal("SMS")),
|
|
144
144
|
campaignId: v.id("campaigns"),
|
|
145
145
|
snapshotId: v.id("audience_snapshots"),
|
|
146
|
-
patientId: v.
|
|
146
|
+
patientId: v.string(),
|
|
147
147
|
stepId: v.id("campaign_steps"),
|
|
148
148
|
payload: v.any(),
|
|
149
149
|
status: v.string(),
|
|
@@ -155,8 +155,8 @@ export const campaignsTables = {
|
|
|
155
155
|
.index("by_dedupe_key", ["dedupeKey"]),
|
|
156
156
|
|
|
157
157
|
call_tasks_outbox: defineTable({
|
|
158
|
-
patientId: v.
|
|
159
|
-
clinicId: v.optional(v.
|
|
158
|
+
patientId: v.string(),
|
|
159
|
+
clinicId: v.optional(v.string()),
|
|
160
160
|
campaignId: v.id("campaigns"),
|
|
161
161
|
snapshotId: v.id("audience_snapshots"),
|
|
162
162
|
stepId: v.id("campaign_steps"),
|