@primocaredentgroup/convex-campaigns-component 0.1.3 → 0.3.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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,52 @@
1
+ # Changelog
2
+
3
+ ## 0.3.0
4
+
5
+ - **Public API stabilized** per PrimoUpCore e componente Richiami:
6
+ - `listActiveCampaignsForOrg` – campagne active con latest version
7
+ - `getLatestPublishedVersion` – versione più recente
8
+ - `getPatientCampaignMemberships` – membership lookup (index `by_org_patient`)
9
+ - `getAudiencePage` – paginazione deterministica (`by_version_createdAt`)
10
+ - `recordContactAttempt` – contatti con orgId
11
+ - `getFieldRegistry` – field registry pubblico
12
+ - **Demo datasource toggle**: `CAMPAIGNS_USE_DEMO_DATASOURCE` (default: true dev, false prod)
13
+ - **Indici**: `contactLog.by_org_patient_time`, `audienceMembers.by_version_createdAt`, `by_version_clinic_createdAt`
14
+ - **Hardening**: lifecycle transitions (`canTransitionCampaignStatus`), priority >= 1, clinicId normalization
15
+ - **Packaging**: `campaignsPublic` export, tipi `CampaignCallPolicy`, `RuleGroup` esportati
16
+
17
+ ## 0.2.0
18
+
19
+ - Added campaigns v2 backend engine with:
20
+ - rule DSL (`AND/OR` + atomic conditions) validated with `zod`
21
+ - field registry query (`getFieldRegistry`) and typed resolvers
22
+ - preview audience (`previewCampaignAudience`) with by-clinic breakdown + sample
23
+ - immutable campaign versions (`publishCampaignVersion`) and paginated audience (`getAudiencePage`)
24
+ - contact logging (`recordContactAttempt`)
25
+ - governance APIs (`upsertCampaign`, `setCampaignLifecycleStatus`, `listCampaignsV2`, `listCampaignVersions`)
26
+ - Added demo data model and seed mutation (`seedDemoData`) for standalone testing.
27
+ - Added data source adapter with demo/core fallback behavior.
28
+ - Extended schema with new tables:
29
+ - `campaignVersions`, `audienceMembers`, `contactLog`
30
+ - `demoPatients`, `demoAppointments`, `demoEstimates`, `demoTreatmentPlans`
31
+ - Added org-based indexes on `campaigns` (`by_org`, `by_org_status`, `by_org_priority`).
32
+ - Added example React playground under `examples/campaigns-playground`.
33
+ - Added automated tests for auth/index fallback and schema/input compatibility.
34
+
35
+ ## 0.1.3
36
+
37
+ - Fixed robust identity mapping in auth:
38
+ - `subject -> by_auth0` with fallback if index unavailable
39
+ - `email -> by_email` with fallback if index unavailable
40
+ - final fallback `collect + find` for host compatibility
41
+ - Improved authorization errors with structured, explicit codes:
42
+ - `CAMPAIGNS_UNAUTHORIZED`
43
+ - `CAMPAIGNS_AUTH_USER_NOT_MAPPED`
44
+ - `CAMPAIGNS_AUTH_NO_ACTIVE_ROLES`
45
+ - `CAMPAIGNS_FORBIDDEN`
46
+ - Hardened public queries (`listCampaigns`, `getCampaign`, `getSnapshot`, `getCampaignReport`)
47
+ to return safe fallbacks on recoverable auth/index errors instead of crashing host UI.
48
+ - Added safe index fallbacks for key campaigns lookups, including `campaign_steps.by_campaign`.
49
+ - Added clinic scope compatibility:
50
+ - `scopeClinicIds` now accepts both `v.id("clinics")` and plain strings
51
+ - internal normalization/comparison is string-safe cross-host.
52
+ - Added minimal automated tests for permissions fallback behavior.
package/README.md CHANGED
@@ -40,7 +40,7 @@ Poi nel `convex.config.ts` dell'host installa il componente:
40
40
 
