@primocaredentgroup/convex-campaigns-component 0.1.3 → 0.3.0

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,248 @@
1
+ /**
2
+ * Public API host-facing: surface stabile per PrimoUpCore e componente Richiami.
3
+ * Usare queste API per integrazione in produzione.
4
+ */
5
+ import { v } from "convex/values";
6
+ import { mutation, query } from "../../../_generated/server";
7
+ import { assertCanManageCampaigns } from "../permissions";
8
+ import { getFieldRegistryPublic } from "../v2/fieldRegistry";
9
+ import { getDefaultCallPolicy } from "../v2/callPolicy";
10
+ import { normalizeClinicId, normalizeClinicIds } from "../lib/helpers";
11
+ import type { Id } from "../../../_generated/dataModel";
12
+
13
+ async function safeCampaignsByOrgStatus(ctx: any, orgId: string, status: string) {
14
+ try {
15
+ return await ctx.db
16
+ .query("campaigns")
17
+ .withIndex("by_org_status", (q: any) => q.eq("orgId", orgId).eq("status", status))
18
+ .collect();
19
+ } catch {
20
+ return await ctx.db
21
+ .query("campaigns")
22
+ .filter((q: any) => q.and(q.eq(q.field("orgId"), orgId), q.eq(q.field("status"), status)))
23
+ .collect();
24
+ }
25
+ }
26
+
27
+ async function safeCampaignsByOrgPriority(ctx: any, orgId: string) {
28
+ try {
29
+ return await ctx.db
30
+ .query("campaigns")
31
+ .withIndex("by_org_priority", (q: any) => q.eq("orgId", orgId))
32
+ .collect();
33
+ } catch {
34
+ return await ctx.db
35
+ .query("campaigns")
36
+ .filter((q: any) => q.eq(q.field("orgId"), orgId))
37
+ .collect();
38
+ }
39
+ }
40
+
41
+ async function safeAudienceMembersByOrgPatient(ctx: any, orgId: string, patientId: string) {
42
+ try {
43
+ return await ctx.db
44
+ .query("audienceMembers")
45
+ .withIndex("by_org_patient", (q: any) => q.eq("orgId", orgId).eq("patientId", patientId))
46
+ .collect();
47
+ } catch {
48
+ return await ctx.db
49
+ .query("audienceMembers")
50
+ .filter(
51
+ (q: any) => q.eq(q.field("orgId"), orgId) && q.eq(q.field("patientId"), patientId),
52
+ )
53
+ .collect();
54
+ }
55
+ }
56
+
57
+ export const listActiveCampaignsForOrg = query({
58
+ args: {
59
+ orgId: v.string(),
60
+ clinicId: v.optional(v.string()),
61
+ },
62
+ handler: async (ctx, args) => {
63
+ await assertCanManageCampaigns(ctx);
64
+ const campaigns = await safeCampaignsByOrgStatus(ctx, args.orgId, "ready");
65
+ const clinicIdNorm = args.clinicId ? normalizeClinicId(args.clinicId) : null;
66
+
67
+ const filtered =
68
+ clinicIdNorm === null
69
+ ? campaigns
70
+ : campaigns.filter((c: any) => {
71
+ const scopeIds = (c.scope?.clinicIds ?? c.scopeClinicIds ?? []).map(String);
72
+ return scopeIds.length === 0 || scopeIds.includes(clinicIdNorm);
73
+ });
74
+
75
+ const result: Array<{
76
+ campaignId: Id<"campaigns">;
77
+ name: string;
78
+ priority: number;
79
+ status: string;
80
+ scopeClinicIds: string[];
81
+ latestPublishedVersionId?: Id<"campaignVersions">;
82
+ latestPublishedVersionNumber?: number;
83
+ callPolicy: any;
84
+ }> = [];
85
+
86
+ for (const camp of filtered) {
87
+ const versions = await ctx.db
88
+ .query("campaignVersions")
89
+ .withIndex("by_campaign", (q: any) => q.eq("campaignId", camp._id))
90
+ .collect();
91
+ const latest = versions.sort((a: any, b: any) => b.version - a.version)[0];
92
+ const scopeClinicIds = (camp.scope?.clinicIds ?? camp.scopeClinicIds ?? []).map(String);
93
+ result.push({
94
+ campaignId: camp._id,
95
+ name: camp.name,
96
+ priority: Math.max(1, camp.priority ?? 100),
97
+ status: camp.status,
98
+ scopeClinicIds,
99
+ latestPublishedVersionId: latest?._id,
100
+ latestPublishedVersionNumber: latest?.version,
101
+ callPolicy: camp.callPolicy ?? getDefaultCallPolicy(),
102
+ });
103
+ }
104
+
105
+ return result.sort((a, b) => a.priority - b.priority);
106
+ },
107
+ });
108
+
109
+ export const getLatestPublishedVersion = query({
110
+ args: { campaignId: v.id("campaigns") },
111
+ handler: async (ctx, args) => {
112
+ await assertCanManageCampaigns(ctx);
113
+ const versions = await ctx.db
114
+ .query("campaignVersions")
115
+ .withIndex("by_campaign", (q: any) => q.eq("campaignId", args.campaignId))
116
+ .collect();
117
+ const latest = versions.sort((a: any, b: any) => b.version - a.version)[0];
118
+ if (!latest) return null;
119
+ return {
120
+ versionId: latest._id,
121
+ versionNumber: latest.version,
122
+ publishedAt: latest.publishedAt,
123
+ callPolicySnapshot: latest.callPolicySnapshot ?? getDefaultCallPolicy(),
124
+ rulesSnapshot: latest.rulesSnapshot,
125
+ scopeSnapshot: latest.scopeSnapshot,
126
+ };
127
+ },
128
+ });
129
+
130
+ export const getPatientCampaignMemberships = query({
131
+ args: { orgId: v.string(), patientId: v.string() },
132
+ handler: async (ctx, args) => {
133
+ await assertCanManageCampaigns(ctx);
134
+ const memberships = await safeAudienceMembersByOrgPatient(
135
+ ctx,
136
+ args.orgId,
137
+ args.patientId,
138
+ );
139
+ if (memberships.length === 0) return [];
140
+
141
+ const versionIds = [...new Set(memberships.map((m: any) => m.versionId))];
142
+ const versionsMap = new Map<string, any>();
143
+ for (const vid of versionIds) {
144
+ const ver = await ctx.db.get(vid);
145
+ if (ver) versionsMap.set(vid, ver);
146
+ }
147
+
148
+ const campaignIds = [...new Set([...versionsMap.values()].map((v: any) => v.campaignId))];
149
+ const campaignsMap = new Map<string, any>();
150
+ for (const cid of campaignIds) {
151
+ const camp = await ctx.db.get(cid);
152
+ if (camp && camp.status === "ready") campaignsMap.set(cid, camp);
153
+ }
154
+
155
+ const byCampaign = new Map<string, { versionId: string; versionNumber: number }>();
156
+ for (const m of memberships) {
157
+ const ver = versionsMap.get(m.versionId);
158
+ const camp = campaignsMap.get(ver?.campaignId);
159
+ if (!ver || !camp) continue;
160
+ const prev = byCampaign.get(ver.campaignId);
161
+ if (!prev || ver.version > prev.versionNumber) {
162
+ byCampaign.set(ver.campaignId, { versionId: m.versionId, versionNumber: ver.version });
163
+ }
164
+ }
165
+
166
+ const result = [...byCampaign.entries()]
167
+ .map(([campaignId, v]) => {
168
+ const campaign = campaignsMap.get(campaignId);
169
+ if (!campaign) return null;
170
+ return {
171
+ campaignId,
172
+ campaignName: campaign.name,
173
+ priority: Math.max(1, campaign.priority ?? 100),
174
+ status: campaign.status,
175
+ latestPublishedVersionId: v.versionId,
176
+ latestPublishedVersionNumber: v.versionNumber,
177
+ };
178
+ })
179
+ .filter(Boolean) as any[];
180
+
181
+ return result.sort((a, b) => a.priority - b.priority);
182
+ },
183
+ });
184
+
185
+ export const getAudiencePage = query({
186
+ args: {
187
+ versionId: v.id("campaignVersions"),
188
+ clinicId: v.optional(v.string()),
189
+ cursor: v.optional(v.string()),
190
+ limit: v.optional(v.number()),
191
+ },
192
+ handler: async (ctx, args) => {
193
+ await assertCanManageCampaigns(ctx);
194
+ const limit = Math.min(200, Math.max(1, args.limit ?? 50));
195
+ const clinicIdNorm = args.clinicId ? normalizeClinicId(args.clinicId) : undefined;
196
+
197
+ if (clinicIdNorm) {
198
+ const page = await ctx.db
199
+ .query("audienceMembers")
200
+ .withIndex("by_version_clinic_createdAt", (q: any) =>
201
+ q.eq("versionId", args.versionId).eq("clinicId", clinicIdNorm),
202
+ )
203
+ .paginate({ cursor: args.cursor ?? null, numItems: limit });
204
+ return { page: page.page, isDone: page.isDone, continueCursor: page.continueCursor };
205
+ }
206
+
207
+ const page = await ctx.db
208
+ .query("audienceMembers")
209
+ .withIndex("by_version_createdAt", (q: any) => q.eq("versionId", args.versionId))
210
+ .paginate({ cursor: args.cursor ?? null, numItems: limit });
211
+ return { page: page.page, isDone: page.isDone, continueCursor: page.continueCursor };
212
+ },
213
+ });
214
+
215
+ export const recordContactAttempt = mutation({
216
+ args: {
217
+ orgId: v.string(),
218
+ clinicId: v.string(),
219
+ patientId: v.string(),
220
+ campaignId: v.id("campaigns"),
221
+ versionId: v.id("campaignVersions"),
222
+ channel: v.union(v.literal("call"), v.literal("sms"), v.literal("email")),
223
+ outcome: v.union(
224
+ v.literal("answered"),
225
+ v.literal("no_answer"),
226
+ v.literal("busy"),
227
+ v.literal("wrong_number"),
228
+ v.literal("opt_out"),
229
+ v.literal("success"),
230
+ v.literal("failed"),
231
+ ),
232
+ },
233
+ handler: async (ctx, args) => {
234
+ await assertCanManageCampaigns(ctx);
235
+ const clinicIdNorm = normalizeClinicId(args.clinicId);
236
+ const id = await ctx.db.insert("contactLog", {
237
+ ...args,
238
+ clinicId: clinicIdNorm,
239
+ createdAt: Date.now(),
240
+ });
241
+ return { id };
242
+ },
243
+ });
244
+
245
+ export const getFieldRegistry = query({
246
+ args: {},
247
+ handler: async () => getFieldRegistryPublic(),
248
+ });
@@ -3,76 +3,150 @@ 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());
7
+
8
+ function isRecoverableQueryError(error: unknown): boolean {
9
+ const message = error instanceof Error ? error.message : String(error);
10
+ return (
11
+ message.includes("CAMPAIGNS_UNAUTHORIZED") ||
12
+ message.includes("CAMPAIGNS_AUTH_") ||
13
+ (message.includes("Index") && message.includes("not found"))
14
+ );
15
+ }
16
+
17
+ async function safeCollectCampaignsByStatus(ctx: any, status: any) {
18
+ try {
19
+ return await ctx.db
20
+ .query("campaigns")
21
+ .withIndex("by_status", (q: any) => q.eq("status", status))
22
+ .collect();
23
+ } catch {
24
+ return await ctx.db
25
+ .query("campaigns")
26
+ .filter((q: any) => q.eq(q.field("status"), status))
27
+ .collect();
28
+ }
29
+ }
30
+
31
+ async function safeCollectStepsByCampaign(ctx: any, campaignId: string) {
32
+ try {
33
+ return await ctx.db
34
+ .query("campaign_steps")
35
+ .withIndex("by_campaign", (q: any) => q.eq("campaignId", campaignId))
36
+ .collect();
37
+ } catch {
38
+ return await ctx.db
39
+ .query("campaign_steps")
40
+ .filter((q: any) => q.eq(q.field("campaignId"), campaignId))
41
+ .collect();
42
+ }
43
+ }
44
+
45
+ async function safeCollectSnapshotsByCampaign(ctx: any, campaignId: string) {
46
+ try {
47
+ return await ctx.db
48
+ .query("audience_snapshots")
49
+ .withIndex("by_campaign_createdAt", (q: any) => q.eq("campaignId", campaignId))
50
+ .collect();
51
+ } catch {
52
+ return await ctx.db
53
+ .query("audience_snapshots")
54
+ .filter((q: any) => q.eq(q.field("campaignId"), campaignId))
55
+ .collect();
56
+ }
57
+ }
58
+
59
+ async function safeCollectMembersBySnapshot(ctx: any, snapshotId: string) {
60
+ try {
61
+ return await ctx.db
62
+ .query("audience_members")
63
+ .withIndex("by_snapshot", (q: any) => q.eq("snapshotId", snapshotId))
64
+ .collect();
65
+ } catch {
66
+ return await ctx.db
67
+ .query("audience_members")
68
+ .filter((q: any) => q.eq(q.field("snapshotId"), snapshotId))
69
+ .collect();
70
+ }
71
+ }
72
+
6
73
  export const listCampaigns = query({
7
74
  args: {
8
75
  filters: v.optional(
9
76
  v.object({
10
77
  status: v.optional(campaignStatusValidator),
11
78
  scopeType: v.optional(scopeTypeValidator),
12
- clinicId: v.optional(v.id("clinics")),
79
+ clinicId: v.optional(clinicIdFilterValidator),
13
80
  }),
14
81
  ),
15
82
  },
16
83
  handler: async (ctx, args) => {
17
- await assertAuthorized(ctx, ["Admin", "Marketing"]);
18
- let campaigns = args.filters?.status
19
- ? await ctx.db
20
- .query("campaigns")
21
- .withIndex("by_status", (q) => q.eq("status", args.filters!.status!))
22
- .collect()
23
- : await ctx.db.query("campaigns").collect();
24
-
25
- if (args.filters?.scopeType) {
26
- campaigns = campaigns.filter((c) => c.scopeType === args.filters?.scopeType);
27
- }
28
- if (args.filters?.clinicId) {
29
- campaigns = campaigns.filter((c) => c.scopeClinicIds.includes(args.filters!.clinicId!));
84
+ try {
85
+ await assertAuthorized(ctx, ["Admin", "Marketing"]);
86
+ let campaigns = args.filters?.status
87
+ ? await safeCollectCampaignsByStatus(ctx, args.filters.status)
88
+ : await ctx.db.query("campaigns").collect();
89
+
90
+ if (args.filters?.scopeType) {
91
+ campaigns = campaigns.filter((c: any) => c.scopeType === args.filters?.scopeType);
92
+ }
93
+ if (args.filters?.clinicId) {
94
+ campaigns = campaigns.filter((c: any) =>
95
+ c.scopeClinicIds.map((id: any) => String(id)).includes(String(args.filters!.clinicId!)),
96
+ );
97
+ }
98
+ return campaigns.sort((a: any, b: any) => b.updatedAt - a.updatedAt);
99
+ } catch (error) {
100
+ if (!isRecoverableQueryError(error)) throw error;
101
+ console.error("[campaigns.listCampaigns] recoverable error:", error);
102
+ return [];
30
103
  }
31
- return campaigns.sort((a, b) => b.updatedAt - a.updatedAt);
32
104
  },
33
105
  });
34
106
 
35
107
  export const getCampaign = query({
36
108
  args: { campaignId: v.id("campaigns") },
37
109
  handler: async (ctx, args) => {
38
- await assertAuthorized(ctx, ["Admin", "Marketing"]);
39
- const campaign = await ctx.db.get(args.campaignId);
40
- if (!campaign) return null;
110
+ try {
111
+ await assertAuthorized(ctx, ["Admin", "Marketing"]);
112
+ const campaign = await ctx.db.get(args.campaignId);
113
+ if (!campaign) return null;
41
114
 
42
- const steps = await ctx.db
43
- .query("campaign_steps")
44
- .withIndex("by_campaign", (q) => q.eq("campaignId", args.campaignId))
45
- .collect();
46
-
47
- const snapshots = await ctx.db
48
- .query("audience_snapshots")
49
- .withIndex("by_campaign_createdAt", (q) => q.eq("campaignId", args.campaignId))
50
- .collect();
115
+ const steps = await safeCollectStepsByCampaign(ctx, args.campaignId);
116
+ const snapshots = await safeCollectSnapshotsByCampaign(ctx, args.campaignId);
51
117
 
52
- return {
53
- campaign,
54
- steps: steps.sort((a, b) => a.order - b.order),
55
- snapshots: snapshots.sort((a, b) => b.createdAt - a.createdAt),
56
- };
118
+ return {
119
+ campaign,
120
+ steps: steps.sort((a: any, b: any) => a.order - b.order),
121
+ snapshots: snapshots.sort((a: any, b: any) => b.createdAt - a.createdAt),
122
+ };
123
+ } catch (error) {
124
+ if (!isRecoverableQueryError(error)) throw error;
125
+ console.error("[campaigns.getCampaign] recoverable error:", error);
126
+ return null;
127
+ }
57
128
  },
58
129
  });
59
130
 
60
131
  export const getSnapshot = query({
61
132
  args: { snapshotId: v.id("audience_snapshots") },
62
133
  handler: async (ctx, args) => {
63
- await assertAuthorized(ctx, ["Admin", "Marketing"]);
64
- const snapshot = await ctx.db.get(args.snapshotId);
65
- if (!snapshot) return null;
134
+ try {
135
+ await assertAuthorized(ctx, ["Admin", "Marketing"]);
136
+ const snapshot = await ctx.db.get(args.snapshotId);
137
+ if (!snapshot) return null;
66
138
 
67
- const members = await ctx.db
68
- .query("audience_members")
69
- .withIndex("by_snapshot", (q) => q.eq("snapshotId", args.snapshotId))
70
- .collect();
139
+ const members = await safeCollectMembersBySnapshot(ctx, args.snapshotId);
71
140
 
72
- return {
73
- snapshot,
74
- membersCount: members.length,
75
- };
141
+ return {
142
+ snapshot,
143
+ membersCount: members.length,
144
+ };
145
+ } catch (error) {
146
+ if (!isRecoverableQueryError(error)) throw error;
147
+ console.error("[campaigns.getSnapshot] recoverable error:", error);
148
+ return null;
149
+ }
76
150
  },
77
151
  });
78
152
 
@@ -85,47 +159,67 @@ export const getCampaignReport = query({
85
159
  }),
86
160
  },
