@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.
@@ -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
- try {
22
- // Fallback for hosts where users.by_auth0 is unavailable.
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.db
35
- .query("users")
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 new Error("Unauthorized: missing identity");
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
- throw new Error("Unauthorized: authenticated identity not mapped to active user");
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
- throw new Error("Unauthorized: identity has no mapped active roles");
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 new Error("Unauthorized: unsupported execution context");
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 new Error(
112
- `Forbidden: required one of roles [${requiredRoles.join(", ")}], user roles: [${[
113
- ...userRoles,
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: Id<"clinics">[];
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
- scopeClinicIds: v.array(v.id("clinics")),
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
- }).index("by_status", ["status"]),
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
+ };