41
41
  ```ts
42
42
  import { defineApp } from "convex/server";
43
- import campaignsComponent from "@primocaredentgroup/convex-campaigns-component/convex.config";
43
+ import { campaignsComponent } from "@primocaredentgroup/convex-campaigns-component/convex.config";
44
44
 
45
45
  const app = defineApp();
46
46
  app.use(campaignsComponent, { name: "campaigns" });
@@ -54,8 +54,120 @@ Infine esegui:
54
54
  npx convex dev
55
55
  ```
56
56
 
57
+ ## Sviluppo standalone (npx convex dev)
58
+
59
+ Il componente ha il suo backend e può essere sviluppato in isolamento:
60
+
61
+ ```bash
62
+ cd /path/to/campagne # root del package (dove c'è package.json)
63
+ npx convex dev
64
+ ```
65
+
66
+ La `convex.config.ts` esporta un'app che monta il componente, così `convex dev` funziona dalla root del package.
67
+
57
68
  ## Note
58
69
 
59
70
  - Il componente espone logica backend Campaigns (no UI).
60
71
  - L'autorizzazione è server-side (`assertAuthorized`) e va collegata al provider auth del deployment host in produzione.
61
72
  - La port Consent resta stub fino a disponibilità del componente consensi ufficiale.
73
+
74
+ ## Compatibilità host e prerequisiti schema
75
+
76
+ - Il componente definisce e usa indici Convex per le proprie tabelle (`campaign_steps.by_campaign`, `campaign_steps.by_campaign_order`, ecc.).
77
+ - In caso di mismatch temporaneo tra codice e indici dopo deploy, le query principali usano fallback safe per evitare crash UI.
78
+ - Per le tabelle host (`users`) il componente prova prima lookup indicizzato (`by_auth0`, `by_email`) e, se l'indice non è disponibile, applica fallback `collect + find`.
79
+
80
+ ## Compatibilità clinic IDs
81
+
82
+ - `scopeClinicIds` accetta sia:
83
+ - Convex IDs (`v.id("clinics")`)
84
+ - stringhe clinicId
85
+ - Strategia adottata: **accettazione stringhe + normalizzazione interna** (`String(id)`), per compatibilità cross-host senza patch nel node_modules host.
86
+
87
+ ## Comportamento auth fallback
88
+
89
+ - Mapping identity robusto:
90
+ - prova `subject -> users.by_auth0`
91
+ - fallback `email -> users.by_email`
92
+ - fallback finale `collect + find` se indici host non disponibili
93
+ - Role matching case-insensitive (`admin`, `Admin`, `ADMIN` equivalenti).
94
+ - Se identity esiste ma utente/ruoli non mappati:
95
+ - default: errore controllato (`CAMPAIGNS_AUTH_*`)
96
+ - opzionale dev-safe: impostare `CAMPAIGNS_AUTH_MISSING_USER_MODE=allow`
97
+
98
+ ## Embedding in PrimoUpCore
99
+
100
+ ### API pubbliche host-facing (production-ready)
101
+
102
+ Usare `campaignsPublic` o le funzioni esportate direttamente:
103
+
104
+ - `listActiveCampaignsForOrg` – campagne ready per org, opzionale filter per clinicId
105
+ - `getLatestPublishedVersion` – versione più recente pubblicata di una campagna
106
+ - `getPatientCampaignMemberships` – memberships di un paziente, ordinate per priority
107
+ - `getAudiencePage` – audience paginata (ordine deterministico)
108
+ - `recordContactAttempt` – registra esito contatto
109
+ - `getFieldRegistry` – registry campi per UI builder
110
+
111
+ ### Variabili d'ambiente
112
+
113
+ | Variabile | Valore | Default | Descrizione |
114
+ |-----------|--------|---------|-------------|
115
+ | `CAMPAIGNS_USE_DEMO_DATASOURCE` | `"true"` \| `"false"` | `true` in dev, `false` in prod | Se `false`, usa solo core datasource (tabella `patients`). In prod impostare `false` per evitare uso di dati demo. |
116
+
117
+ ### Normalizzazione clinicId
118
+
119
+ Tutte le API pubbliche normalizzano `clinicId` al boundary: `String(id)` per compatibilità con Convex IDs e stringhe.
120
+
121
+ ### Checklist installazione in PrimoUpCore
122
+
123
+ 1. **npm pack / publish** – `npm pack` dal repo o `npm publish` su registry
124
+ 2. **install su PrimoUpCore** – `npm install @primocaredentgroup/convex-campaigns-component`
125
+ 3. **set env vars** – `CAMPAIGNS_USE_DEMO_DATASOURCE=false` in produzione
126
+ 4. **chiamare listActiveCampaignsForOrg** – verifica integrazione da host
127
+ 5. **verificare demo datasource disabilitato** – in prod non deve usare tabelle demo
128
+
129
+ ## API v2 (rule engine + snapshot versionati)
130
+
131
+ API playground/dev (test, examples):
132
+
133
+ - `campaigns:seedDemoData`
134
+ - `campaigns:upsertCampaign`
135
+ - `campaigns:setCampaignLifecycleStatus`
136
+ - `campaigns:listCampaignsV2`
137
+ - `campaigns:getCampaignV2`
138
+ - `campaigns:listCampaignVersions`
139
+ - `campaigns:previewCampaignAudience`
140
+ - `campaigns:publishCampaignVersion`
141
+ - `campaigns:updateCampaignCallPolicy`
142
+ - `campaigns:getCampaignCallPolicy`
143
+
144
+ API pubbliche (vedi sopra): `listActiveCampaignsForOrg`, `getLatestPublishedVersion`, `getPatientCampaignMemberships`, `getAudiencePage`, `recordContactAttempt`, `getFieldRegistry`.
145
+
146
+ Il motore regole usa DSL JSON (`AND/OR` + condizioni atomiche) validata con `zod`.
147
+
148
+ ## Playground React
149
+
150
+ Esempio frontend standalone disponibile in:
151
+
152
+ - `examples/campaigns-playground`
153
+
154
+ Vedi guida completa:
155
+
156
+ - `examples/campaigns-playground/README.md`
157
+
158
+ ## Smoke test
159
+
160
+ Script per verificare le API in sequenza. **Esegui dalla root del componente** (dove si trova `package.json`):
161
+
162
+ ```bash
163
+ cd /path/to/campagne # repo del componente campaigns
164
+ npm run smoke
165
+ ```
166
+
167
+ L'URL Convex viene letto da `.env.local` (creato da `npx convex dev`) o da variabili d'ambiente:
168
+
169
+ ```bash
170
+ CONVEX_URL=https://tuo-deployment.convex.cloud npm run smoke
171
+ ```
172
+
173
+ Variabili: `SMOKE_ORG_ID`, `SMOKE_CLINIC_IDS`, `SMOKE_SKIP_SEED=1` per saltare seed.
@@ -2,3 +2,4 @@ import { componentsGeneric } from "convex/server";
2
2
 
