@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
|
@@ -12,30 +12,66 @@ function normalizeRole(value: string | undefined): string {
|
|
|
12
12
|
return (value ?? "").trim().toLowerCase();
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
function authError(code: string, message: string): Error {
|
|
16
|
+
return new Error(`${code}: ${message}`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function isIndexError(error: unknown): boolean {
|
|
20
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
21
|
+
return message.includes("Index") && message.includes("not found");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function missingUserModeAllowsAccess(): boolean {
|
|
25
|
+
return (process.env.CAMPAIGNS_AUTH_MISSING_USER_MODE ?? "").toLowerCase() === "allow";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function queryUserByAuth0WithFallback(ctx: AnyCtx, subject: string) {
|
|
29
|
+
if (!ctx.db) return null;
|
|
30
|
+
try {
|
|
31
|
+
const byAuth0 = await ctx.db
|
|
32
|
+
.query("users")
|
|
33
|
+
.withIndex("by_auth0", (q: any) => q.eq("auth0Id", subject))
|
|
34
|
+
.first();
|
|
35
|
+
if (byAuth0?.isActive) return byAuth0;
|
|
36
|
+
} catch (error) {
|
|
37
|
+
// Fallback for hosts where users.by_auth0 is unavailable.
|
|
38
|
+
if (!isIndexError(error)) throw error;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const users = await ctx.db.query("users").collect();
|
|
42
|
+
return users.find((user: any) => user?.isActive && user?.auth0Id === subject) ?? null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function queryUserByEmailWithFallback(ctx: AnyCtx, email: string) {
|
|
46
|
+
if (!ctx.db) return null;
|
|
47
|
+
try {
|
|
48
|
+
const byEmail = await ctx.db
|
|
49
|
+
.query("users")
|
|
50
|
+
.withIndex("by_email", (q: any) => q.eq("email", email))
|
|
51
|
+
.first();
|
|
52
|
+
if (byEmail?.isActive) return byEmail;
|
|
53
|
+
} catch (error) {
|
|
54
|
+
// Fallback for hosts where users.by_email is unavailable.
|
|
55
|
+
if (!isIndexError(error)) throw error;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const users = await ctx.db.query("users").collect();
|
|
59
|
+
return users.find((user: any) => user?.isActive && user?.email === email) ?? null;
|
|
60
|
+
}
|
|
61
|
+
|
|
15
62
|
async function resolveCurrentUserWithDb(ctx: AnyCtx) {
|
|
16
63
|
if (!ctx.db) return null;
|
|
17
64
|
const identity = await ctx.auth.getUserIdentity();
|
|
18
65
|
if (!identity) return null;
|
|
19
66
|
|
|
20
67
|
if (identity.subject) {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
const byAuth0 = await ctx.db
|
|
24
|
-
.query("users")
|
|
25
|
-
.withIndex("by_auth0", (q: any) => q.eq("auth0Id", identity.subject))
|
|
26
|
-
.first();
|
|
27
|
-
if (byAuth0?.isActive) return byAuth0;
|
|
28
|
-
} catch {
|
|
29
|
-
// Ignore index-resolution errors and continue with by_email fallback.
|
|
30
|
-
}
|
|
68
|
+
const bySubject = await queryUserByAuth0WithFallback(ctx, identity.subject);
|
|
69
|
+
if (bySubject) return bySubject;
|
|
31
70
|
}
|
|
32
71
|
|
|
33
72
|
if (identity.email) {
|
|
34
|
-
const byEmail = await ctx.
|
|
35
|
-
|
|
36
|
-
.withIndex("by_email", (q: any) => q.eq("email", identity.email!))
|
|
37
|
-
.first();
|
|
38
|
-
if (byEmail?.isActive) return byEmail;
|
|
73
|
+
const byEmail = await queryUserByEmailWithFallback(ctx, identity.email);
|
|
74
|
+
if (byEmail) return byEmail;
|
|
39
75
|
}
|
|
40
76
|
|
|
41
77
|
return null;
|
|
@@ -78,14 +114,20 @@ export async function assertAuthorized(ctx: AnyCtx, requiredRoles: Role[]): Prom
|
|
|
78
114
|
if (requiredRoles.includes("System")) {
|
|
79
115
|
return;
|
|
80
116
|
}
|
|
81
|
-
throw
|
|
117
|
+
throw authError("CAMPAIGNS_UNAUTHORIZED", "Missing identity in request context.");
|
|
82
118
|
}
|
|
83
119
|
|
|
84
120
|
let userRoles = new Set<string>();
|
|
85
121
|
if (ctx.db) {
|
|
86
122
|
const user = await resolveCurrentUserWithDb(ctx);
|
|
87
123
|
if (!user) {
|
|
88
|
-
|
|
124
|
+
if (missingUserModeAllowsAccess()) {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
throw authError(
|
|
128
|
+
"CAMPAIGNS_AUTH_USER_NOT_MAPPED",
|
|
129
|
+
"Authenticated identity is not mapped to an active user.",
|
|
130
|
+
);
|
|
89
131
|
}
|
|
90
132
|
userRoles = await getUserRoleCodes(ctx, user._id);
|
|
91
133
|
} else if (ctx.runQuery) {
|
|
@@ -98,20 +140,37 @@ export async function assertAuthorized(ctx: AnyCtx, requiredRoles: Role[]): Prom
|
|
|
98
140
|
);
|
|
99
141
|
userRoles = new Set<string>((resolved?.roles ?? []) as string[]);
|
|
100
142
|
if (userRoles.size === 0) {
|
|
101
|
-
|
|
143
|
+
if (missingUserModeAllowsAccess()) {
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
throw authError(
|
|
147
|
+
"CAMPAIGNS_AUTH_NO_ACTIVE_ROLES",
|
|
148
|
+
"Identity resolved but no active mapped roles were found.",
|
|
149
|
+
);
|
|
102
150
|
}
|
|
103
151
|
} else {
|
|
104
|
-
throw
|
|
152
|
+
throw authError(
|
|
153
|
+
"CAMPAIGNS_UNAUTHORIZED_CONTEXT",
|
|
154
|
+
"Unsupported execution context for authorization.",
|
|
155
|
+
);
|
|
105
156
|
}
|
|
106
157
|
|
|
107
158
|
const required = requiredRoles.map((r) => normalizeRole(r));
|
|
108
159
|
const hasRole = required.some((role) => userRoles.has(role));
|
|
109
160
|
|
|
110
161
|
if (!hasRole) {
|
|
111
|
-
throw
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
].join(", ")}]`,
|
|
162
|
+
throw authError(
|
|
163
|
+
"CAMPAIGNS_FORBIDDEN",
|
|
164
|
+
`Required roles: [${requiredRoles.join(", ")}], user roles: [${[...userRoles].join(", ")}].`,
|
|
115
165
|
);
|
|
116
166
|
}
|
|
117
167
|
}
|
|
168
|
+
|
|
169
|
+
export async function assertCanManageCampaigns(ctx: AnyCtx): Promise<void> {
|
|
170
|
+
const identity = await ctx.auth.getUserIdentity();
|
|
171
|
+
if (!identity) {
|
|
172
|
+
throw authError("CAMPAIGNS_UNAUTHORIZED", "Missing identity in request context.");
|
|
173
|
+
}
|
|
174
|
+
// Placeholder policy: any authenticated user can manage campaigns.
|
|
175
|
+
// TODO: replace with PrimoCore role checks (admin/marketing).
|
|
176
|
+
}
|
|
@@ -3,7 +3,7 @@ import type { Doc, Id } from "../../../_generated/dataModel";
|
|
|
3
3
|
|
|
4
4
|
export type CampaignScope = {
|
|
5
5
|
scopeType: "HQ" | "CLINIC";
|
|
6
|
-
scopeClinicIds:
|
|
6
|
+
scopeClinicIds: string[];
|
|
7
7
|
};
|
|
8
8
|
|
|
9
9
|
export type SegmentationRules = {
|
|
@@ -36,7 +36,7 @@ async function deriveClinicIdFromAppointments(
|
|
|
36
36
|
const withClinic = appointments.find((a) => a.clinicId);
|
|
37
37
|
if (!withClinic?.clinicId) return undefined;
|
|
38
38
|
if (scope.scopeType === "HQ") return withClinic.clinicId;
|
|
39
|
-
if (scope.scopeClinicIds.includes(withClinic.clinicId)) return withClinic.clinicId;
|
|
39
|
+
if (scope.scopeClinicIds.includes(String(withClinic.clinicId))) return withClinic.clinicId;
|
|
40
40
|
return undefined;
|
|
41
41
|
}
|
|
42
42
|
|
|
@@ -50,7 +50,7 @@ function isInScope(
|
|
|
50
50
|
): boolean {
|
|
51
51
|
if (scope.scopeType === "HQ") return true;
|
|
52
52
|
if (!clinicId) return false;
|
|
53
|
-
return scope.scopeClinicIds.includes(clinicId);
|
|
53
|
+
return scope.scopeClinicIds.includes(String(clinicId));
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
async function resolveAppointmentTypeIdsByCodes(
|
|
@@ -2,6 +2,7 @@ import { defineTable } from "convex/server";
|
|
|
2
2
|
import { v } from "convex/values";
|
|
3
3
|
import {
|
|
4
4
|
campaignStatusValidator,
|
|
5
|
+
callPolicyValidator,
|
|
5
6
|
recipientStateValidator,
|
|
6
7
|
runConfigValidator,
|
|
7
8
|
scopeTypeValidator,
|
|
@@ -13,17 +14,36 @@ import {
|
|
|
13
14
|
|
|
14
15
|
export const campaignsTables = {
|
|
15
16
|
campaigns: defineTable({
|
|
17
|
+
orgId: v.optional(v.string()),
|
|
16
18
|
name: v.string(),
|
|
17
19
|
description: v.optional(v.string()),
|
|
18
20
|
ownerUserId: v.optional(v.id("users")),
|
|
19
21
|
scopeType: scopeTypeValidator,
|
|
20
|
-
|
|
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())),
|
|
21
24
|
priority: v.number(),
|
|
22
25
|
status: campaignStatusValidator,
|
|
26
|
+
scope: v.optional(
|
|
27
|
+
v.object({
|
|
28
|
+
orgId: v.string(),
|
|
29
|
+
clinicIds: v.array(v.string()),
|
|
30
|
+
}),
|
|
31
|
+
),
|
|
32
|
+
rules: v.optional(v.any()),
|
|
33
|
+
frequencyCapDays: v.optional(v.number()),
|
|
34
|
+
callPolicy: v.optional(callPolicyValidator),
|
|
35
|
+
policyUpdatedAt: v.optional(v.number()),
|
|
36
|
+
policyUpdatedBy: v.optional(v.string()),
|
|
37
|
+
createdBy: v.optional(v.string()),
|
|
38
|
+
updatedBy: v.optional(v.string()),
|
|
23
39
|
defaultRunConfig: runConfigValidator,
|
|
24
40
|
createdAt: v.number(),
|
|
25
41
|
updatedAt: v.number(),
|
|
26
|
-
})
|
|
42
|
+
})
|
|
43
|
+
.index("by_status", ["status"])
|
|
44
|
+
.index("by_org", ["orgId"])
|
|
45
|
+
.index("by_org_status", ["orgId", "status"])
|
|
46
|
+
.index("by_org_priority", ["orgId", "priority"]),
|
|
27
47
|
|
|
28
48
|
campaign_steps: defineTable({
|
|
29
49
|
campaignId: v.id("campaigns"),
|
|
@@ -151,4 +171,106 @@ export const campaignsTables = {
|
|
|
151
171
|
})
|
|
152
172
|
.index("by_status", ["status"])
|
|
153
173
|
.index("by_dedupe_key", ["dedupeKey"]),
|
|
174
|
+
|
|
175
|
+
campaignVersions: defineTable({
|
|
176
|
+
campaignId: v.id("campaigns"),
|
|
177
|
+
version: v.number(),
|
|
178
|
+
label: v.optional(v.string()),
|
|
179
|
+
scopeSnapshot: v.object({
|
|
180
|
+
orgId: v.string(),
|
|
181
|
+
clinicIds: v.array(v.string()),
|
|
182
|
+
}),
|
|
183
|
+
rulesSnapshot: v.any(),
|
|
184
|
+
rulesHash: v.string(),
|
|
185
|
+
callPolicySnapshot: v.optional(callPolicyValidator),
|
|
186
|
+
policyHash: v.optional(v.string()),
|
|
187
|
+
createdAt: v.number(),
|
|
188
|
+
createdBy: v.string(),
|
|
189
|
+
publishedAt: v.number(),
|
|
190
|
+
publishedBy: v.string(),
|
|
191
|
+
})
|
|
192
|
+
.index("by_campaign", ["campaignId"])
|
|
193
|
+
.index("by_campaign_version", ["campaignId", "version"]),
|
|
194
|
+
|
|
195
|
+
audienceMembers: defineTable({
|
|
196
|
+
orgId: v.string(),
|
|
197
|
+
clinicId: v.string(),
|
|
198
|
+
campaignId: v.id("campaigns"),
|
|
199
|
+
versionId: v.id("campaignVersions"),
|
|
200
|
+
patientId: v.string(),
|
|
201
|
+
reasons: v.array(v.string()),
|
|
202
|
+
createdAt: v.number(),
|
|
203
|
+
})
|
|
204
|
+
.index("by_version", ["versionId"])
|
|
205
|
+
.index("by_version_clinic", ["versionId", "clinicId"])
|
|
206
|
+
.index("by_version_createdAt", ["versionId", "createdAt"])
|
|
207
|
+
.index("by_version_clinic_createdAt", ["versionId", "clinicId", "createdAt"])
|
|
208
|
+
.index("by_patient_version", ["patientId", "versionId"])
|
|
209
|
+
.index("by_org_patient", ["orgId", "patientId"]),
|
|
210
|
+
|
|
211
|
+
contactLog: defineTable({
|
|
212
|
+
orgId: v.string(),
|
|
213
|
+
clinicId: v.string(),
|
|
214
|
+
patientId: v.string(),
|
|
215
|
+
campaignId: v.id("campaigns"),
|
|
216
|
+
versionId: v.id("campaignVersions"),
|
|
217
|
+
channel: v.union(v.literal("call"), v.literal("sms"), v.literal("email")),
|
|
218
|
+
outcome: v.union(
|
|
219
|
+
v.literal("answered"),
|
|
220
|
+
v.literal("no_answer"),
|
|
221
|
+
v.literal("busy"),
|
|
222
|
+
v.literal("wrong_number"),
|
|
223
|
+
v.literal("opt_out"),
|
|
224
|
+
v.literal("success"),
|
|
225
|
+
v.literal("failed"),
|
|
226
|
+
),
|
|
227
|
+
createdAt: v.number(),
|
|
228
|
+
})
|
|
229
|
+
.index("by_patient_time", ["patientId", "createdAt"])
|
|
230
|
+
.index("by_org_patient_time", ["orgId", "patientId", "createdAt"])
|
|
231
|
+
.index("by_campaign_time", ["campaignId", "createdAt"])
|
|
232
|
+
.index("by_version_time", ["versionId", "createdAt"]),
|
|
233
|
+
|
|
234
|
+
demoPatients: defineTable({
|
|
235
|
+
orgId: v.string(),
|
|
236
|
+
clinicId: v.string(),
|
|
237
|
+
patientId: v.string(),
|
|
238
|
+
firstName: v.string(),
|
|
239
|
+
lastName: v.string(),
|
|
240
|
+
city: v.string(),
|
|
241
|
+
birthDate: v.string(),
|
|
242
|
+
hasCallConsent: v.boolean(),
|
|
243
|
+
phoneMasked: v.optional(v.string()),
|
|
244
|
+
})
|
|
245
|
+
.index("by_org", ["orgId"])
|
|
246
|
+
.index("by_org_clinic", ["orgId", "clinicId"])
|
|
247
|
+
.index("by_org_patient", ["orgId", "patientId"]),
|
|
248
|
+
|
|
249
|
+
demoAppointments: defineTable({
|
|
250
|
+
orgId: v.string(),
|
|
251
|
+
clinicId: v.string(),
|
|
252
|
+
patientId: v.string(),
|
|
253
|
+
date: v.number(),
|
|
254
|
+
status: v.union(v.literal("done"), v.literal("missed"), v.literal("booked")),
|
|
255
|
+
})
|
|
256
|
+
.index("by_org_patient_date", ["orgId", "patientId", "date"])
|
|
257
|
+
.index("by_org_clinic_date", ["orgId", "clinicId", "date"]),
|
|
258
|
+
|
|
259
|
+
demoEstimates: defineTable({
|
|
260
|
+
orgId: v.string(),
|
|
261
|
+
clinicId: v.string(),
|
|
262
|
+
patientId: v.string(),
|
|
263
|
+
status: v.union(v.literal("open"), v.literal("accepted"), v.literal("expired")),
|
|
264
|
+
amount: v.number(),
|
|
265
|
+
createdAt: v.number(),
|
|
266
|
+
expiredAt: v.optional(v.number()),
|
|
267
|
+
}).index("by_org_patient_createdAt", ["orgId", "patientId", "createdAt"]),
|
|
268
|
+
|
|
269
|
+
demoTreatmentPlans: defineTable({
|
|
270
|
+
orgId: v.string(),
|
|
271
|
+
clinicId: v.string(),
|
|
272
|
+
patientId: v.string(),
|
|
273
|
+
status: v.union(v.literal("open"), v.literal("closed")),
|
|
274
|
+
lastActivityAt: v.number(),
|
|
275
|
+
}).index("by_org_patient_lastActivity", ["orgId", "patientId", "lastActivityAt"]),
|
|
154
276
|
};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { DEFAULT_CALL_POLICY } from "../domain/types";
|
|
3
|
+
import type { CampaignCallPolicy, CampaignOutcome } from "./types";
|
|
4
|
+
|
|
5
|
+
const outcomeCategorySchema = z.enum(["success", "retry", "fail", "optout"]);
|
|
6
|
+
|
|
7
|
+
export const campaignOutcomeSchema = z.object({
|
|
8
|
+
id: z.string().min(1, "outcome id richiesto"),
|
|
9
|
+
label: z.string().min(1, "label richiesta"),
|
|
10
|
+
category: outcomeCategorySchema,
|
|
11
|
+
isActive: z.boolean(),
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
export const callPolicyInputSchema = z
|
|
15
|
+
.object({
|
|
16
|
+
dailyQuotaByClinic: z.record(z.string(), z.number()).optional(),
|
|
17
|
+
maxAttempts: z.number().min(1, "maxAttempts deve essere >= 1"),
|
|
18
|
+
retryDelaysDays: z
|
|
19
|
+
.array(z.number().int().min(0))
|
|
20
|
+
.max(10, "retryDelaysDays max 10 elementi"),
|
|
21
|
+
outcomes: z
|
|
22
|
+
.array(campaignOutcomeSchema)
|
|
23
|
+
.refine(
|
|
24
|
+
(arr) => new Set(arr.map((o) => o.id)).size === arr.length,
|
|
25
|
+
"outcome ids devono essere univoci",
|
|
26
|
+
)
|
|
27
|
+
.refine(
|
|
28
|
+
(arr) => arr.every((o) => o.label.trim().length > 0),
|
|
29
|
+
"label non vuota per ogni outcome",
|
|
30
|
+
),
|
|
31
|
+
})
|
|
32
|
+
.strict();
|
|
33
|
+
|
|
34
|
+
export type CallPolicyInput = z.infer<typeof callPolicyInputSchema>;
|
|
35
|
+
|
|
36
|
+
function formatZodErrors(err: z.ZodError): string[] {
|
|
37
|
+
const issues = err.issues ?? [];
|
|
38
|
+
return issues.map((e: { path?: (string | number)[]; message?: string }) =>
|
|
39
|
+
(e.path ?? []).length > 0 ? `${(e.path ?? []).join(".")}: ${e.message ?? "invalid"}` : (e.message ?? "invalid"),
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function validateCallPolicyInput(
|
|
44
|
+
input: unknown,
|
|
45
|
+
): { ok: true; data: CampaignCallPolicy } | { ok: false; errors: string[] } {
|
|
46
|
+
const parsed = callPolicyInputSchema.safeParse(input);
|
|
47
|
+
if (parsed.success) {
|
|
48
|
+
const data: CampaignCallPolicy = {
|
|
49
|
+
dailyQuotaByClinic: parsed.data.dailyQuotaByClinic ?? {},
|
|
50
|
+
maxAttempts: parsed.data.maxAttempts,
|
|
51
|
+
retryDelaysDays: parsed.data.retryDelaysDays,
|
|
52
|
+
outcomes: parsed.data.outcomes as CampaignOutcome[],
|
|
53
|
+
};
|
|
54
|
+
return { ok: true, data };
|
|
55
|
+
}
|
|
56
|
+
const errors = formatZodErrors(parsed.error);
|
|
57
|
+
return { ok: false, errors };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function getDefaultCallPolicy(): CampaignCallPolicy {
|
|
61
|
+
return {
|
|
62
|
+
dailyQuotaByClinic: DEFAULT_CALL_POLICY.dailyQuotaByClinic,
|
|
63
|
+
maxAttempts: DEFAULT_CALL_POLICY.maxAttempts,
|
|
64
|
+
retryDelaysDays: [...DEFAULT_CALL_POLICY.retryDelaysDays],
|
|
65
|
+
outcomes: DEFAULT_CALL_POLICY.outcomes.map((o) => ({ ...o })),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import type { QueryCtx, MutationCtx } from "../../../_generated/server";
|
|
2
|
+
import type { PatientContext } from "./types";
|
|
3
|
+
|
|
4
|
+
type AnyCtx = QueryCtx | MutationCtx | any;
|
|
5
|
+
|
|
6
|
+
export type CampaignDataSource = {
|
|
7
|
+
listPatients: (
|
|
8
|
+
ctx: AnyCtx,
|
|
9
|
+
orgId: string,
|
|
10
|
+
clinicIds: string[],
|
|
11
|
+
cursor: string | null,
|
|
12
|
+
limit: number,
|
|
13
|
+
) => Promise<{ page: Array<{ orgId: string; clinicId: string; patientId: string }>; nextCursor: string | null }>;
|
|
14
|
+
getPatientContext: (
|
|
15
|
+
ctx: AnyCtx,
|
|
16
|
+
orgId: string,
|
|
17
|
+
clinicId: string,
|
|
18
|
+
patientId: string,
|
|
19
|
+
) => Promise<PatientContext | null>;
|
|
20
|
+
getSamplePatients: (
|
|
21
|
+
ctx: AnyCtx,
|
|
22
|
+
orgId: string,
|
|
23
|
+
clinicIds: string[],
|
|
24
|
+
limit: number,
|
|
25
|
+
) => Promise<Array<{ orgId: string; clinicId: string; patientId: string }>>;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
29
|
+
|
|
30
|
+
/** Default: true in dev (NODE_ENV !== "production"), false in production. */
|
|
31
|
+
function useDemoDataSource(): boolean {
|
|
32
|
+
const envVal = process.env.CAMPAIGNS_USE_DEMO_DATASOURCE;
|
|
33
|
+
if (envVal === "false" || envVal === "0") return false;
|
|
34
|
+
if (envVal === "true" || envVal === "1") return true;
|
|
35
|
+
return process.env.NODE_ENV !== "production";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function toAge(birthDate: string): number | null {
|
|
39
|
+
const ts = Date.parse(birthDate);
|
|
40
|
+
if (Number.isNaN(ts)) return null;
|
|
41
|
+
const diffYears = (Date.now() - ts) / (365.25 * DAY_MS);
|
|
42
|
+
return Math.max(0, Math.floor(diffYears));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function demoAvailable(ctx: AnyCtx): Promise<boolean> {
|
|
46
|
+
try {
|
|
47
|
+
const first = await ctx.db.query("demoPatients").first();
|
|
48
|
+
return Boolean(first);
|
|
49
|
+
} catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function listDemoPatients(
|
|
55
|
+
ctx: AnyCtx,
|
|
56
|
+
orgId: string,
|
|
57
|
+
clinicIds: string[],
|
|
58
|
+
cursor: string | null,
|
|
59
|
+
limit: number,
|
|
60
|
+
) {
|
|
61
|
+
const page =
|
|
62
|
+
clinicIds.length > 0
|
|
63
|
+
? await ctx.db
|
|
64
|
+
.query("demoPatients")
|
|
65
|
+
.withIndex("by_org_clinic", (q: any) => q.eq("orgId", orgId).eq("clinicId", clinicIds[0]))
|
|
66
|
+
.paginate({ cursor, numItems: limit })
|
|
67
|
+
: await ctx.db
|
|
68
|
+
.query("demoPatients")
|
|
69
|
+
.withIndex("by_org", (q: any) => q.eq("orgId", orgId))
|
|
70
|
+
.paginate({ cursor, numItems: limit });
|
|
71
|
+
|
|
72
|
+
const filtered = clinicIds.length
|
|
73
|
+
? page.page.filter((p: any) => clinicIds.includes(p.clinicId))
|
|
74
|
+
: page.page;
|
|
75
|
+
return {
|
|
76
|
+
page: filtered.map((p: any) => ({ orgId: p.orgId, clinicId: p.clinicId, patientId: p.patientId })),
|
|
77
|
+
nextCursor: page.isDone ? null : page.continueCursor,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function getLastContactAt(ctx: AnyCtx, orgId: string, patientId: string): Promise<number | null> {
|
|
82
|
+
try {
|
|
83
|
+
const logs = await ctx.db
|
|
84
|
+
.query("contactLog")
|
|
85
|
+
.withIndex("by_patient_time", (q: any) => q.eq("patientId", patientId))
|
|
86
|
+
.collect();
|
|
87
|
+
const byOrg = logs.filter((l: any) => l.orgId === orgId);
|
|
88
|
+
if (byOrg.length === 0) return null;
|
|
89
|
+
return byOrg.reduce((acc: number, curr: any) => Math.max(acc, curr.createdAt), 0);
|
|
90
|
+
} catch {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function getDemoContext(
|
|
96
|
+
ctx: AnyCtx,
|
|
97
|
+
orgId: string,
|
|
98
|
+
clinicId: string,
|
|
99
|
+
patientId: string,
|
|
100
|
+
): Promise<PatientContext | null> {
|
|
101
|
+
const patient = await ctx.db
|
|
102
|
+
.query("demoPatients")
|
|
103
|
+
.withIndex("by_org_patient", (q: any) => q.eq("orgId", orgId).eq("patientId", patientId))
|
|
104
|
+
.first();
|
|
105
|
+
if (!patient) return null;
|
|
106
|
+
|
|
107
|
+
const appointments = await ctx.db
|
|
108
|
+
.query("demoAppointments")
|
|
109
|
+
.withIndex("by_org_patient_date", (q: any) => q.eq("orgId", orgId).eq("patientId", patientId))
|
|
110
|
+
.collect();
|
|
111
|
+
const estimates = await ctx.db
|
|
112
|
+
.query("demoEstimates")
|
|
113
|
+
.withIndex("by_org_patient_createdAt", (q: any) => q.eq("orgId", orgId).eq("patientId", patientId))
|
|
114
|
+
.collect();
|
|
115
|
+
const plans = await ctx.db
|
|
116
|
+
.query("demoTreatmentPlans")
|
|
117
|
+
.withIndex("by_org_patient_lastActivity", (q: any) => q.eq("orgId", orgId).eq("patientId", patientId))
|
|
118
|
+
.collect();
|
|
119
|
+
|
|
120
|
+
const doneAppointments = appointments.filter((a: any) => a.status === "done");
|
|
121
|
+
const lastAppointmentDate =
|
|
122
|
+
doneAppointments.length === 0
|
|
123
|
+
? null
|
|
124
|
+
: doneAppointments.reduce((acc: number, curr: any) => Math.max(acc, curr.date), 0);
|
|
125
|
+
const missedLast30d = appointments.some(
|
|
126
|
+
(a: any) => a.status === "missed" && a.date >= Date.now() - 30 * DAY_MS,
|
|
127
|
+
);
|
|
128
|
+
const hasUpcomingNext30d = appointments.some(
|
|
129
|
+
(a: any) => a.status === "booked" && a.date <= Date.now() + 30 * DAY_MS && a.date >= Date.now(),
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
const latestEstimate = [...estimates].sort((a: any, b: any) => b.createdAt - a.createdAt)[0];
|
|
133
|
+
const estimateStatus = latestEstimate ? latestEstimate.status : "none";
|
|
134
|
+
const estimateAmount = latestEstimate ? latestEstimate.amount : null;
|
|
135
|
+
const expiredDaysAgo =
|
|
136
|
+
latestEstimate?.status === "expired" && latestEstimate.expiredAt
|
|
137
|
+
? Math.floor((Date.now() - latestEstimate.expiredAt) / DAY_MS)
|
|
138
|
+
: null;
|
|
139
|
+
|
|
140
|
+
const openPlan = plans.find((p: any) => p.status === "open");
|
|
141
|
+
const latestPlan = [...plans].sort((a: any, b: any) => b.lastActivityAt - a.lastActivityAt)[0];
|
|
142
|
+
const daysSinceLastActivity = latestPlan
|
|
143
|
+
? Math.floor((Date.now() - latestPlan.lastActivityAt) / DAY_MS)
|
|
144
|
+
: null;
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
orgId,
|
|
148
|
+
clinicId: patient.clinicId ?? clinicId,
|
|
149
|
+
patientId,
|
|
150
|
+
firstName: patient.firstName,
|
|
151
|
+
lastName: patient.lastName,
|
|
152
|
+
city: patient.city,
|
|
153
|
+
birthDate: patient.birthDate,
|
|
154
|
+
hasCallConsent: Boolean(patient.hasCallConsent),
|
|
155
|
+
phoneMasked: patient.phoneMasked,
|
|
156
|
+
age: toAge(patient.birthDate),
|
|
157
|
+
hasPhone: Boolean(patient.phoneMasked),
|
|
158
|
+
appointment: {
|
|
159
|
+
lastDate: lastAppointmentDate,
|
|
160
|
+
daysSinceLast: lastAppointmentDate ? Math.floor((Date.now() - lastAppointmentDate) / DAY_MS) : null,
|
|
161
|
+
missedLast30d,
|
|
162
|
+
hasUpcomingNext30d,
|
|
163
|
+
},
|
|
164
|
+
estimate: {
|
|
165
|
+
status: estimateStatus,
|
|
166
|
+
amount: estimateAmount,
|
|
167
|
+
expiredDaysAgo,
|
|
168
|
+
},
|
|
169
|
+
treatment: {
|
|
170
|
+
hasOpenPlan: Boolean(openPlan),
|
|
171
|
+
daysSinceLastActivity,
|
|
172
|
+
},
|
|
173
|
+
contact: {
|
|
174
|
+
lastContactAt: await getLastContactAt(ctx, orgId, patientId),
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function listCorePatients(
|
|
180
|
+
ctx: AnyCtx,
|
|
181
|
+
orgId: string,
|
|
182
|
+
clinicIds: string[],
|
|
183
|
+
cursor: string | null,
|
|
184
|
+
limit: number,
|
|
185
|
+
) {
|
|
186
|
+
const doQuery = async () => {
|
|
187
|
+
const page = await ctx.db.query("patients").paginate({ cursor, numItems: limit });
|
|
188
|
+
const mapped = page.page
|
|
189
|
+
.filter((p: any) => p.status !== "archived")
|
|
190
|
+
.map((p: any) => ({
|
|
191
|
+
orgId,
|
|
192
|
+
clinicId: String(p.clinicId ?? clinicIds[0] ?? "unknown"),
|
|
193
|
+
patientId: String(p.patientId ?? p._id),
|
|
194
|
+
}))
|
|
195
|
+
.filter((p: any) => clinicIds.length === 0 || clinicIds.includes(p.clinicId));
|
|
196
|
+
return { page: mapped, nextCursor: page.isDone ? null : page.continueCursor };
|
|
197
|
+
};
|
|
198
|
+
if (useDemoDataSource()) {
|
|
199
|
+
try {
|
|
200
|
+
return await doQuery();
|
|
201
|
+
} catch {
|
|
202
|
+
return { page: [], nextCursor: null };
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return doQuery();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function getCoreContext(
|
|
209
|
+
ctx: AnyCtx,
|
|
210
|
+
orgId: string,
|
|
211
|
+
clinicId: string,
|
|
212
|
+
patientId: string,
|
|
213
|
+
): Promise<PatientContext | null> {
|
|
214
|
+
try {
|
|
215
|
+
const all = await ctx.db.query("patients").collect();
|
|
216
|
+
const patient = all.find((p: any) => String(p._id) === patientId || String(p.patientId) === patientId);
|
|
217
|
+
if (!patient) return null;
|
|
218
|
+
return {
|
|
219
|
+
orgId,
|
|
220
|
+
clinicId,
|
|
221
|
+
patientId,
|
|
222
|
+
firstName: patient.firstName ?? "N/A",
|
|
223
|
+
lastName: patient.lastName ?? "N/A",
|
|
224
|
+
city: patient.city ?? "N/A",
|
|
225
|
+
birthDate: patient.birthDate ?? "1990-01-01",
|
|
226
|
+
hasCallConsent: true,
|
|
227
|
+
phoneMasked: patient.phone,
|
|
228
|
+
age: patient.birthDate ? toAge(patient.birthDate) : null,
|
|
229
|
+
hasPhone: Boolean(patient.phone),
|
|
230
|
+
appointment: {
|
|
231
|
+
lastDate: null,
|
|
232
|
+
daysSinceLast: null,
|
|
233
|
+
missedLast30d: false,
|
|
234
|
+
hasUpcomingNext30d: false,
|
|
235
|
+
},
|
|
236
|
+
estimate: { status: "none", amount: null, expiredDaysAgo: null },
|
|
237
|
+
treatment: { hasOpenPlan: false, daysSinceLastActivity: null },
|
|
238
|
+
contact: { lastContactAt: await getLastContactAt(ctx, orgId, patientId) },
|
|
239
|
+
};
|
|
240
|
+
} catch {
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export const dataSource: CampaignDataSource = {
|
|
246
|
+
async listPatients(ctx, orgId, clinicIds, cursor, limit) {
|
|
247
|
+
if (useDemoDataSource() && (await demoAvailable(ctx))) {
|
|
248
|
+
return listDemoPatients(ctx, orgId, clinicIds, cursor, limit);
|
|
249
|
+
}
|
|
250
|
+
return listCorePatients(ctx, orgId, clinicIds, cursor, limit);
|
|
251
|
+
},
|
|
252
|
+
async getPatientContext(ctx, orgId, clinicId, patientId) {
|
|
253
|
+
if (useDemoDataSource() && (await demoAvailable(ctx))) {
|
|
254
|
+
return getDemoContext(ctx, orgId, clinicId, patientId);
|
|
255
|
+
}
|
|
256
|
+
return getCoreContext(ctx, orgId, clinicId, patientId);
|
|
257
|
+
},
|
|
258
|
+
async getSamplePatients(ctx, orgId, clinicIds, limit) {
|
|
259
|
+
const firstPage = await this.listPatients(ctx, orgId, clinicIds, null, limit);
|
|
260
|
+
return firstPage.page;
|
|
261
|
+
},
|
|
262
|
+
};
|