@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.
- package/CHANGELOG.md +52 -0
- package/README.md +113 -1
- package/convex/_generated/api.ts +1 -0
- package/convex/campaigns.ts +25 -0
- package/convex/components/campaigns/domain/stateMachine.ts +16 -0
- package/convex/components/campaigns/domain/types.ts +54 -1
- package/convex/components/campaigns/functions/authzInternal.ts +39 -13
- package/convex/components/campaigns/functions/internal.ts +67 -22
- package/convex/components/campaigns/functions/mutations.ts +14 -2
- package/convex/components/campaigns/functions/playground.ts +780 -0
- package/convex/components/campaigns/functions/public.ts +248 -0
- package/convex/components/campaigns/functions/queries.ts +175 -81
- package/convex/components/campaigns/index.ts +4 -0
- package/convex/components/campaigns/lib/helpers.ts +9 -0
- package/convex/components/campaigns/permissions.ts +82 -23
- package/convex/components/campaigns/ports/patientData.ts +3 -3
- package/convex/components/campaigns/schema.ts +124 -2
- package/convex/components/campaigns/v2/callPolicy.ts +67 -0
- package/convex/components/campaigns/v2/dataSource.ts +262 -0
- package/convex/components/campaigns/v2/fieldRegistry.ts +174 -0
- package/convex/components/campaigns/v2/rules.ts +175 -0
- package/convex/components/campaigns/v2/types.ts +62 -0
- package/convex/playground.ts +149 -0
- package/convex.config.ts +11 -4
- package/package.json +17 -3
- package/scripts/smoke-test.mjs +171 -0
- package/scripts/sync-from-repo.mjs +42 -0
|
@@ -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(
|
|
79
|
+
clinicId: v.optional(clinicIdFilterValidator),
|
|
13
80
|
}),
|
|
14
81
|
),
|
|
15
82
|
},
|
|
16
83
|
handler: async (ctx, args) => {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
43
|
-
.
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
.
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
counters
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
+
}
|