3
3
  export const internal = componentsGeneric() as any;
4
4
  export const api = {} as any;
5
+ export const components = componentsGeneric();
@@ -20,3 +20,28 @@ export {
20
20
  ingestDeliveryStatus,
21
21
  runTick,
22
22
  } from "./components/campaigns/functions/actions";
23
+
24
+ /** Public API stabile per PrimoUpCore e componente Richiami */
25
+ export {
26
+ listActiveCampaignsForOrg,
27
+ getLatestPublishedVersion,
28
+ getPatientCampaignMemberships,
29
+ getAudiencePage,
30
+ recordContactAttempt,
31
+ getFieldRegistry,
32
+ } from "./components/campaigns/functions/public";
33
+
34
+ /** Playground/dev API (test, examples) */
35
+ export {
36
+ getPlaygroundBootstrap,
37
+ seedDemoData,
38
+ upsertCampaign,
39
+ setCampaignLifecycleStatus,
40
+ listCampaignsV2,
41
+ getCampaignV2,
42
+ listCampaignVersions,
43
+ previewCampaignAudience,
44
+ publishCampaignVersion,
45
+ updateCampaignCallPolicy,
46
+ getCampaignCallPolicy,
47
+ } from "./components/campaigns/functions/playground";
@@ -1,5 +1,21 @@
1
1
  import type { RecipientState } from "./types";
