@primocaredentgroup/convex-campaigns-component 0.1.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.
@@ -0,0 +1,508 @@
1
+ import { v } from "convex/values";
2
+ import { internalMutation } from "../../../_generated/server";
3
+ import type { Id, Doc } from "../../../_generated/dataModel";
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
+ import * as messagingPort from "../ports/messaging";
10
+ import * as callCenterPort from "../ports/callCenter";
11
+
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
+ function computeDueAt(
22
+ schedule: Doc<"campaign_steps">["scheduleSpec"],
23
+ baseline: number,
24
+ now: number,
25
+ ): number {
26
+ if (schedule.mode === "AT" && schedule.at) return schedule.at;
27
+ if (schedule.mode === "DELAY") {
28
+ return baseline + (schedule.delayMinutes ?? 0) * 60_000;
29
+ }
30
+ return now;
31
+ }
32
+
33
+ async function upsertRecipientState(
34
+ ctx: any,
35
+ input: {
36
+ snapshotId: Id<"audience_snapshots">;
37
+ campaignId: Id<"campaigns">;
38
+ patientId: Id<"patients">;
39
+ stepId: Id<"campaign_steps">;
40
+ state: Doc<"recipient_states">["state"];
41
+ reason?: string;
42
+ dueAt?: number;
43
+ },
44
+ ) {
45
+ const now = Date.now();
46
+ const existing = await ctx.db
47
+ .query("recipient_states")
48
+ .withIndex("by_snapshot_patient_step", (q: any) =>
49
+ q
50
+ .eq("snapshotId", input.snapshotId)
51
+ .eq("patientId", input.patientId)
52
+ .eq("stepId", input.stepId),
53
+ )
54
+ .first();
55
+
56
+ if (!existing) {
57
+ await ctx.db.insert("recipient_states", {
58
+ ...input,
59
+ createdAt: now,
60
+ updatedAt: now,
61
+ lastEventAt: now,
62
+ });
63
+ return;
64
+ }
65
+
66
+ if (!canTransitionRecipientState(existing.state as any, input.state as any)) {
67
+ return;
68
+ }
69
+
70
+ if (
71
+ existing.state !== input.state ||
72
+ existing.reason !== input.reason ||
73
+ existing.dueAt !== input.dueAt
74
+ ) {
75
+ await ctx.db.patch(existing._id, {
76
+ state: input.state,
77
+ reason: input.reason,
78
+ dueAt: input.dueAt,
79
+ updatedAt: now,
80
+ lastEventAt: now,
81
+ });
82
+ }
83
+ }
84
+
85
+ async function countRecentContacts(
86
+ ctx: any,
87
+ patientId: Id<"patients">,
88
+ windowStart: number,
89
+ ) {
90
+ const events = await ctx.db
91
+ .query("event_log")
92
+ .withIndex("by_patient_createdAt", (q: any) =>
93
+ q.eq("patientId", patientId).gte("createdAt", windowStart),
94
+ )
95
+ .collect();
96
+
97
+ return events.filter(
98
+ (e: any) =>
99
+ e.type === "message_sent" ||
100
+ e.type === "message_queued" ||
101
+ e.type === "call_task_created",
102
+ ).length;
103
+ }
104
+
105
+ async function resolveSuppressionReason(input: {
106
+ lockedByOther: boolean;
107
+ capExceeded: boolean;
108
+ consent: Awaited<ReturnType<typeof consentPort.getConsent>>;
109
+ stepType: Doc<"campaign_steps">["type"];
110
+ hasEmail: boolean;
111
+ hasPhone: boolean;
112
+ }): Promise<SuppressionReason | null> {
113
+ if (input.lockedByOther) return "LOCKED";
114
+ if (input.capExceeded) return "CAP";
115
+ if (input.consent.blacklisted) return "BLACKLIST";
116
+
117
+ if (input.stepType === "EMAIL") {
118
+ if (!input.consent.email) return "NO_CONSENT";
119
+ if (!input.hasEmail) return "INVALID_CONTACT";
120
+ }
121
+ if (input.stepType === "SMS") {
122
+ if (!input.consent.sms) return "NO_CONSENT";
123
+ if (!input.hasPhone) return "INVALID_CONTACT";
124
+ }
125
+ if (input.stepType === "CALL_CENTER" || input.stepType === "CALL_CLINIC") {
126
+ if (!input.consent.phone) return "NO_CONSENT";
127
+ if (!input.hasPhone) return "INVALID_CONTACT";
128
+ }
129
+
130
+ return null;
131
+ }
132
+
133
+ async function buildSnapshotChunkCore(
134
+ ctx: any,
135
+ snapshotId: Id<"audience_snapshots">,
136
+ forcedCursor?: string,
137
+ ) {
138
+ const snapshot = await ctx.db.get(snapshotId);
139
+ if (!snapshot || snapshot.status !== "BUILDING") return { done: true, nextCursor: null as string | null };
140
+
141
+ const campaign = await ctx.db.get(snapshot.campaignId);
142
+ if (!campaign) {
143
+ await ctx.db.patch(snapshotId, { status: "FAILED", updatedAt: Date.now() });
144
+ return { done: true, nextCursor: null as string | null };
145
+ }
146
+
147
+ const steps = await ctx.db
148
+ .query("campaign_steps")
149
+ .withIndex("by_campaign", (q: any) => q.eq("campaignId", campaign._id))
150
+ .collect();
151
+ const orderedSteps = [...steps].sort((a, b) => a.order - b.order);
152
+ if (orderedSteps.length === 0) {
153
+ await ctx.db.patch(snapshotId, { status: "FAILED", updatedAt: Date.now() });
154
+ return { done: true, nextCursor: null as string | null };
155
+ }
156
+
157
+ const runConfig = resolveRunConfig(snapshot.runConfig);
158
+ const cursor = forcedCursor ?? snapshot.nextCursor ?? null;
159
+ const { patients, nextCursor } = await patientDataPort.queryCandidates(
160
+ ctx,
161
+ { scopeType: campaign.scopeType, scopeClinicIds: campaign.scopeClinicIds },
162
+ snapshot.segmentationRules as any,
163
+ cursor,
164
+ );
165
+
166
+ const now = Date.now();
167
+ let deltaTotal = 0;
168
+ let deltaEligible = 0;
169
+ let deltaQueued = 0;
170
+ const suppressedByReason: Record<string, number> = {};
171
+
172
+ for (const p of patients) {
173
+ deltaTotal += 1;
174
+ const consent = await consentPort.getConsent(ctx, p.patientId);
175
+ const existingMember = await ctx.db
176
+ .query("audience_members")
177
+ .withIndex("by_snapshot_patient", (q: any) =>
178
+ q.eq("snapshotId", snapshot._id).eq("patientId", p.patientId),
179
+ )
180
+ .first();
181
+
182
+ if (!existingMember) {
183
+ await ctx.db.insert("audience_members", {
184
+ snapshotId: snapshot._id,
185
+ campaignId: campaign._id,
186
+ patientId: p.patientId,
187
+ clinicId: p.clinicId,
188
+ contactRefs: { email: p.email, phone: p.phone },
189
+ eligibility: {
190
+ email: consent.email,
191
+ sms: consent.sms,
192
+ phone: consent.phone,
193
+ blacklisted: consent.blacklisted,
194
+ },
195
+ createdAt: now,
196
+ });
197
+ }
198
+
199
+ const activeLock = await ctx.db
200
+ .query("contact_locks")
201
+ .withIndex("by_patient", (q: any) => q.eq("patientId", p.patientId))
202
+ .first();
203
+ const lockedByOther =
204
+ Boolean(activeLock) &&
205
+ (activeLock?.until ?? 0) > now &&
206
+ activeLock?.lockedByCampaignId !== campaign._id;
207
+
208
+ const contactCount = await countRecentContacts(
209
+ ctx,
210
+ p.patientId,
211
+ now - runConfig.capWindowDays * 24 * 60 * 60 * 1000,
212
+ );
213
+ const capExceeded = contactCount >= runConfig.capMaxContacts;
214
+
215
+ let patientHasAtLeastOneEligibleStep = false;
216
+ for (const step of orderedSteps) {
217
+ const suppression = await resolveSuppressionReason({
218
+ lockedByOther,
219
+ capExceeded,
220
+ consent,
221
+ stepType: step.type,
222
+ hasEmail: Boolean(p.email),
223
+ hasPhone: Boolean(p.phone),
224
+ });
225
+
226
+ if (suppression) {
227
+ suppressedByReason[suppression] = (suppressedByReason[suppression] ?? 0) + 1;
228
+ await upsertRecipientState(ctx, {
229
+ snapshotId: snapshot._id,
230
+ campaignId: campaign._id,
231
+ patientId: p.patientId,
232
+ stepId: step._id,
233
+ state: "SUPPRESSED",
234
+ reason: suppression,
235
+ });
236
+ continue;
237
+ }
238
+
239
+ const dueAt = computeDueAt(step.scheduleSpec, snapshot.createdAt, now);
240
+ const nextState = dueAt <= now ? "QUEUED" : "ELIGIBLE";
241
+ if (nextState === "QUEUED") deltaQueued += 1;
242
+ patientHasAtLeastOneEligibleStep = true;
243
+
244
+ await upsertRecipientState(ctx, {
245
+ snapshotId: snapshot._id,
246
+ campaignId: campaign._id,
247
+ patientId: p.patientId,
248
+ stepId: step._id,
249
+ state: nextState,
250
+ dueAt,
251
+ });
252
+ }
253
+
254
+ if (patientHasAtLeastOneEligibleStep && !lockedByOther) {
255
+ deltaEligible += 1;
256
+ if (!activeLock || activeLock.lockedByCampaignId !== campaign._id) {
257
+ const until = now + runConfig.lockDays * 24 * 60 * 60 * 1000;
258
+ if (!activeLock) {
259
+ await ctx.db.insert("contact_locks", {
260
+ patientId: p.patientId,
261
+ lockedByCampaignId: campaign._id,
262
+ until,
263
+ reason: "campaign_snapshot",
264
+ createdAt: now,
265
+ updatedAt: now,
266
+ });
267
+ } else {
268
+ await ctx.db.patch(activeLock._id, {
269
+ lockedByCampaignId: campaign._id,
270
+ until,
271
+ reason: "campaign_snapshot",
272
+ updatedAt: now,
273
+ });
274
+ }
275
+ }
276
+ } else if (lockedByOther) {
277
+ await ctx.db.insert("event_log", {
278
+ campaignId: campaign._id,
279
+ snapshotId: snapshot._id,
280
+ patientId: p.patientId,
281
+ type: "recipient_suppressed",
282
+ payload: { reason: "LOCKED" },
283
+ createdAt: now,
284
+ });
285
+ }
286
+ }
287
+
288
+ const mergedSuppressed = { ...snapshot.stats.suppressedByReason };
289
+ for (const [reason, count] of Object.entries(suppressedByReason)) {
290
+ mergedSuppressed[reason] = (mergedSuppressed[reason] ?? 0) + count;
291
+ }
292
+
293
+ const finished = nextCursor === null;
294
+ await ctx.db.patch(snapshot._id, {
295
+ nextCursor: nextCursor ?? undefined,
296
+ status: finished ? "READY" : "BUILDING",
297
+ updatedAt: now,
298
+ stats: {
299
+ ...snapshot.stats,
300
+ totalCandidates: snapshot.stats.totalCandidates + deltaTotal,
301
+ eligible: snapshot.stats.eligible + deltaEligible,
302
+ queued: snapshot.stats.queued + deltaQueued,
303
+ suppressedByReason: mergedSuppressed,
304
+ },
305
+ });
306
+
307
+ if (finished) {
308
+ await ctx.db.insert("event_log", {
309
+ campaignId: campaign._id,
310
+ snapshotId: snapshot._id,
311
+ type: "audience_snapshot_built",
312
+ payload: { totalCandidates: deltaTotal },
313
+ createdAt: now,
314
+ });
315
+ }
316
+
317
+ return { done: finished, nextCursor };
318
+ }
319
+
320
+ async function enqueueStepJobsCore(
321
+ ctx: any,
322
+ snapshotId: Id<"audience_snapshots">,
323
+ stepId: Id<"campaign_steps">,
324
+ cursor?: string,
325
+ ) {
326
+ const step = await ctx.db.get(stepId);
327
+ const snapshot = await ctx.db.get(snapshotId);
328
+ if (!step || !snapshot) return { nextCursor: null as string | null, processed: 0 };
329
+
330
+ const now = Date.now();
331
+ const allQueued = await ctx.db
332
+ .query("recipient_states")
333
+ .withIndex("by_snapshot_step_state", (q: any) =>
334
+ q.eq("snapshotId", snapshotId).eq("stepId", stepId).eq("state", "QUEUED"),
335
+ )
336
+ .collect();
337
+ const due = allQueued.filter((r: any) => (r.dueAt ?? 0) <= now);
338
+ const offset = cursor ? Number(cursor) : 0;
339
+ const batch = due.slice(offset, offset + 100);
340
+
341
+ let sent = 0;
342
+ let taskCreated = 0;
343
+ let failed = 0;
344
+
345
+ for (const rs of batch) {
346
+ try {
347
+ const dedupeKey = `${snapshotId}:${stepId}:${rs.patientId}:${step.type}`;
348
+ if (step.type === "EMAIL") {
349
+ await messagingPort.enqueueEmail(ctx, {
350
+ campaignId: rs.campaignId,
351
+ snapshotId,
352
+ patientId: rs.patientId,
353
+ stepId,
354
+ dedupeKey,
355
+ payload: { templateRef: step.templateRef, patientId: rs.patientId },
356
+ });
357
+ await ctx.db.patch(rs._id, { state: "SENT", updatedAt: now, lastEventAt: now });
358
+ sent += 1;
359
+ await ctx.db.insert("event_log", {
360
+ campaignId: rs.campaignId,
361
+ snapshotId,
362
+ patientId: rs.patientId,
363
+ type: "message_queued",
364
+ payload: { channel: "EMAIL", stepId },
365
+ createdAt: now,
366
+ });
367
+ } else if (step.type === "SMS") {
368
+ await messagingPort.enqueueSms(ctx, {
369
+ campaignId: rs.campaignId,
370
+ snapshotId,
371
+ patientId: rs.patientId,
372
+ stepId,
373
+ dedupeKey,
374
+ payload: { templateRef: step.templateRef, patientId: rs.patientId },
375
+ });
376
+ await ctx.db.patch(rs._id, { state: "SENT", updatedAt: now, lastEventAt: now });
377
+ sent += 1;
378
+ await ctx.db.insert("event_log", {
379
+ campaignId: rs.campaignId,
380
+ snapshotId,
381
+ patientId: rs.patientId,
382
+ type: "message_queued",
383
+ payload: { channel: "SMS", stepId },
384
+ createdAt: now,
385
+ });
386
+ } else {
387
+ await callCenterPort.createOutboxTasks(ctx, [
388
+ {
389
+ patientId: rs.patientId,
390
+ clinicId: undefined,
391
+ campaignId: rs.campaignId,
392
+ snapshotId,
393
+ stepId,
394
+ dueAt: now,
395
+ priority: 0,
396
+ script: step.templateRef,
397
+ dedupeKey,
398
+ },
399
+ ]);
400
+ await ctx.db.patch(rs._id, { state: "TASK_CREATED", updatedAt: now, lastEventAt: now });
401
+ taskCreated += 1;
402
+ await ctx.db.insert("event_log", {
403
+ campaignId: rs.campaignId,
404
+ snapshotId,
405
+ patientId: rs.patientId,
406
+ type: "call_task_created",
407
+ payload: { stepId, channel: step.type },
408
+ createdAt: now,
409
+ });
410
+ }
411
+ } catch (error) {
412
+ await ctx.db.patch(rs._id, {
413
+ state: "FAILED",
414
+ reason: `enqueue_failed:${(error as Error).message}`,
415
+ updatedAt: now,
416
+ lastEventAt: now,
417
+ });
418
+ failed += 1;
419
+ await ctx.db.insert("event_log", {
420
+ campaignId: rs.campaignId,
421
+ snapshotId,
422
+ patientId: rs.patientId,
423
+ type: "message_failed",
424
+ payload: { stepId, error: (error as Error).message },
425
+ createdAt: now,
426
+ });
427
+ }
428
+ }
429
+
430
+ await ctx.db.patch(snapshotId, {
431
+ updatedAt: now,
432
+ stats: {
433
+ ...snapshot.stats,
434
+ sent: snapshot.stats.sent + sent,
435
+ taskCreated: snapshot.stats.taskCreated + taskCreated,
436
+ failed: snapshot.stats.failed + failed,
437
+ },
438
+ });
439
+
440
+ const next = offset + batch.length;
441
+ return { nextCursor: next < due.length ? String(next) : null, processed: batch.length };
442
+ }
443
+
444
+ export const _buildSnapshotChunk = internalMutation({
445
+ args: {
446
+ snapshotId: v.id("audience_snapshots"),
447
+ cursor: v.optional(v.string()),
448
+ },
449
+ handler: async (ctx, args) => {
450
+ await assertAuthorized(ctx, ["Admin", "Marketing", "System"]);
451
+ return buildSnapshotChunkCore(ctx as any, args.snapshotId, args.cursor);
452
+ },
453
+ });
454
+
455
+ export const _enqueueStepJobs = internalMutation({
456
+ args: {
457
+ snapshotId: v.id("audience_snapshots"),
458
+ stepId: v.id("campaign_steps"),
459
+ cursor: v.optional(v.string()),
460
+ },
461
+ handler: async (ctx, args) => {
462
+ await assertAuthorized(ctx, ["Admin", "Marketing", "System"]);
463
+ return enqueueStepJobsCore(ctx as any, args.snapshotId, args.stepId, args.cursor);
464
+ },
465
+ });
466
+
467
+ export const _runTickInternal = internalMutation({
468
+ args: {},
469
+ handler: async (ctx) => {
470
+ await assertAuthorized(ctx, ["Admin", "Marketing", "System"]);
471
+
472
+ const buildingSnapshot = await ctx.db
473
+ .query("audience_snapshots")
474
+ .withIndex("by_status", (q) => q.eq("status", "BUILDING"))
475
+ .first();
476
+ if (buildingSnapshot) {
477
+ await buildSnapshotChunkCore(ctx as any, buildingSnapshot._id);
478
+ }
479
+
480
+ const runningCampaigns = await ctx.db
481
+ .query("campaigns")
482
+ .withIndex("by_status", (q) => q.eq("status", "RUNNING"))
483
+ .collect();
484
+
485
+ for (const campaign of runningCampaigns.slice(0, 10)) {
486
+ const snapshots = await ctx.db
487
+ .query("audience_snapshots")
488
+ .withIndex("by_campaign_createdAt", (q) => q.eq("campaignId", campaign._id))
489
+ .collect();
490
+ const latestReady = snapshots
491
+ .filter((s) => s.status === "READY")
492
+ .sort((a, b) => b.createdAt - a.createdAt)[0];
493
+ if (!latestReady) continue;
494
+
495
+ const steps = await ctx.db
496
+ .query("campaign_steps")
497
+ .withIndex("by_campaign", (q) => q.eq("campaignId", campaign._id))
498
+ .collect();
499
+ const ordered = [...steps].sort((a, b) => a.order - b.order);
500
+
501
+ for (const step of ordered.slice(0, 5)) {
502
+ await enqueueStepJobsCore(ctx as any, latestReady._id, step._id);
503
+ }
504
+ }
505
+
506
+ return { ok: true, at: Date.now() };
507
+ },
508
+ });