@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,273 @@
1
+ import type { QueryCtx, MutationCtx } from "../../../_generated/server";
2
+ import type { Doc, Id } from "../../../_generated/dataModel";
3
+
4
+ export type CampaignScope = {
5
+ scopeType: "HQ" | "CLINIC";
6
+ scopeClinicIds: Id<"clinics">[];
7
+ };
8
+
9
+ export type SegmentationRules = {
10
+ segmentType: "RECALL" | "QUOTE_FOLLOWUP" | "NO_SHOW" | "CUSTOM_IDS";
11
+ params?: any;
12
+ };
13
+
14
+ export type CandidatePatient = {
15
+ patientId: Id<"patients">;
16
+ clinicId?: Id<"clinics">;
17
+ email?: string;
18
+ phone?: string;
19
+ };
20
+
21
+ const DEFAULT_PAGE_SIZE = 200;
22
+ const DAY_MS = 24 * 60 * 60 * 1000;
23
+
24
+ type AppointmentDoc = Doc<"appointments">;
25
+
26
+ async function deriveClinicIdFromAppointments(
27
+ ctx: QueryCtx | MutationCtx,
28
+ patient: Doc<"patients">,
29
+ scope: CampaignScope,
30
+ ): Promise<Id<"clinics"> | undefined> {
31
+ const appointments = await ctx.db
32
+ .query("appointments")
33
+ .withIndex("by_patient", (q) => q.eq("patientId", patient._id))
34
+ .collect();
35
+
36
+ const withClinic = appointments.find((a) => a.clinicId);
37
+ if (!withClinic?.clinicId) return undefined;
38
+ if (scope.scopeType === "HQ") return withClinic.clinicId;
39
+ if (scope.scopeClinicIds.includes(withClinic.clinicId)) return withClinic.clinicId;
40
+ return undefined;
41
+ }
42
+
43
+ function isAppointmentActive(appointment: AppointmentDoc): boolean {
44
+ return !appointment.deletedAt && !appointment.isSuspended;
45
+ }
46
+
47
+ function isInScope(
48
+ scope: CampaignScope,
49
+ clinicId: Id<"clinics"> | undefined,
50
+ ): boolean {
51
+ if (scope.scopeType === "HQ") return true;
52
+ if (!clinicId) return false;
53
+ return scope.scopeClinicIds.includes(clinicId);
54
+ }
55
+
56
+ async function resolveAppointmentTypeIdsByCodes(
57
+ ctx: QueryCtx | MutationCtx,
58
+ codes: string[],
59
+ ): Promise<Set<Id<"appointment_types">>> {
60
+ const ids = new Set<Id<"appointment_types">>();
61
+ for (const code of codes) {
62
+ const typeDoc = await ctx.db
63
+ .query("appointment_types")
64
+ .withIndex("by_code", (q) => q.eq("code", code))
65
+ .first();
66
+ if (typeDoc) ids.add(typeDoc._id);
67
+ }
68
+ return ids;
69
+ }
70
+
71
+ async function queryNoShowCandidates(
72
+ ctx: QueryCtx | MutationCtx,
73
+ campaignScope: CampaignScope,
74
+ params: any,
75
+ paginationCursor: string | null,
76
+ ): Promise<{ patients: CandidatePatient[]; nextCursor: string | null }> {
77
+ const daysBack = typeof params?.daysBack === "number" ? params.daysBack : 90;
78
+ const cutoff = Date.now() - daysBack * DAY_MS;
79
+ const patientPage = await ctx.db
80
+ .query("patients")
81
+ .withIndex("by_status", (q) => q.eq("status", "active"))
82
+ .paginate({ numItems: DEFAULT_PAGE_SIZE, cursor: paginationCursor ?? null });
83
+
84
+ const patients: CandidatePatient[] = [];
85
+ for (const patient of patientPage.page) {
86
+ const appointments = await ctx.db
87
+ .query("appointments")
88
+ .withIndex("by_patient", (q) => q.eq("patientId", patient._id))
89
+ .collect();
90
+
91
+ const hasNoShow = appointments.some(
92
+ (appointment) =>
93
+ isAppointmentActive(appointment) &&
94
+ appointment.status === "no-show" &&
95
+ appointment.appointmentDate >= cutoff &&
96
+ isInScope(campaignScope, appointment.clinicId),
97
+ );
98
+ if (!hasNoShow) continue;
99
+
100
+ const clinicId = await deriveClinicIdFromAppointments(ctx, patient, campaignScope);
101
+ if (campaignScope.scopeType === "CLINIC" && !clinicId) continue;
102
+ patients.push({
103
+ patientId: patient._id,
104
+ clinicId,
105
+ email: patient.email,
106
+ phone: patient.phone,
107
+ });
108
+ }
109
+
110
+ return {
111
+ patients,
112
+ nextCursor: patientPage.isDone ? null : patientPage.continueCursor,
113
+ };
114
+ }
115
+
116
+ function hasFutureAppointment(appointments: AppointmentDoc[], now: number): boolean {
117
+ return appointments.some(
118
+ (a) =>
119
+ isAppointmentActive(a) &&
120
+ a.appointmentDate >= now &&
121
+ (a.status === "scheduled" || a.status === "confirmed"),
122
+ );
123
+ }
124
+
125
+ async function queryRecallOrQuoteFollowupCandidates(
126
+ ctx: QueryCtx | MutationCtx,
127
+ campaignScope: CampaignScope,
128
+ segmentationRules: SegmentationRules,
129
+ paginationCursor: string | null,
130
+ ): Promise<{ patients: CandidatePatient[]; nextCursor: string | null }> {
131
+ const now = Date.now();
132
+ const page = await ctx.db
133
+ .query("patients")
134
+ .withIndex("by_status", (q) => q.eq("status", "active"))
135
+ .paginate({ numItems: DEFAULT_PAGE_SIZE, cursor: paginationCursor ?? null });
136
+
137
+ const isRecall = segmentationRules.segmentType === "RECALL";
138
+ const monthsSince = typeof segmentationRules.params?.monthsSince === "number"
139
+ ? segmentationRules.params.monthsSince
140
+ : 6;
141
+ const daysSince = typeof segmentationRules.params?.daysSince === "number"
142
+ ? segmentationRules.params.daysSince
143
+ : 14;
144
+ const cutoffRecall = now - monthsSince * 30 * DAY_MS;
145
+ const cutoffQuote = now - daysSince * DAY_MS;
146
+
147
+ const defaultQuoteCodes = ["consultation", "quote", "preventivo"];
148
+ const quoteCodes = Array.isArray(segmentationRules.params?.appointmentTypeCodes)
149
+ ? (segmentationRules.params.appointmentTypeCodes as string[])
150
+ : defaultQuoteCodes;
151
+ const recallCodes = Array.isArray(segmentationRules.params?.appointmentTypeCodes)
152
+ ? (segmentationRules.params.appointmentTypeCodes as string[])
153
+ : [];
154
+ const typeCodes = isRecall ? recallCodes : quoteCodes;
155
+ let typeIds = typeCodes.length
156
+ ? await resolveAppointmentTypeIdsByCodes(ctx, typeCodes)
157
+ : new Set<Id<"appointment_types">>();
158
+ // If requested quote codes are not configured yet, fallback to all completed appointments.
159
+ if (!isRecall && typeCodes.length > 0 && typeIds.size === 0) {
160
+ typeIds = new Set<Id<"appointment_types">>();
161
+ }
162
+
163
+ const patients: CandidatePatient[] = [];
164
+ for (const patient of page.page) {
165
+ const clinicId = await deriveClinicIdFromAppointments(ctx, patient, campaignScope);
166
+ if (campaignScope.scopeType === "CLINIC" && !clinicId) continue;
167
+
168
+ const appointments = await ctx.db
169
+ .query("appointments")
170
+ .withIndex("by_patient", (q) => q.eq("patientId", patient._id))
171
+ .collect();
172
+ const activeAppointments = appointments.filter(isAppointmentActive);
173
+ if (activeAppointments.length === 0) continue;
174
+ if (hasFutureAppointment(activeAppointments, now)) continue;
175
+
176
+ const completed = activeAppointments
177
+ .filter((a) => a.status === "completed")
178
+ .filter((a) =>
179
+ typeIds.size > 0 ? (a.appointmentTypeId ? typeIds.has(a.appointmentTypeId) : false) : true,
180
+ );
181
+
182
+ if (completed.length === 0) continue;
183
+ const lastCompleted = completed.reduce((acc, curr) =>
184
+ curr.appointmentDate > acc.appointmentDate ? curr : acc,
185
+ );
186
+
187
+ const qualifies = isRecall
188
+ ? lastCompleted.appointmentDate <= cutoffRecall
189
+ : lastCompleted.appointmentDate <= cutoffQuote;
190
+ if (!qualifies) continue;
191
+
192
+ patients.push({
193
+ patientId: patient._id,
194
+ clinicId,
195
+ email: patient.email,
196
+ phone: patient.phone,
197
+ });
198
+ }
199
+
200
+ return { patients, nextCursor: page.isDone ? null : page.continueCursor };
201
+ }
202
+
203
+ export async function queryCandidates(
204
+ ctx: QueryCtx | MutationCtx,
205
+ campaignScope: CampaignScope,
206
+ segmentationRules: SegmentationRules,
207
+ paginationCursor: string | null,
208
+ ): Promise<{ patients: CandidatePatient[]; nextCursor: string | null }> {
209
+ if (segmentationRules.segmentType === "CUSTOM_IDS") {
210
+ const patientIds = Array.isArray(segmentationRules.params?.patientIds)
211
+ ? (segmentationRules.params.patientIds as Id<"patients">[])
212
+ : [];
213
+ const offset = paginationCursor ? Number(paginationCursor) : 0;
214
+ const chunk = patientIds.slice(offset, offset + DEFAULT_PAGE_SIZE);
215
+
216
+ const patients: CandidatePatient[] = [];
217
+ for (const patientId of chunk) {
218
+ const patient = await ctx.db.get(patientId);
219
+ if (!patient || patient.status === "archived") continue;
220
+ const clinicId = await deriveClinicIdFromAppointments(ctx, patient, campaignScope);
221
+ if (campaignScope.scopeType === "CLINIC" && !clinicId) continue;
222
+ patients.push({
223
+ patientId: patient._id,
224
+ clinicId,
225
+ email: patient.email,
226
+ phone: patient.phone,
227
+ });
228
+ }
229
+
230
+ const next = offset + chunk.length;
231
+ return { patients, nextCursor: next < patientIds.length ? String(next) : null };
232
+ }
233
+
234
+ if (segmentationRules.segmentType === "NO_SHOW") {
235
+ return queryNoShowCandidates(
236
+ ctx,
237
+ campaignScope,
238
+ segmentationRules.params,
239
+ paginationCursor,
240
+ );
241
+ }
242
+
243
+ if (
244
+ segmentationRules.segmentType === "RECALL" ||
245
+ segmentationRules.segmentType === "QUOTE_FOLLOWUP"
246
+ ) {
247
+ return queryRecallOrQuoteFollowupCandidates(
248
+ ctx,
249
+ campaignScope,
250
+ segmentationRules,
251
+ paginationCursor,
252
+ );
253
+ }
254
+
255
+ const page = await ctx.db
256
+ .query("patients")
257
+ .withIndex("by_status", (q) => q.eq("status", "active"))
258
+ .paginate({ numItems: DEFAULT_PAGE_SIZE, cursor: paginationCursor ?? null });
259
+
260
+ const patients: CandidatePatient[] = [];
261
+ for (const patient of page.page) {
262
+ const clinicId = await deriveClinicIdFromAppointments(ctx, patient, campaignScope);
263
+ if (campaignScope.scopeType === "CLINIC" && !clinicId) continue;
264
+ patients.push({
265
+ patientId: patient._id,
266
+ clinicId,
267
+ email: patient.email,
268
+ phone: patient.phone,
269
+ });
270
+ }
271
+
272
+ return { patients, nextCursor: page.isDone ? null : page.continueCursor };
273
+ }
@@ -0,0 +1,154 @@
1
+ import { defineTable } from "convex/server";
2
+ import { v } from "convex/values";
3
+ import {
4
+ campaignStatusValidator,
5
+ recipientStateValidator,
6
+ runConfigValidator,
7
+ scopeTypeValidator,
8
+ scheduleSpecValidator,
9
+ segmentationRulesValidator,
10
+ snapshotStatusValidator,
11
+ stepTypeValidator,
12
+ } from "./domain/types";
13
+
14
+ export const campaignsTables = {
15
+ campaigns: defineTable({
16
+ name: v.string(),
17
+ description: v.optional(v.string()),
18
+ ownerUserId: v.optional(v.id("users")),
19
+ scopeType: scopeTypeValidator,
20
+ scopeClinicIds: v.array(v.id("clinics")),
21
+ priority: v.number(),
22
+ status: campaignStatusValidator,
23
+ defaultRunConfig: runConfigValidator,
24
+ createdAt: v.number(),
25
+ updatedAt: v.number(),
26
+ }).index("by_status", ["status"]),
27
+
28
+ campaign_steps: defineTable({
29
+ campaignId: v.id("campaigns"),
30
+ order: v.number(),
31
+ type: stepTypeValidator,
32
+ templateRef: v.optional(v.string()),
33
+ scheduleSpec: scheduleSpecValidator,
34
+ createdAt: v.number(),
35
+ updatedAt: v.number(),
36
+ })
37
+ .index("by_campaign", ["campaignId"])
38
+ .index("by_campaign_order", ["campaignId", "order"]),
39
+
40
+ audience_snapshots: defineTable({
41
+ campaignId: v.id("campaigns"),
42
+ createdAt: v.number(),
43
+ createdBy: v.optional(v.id("users")),
44
+ status: snapshotStatusValidator,
45
+ segmentationRules: segmentationRulesValidator,
46
+ runConfig: runConfigValidator,
47
+ nextCursor: v.optional(v.string()),
48
+ stats: v.object({
49
+ totalCandidates: v.number(),
50
+ eligible: v.number(),
51
+ suppressedByReason: v.record(v.string(), v.number()),
52
+ queued: v.number(),
53
+ sent: v.number(),
54
+ taskCreated: v.number(),
55
+ completed: v.number(),
56
+ converted: v.number(),
57
+ failed: v.number(),
58
+ }),
59
+ updatedAt: v.number(),
60
+ })
61
+ .index("by_campaign_createdAt", ["campaignId", "createdAt"])
62
+ .index("by_status", ["status"]),
63
+
64
+ audience_members: defineTable({
65
+ snapshotId: v.id("audience_snapshots"),
66
+ campaignId: v.id("campaigns"),
67
+ patientId: v.id("patients"),
68
+ clinicId: v.optional(v.id("clinics")),
69
+ contactRefs: v.object({
70
+ email: v.optional(v.string()),
71
+ phone: v.optional(v.string()),
72
+ }),
73
+ eligibility: v.object({
74
+ email: v.boolean(),
75
+ sms: v.boolean(),
76
+ phone: v.boolean(),
77
+ blacklisted: v.boolean(),
78
+ }),
79
+ createdAt: v.number(),
80
+ })
81
+ .index("by_snapshot", ["snapshotId"])
82
+ .index("by_snapshot_patient", ["snapshotId", "patientId"]),
83
+
84
+ recipient_states: defineTable({
85
+ campaignId: v.id("campaigns"),
86
+ snapshotId: v.id("audience_snapshots"),
87
+ patientId: v.id("patients"),
88
+ stepId: v.id("campaign_steps"),
89
+ state: recipientStateValidator,
90
+ reason: v.optional(v.string()),
91
+ dueAt: v.optional(v.number()),
92
+ lastEventAt: v.optional(v.number()),
93
+ updatedAt: v.number(),
94
+ createdAt: v.number(),
95
+ })
96
+ .index("by_snapshot_step_state", ["snapshotId", "stepId", "state"])
97
+ .index("by_snapshot_step_state_dueAt", ["snapshotId", "stepId", "state", "dueAt"])
98
+ .index("by_snapshot_patient_step", ["snapshotId", "patientId", "stepId"]),
99
+
100
+ contact_locks: defineTable({
101
+ patientId: v.id("patients"),
102
+ lockedByCampaignId: v.id("campaigns"),
103
+ until: v.number(),
104
+ reason: v.optional(v.string()),
105
+ createdAt: v.number(),
106
+ updatedAt: v.number(),
107
+ }).index("by_patient", ["patientId"]),
108
+
109
+ event_log: defineTable({
110
+ campaignId: v.optional(v.id("campaigns")),
111
+ snapshotId: v.optional(v.id("audience_snapshots")),
112
+ patientId: v.optional(v.id("patients")),
113
+ type: v.string(),
114
+ payload: v.optional(v.any()),
115
+ idempotencyKey: v.optional(v.string()),
116
+ createdAt: v.number(),
117
+ })
118
+ .index("by_campaign_createdAt", ["campaignId", "createdAt"])
119
+ .index("by_idempotency_key", ["idempotencyKey"])
120
+ .index("by_patient_createdAt", ["patientId", "createdAt"]),
121
+
122
+ message_jobs: defineTable({
123
+ channel: v.union(v.literal("EMAIL"), v.literal("SMS")),
124
+ campaignId: v.id("campaigns"),
125
+ snapshotId: v.id("audience_snapshots"),
126
+ patientId: v.id("patients"),
127
+ stepId: v.id("campaign_steps"),
128
+ payload: v.any(),
129
+ status: v.string(),
130
+ dedupeKey: v.string(),
131
+ createdAt: v.number(),
132
+ updatedAt: v.number(),
133
+ })
134
+ .index("by_status", ["status"])
135
+ .index("by_dedupe_key", ["dedupeKey"]),
136
+
137
+ call_tasks_outbox: defineTable({
138
+ patientId: v.id("patients"),
139
+ clinicId: v.optional(v.id("clinics")),
140
+ campaignId: v.id("campaigns"),
141
+ snapshotId: v.id("audience_snapshots"),
142
+ stepId: v.id("campaign_steps"),
143
+ dueAt: v.number(),
144
+ priority: v.number(),
145
+ script: v.optional(v.string()),
146
+ attempt: v.number(),
147
+ status: v.string(),
148
+ dedupeKey: v.string(),
149
+ createdAt: v.number(),
150
+ updatedAt: v.number(),
151
+ })
152
+ .index("by_status", ["status"])
153
+ .index("by_dedupe_key", ["dedupeKey"]),
154
+ };
@@ -0,0 +1,6 @@
1
+ import { defineComponent } from "convex/server";
2
+
3
+ // Convex component definition entrypoint for npm distribution.
4
+ const component = defineComponent("campaigns");
5
+
6
+ export default component;
package/package.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "@primocaredentgroup/convex-campaigns-component",
3
+ "version": "0.1.1",
4
+ "description": "Convex Campaigns backend component for PrimoCore",
5
+ "license": "UNLICENSED",
6
+ "type": "module",
7
+ "main": "convex.config.ts",
8
+ "files": [
9
+ "convex.config.ts",
10
+ "README.md",
11
+ "convex/**"
12
+ ],
13
+ "scripts": {
14
+ "sync:from-repo": "node ./scripts/sync-from-repo.mjs",
15
+ "prepack": "npm run sync:from-repo"
16
+ },
17
+ "peerDependencies": {
18
+ "convex": "^1.14.0"
19
+ },
20
+ "publishConfig": {
21
+ "access": "restricted"
22
+ }
23
+ }