@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.
@@ -13,6 +13,8 @@ export {
13
13
  addOrUpdateSteps,
14
14
  previewAudience,
15
15
  buildAudienceSnapshot,
16
+ ingestAudienceMembers,
17
+ sealSnapshot,
16
18
  } from "./components/campaigns/functions/mutations";
17
19
 
18
20
  export {
@@ -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.id("patients"),
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.id("patients"),
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.id("patients"),
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.id("patients"),
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.id("patients"),
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.id("patients"),
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.union(v.id("clinics"), v.string());
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.id("users")),
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.id("users")),
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: cursor
230
- ? ["Preview limited to first pages for performance. Build snapshot for full results."]
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.union(v.id("clinics"), v.string());
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
- const patient = await ctx.db.get(m.patientId);
192
- if (!patient) continue;
193
- const p = patient as any;
194
- let residenceCityName: string | undefined;
195
- if (p.residenceCityId) {
196
- try {
197
- const city = await ctx.db.get(p.residenceCityId);
198
- residenceCityName = (city as any)?.name;
199
- } catch {
200
- // Host may not have cities table
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: Id<"users">): Promise<Set<string>> {
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: Id<"patients">;
8
- clinicId?: Id<"clinics">;
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: Id<"patients">,
12
+ patientId: string,
14
13
  ): Promise<ConsentResult> {
15
- // TODO: Replace with real Consent component port call:
16
- // consents.getConsent(patientId) -> { email, sms, phone, blacklisted }
17
- const patient = await ctx.db.get(patientId);
18
- const p = patient as
19
- | (typeof patient & {
20
- consentEmail?: boolean;
21
- consentSms?: boolean;
22
- consentPhone?: boolean;
23
- blacklisted?: boolean;
24
- })
25
- | null;
26
-
27
- if (!patient) {
28
- return { email: false, sms: false, phone: false, blacklisted: false };
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: Id<"patients">;
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: Id<"patients">;
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: (Id<"clinics"> | string)[];
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: Id<"patients">;
16
- clinicId?: Id<"clinics">;
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 = Doc<"appointments">;
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: Id<"clinics"> | undefined,
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: Id<"clinics"> | undefined,
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: Doc<"patients">,
124
+ patient: any,
126
125
  scope: CampaignScope,
127
- ): Promise<Id<"clinics"> | undefined> {
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: Id<"clinics"> | undefined,
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<Id<"appointment_types">>> {
157
- const ids = new Set<Id<"appointment_types">>();
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
- const patientPage = await ctx.db
176
+
177
+ let q = ctx.db
178
178
  .query("patients")
179
- .withIndex("by_status", (q) => q.eq("status", "active"))
180
- .paginate({ numItems: DEFAULT_PAGE_SIZE, cursor: paginationCursor ?? null });
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 patientPage.page) {
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
- return {
209
- patients,
210
- nextCursor: patientPage.isDone ? null : patientPage.continueCursor,
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
- const page = await ctx.db
237
+
238
+ let q = ctx.db
232
239
  .query("patients")
233
- .withIndex("by_status", (q) => q.eq("status", "active"))
234
- .paginate({ numItems: DEFAULT_PAGE_SIZE, cursor: paginationCursor ?? null });
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<Id<"appointment_types">>();
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<Id<"appointment_types">>();
271
+ typeIds = new Set<string>();
260
272
  }
261
273
 
262
274
  const patients: CandidatePatient[] = [];
263
- for (const patient of page.page) {
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
- return { patients, nextCursor: page.isDone ? null : page.continueCursor };
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 Id<"patients">[])
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
- const page = await ctx.db
372
+ let q = ctx.db
358
373
  .query("patients")
359
- .withIndex("by_status", (q) => q.eq("status", "active"))
360
- .paginate({ numItems: DEFAULT_PAGE_SIZE, cursor: paginationCursor ?? null });
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 page.page) {
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
- return { patients, nextCursor: page.isDone ? null : page.continueCursor };
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.id("users")),
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.union(v.id("clinics"), v.string())),
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.id("users")),
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.id("patients"),
88
- clinicId: v.optional(v.id("clinics")),
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.id("patients"),
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.id("patients"),
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.id("patients")),
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.id("patients"),
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.id("patients"),
159
- clinicId: v.optional(v.id("clinics")),
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"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@primocaredentgroup/convex-campaigns-component",
3
- "version": "0.3.17",
3
+ "version": "0.3.22",
4
4
  "description": "Convex Campaigns backend component for PrimoCore",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",