87
161
  handler: async (ctx, args) => {
88
- await assertAuthorized(ctx, ["Admin", "Marketing"]);
89
- const snapshots = await ctx.db
90
- .query("audience_snapshots")
91
- .withIndex("by_campaign_createdAt", (q) => q.eq("campaignId", args.campaignId))
92
- .collect();
93
-
94
- const inRange = snapshots.filter(
95
- (s) => s.createdAt >= args.range.from && s.createdAt <= args.range.to,
96
- );
97
- const counters = {
98
- totalCandidates: 0,
99
- eligible: 0,
100
- queued: 0,
101
- sent: 0,
102
- taskCreated: 0,
103
- completed: 0,
104
- converted: 0,
105
- failed: 0,
106
- suppressedByReason: {} as Record<string, number>,
162
+ const emptyReport = {
163
+ campaignId: args.campaignId,
164
+ range: args.range,
165
+ snapshotsCount: 0,
166
+ counters: {
167
+ totalCandidates: 0,
168
+ eligible: 0,
169
+ queued: 0,
170
+ sent: 0,
171
+ taskCreated: 0,
172
+ completed: 0,
173
+ converted: 0,
174
+ failed: 0,
175
+ suppressedByReason: {} as Record<string, number>,
176
+ },
107
177
  };
108
178
 
109
- for (const snapshot of inRange) {
110
- counters.totalCandidates += snapshot.stats.totalCandidates;
111
- counters.eligible += snapshot.stats.eligible;
112
- counters.queued += snapshot.stats.queued;
113
- counters.sent += snapshot.stats.sent;
114
- counters.taskCreated += snapshot.stats.taskCreated;
115
- counters.completed += snapshot.stats.completed;
116
- counters.converted += snapshot.stats.converted;
117
- counters.failed += snapshot.stats.failed;
118
- for (const [reason, count] of Object.entries(snapshot.stats.suppressedByReason)) {
119
- counters.suppressedByReason[reason] =
120
- (counters.suppressedByReason[reason] ?? 0) + count;
179
+ try {
180
+ await assertAuthorized(ctx, ["Admin", "Marketing"]);
181
+ const snapshots = await safeCollectSnapshotsByCampaign(ctx, args.campaignId);
182
+
183
+ const inRange = snapshots.filter(
184
+ (s: any) => s.createdAt >= args.range.from && s.createdAt <= args.range.to,
185
+ );
186
+ const counters = {
187
+ totalCandidates: 0,
188
+ eligible: 0,
189
+ queued: 0,
190
+ sent: 0,
191
+ taskCreated: 0,
192
+ completed: 0,
193
+ converted: 0,
194
+ failed: 0,
195
+ suppressedByReason: {} as Record<string, number>,
196
+ };
197
+
198
+ for (const snapshot of inRange) {
199
+ counters.totalCandidates += snapshot.stats.totalCandidates;
200
+ counters.eligible += snapshot.stats.eligible;
201
+ counters.queued += snapshot.stats.queued;
202
+ counters.sent += snapshot.stats.sent;
203
+ counters.taskCreated += snapshot.stats.taskCreated;
204
+ counters.completed += snapshot.stats.completed;
205
+ counters.converted += snapshot.stats.converted;
206
+ counters.failed += snapshot.stats.failed;
207
+ for (const [reason, count] of Object.entries(snapshot.stats.suppressedByReason)) {
208
+ counters.suppressedByReason[reason] =
209
+ (counters.suppressedByReason[reason] ?? 0) + Number(count);
210
+ }
121
211
  }
122
- }
123
212
 
124
- return {
125
- campaignId: args.campaignId,
126
- range: args.range,
127
- snapshotsCount: inRange.length,
128
- counters,
129
- };
213
+ return {
214
+ campaignId: args.campaignId,
215
+ range: args.range,
216
+ snapshotsCount: inRange.length,
217
+ counters,
218
+ };
219
+ } catch (error) {
220
+ if (!isRecoverableQueryError(error)) throw error;
221
+ console.error("[campaigns.getCampaignReport] recoverable error:", error);
222
+ return emptyReport;
223
+ }
130
224
  },
131
225
  });
@@ -2,3 +2,7 @@ export * as campaignsQueries from "./functions/queries";
2
2
  export * as campaignsMutations from "./functions/mutations";
3
3
  export * as campaignsActions from "./functions/actions";
4
4
  export * as campaignsInternal from "./functions/internal";
5
+ export * as campaignsPublic from "./functions/public";
6
+ export * as campaignsPlayground from "./functions/playground";
7
+
8
+ export type { CampaignCallPolicy, CampaignOutcome, RuleGroup, RuleCondition } from "./v2/types";
@@ -0,0 +1,9 @@
1
+ /** Normalizza clinicId al boundary: accetta string o Convex id, ritorna string. */
2
+ export function normalizeClinicId(id: string | unknown): string {
3
+ return String(id ?? "").trim();
4
+ }
5
+
6
+ /** Normalizza array di clinicIds. */
7
+ export function normalizeClinicIds(ids?: (string | unknown)[]): string[] {
8
+ return [...new Set((ids ?? []).map((id) => normalizeClinicId(id)).filter(Boolean))];
9
+ }