@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,225 @@
1
+ import { v } from "convex/values";
2
+ import { mutation } from "../../../_generated/server";
3
+ import { assertAuthorized } from "../permissions";
4
+ import {
5
+ campaignStatusValidator,
6
+ runConfigValidator,
7
+ scheduleSpecValidator,
8
+ segmentationRulesValidator,
9
+ stepTypeValidator,
10
+ DEFAULT_RUN_CONFIG,
11
+ } from "../domain/types";
12
+ import * as consentPort from "../ports/consent";
13
+ import * as patientDataPort from "../ports/patientData";
14
+
15
+ function withDefaultRunConfig(input?: {
16
+ capWindowDays?: number;
17
+ capMaxContacts?: number;
18
+ lockDays?: number;
19
+ quietHours?: { startHour: number; endHour: number; timezone?: string };
20
+ }) {
21
+ return {
22
+ capWindowDays: input?.capWindowDays ?? DEFAULT_RUN_CONFIG.capWindowDays,
23
+ capMaxContacts: input?.capMaxContacts ?? DEFAULT_RUN_CONFIG.capMaxContacts,
24
+ lockDays: input?.lockDays ?? DEFAULT_RUN_CONFIG.lockDays,
25
+ quietHours: input?.quietHours,
26
+ };
27
+ }
28
+
29
+ export const createCampaign = mutation({
30
+ args: {
31
+ input: v.object({
32
+ name: v.string(),
33
+ description: v.optional(v.string()),
34
+ ownerUserId: v.optional(v.id("users")),
35
+ scopeType: v.union(v.literal("HQ"), v.literal("CLINIC")),
36
+ scopeClinicIds: v.array(v.id("clinics")),
37
+ priority: v.number(),
38
+ defaultRunConfig: v.optional(runConfigValidator),
39
+ }),
40
+ },
41
+ handler: async (ctx, args) => {
42
+ await assertAuthorized(ctx, ["Admin", "Marketing"]);
43
+ const now = Date.now();
44
+ return ctx.db.insert("campaigns", {
45
+ ...args.input,
46
+ defaultRunConfig: withDefaultRunConfig(args.input.defaultRunConfig),
47
+ status: "DRAFT",
48
+ createdAt: now,
49
+ updatedAt: now,
50
+ });
51
+ },
52
+ });
53
+
54
+ export const updateCampaign = mutation({
55
+ args: {
56
+ campaignId: v.id("campaigns"),
57
+ patch: v.object({
58
+ name: v.optional(v.string()),
59
+ description: v.optional(v.string()),
60
+ ownerUserId: v.optional(v.id("users")),
61
+ scopeType: v.optional(v.union(v.literal("HQ"), v.literal("CLINIC"))),
62
+ scopeClinicIds: v.optional(v.array(v.id("clinics"))),
63
+ priority: v.optional(v.number()),
64
+ defaultRunConfig: v.optional(runConfigValidator),
65
+ }),
66
+ },
67
+ handler: async (ctx, args) => {
68
+ await assertAuthorized(ctx, ["Admin", "Marketing"]);
69
+ const campaign = await ctx.db.get(args.campaignId);
70
+ if (!campaign) throw new Error("Campaign not found");
71
+ await ctx.db.patch(args.campaignId, {
72
+ ...args.patch,
73
+ defaultRunConfig: args.patch.defaultRunConfig
74
+ ? withDefaultRunConfig(args.patch.defaultRunConfig)
75
+ : campaign.defaultRunConfig,
76
+ updatedAt: Date.now(),
77
+ });
78
+ return { success: true };
79
+ },
80
+ });
81
+
82
+ export const setCampaignStatus = mutation({
83
+ args: {
84
+ campaignId: v.id("campaigns"),
85
+ status: campaignStatusValidator,
86
+ },
87
+ handler: async (ctx, args) => {
88
+ await assertAuthorized(ctx, ["Admin", "Marketing"]);
89
+ await ctx.db.patch(args.campaignId, { status: args.status, updatedAt: Date.now() });
90
+ await ctx.db.insert("event_log", {
91
+ campaignId: args.campaignId,
92
+ type: "campaign_status_changed",
93
+ payload: { status: args.status },
94
+ createdAt: Date.now(),
95
+ });
96
+ return { success: true };
97
+ },
98
+ });
99
+
100
+ export const addOrUpdateSteps = mutation({
101
+ args: {
102
+ campaignId: v.id("campaigns"),
103
+ steps: v.array(
104
+ v.object({
105
+ stepId: v.optional(v.id("campaign_steps")),
106
+ order: v.number(),
107
+ type: stepTypeValidator,
108
+ templateRef: v.optional(v.string()),
109
+ scheduleSpec: scheduleSpecValidator,
110
+ }),
111
+ ),
112
+ },
113
+ handler: async (ctx, args) => {
114
+ await assertAuthorized(ctx, ["Admin", "Marketing"]);
115
+ const now = Date.now();
116
+ const createdOrUpdated: string[] = [];
117
+ for (const step of args.steps) {
118
+ if (step.stepId) {
119
+ await ctx.db.patch(step.stepId, {
120
+ order: step.order,
121
+ type: step.type,
122
+ templateRef: step.templateRef,
123
+ scheduleSpec: step.scheduleSpec,
124
+ updatedAt: now,
125
+ });
126
+ createdOrUpdated.push(step.stepId);
127
+ } else {
128
+ const id = await ctx.db.insert("campaign_steps", {
129
+ campaignId: args.campaignId,
130
+ order: step.order,
131
+ type: step.type,
132
+ templateRef: step.templateRef,
133
+ scheduleSpec: step.scheduleSpec,
134
+ createdAt: now,
135
+ updatedAt: now,
136
+ });
137
+ createdOrUpdated.push(id);
138
+ }
139
+ }
140
+ return { stepIds: createdOrUpdated };
141
+ },
142
+ });
143
+
144
+ export const previewAudience = mutation({
145
+ args: {
146
+ campaignId: v.id("campaigns"),
147
+ segmentationRules: segmentationRulesValidator,
148
+ },
149
+ handler: async (ctx, args) => {
150
+ await assertAuthorized(ctx, ["Admin", "Marketing"]);
151
+ const campaign = await ctx.db.get(args.campaignId);
152
+ if (!campaign) throw new Error("Campaign not found");
153
+
154
+ let cursor: string | null = null;
155
+ let total = 0;
156
+ let withEmail = 0;
157
+ let withPhone = 0;
158
+ let blacklisted = 0;
159
+ const maxPages = 5;
160
+ let pages = 0;
161
+
162
+ while (pages < maxPages) {
163
+ const page = await patientDataPort.queryCandidates(
164
+ ctx,
165
+ { scopeType: campaign.scopeType, scopeClinicIds: campaign.scopeClinicIds },
166
+ args.segmentationRules as any,
167
+ cursor,
168
+ );
169
+ for (const p of page.patients) {
170
+ total += 1;
171
+ const consent = await consentPort.getConsent(ctx, p.patientId);
172
+ if (p.email && consent.email) withEmail += 1;
173
+ if (p.phone && consent.phone) withPhone += 1;
174
+ if (consent.blacklisted) blacklisted += 1;
175
+ }
176
+ cursor = page.nextCursor;
177
+ pages += 1;
178
+ if (!cursor) break;
179
+ }
180
+
181
+ return {
182
+ counts: { total, withEmail, withPhone, blacklisted },
183
+ warnings: cursor
184
+ ? ["Preview limited to first pages for performance. Build snapshot for full results."]
185
+ : [],
186
+ };
187
+ },
188
+ });
189
+
190
+ export const buildAudienceSnapshot = mutation({
191
+ args: {
192
+ campaignId: v.id("campaigns"),
193
+ segmentationRules: segmentationRulesValidator,
194
+ runConfig: v.optional(runConfigValidator),
195
+ },
196
+ handler: async (ctx, args) => {
197
+ await assertAuthorized(ctx, ["Admin", "Marketing"]);
198
+ const campaign = await ctx.db.get(args.campaignId);
199
+ if (!campaign) throw new Error("Campaign not found");
200
+
201
+ const now = Date.now();
202
+ const snapshotId = await ctx.db.insert("audience_snapshots", {
203
+ campaignId: args.campaignId,
204
+ createdAt: now,
205
+ createdBy: undefined,
206
+ status: "BUILDING",
207
+ segmentationRules: args.segmentationRules as any,
208
+ runConfig: withDefaultRunConfig(args.runConfig ?? campaign.defaultRunConfig),
209
+ nextCursor: undefined,
210
+ stats: {
211
+ totalCandidates: 0,
212
+ eligible: 0,
213
+ suppressedByReason: {},
214
+ queued: 0,
215
+ sent: 0,
216
+ taskCreated: 0,
217
+ completed: 0,
218
+ converted: 0,
219
+ failed: 0,
220
+ },
221
+ updatedAt: now,
222
+ });
223
+ return { snapshotId };
224
+ },
225
+ });
@@ -0,0 +1,131 @@
1
+ import { v } from "convex/values";
2
+ import { query } from "../../../_generated/server";
3
+ import { assertAuthorized } from "../permissions";
4
+ import { campaignStatusValidator, scopeTypeValidator } from "../domain/types";
5
+
6
+ export const listCampaigns = query({
7
+ args: {
8
+ filters: v.optional(
9
+ v.object({
10
+ status: v.optional(campaignStatusValidator),
11
+ scopeType: v.optional(scopeTypeValidator),
12
+ clinicId: v.optional(v.id("clinics")),
13
+ }),
14
+ ),
15
+ },
16
+ 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!));
30
+ }
31
+ return campaigns.sort((a, b) => b.updatedAt - a.updatedAt);
32
+ },
33
+ });
34
+
35
+ export const getCampaign = query({
36
+ args: { campaignId: v.id("campaigns") },
37
+ handler: async (ctx, args) => {
38
+ await assertAuthorized(ctx, ["Admin", "Marketing"]);
39
+ const campaign = await ctx.db.get(args.campaignId);
40
+ if (!campaign) return null;
41
+
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();
51
+
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
+ };
57
+ },
58
+ });
59
+
60
+ export const getSnapshot = query({
61
+ args: { snapshotId: v.id("audience_snapshots") },
62
+ handler: async (ctx, args) => {
63
+ await assertAuthorized(ctx, ["Admin", "Marketing"]);
64
+ const snapshot = await ctx.db.get(args.snapshotId);
65
+ if (!snapshot) return null;
66
+
67
+ const members = await ctx.db
68
+ .query("audience_members")
69
+ .withIndex("by_snapshot", (q) => q.eq("snapshotId", args.snapshotId))
70
+ .collect();
71
+
72
+ return {
73
+ snapshot,
74
+ membersCount: members.length,
75
+ };
76
+ },
77
+ });
78
+
79
+ export const getCampaignReport = query({
80
+ args: {
81
+ campaignId: v.id("campaigns"),
82
+ range: v.object({
83
+ from: v.number(),
84
+ to: v.number(),
85
+ }),
86
+ },
87
+ 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>,
107
+ };
108
+
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;
121
+ }
122
+ }
123
+
124
+ return {
125
+ campaignId: args.campaignId,
126
+ range: args.range,
127
+ snapshotsCount: inRange.length,
128
+ counters,
129
+ };
130
+ },
131
+ });
@@ -0,0 +1,4 @@
1
+ export * as campaignsQueries from "./functions/queries";
2
+ export * as campaignsMutations from "./functions/mutations";
3
+ export * as campaignsActions from "./functions/actions";
4
+ export * as campaignsInternal from "./functions/internal";
@@ -0,0 +1,112 @@
1
+ import type { Id } from "../../_generated/dataModel";
2
+ import { internal } from "../../_generated/api";
3
+
4
+ type Role = "Admin" | "Marketing" | "System";
5
+ type AnyCtx = {
6
+ auth: { getUserIdentity: () => Promise<any> };
7
+ db?: any;
8
+ runQuery?: (fn: any, args: any) => Promise<any>;
9
+ };
10
+
11
+ function normalizeRole(value: string | undefined): string {
12
+ return (value ?? "").trim().toLowerCase();
13
+ }
14
+
15
+ async function resolveCurrentUserWithDb(ctx: AnyCtx) {
16
+ if (!ctx.db) return null;
17
+ const identity = await ctx.auth.getUserIdentity();
18
+ if (!identity) return null;
19
+
20
+ if (identity.subject) {
21
+ const byAuth0 = await ctx.db
22
+ .query("users")
23
+ .withIndex("by_auth0", (q: any) => q.eq("auth0Id", identity.subject))
24
+ .first();
25
+ if (byAuth0?.isActive) return byAuth0;
26
+ }
27
+
28
+ if (identity.email) {
29
+ const byEmail = await ctx.db
30
+ .query("users")
31
+ .withIndex("by_email", (q: any) => q.eq("email", identity.email!))
32
+ .first();
33
+ if (byEmail?.isActive) return byEmail;
34
+ }
35
+
36
+ return null;
37
+ }
38
+
39
+ async function getUserRoleCodes(ctx: AnyCtx, userId: Id<"users">): Promise<Set<string>> {
40
+ if (!ctx.db) return new Set<string>();
41
+ const roles = new Set<string>();
42
+
43
+ const user = await ctx.db.get(userId);
44
+ if (user?.role) {
45
+ roles.add(normalizeRole(user.role));
46
+ }
47
+
48
+ const userClinicRoles = await ctx.db
49
+ .query("user_clinic_roles")
50
+ .withIndex("by_user", (q: any) => q.eq("userId", userId))
51
+ .collect();
52
+ for (const userClinicRole of userClinicRoles) {
53
+ if (!userClinicRole.isActive) continue;
54
+ const roleDoc = await ctx.db.get(userClinicRole.roleId);
55
+ if (roleDoc?.isActive && roleDoc.code) {
56
+ roles.add(normalizeRole(roleDoc.code));
57
+ }
58
+ }
59
+
60
+ return roles;
61
+ }
62
+
63
+ /**
64
+ * MVP authorization guard.
65
+ * - If auth identity is available: enforces required roles.
66
+ * - If auth identity is missing: currently allows access to avoid blocking service calls
67
+ * while host identity wiring is being finalized.
68
+ * TODO: tighten unauthenticated fallback once host auth is fully configured.
69
+ */
70
+ export async function assertAuthorized(ctx: AnyCtx, requiredRoles: Role[]): Promise<void> {
71
+ const identity = await ctx.auth.getUserIdentity();
72
+ if (!identity) {
73
+ if (requiredRoles.includes("System")) {
74
+ return;
75
+ }
76
+ throw new Error("Unauthorized: missing identity");
77
+ }
78
+
79
+ let userRoles = new Set<string>();
80
+ if (ctx.db) {
81
+ const user = await resolveCurrentUserWithDb(ctx);
82
+ if (!user) {
83
+ throw new Error("Unauthorized: authenticated identity not mapped to active user");
84
+ }
85
+ userRoles = await getUserRoleCodes(ctx, user._id);
86
+ } else if (ctx.runQuery) {
87
+ const resolved = await ctx.runQuery(
88
+ (internal as any).components.campaigns.functions.authzInternal.resolveRolesForIdentity,
89
+ {
90
+ subject: identity.subject ?? undefined,
91
+ email: identity.email ?? undefined,
92
+ },
93
+ );
94
+ userRoles = new Set<string>((resolved?.roles ?? []) as string[]);
95
+ if (userRoles.size === 0) {
96
+ throw new Error("Unauthorized: identity has no mapped active roles");
97
+ }
98
+ } else {
99
+ throw new Error("Unauthorized: unsupported execution context");
100
+ }
101
+
102
+ const required = requiredRoles.map((r) => normalizeRole(r));
103
+ const hasRole = required.some((role) => userRoles.has(role));
104
+
105
+ if (!hasRole) {
106
+ throw new Error(
107
+ `Forbidden: required one of roles [${requiredRoles.join(", ")}], user roles: [${[
108
+ ...userRoles,
109
+ ].join(", ")}]`,
110
+ );
111
+ }
112
+ }
@@ -0,0 +1,47 @@
1
+ import type { MutationCtx } from "../../../_generated/server";
2
+ import type { Id } from "../../../_generated/dataModel";
3
+
4
+ export async function createOutboxTasks(
5
+ ctx: MutationCtx,
6
+ tasks: Array<{
7
+ patientId: Id<"patients">;
8
+ clinicId?: Id<"clinics">;
9
+ campaignId: Id<"campaigns">;
10
+ snapshotId: Id<"audience_snapshots">;
11
+ stepId: Id<"campaign_steps">;
12
+ dueAt: number;
13
+ priority: number;
14
+ script?: string;
15
+ dedupeKey: string;
16
+ }>,
17
+ ) {
18
+ const ids: Id<"call_tasks_outbox">[] = [];
19
+ for (const task of tasks) {
20
+ const existing = await ctx.db
21
+ .query("call_tasks_outbox")
22
+ .withIndex("by_dedupe_key", (q) => q.eq("dedupeKey", task.dedupeKey))
23
+ .first();
24
+ if (existing) {
25
+ ids.push(existing._id);
26
+ continue;
27
+ }
28
+
29
+ const id = await ctx.db.insert("call_tasks_outbox", {
30
+ patientId: task.patientId,
31
+ clinicId: task.clinicId,
32
+ campaignId: task.campaignId,
33
+ snapshotId: task.snapshotId,
34
+ stepId: task.stepId,
35
+ dueAt: task.dueAt,
36
+ priority: task.priority,
37
+ script: task.script,
38
+ attempt: 0,
39
+ status: "PENDING",
40
+ dedupeKey: task.dedupeKey,
41
+ createdAt: Date.now(),
42
+ updatedAt: Date.now(),
43
+ });
44
+ ids.push(id);
45
+ }
46
+ return ids;
47
+ }
@@ -0,0 +1,37 @@
1
+ import type { QueryCtx, MutationCtx } from "../../../_generated/server";
2
+ import type { Id } from "../../../_generated/dataModel";
3
+
4
+ export type ConsentResult = {
5
+ email: boolean;
6
+ sms: boolean;
7
+ phone: boolean;
8
+ blacklisted: boolean;
9
+ };
10
+
11
+ export async function getConsent(
12
+ ctx: QueryCtx | MutationCtx,
13
+ patientId: Id<"patients">,
14
+ ): 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 };
29
+ }
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
+ }
@@ -0,0 +1,64 @@
1
+ import type { MutationCtx } from "../../../_generated/server";
2
+ import type { Id } from "../../../_generated/dataModel";
3
+
4
+ export async function enqueueEmail(
5
+ ctx: MutationCtx,
6
+ input: {
7
+ campaignId: Id<"campaigns">;
8
+ snapshotId: Id<"audience_snapshots">;
9
+ patientId: Id<"patients">;
10
+ stepId: Id<"campaign_steps">;
11
+ dedupeKey: string;
12
+ payload: Record<string, any>;
13
+ },
14
+ ) {
15
+ const existing = await ctx.db
16
+ .query("message_jobs")
17
+ .withIndex("by_dedupe_key", (q) => q.eq("dedupeKey", input.dedupeKey))
18
+ .first();
19
+ if (existing) return existing._id;
20
+
21
+ return ctx.db.insert("message_jobs", {
22
+ channel: "EMAIL",
23
+ campaignId: input.campaignId,
24
+ snapshotId: input.snapshotId,
25
+ patientId: input.patientId,
26
+ stepId: input.stepId,
27
+ payload: input.payload,
28
+ status: "QUEUED",
29
+ dedupeKey: input.dedupeKey,
30
+ createdAt: Date.now(),
31
+ updatedAt: Date.now(),
32
+ });
33
+ }
34
+
35
+ export async function enqueueSms(
36
+ ctx: MutationCtx,
37
+ input: {
38
+ campaignId: Id<"campaigns">;
39
+ snapshotId: Id<"audience_snapshots">;
40
+ patientId: Id<"patients">;
41
+ stepId: Id<"campaign_steps">;
42
+ dedupeKey: string;
43
+ payload: Record<string, any>;
44
+ },
45
+ ) {
46
+ const existing = await ctx.db
47
+ .query("message_jobs")
48
+ .withIndex("by_dedupe_key", (q) => q.eq("dedupeKey", input.dedupeKey))
49
+ .first();
50
+ if (existing) return existing._id;
51
+
52
+ return ctx.db.insert("message_jobs", {
53
+ channel: "SMS",
54
+ campaignId: input.campaignId,
55
+ snapshotId: input.snapshotId,
56
+ patientId: input.patientId,
57
+ stepId: input.stepId,
58
+ payload: input.payload,
59
+ status: "QUEUED",
60
+ dedupeKey: input.dedupeKey,
61
+ createdAt: Date.now(),
62
+ updatedAt: Date.now(),
63
+ });
64
+ }