2
2
 
3
+ export type CampaignLifecycleStatus = "draft" | "ready" | "archived";
4
+
5
+ const CAMPAIGN_ALLOWED_TRANSITIONS: Record<CampaignLifecycleStatus, CampaignLifecycleStatus[]> = {
6
+ draft: ["ready", "archived"],
7
+ ready: ["draft", "archived"],
8
+ archived: ["draft"],
9
+ };
10
+
11
+ export function canTransitionCampaignStatus(
12
+ from: CampaignLifecycleStatus,
13
+ to: CampaignLifecycleStatus,
14
+ ): boolean {
15
+ if (from === to) return true;
16
+ return CAMPAIGN_ALLOWED_TRANSITIONS[from]?.includes(to) ?? false;
17
+ }
18
+
3
19
  const ALLOWED_TRANSITIONS: Record<RecipientState, RecipientState[]> = {
4
20
  ELIGIBLE: ["SUPPRESSED", "QUEUED", "CONVERTED"],
5
21
  SUPPRESSED: ["QUEUED", "CONVERTED"],
@@ -1,7 +1,16 @@
1
1
  import { v } from "convex/values";
2
2
 
3
3
  export const CAMPAIGN_SCOPE_TYPES = ["HQ", "CLINIC"] as const;
4
- export const CAMPAIGN_STATUSES = ["DRAFT", "RUNNING", "PAUSED", "COMPLETED", "ARCHIVED"] as const;
4
+ export const CAMPAIGN_STATUSES = [
5
+ "DRAFT",
6
+ "RUNNING",
7
+ "PAUSED",
8
+ "COMPLETED",
9
+ "ARCHIVED",
10
+ "draft",
11
+ "ready",
12
+ "archived",
13
+ ] as const;
5
14
  export const STEP_TYPES = ["EMAIL", "SMS", "CALL_CLINIC", "CALL_CENTER"] as const;
6
15
  export const SNAPSHOT_STATUSES = ["BUILDING", "READY", "FAILED"] as const;
7
16
  export const SEGMENT_TYPES = ["RECALL", "QUOTE_FOLLOWUP", "NO_SHOW", "CUSTOM_IDS"] as const;
@@ -32,6 +41,9 @@ export const campaignStatusValidator = v.union(
32
41
  v.literal("PAUSED"),
33
42
  v.literal("COMPLETED"),
34
43
  v.literal("ARCHIVED"),
44
+ v.literal("draft"),
45
+ v.literal("ready"),
46
+ v.literal("archived"),
35
47
  );
36
48
  export const stepTypeValidator = v.union(
37
49
  v.literal("EMAIL"),
@@ -107,3 +119,44 @@ export const DEFAULT_RUN_CONFIG = {
107
119
  capMaxContacts: 1,
108
120
  lockDays: 7,
109
121
  };
122
+
123
+ // --- Call Policy (marketing) ---
124
+ export const OUTCOME_CATEGORIES = ["success", "retry", "fail", "optout"] as const;
125
+ export type OutcomeCategory = (typeof OUTCOME_CATEGORIES)[number];
126
+
127
+ export const outcomeCategoryValidator = v.union(
128
+ v.literal("success"),
129
+ v.literal("retry"),
130
+ v.literal("fail"),
131
+ v.literal("optout"),
132
+ );
133
+
134
+ export const campaignOutcomeValidator = v.object({
135
+ id: v.string(),
136
+ label: v.string(),
137
+ category: outcomeCategoryValidator,
138
+ isActive: v.boolean(),
139
+ });
140
+
141
+ export const callPolicyValidator = v.object({
142
+ dailyQuotaByClinic: v.optional(v.record(v.string(), v.number())),
143
+ maxAttempts: v.number(),
144
+ retryDelaysDays: v.array(v.number()),
145
+ outcomes: v.array(campaignOutcomeValidator),
146
+ });
147
+
148
+ export const DEFAULT_CALL_POLICY_OUTCOMES = [
149
+ { id: "success_appointment", label: "Appuntamento fissato", category: "success" as const, isActive: true },
150
+ { id: "retry_call_later", label: "Richiamare più tardi", category: "retry" as const, isActive: true },
151
+ { id: "no_answer", label: "Nessuna risposta", category: "retry" as const, isActive: true },
152
+ { id: "wrong_number", label: "Numero errato", category: "fail" as const, isActive: true },
153
+ { id: "not_interested", label: "Non interessato", category: "fail" as const, isActive: true },
154
+ { id: "opt_out", label: "Opt-out", category: "optout" as const, isActive: true },
155
+ ];
156
+
157
+ export const DEFAULT_CALL_POLICY = {
158
+ dailyQuotaByClinic: {} as Record<string, number>,
159
+ maxAttempts: 3,
160
+ retryDelaysDays: [1, 3, 7],
161
+ outcomes: DEFAULT_CALL_POLICY_OUTCOMES,
162
+ };
@@ -6,6 +6,43 @@ function normalizeRole(role: string | undefined): string | null {
6
6
  return value.length > 0 ? value : null;
7
7
  }
8
8
 
9
+ function isIndexError(error: unknown): boolean {
10
+ const message = error instanceof Error ? error.message : String(error);
11
+ return message.includes("Index") && message.includes("not found");
12
+ }
13
+
14
+ async function queryUserByAuth0WithFallback(ctx: any, subject: string) {
15
+ try {
16
+ const byAuth0 = await ctx.db
17
+ .query("users")
18
+ .withIndex("by_auth0", (q: any) => q.eq("auth0Id", subject))
19
+ .first();
20
+ if (byAuth0?.isActive) return byAuth0;
21
+ } catch (error) {
22
+ // Fallback for hosts where users.by_auth0 is unavailable.
23
+ if (!isIndexError(error)) throw error;
24
+ }
25
+
26
+ const users = await ctx.db.query("users").collect();
27
+ return users.find((user: any) => user?.isActive && user?.auth0Id === subject) ?? null;
28
+ }
29
+
30
+ async function queryUserByEmailWithFallback(ctx: any, email: string) {
31
+ try {
32
+ const byEmail = await ctx.db
33
+ .query("users")
34
+ .withIndex("by_email", (q: any) => q.eq("email", email))
35
+ .first();
36
+ if (byEmail?.isActive) return byEmail;
37
+ } catch (error) {
38
+ // Fallback for hosts where users.by_email is unavailable.
39
+ if (!isIndexError(error)) throw error;
40
+ }
41
+
42
+ const users = await ctx.db.query("users").collect();
43
+ return users.find((user: any) => user?.isActive && user?.email === email) ?? null;
44
+ }
45
+
9
46
  export const resolveRolesForIdentity = internalQuery({
10
47
  args: {
11
48
  subject: v.optional(v.string()),
@@ -15,22 +52,11 @@ export const resolveRolesForIdentity = internalQuery({
15
52
  let user = null;
16
53
 
17
54
  if (args.subject) {
18
- try {
19
- // Fallback for hosts where users.by_auth0 is unavailable.
20
- user = await ctx.db
21
- .query("users")
22
- .withIndex("by_auth0", (q) => q.eq("auth0Id", args.subject))
23
- .first();
24
- } catch {
25
- // Ignore index-resolution errors and continue with by_email fallback.
26
- }
55
+ user = await queryUserByAuth0WithFallback(ctx, args.subject);
27
56
  }
28
57
 
29
58
  if (!user && args.email) {
30
- user = await ctx.db
31
- .query("users")
32
- .withIndex("by_email", (q) => q.eq("email", args.email!))
33
- .first();
59
+ user = await queryUserByEmailWithFallback(ctx, args.email);
34
60
  }
35
61
 
36
62
  if (!user || !user.isActive) {
@@ -18,6 +18,66 @@ function resolveRunConfig(input?: Doc<"audience_snapshots">["runConfig"]) {
18
18
  };
19
19
  }
20
20
 
21
+ async function safeCollectCampaignSteps(ctx: any, campaignId: Id<"campaigns">) {
22
+ try {
23
+ return await ctx.db
24
+ .query("campaign_steps")
25
+ .withIndex("by_campaign", (q: any) => q.eq("campaignId", campaignId))
26
+ .collect();
27
+ } catch {
28
+ return await ctx.db
29
+ .query("campaign_steps")
30
+ .filter((q: any) => q.eq(q.field("campaignId"), campaignId))
31
+ .collect();
32
+ }
33
+ }
34
+
35
+ async function safeCollectSnapshotsByCampaign(ctx: any, campaignId: Id<"campaigns">) {
36
+ try {
37
+ return await ctx.db
38
+ .query("audience_snapshots")
39
+ .withIndex("by_campaign_createdAt", (q: any) => q.eq("campaignId", campaignId))
40
+ .collect();
41
+ } catch {
42
+ return await ctx.db
43
+ .query("audience_snapshots")
44
+ .filter((q: any) => q.eq(q.field("campaignId"), campaignId))
45
+ .collect();
46
+ }
47
+ }
48
+
49
+ async function safeFirstSnapshotByStatus(ctx: any, status: "BUILDING" | "READY" | "FAILED") {
50
+ try {
51
+ return await ctx.db
52
+ .query("audience_snapshots")
53
+ .withIndex("by_status", (q: any) => q.eq("status", status))
54
+ .first();
55
+ } catch {
56
+ const docs = await ctx.db
57
+ .query("audience_snapshots")
58
+ .filter((q: any) => q.eq(q.field("status"), status))
59
+ .collect();
60
+ return docs[0] ?? null;
61
+ }
62
+ }
63
+
64
+ async function safeCollectCampaignsByStatus(
65
+ ctx: any,
66
+ status: "DRAFT" | "RUNNING" | "PAUSED" | "COMPLETED" | "ARCHIVED",
67
+ ) {
68
+ try {
69
+ return await ctx.db
70
+ .query("campaigns")
71
+ .withIndex("by_status", (q: any) => q.eq("status", status))
72
+ .collect();
73
+ } catch {
74
+ return await ctx.db
75
+ .query("campaigns")
76
+ .filter((q: any) => q.eq(q.field("status"), status))
77
+ .collect();
78
+ }
79
+ }
80
+
21
81
  function computeDueAt(
22
82
  schedule: Doc<"campaign_steps">["scheduleSpec"],
23
83
  baseline: number,
@@ -144,10 +204,7 @@ async function buildSnapshotChunkCore(
144
204
  return { done: true, nextCursor: null as string | null };
145
205
  }
146
206
 
147
- const steps = await ctx.db
148
- .query("campaign_steps")
149
- .withIndex("by_campaign", (q: any) => q.eq("campaignId", campaign._id))
150
- .collect();
207
+ const steps = await safeCollectCampaignSteps(ctx, campaign._id);
151
208
  const orderedSteps = [...steps].sort((a, b) => a.order - b.order);
152
209
  if (orderedSteps.length === 0) {
153
210
  await ctx.db.patch(snapshotId, { status: "FAILED", updatedAt: Date.now() });
@@ -469,33 +526,21 @@ export const _runTickInternal = internalMutation({
469
526
  handler: async (ctx) => {
470
527
  await assertAuthorized(ctx, ["Admin", "Marketing", "System"]);
471
528
 
472
- const buildingSnapshot = await ctx.db
473
- .query("audience_snapshots")
474
- .withIndex("by_status", (q) => q.eq("status", "BUILDING"))
475
- .first();
529
+ const buildingSnapshot = await safeFirstSnapshotByStatus(ctx, "BUILDING");
476
530
  if (buildingSnapshot) {
477
531
  await buildSnapshotChunkCore(ctx as any, buildingSnapshot._id);
478
532
  }
479
533
 
480
- const runningCampaigns = await ctx.db
481
- .query("campaigns")
482
- .withIndex("by_status", (q) => q.eq("status", "RUNNING"))
483
- .collect();
534
+ const runningCampaigns = await safeCollectCampaignsByStatus(ctx, "RUNNING");
484
535
 
485
536
  for (const campaign of runningCampaigns.slice(0, 10)) {
486
- const snapshots = await ctx.db
487
- .query("audience_snapshots")
488
- .withIndex("by_campaign_createdAt", (q) => q.eq("campaignId", campaign._id))
489
- .collect();
537
+ const snapshots = await safeCollectSnapshotsByCampaign(ctx, campaign._id);
490
538
  const latestReady = snapshots
491
- .filter((s) => s.status === "READY")
492
- .sort((a, b) => b.createdAt - a.createdAt)[0];
539
+ .filter((s: any) => s.status === "READY")
540
+ .sort((a: any, b: any) => b.createdAt - a.createdAt)[0];
493
541
  if (!latestReady) continue;
494
542
 
495
- const steps = await ctx.db
496
- .query("campaign_steps")
497
- .withIndex("by_campaign", (q) => q.eq("campaignId", campaign._id))
498
- .collect();
543
+ const steps = await safeCollectCampaignSteps(ctx, campaign._id);
499
544
  const ordered = [...steps].sort((a, b) => a.order - b.order);
500
545
 
501
546
  for (const step of ordered.slice(0, 5)) {
@@ -12,6 +12,8 @@ import {
12
12
  import * as consentPort from "../ports/consent";
13
13
  import * as patientDataPort from "../ports/patientData";
14
14
 
15
+ const clinicScopeIdValidator = v.union(v.id("clinics"), v.string());
16
+
15
17
  function withDefaultRunConfig(input?: {
16
18
  capWindowDays?: number;
17
19
  capMaxContacts?: number;
@@ -26,6 +28,12 @@ function withDefaultRunConfig(input?: {
26
28
  };
27
29
  }
28
30
 
31
+ function normalizeScopeClinicIds(
32
+ scopeClinicIds: string[],
33
+ ): string[] {
34
+ return [...new Set(scopeClinicIds.map((id) => String(id).trim()).filter(Boolean))];
35
+ }
36
+
29
37
  export const createCampaign = mutation({
30
38
  args: {
31
39
  input: v.object({
@@ -33,7 +41,7 @@ export const createCampaign = mutation({
33
41
  description: v.optional(v.string()),
34
42
  ownerUserId: v.optional(v.id("users")),
35
43
  scopeType: v.union(v.literal("HQ"), v.literal("CLINIC")),
36
- scopeClinicIds: v.array(v.id("clinics")),
44
+ scopeClinicIds: v.array(clinicScopeIdValidator),
37
45
  priority: v.number(),
38
46
  defaultRunConfig: v.optional(runConfigValidator),
39
47
  }),
@@ -43,6 +51,7 @@ export const createCampaign = mutation({
43
51
  const now = Date.now();
44
52
  return ctx.db.insert("campaigns", {
45
53
  ...args.input,
54
+ scopeClinicIds: normalizeScopeClinicIds(args.input.scopeClinicIds as string[]),
46
55
  defaultRunConfig: withDefaultRunConfig(args.input.defaultRunConfig),
47
56
  status: "DRAFT",
48
57
  createdAt: now,
@@ -59,7 +68,7 @@ export const updateCampaign = mutation({
59
68
  description: v.optional(v.string()),
60
69
  ownerUserId: v.optional(v.id("users")),
61
70
  scopeType: v.optional(v.union(v.literal("HQ"), v.literal("CLINIC"))),
62
- scopeClinicIds: v.optional(v.array(v.id("clinics"))),
71
+ scopeClinicIds: v.optional(v.array(clinicScopeIdValidator)),
63
72
  priority: v.optional(v.number()),
64
73
  defaultRunConfig: v.optional(runConfigValidator),
65
74
  }),
@@ -70,6 +79,9 @@ export const updateCampaign = mutation({
70
79
  if (!campaign) throw new Error("Campaign not found");
71
80
  await ctx.db.patch(args.campaignId, {
72
81
  ...args.patch,
82
+ scopeClinicIds: args.patch.scopeClinicIds
83
+ ? normalizeScopeClinicIds(args.patch.scopeClinicIds as string[])
84
+ : campaign.scopeClinicIds,
73
85
  defaultRunConfig: args.patch.defaultRunConfig
74
86
  ? withDefaultRunConfig(args.patch.defaultRunConfig)
75
87
  : campaign.defaultRunConfig,