@primocaredentgroup/convex-campaigns-component 0.1.3 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +52 -0
- package/README.md +113 -1
- package/convex/_generated/api.ts +1 -0
- package/convex/campaigns.ts +25 -0
- package/convex/components/campaigns/domain/stateMachine.ts +16 -0
- package/convex/components/campaigns/domain/types.ts +54 -1
- package/convex/components/campaigns/functions/authzInternal.ts +39 -13
- package/convex/components/campaigns/functions/internal.ts +67 -22
- package/convex/components/campaigns/functions/mutations.ts +14 -2
- package/convex/components/campaigns/functions/playground.ts +780 -0
- package/convex/components/campaigns/functions/public.ts +248 -0
- package/convex/components/campaigns/functions/queries.ts +175 -81
- package/convex/components/campaigns/index.ts +4 -0
- package/convex/components/campaigns/lib/helpers.ts +9 -0
- package/convex/components/campaigns/permissions.ts +82 -23
- package/convex/components/campaigns/ports/patientData.ts +3 -3
- package/convex/components/campaigns/schema.ts +124 -2
- package/convex/components/campaigns/v2/callPolicy.ts +67 -0
- package/convex/components/campaigns/v2/dataSource.ts +262 -0
- package/convex/components/campaigns/v2/fieldRegistry.ts +174 -0
- package/convex/components/campaigns/v2/rules.ts +175 -0
- package/convex/components/campaigns/v2/types.ts +62 -0
- package/convex/playground.ts +149 -0
- package/convex.config.ts +11 -4
- package/package.json +17 -3
- package/scripts/smoke-test.mjs +171 -0
- package/scripts/sync-from-repo.mjs +42 -0
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { FieldType, PatientContext } from "./types";
|
|
3
|
+
|
|
4
|
+
export type FieldRegistryItem = {
|
|
5
|
+
key: string;
|
|
6
|
+
label: string;
|
|
7
|
+
type: FieldType;
|
|
8
|
+
category: "Patient" | "Consent" | "Appointments" | "Estimates" | "Treatment" | "Frequency";
|
|
9
|
+
operators: string[];
|
|
10
|
+
valueSchema: z.ZodTypeAny;
|
|
11
|
+
resolver: (ctx: PatientContext) => unknown;
|
|
12
|
+
enumValues?: string[];
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const numberValueSchema = z.union([
|
|
16
|
+
z.number(),
|
|
17
|
+
z.object({ min: z.number(), max: z.number() }),
|
|
18
|
+
z.tuple([z.number(), z.number()]),
|
|
19
|
+
]);
|
|
20
|
+
const stringValueSchema = z.union([z.string(), z.array(z.string())]);
|
|
21
|
+
const boolValueSchema = z.boolean();
|
|
22
|
+
const dateValueSchema = z.union([
|
|
23
|
+
z.number(),
|
|
24
|
+
z.object({ from: z.number(), to: z.number() }),
|
|
25
|
+
z.tuple([z.number(), z.number()]),
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
export const fieldRegistry: FieldRegistryItem[] = [
|
|
29
|
+
{
|
|
30
|
+
key: "patient.age",
|
|
31
|
+
label: "Eta paziente",
|
|
32
|
+
type: "number",
|
|
33
|
+
category: "Patient",
|
|
34
|
+
operators: ["gt", "gte", "lt", "lte", "between", "eq", "neq"],
|
|
35
|
+
valueSchema: numberValueSchema,
|
|
36
|
+
resolver: (ctx) => ctx.age,
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
key: "patient.city",
|
|
40
|
+
label: "Citta",
|
|
41
|
+
type: "string",
|
|
42
|
+
category: "Patient",
|
|
43
|
+
operators: ["eq", "neq", "contains", "in"],
|
|
44
|
+
valueSchema: stringValueSchema,
|
|
45
|
+
resolver: (ctx) => ctx.city,
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
key: "patient.clinicId",
|
|
49
|
+
label: "Clinic ID",
|
|
50
|
+
type: "enum",
|
|
51
|
+
category: "Patient",
|
|
52
|
+
operators: ["eq", "in"],
|
|
53
|
+
valueSchema: stringValueSchema,
|
|
54
|
+
resolver: (ctx) => ctx.clinicId,
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
key: "patient.hasCallConsent",
|
|
58
|
+
label: "Consenso chiamata",
|
|
59
|
+
type: "bool",
|
|
60
|
+
category: "Consent",
|
|
61
|
+
operators: ["is"],
|
|
62
|
+
valueSchema: boolValueSchema,
|
|
63
|
+
resolver: (ctx) => ctx.hasCallConsent,
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
key: "patient.hasPhone",
|
|
67
|
+
label: "Ha telefono",
|
|
68
|
+
type: "bool",
|
|
69
|
+
category: "Consent",
|
|
70
|
+
operators: ["is"],
|
|
71
|
+
valueSchema: boolValueSchema,
|
|
72
|
+
resolver: (ctx) => ctx.hasPhone,
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
key: "appointment.lastDate",
|
|
76
|
+
label: "Data ultimo appuntamento",
|
|
77
|
+
type: "date",
|
|
78
|
+
category: "Appointments",
|
|
79
|
+
operators: ["before", "after", "between"],
|
|
80
|
+
valueSchema: dateValueSchema,
|
|
81
|
+
resolver: (ctx) => ctx.appointment.lastDate,
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
key: "appointment.daysSinceLast",
|
|
85
|
+
label: "Giorni da ultimo appuntamento",
|
|
86
|
+
type: "number",
|
|
87
|
+
category: "Appointments",
|
|
88
|
+
operators: ["gt", "gte", "lt", "lte", "between"],
|
|
89
|
+
valueSchema: numberValueSchema,
|
|
90
|
+
resolver: (ctx) => ctx.appointment.daysSinceLast,
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
key: "appointment.missedLast30d",
|
|
94
|
+
label: "Appuntamento perso ultimi 30 giorni",
|
|
95
|
+
type: "bool",
|
|
96
|
+
category: "Appointments",
|
|
97
|
+
operators: ["is"],
|
|
98
|
+
valueSchema: boolValueSchema,
|
|
99
|
+
resolver: (ctx) => ctx.appointment.missedLast30d,
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
key: "appointment.hasUpcomingNext30d",
|
|
103
|
+
label: "Prossimo appuntamento entro 30 giorni",
|
|
104
|
+
type: "bool",
|
|
105
|
+
category: "Appointments",
|
|
106
|
+
operators: ["is"],
|
|
107
|
+
valueSchema: boolValueSchema,
|
|
108
|
+
resolver: (ctx) => ctx.appointment.hasUpcomingNext30d,
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
key: "estimate.status",
|
|
112
|
+
label: "Stato preventivo",
|
|
113
|
+
type: "enum",
|
|
114
|
+
category: "Estimates",
|
|
115
|
+
operators: ["eq", "in"],
|
|
116
|
+
enumValues: ["open", "accepted", "expired", "none"],
|
|
117
|
+
valueSchema: stringValueSchema,
|
|
118
|
+
resolver: (ctx) => ctx.estimate.status,
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
key: "estimate.amount",
|
|
122
|
+
label: "Importo preventivo",
|
|
123
|
+
type: "number",
|
|
124
|
+
category: "Estimates",
|
|
125
|
+
operators: ["gt", "gte", "lt", "lte", "between"],
|
|
126
|
+
valueSchema: numberValueSchema,
|
|
127
|
+
resolver: (ctx) => ctx.estimate.amount,
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
key: "estimate.expiredDaysAgo",
|
|
131
|
+
label: "Giorni da scadenza preventivo",
|
|
132
|
+
type: "number",
|
|
133
|
+
category: "Estimates",
|
|
134
|
+
operators: ["gt", "gte", "lt", "lte", "between"],
|
|
135
|
+
valueSchema: numberValueSchema,
|
|
136
|
+
resolver: (ctx) => ctx.estimate.expiredDaysAgo,
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
key: "treatment.hasOpenPlan",
|
|
140
|
+
label: "Piano di cura aperto",
|
|
141
|
+
type: "bool",
|
|
142
|
+
category: "Treatment",
|
|
143
|
+
operators: ["is"],
|
|
144
|
+
valueSchema: boolValueSchema,
|
|
145
|
+
resolver: (ctx) => ctx.treatment.hasOpenPlan,
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
key: "treatment.daysSinceLastActivity",
|
|
149
|
+
label: "Giorni da ultima attivita piano",
|
|
150
|
+
type: "number",
|
|
151
|
+
category: "Treatment",
|
|
152
|
+
operators: ["gt", "gte", "lt", "lte", "between"],
|
|
153
|
+
valueSchema: numberValueSchema,
|
|
154
|
+
resolver: (ctx) => ctx.treatment.daysSinceLastActivity,
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
key: "contact.lastContactAt",
|
|
158
|
+
label: "Ultimo contatto",
|
|
159
|
+
type: "date",
|
|
160
|
+
category: "Frequency",
|
|
161
|
+
operators: ["before", "after", "between", "in_last_days"],
|
|
162
|
+
valueSchema: z.union([z.number(), z.object({ from: z.number(), to: z.number() })]),
|
|
163
|
+
resolver: (ctx) => ctx.contact.lastContactAt,
|
|
164
|
+
},
|
|
165
|
+
];
|
|
166
|
+
|
|
167
|
+
export const fieldRegistryMap = new Map(fieldRegistry.map((item) => [item.key, item]));
|
|
168
|
+
|
|
169
|
+
export function getFieldRegistryPublic() {
|
|
170
|
+
return fieldRegistry.map(({ resolver: _resolver, valueSchema, ...rest }) => ({
|
|
171
|
+
...rest,
|
|
172
|
+
valueSchema: valueSchema._def.typeName,
|
|
173
|
+
}));
|
|
174
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { fieldRegistryMap } from "./fieldRegistry";
|
|
3
|
+
import type { PatientContext, RuleCondition, RuleGroup } from "./types";
|
|
4
|
+
|
|
5
|
+
const conditionSchema: z.ZodType<RuleCondition> = z.object({
|
|
6
|
+
fieldKey: z.string().min(1),
|
|
7
|
+
operator: z.string().min(1),
|
|
8
|
+
value: z.any().optional(),
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
const ruleGroupSchema: z.ZodType<RuleGroup> = z.lazy(() =>
|
|
12
|
+
z.object({
|
|
13
|
+
op: z.enum(["AND", "OR"]),
|
|
14
|
+
rules: z.array(z.union([ruleGroupSchema, conditionSchema])).min(1),
|
|
15
|
+
}),
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
function isRuleGroup(value: RuleGroup | RuleCondition): value is RuleGroup {
|
|
19
|
+
return (value as RuleGroup).op !== undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function getBetweenRange(value: unknown): { min: number; max: number } | null {
|
|
23
|
+
if (Array.isArray(value) && value.length === 2) {
|
|
24
|
+
const [min, max] = value;
|
|
25
|
+
if (typeof min === "number" && typeof max === "number") return { min, max };
|
|
26
|
+
}
|
|
27
|
+
if (
|
|
28
|
+
value &&
|
|
29
|
+
typeof value === "object" &&
|
|
30
|
+
typeof (value as any).min === "number" &&
|
|
31
|
+
typeof (value as any).max === "number"
|
|
32
|
+
) {
|
|
33
|
+
return { min: (value as any).min, max: (value as any).max };
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function getDateRange(value: unknown): { from: number; to: number } | null {
|
|
39
|
+
if (Array.isArray(value) && value.length === 2) {
|
|
40
|
+
const [from, to] = value;
|
|
41
|
+
if (typeof from === "number" && typeof to === "number") return { from, to };
|
|
42
|
+
}
|
|
43
|
+
if (
|
|
44
|
+
value &&
|
|
45
|
+
typeof value === "object" &&
|
|
46
|
+
typeof (value as any).from === "number" &&
|
|
47
|
+
typeof (value as any).to === "number"
|
|
48
|
+
) {
|
|
49
|
+
return { from: (value as any).from, to: (value as any).to };
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function evaluateCondition(condition: RuleCondition, ctx: PatientContext): { ok: boolean; reason?: string } {
|
|
55
|
+
const field = fieldRegistryMap.get(condition.fieldKey);
|
|
56
|
+
if (!field) return { ok: false };
|
|
57
|
+
if (!field.operators.includes(condition.operator)) return { ok: false };
|
|
58
|
+
const valueCheck = field.valueSchema.safeParse(condition.value);
|
|
59
|
+
if (!valueCheck.success && condition.operator !== "is") return { ok: false };
|
|
60
|
+
|
|
61
|
+
const current = field.resolver(ctx);
|
|
62
|
+
const value = condition.value;
|
|
63
|
+
let ok = false;
|
|
64
|
+
|
|
65
|
+
switch (condition.operator) {
|
|
66
|
+
case "is":
|
|
67
|
+
ok = Boolean(current) === Boolean(value);
|
|
68
|
+
break;
|
|
69
|
+
case "eq":
|
|
70
|
+
ok = current === value;
|
|
71
|
+
break;
|
|
72
|
+
case "neq":
|
|
73
|
+
ok = current !== value;
|
|
74
|
+
break;
|
|
75
|
+
case "contains":
|
|
76
|
+
ok = typeof current === "string" && typeof value === "string" && current.includes(value);
|
|
77
|
+
break;
|
|
78
|
+
case "in":
|
|
79
|
+
ok = Array.isArray(value) && value.map(String).includes(String(current));
|
|
80
|
+
break;
|
|
81
|
+
case "gt":
|
|
82
|
+
ok = typeof current === "number" && typeof value === "number" && current > value;
|
|
83
|
+
break;
|
|
84
|
+
case "gte":
|
|
85
|
+
ok = typeof current === "number" && typeof value === "number" && current >= value;
|
|
86
|
+
break;
|
|
87
|
+
case "lt":
|
|
88
|
+
ok = typeof current === "number" && typeof value === "number" && current < value;
|
|
89
|
+
break;
|
|
90
|
+
case "lte":
|
|
91
|
+
ok = typeof current === "number" && typeof value === "number" && current <= value;
|
|
92
|
+
break;
|
|
93
|
+
case "between": {
|
|
94
|
+
const range = field.type === "date" ? getDateRange(value) : getBetweenRange(value);
|
|
95
|
+
if (range && typeof current === "number") {
|
|
96
|
+
const min = (range as any).min ?? (range as any).from;
|
|
97
|
+
const max = (range as any).max ?? (range as any).to;
|
|
98
|
+
ok = current >= min && current <= max;
|
|
99
|
+
}
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
case "before":
|
|
103
|
+
ok = typeof current === "number" && typeof value === "number" && current < value;
|
|
104
|
+
break;
|
|
105
|
+
case "after":
|
|
106
|
+
ok = typeof current === "number" && typeof value === "number" && current > value;
|
|
107
|
+
break;
|
|
108
|
+
case "in_last_days":
|
|
109
|
+
ok =
|
|
110
|
+
typeof current === "number" &&
|
|
111
|
+
typeof value === "number" &&
|
|
112
|
+
current >= Date.now() - value * 24 * 60 * 60 * 1000;
|
|
113
|
+
break;
|
|
114
|
+
default:
|
|
115
|
+
ok = false;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
ok,
|
|
120
|
+
reason: ok ? `${condition.fieldKey} ${condition.operator} ${JSON.stringify(condition.value)}` : undefined,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function validateRuleDsl(rules: unknown): { ok: true; data: RuleGroup } | { ok: false; errors: string[] } {
|
|
125
|
+
const parsed = ruleGroupSchema.safeParse(rules);
|
|
126
|
+
if (!parsed.success) {
|
|
127
|
+
return { ok: false, errors: parsed.error.issues.map((i) => i.message) };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const errors: string[] = [];
|
|
131
|
+
const walk = (group: RuleGroup) => {
|
|
132
|
+
for (const item of group.rules) {
|
|
133
|
+
if (isRuleGroup(item)) {
|
|
134
|
+
walk(item);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const field = fieldRegistryMap.get(item.fieldKey);
|
|
138
|
+
if (!field) {
|
|
139
|
+
errors.push(`Field '${item.fieldKey}' non supportato.`);
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (!field.operators.includes(item.operator)) {
|
|
143
|
+
errors.push(`Operatore '${item.operator}' non supportato per '${item.fieldKey}'.`);
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (item.value !== undefined) {
|
|
147
|
+
const check = field.valueSchema.safeParse(item.value);
|
|
148
|
+
if (!check.success && item.operator !== "is") {
|
|
149
|
+
errors.push(`Valore non valido per '${item.fieldKey}'.`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
walk(parsed.data);
|
|
155
|
+
|
|
156
|
+
if (errors.length > 0) return { ok: false, errors };
|
|
157
|
+
return { ok: true, data: parsed.data };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function evaluateRuleGroup(rules: RuleGroup, ctx: PatientContext): { match: boolean; reasons: string[] } {
|
|
161
|
+
const reasons: string[] = [];
|
|
162
|
+
|
|
163
|
+
const evalGroup = (group: RuleGroup): boolean => {
|
|
164
|
+
const results = group.rules.map((item) => {
|
|
165
|
+
if (isRuleGroup(item)) return evalGroup(item);
|
|
166
|
+
const result = evaluateCondition(item, ctx);
|
|
167
|
+
if (result.ok && result.reason) reasons.push(result.reason);
|
|
168
|
+
return result.ok;
|
|
169
|
+
});
|
|
170
|
+
return group.op === "AND" ? results.every(Boolean) : results.some(Boolean);
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const match = evalGroup(rules);
|
|
174
|
+
return { match, reasons: match ? reasons.slice(0, 5) : [] };
|
|
175
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { OutcomeCategory } from "../domain/types";
|
|
2
|
+
|
|
3
|
+
export type RuleOp = "AND" | "OR";
|
|
4
|
+
|
|
5
|
+
export type CampaignOutcome = {
|
|
6
|
+
id: string;
|
|
7
|
+
label: string;
|
|
8
|
+
category: OutcomeCategory;
|
|
9
|
+
isActive: boolean;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type CampaignCallPolicy = {
|
|
13
|
+
dailyQuotaByClinic: Record<string, number>;
|
|
14
|
+
maxAttempts: number;
|
|
15
|
+
retryDelaysDays: number[];
|
|
16
|
+
outcomes: CampaignOutcome[];
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type RuleCondition = {
|
|
20
|
+
fieldKey: string;
|
|
21
|
+
operator: string;
|
|
22
|
+
value?: unknown;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type RuleGroup = {
|
|
26
|
+
op: RuleOp;
|
|
27
|
+
rules: Array<RuleGroup | RuleCondition>;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type FieldType = "string" | "number" | "bool" | "date" | "enum";
|
|
31
|
+
|
|
32
|
+
export type PatientContext = {
|
|
33
|
+
orgId: string;
|
|
34
|
+
clinicId: string;
|
|
35
|
+
patientId: string;
|
|
36
|
+
firstName: string;
|
|
37
|
+
lastName: string;
|
|
38
|
+
city: string;
|
|
39
|
+
birthDate: string;
|
|
40
|
+
hasCallConsent: boolean;
|
|
41
|
+
phoneMasked?: string;
|
|
42
|
+
age: number | null;
|
|
43
|
+
hasPhone: boolean;
|
|
44
|
+
appointment: {
|
|
45
|
+
lastDate: number | null;
|
|
46
|
+
daysSinceLast: number | null;
|
|
47
|
+
missedLast30d: boolean;
|
|
48
|
+
hasUpcomingNext30d: boolean;
|
|
49
|
+
};
|
|
50
|
+
estimate: {
|
|
51
|
+
status: "open" | "accepted" | "expired" | "none";
|
|
52
|
+
amount: number | null;
|
|
53
|
+
expiredDaysAgo: number | null;
|
|
54
|
+
};
|
|
55
|
+
treatment: {
|
|
56
|
+
hasOpenPlan: boolean;
|
|
57
|
+
daysSinceLastActivity: number | null;
|
|
58
|
+
};
|
|
59
|
+
contact: {
|
|
60
|
+
lastContactAt: number | null;
|
|
61
|
+
};
|
|
62
|
+
};
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wrapper app-level per esporre le funzioni playground del component campaigns.
|
|
3
|
+
* Le funzioni del component non sono direttamente accessibili da HTTP/client,
|
|
4
|
+
* quindi le ri-esponiamo come API pubbliche dell'app.
|
|
5
|
+
*
|
|
6
|
+
* @see https://docs.convex.dev/components/authoring#re-exporting-component-functions
|
|
7
|
+
*/
|
|
8
|
+
import { v } from "convex/values";
|
|
9
|
+
import { mutation, query } from "./_generated/server";
|
|
10
|
+
import { components } from "./_generated/api";
|
|
11
|
+
|
|
12
|
+
const c = components as {
|
|
13
|
+
campaigns?: {
|
|
14
|
+
campaignsPlayground?: {
|
|
15
|
+
listCampaignsV2: (args: { orgId: string; status?: "draft" | "ready" | "archived" }) => Promise<unknown>;
|
|
16
|
+
getPlaygroundBootstrap: (args: { orgId: string }) => Promise<unknown>;
|
|
17
|
+
listCampaignVersions: (args: { campaignId: string }) => Promise<unknown>;
|
|
18
|
+
previewCampaignAudience: (args: Record<string, unknown>) => Promise<unknown>;
|
|
19
|
+
getAudiencePage: (args: Record<string, unknown>) => Promise<unknown>;
|
|
20
|
+
getPatientCampaignMemberships: (args: { orgId: string; patientId: string }) => Promise<unknown>;
|
|
21
|
+
getCampaignCallPolicy: (args: { campaignId: string }) => Promise<unknown>;
|
|
22
|
+
upsertCampaign: (args: Record<string, unknown>) => Promise<unknown>;
|
|
23
|
+
setCampaignLifecycleStatus: (args: Record<string, unknown>) => Promise<unknown>;
|
|
24
|
+
publishCampaignVersion: (args: Record<string, unknown>) => Promise<unknown>;
|
|
25
|
+
seedDemoData: (args: Record<string, unknown>) => Promise<unknown>;
|
|
26
|
+
updateCampaignCallPolicy: (args: Record<string, unknown>) => Promise<unknown>;
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const listCampaignsV2 = query({
|
|
32
|
+
args: {
|
|
33
|
+
orgId: v.string(),
|
|
34
|
+
status: v.optional(v.union(v.literal("draft"), v.literal("ready"), v.literal("archived"))),
|
|
35
|
+
},
|
|
36
|
+
handler: async (ctx, args) => {
|
|
37
|
+
return await ctx.runQuery(c.campaigns!.campaignsPlayground!.listCampaignsV2, args);
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
export const getPlaygroundBootstrap = query({
|
|
42
|
+
args: { orgId: v.string() },
|
|
43
|
+
handler: async (ctx, args) => {
|
|
44
|
+
return await ctx.runQuery(c.campaigns!.campaignsPlayground!.getPlaygroundBootstrap, args);
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
export const listCampaignVersions = query({
|
|
49
|
+
args: { campaignId: v.string() },
|
|
50
|
+
handler: async (ctx, args) => {
|
|
51
|
+
return await ctx.runQuery(c.campaigns!.campaignsPlayground!.listCampaignVersions, {
|
|
52
|
+
campaignId: args.campaignId,
|
|
53
|
+
});
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
export const previewCampaignAudience = query({
|
|
58
|
+
args: {
|
|
59
|
+
campaignId: v.optional(v.string()),
|
|
60
|
+
orgId: v.optional(v.string()),
|
|
61
|
+
clinicIds: v.optional(v.array(v.string())),
|
|
62
|
+
rules: v.optional(v.any()),
|
|
63
|
+
frequencyCapDays: v.optional(v.number()),
|
|
64
|
+
},
|
|
65
|
+
handler: async (ctx, args) => {
|
|
66
|
+
return await ctx.runQuery(c.campaigns!.campaignsPlayground!.previewCampaignAudience, args);
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
export const getAudiencePage = query({
|
|
71
|
+
args: {
|
|
72
|
+
versionId: v.string(),
|
|
73
|
+
clinicId: v.optional(v.string()),
|
|
74
|
+
cursor: v.optional(v.string()),
|
|
75
|
+
limit: v.optional(v.number()),
|
|
76
|
+
},
|
|
77
|
+
handler: async (ctx, args) => {
|
|
78
|
+
return await ctx.runQuery(c.campaigns!.campaignsPlayground!.getAudiencePage, args);
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
export const getPatientCampaignMemberships = query({
|
|
83
|
+
args: { orgId: v.string(), patientId: v.string() },
|
|
84
|
+
handler: async (ctx, args) => {
|
|
85
|
+
return await ctx.runQuery(c.campaigns!.campaignsPlayground!.getPatientCampaignMemberships, args);
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
export const getCampaignCallPolicy = query({
|
|
90
|
+
args: { campaignId: v.string() },
|
|
91
|
+
handler: async (ctx, args) => {
|
|
92
|
+
return await ctx.runQuery(c.campaigns!.campaignsPlayground!.getCampaignCallPolicy, args);
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
export const upsertCampaign = mutation({
|
|
97
|
+
args: {
|
|
98
|
+
id: v.optional(v.string()),
|
|
99
|
+
orgId: v.string(),
|
|
100
|
+
name: v.string(),
|
|
101
|
+
description: v.optional(v.string()),
|
|
102
|
+
priority: v.number(),
|
|
103
|
+
status: v.union(v.literal("draft"), v.literal("ready"), v.literal("archived")),
|
|
104
|
+
clinicIds: v.array(v.string()),
|
|
105
|
+
rules: v.any(),
|
|
106
|
+
frequencyCapDays: v.optional(v.number()),
|
|
107
|
+
},
|
|
108
|
+
handler: async (ctx, args) => {
|
|
109
|
+
return await ctx.runMutation(c.campaigns!.campaignsPlayground!.upsertCampaign, args);
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
export const setCampaignLifecycleStatus = mutation({
|
|
114
|
+
args: { campaignId: v.string(), status: v.string() },
|
|
115
|
+
handler: async (ctx, args) => {
|
|
116
|
+
return await ctx.runMutation(c.campaigns!.campaignsPlayground!.setCampaignLifecycleStatus, args);
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
export const publishCampaignVersion = mutation({
|
|
121
|
+
args: {
|
|
122
|
+
campaignId: v.string(),
|
|
123
|
+
label: v.optional(v.string()),
|
|
124
|
+
},
|
|
125
|
+
handler: async (ctx, args) => {
|
|
126
|
+
return await ctx.runMutation(c.campaigns!.campaignsPlayground!.publishCampaignVersion, args);
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
export const seedDemoData = mutation({
|
|
131
|
+
args: {
|
|
132
|
+
orgId: v.string(),
|
|
133
|
+
clinicIds: v.array(v.string()),
|
|
134
|
+
nPatients: v.number(),
|
|
135
|
+
},
|
|
136
|
+
handler: async (ctx, args) => {
|
|
137
|
+
return await ctx.runMutation(c.campaigns!.campaignsPlayground!.seedDemoData, args);
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
export const updateCampaignCallPolicy = mutation({
|
|
142
|
+
args: {
|
|
143
|
+
campaignId: v.string(),
|
|
144
|
+
callPolicy: v.any(),
|
|
145
|
+
},
|
|
146
|
+
handler: async (ctx, args) => {
|
|
147
|
+
return await ctx.runMutation(c.campaigns!.campaignsPlayground!.updateCampaignCallPolicy, args);
|
|
148
|
+
},
|
|
149
|
+
});
|
package/convex.config.ts
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
|
-
import { defineComponent } from "convex/server";
|
|
1
|
+
import { defineApp, defineComponent } from "convex/server";
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
const component = defineComponent("campaigns");
|
|
3
|
+
const campaignsComponent = defineComponent("campaigns");
|
|
5
4
|
|
|
6
|
-
|
|
5
|
+
// App per npx convex dev dalla root del package (sviluppo standalone).
|
|
6
|
+
// L'host che installa da npm usa: import campaignsComponent from "package/convex.config"
|
|
7
|
+
// e fa app.use(campaignsComponent) - quindi serve il componente.
|
|
8
|
+
const app = defineApp();
|
|
9
|
+
app.use(campaignsComponent, { name: "campaigns" });
|
|
10
|
+
|
|
11
|
+
export default app;
|
|
12
|
+
// Per host npm: import { campaignsComponent } from "package/convex.config"
|
|
13
|
+
export { campaignsComponent };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@primocaredentgroup/convex-campaigns-component",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Convex Campaigns backend component for PrimoCore",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"type": "module",
|
|
@@ -8,16 +8,30 @@
|
|
|
8
8
|
"files": [
|
|
9
9
|
"convex.config.ts",
|
|
10
10
|
"README.md",
|
|
11
|
-
"
|
|
11
|
+
"CHANGELOG.md",
|
|
12
|
+
"convex/**",
|
|
13
|
+
"scripts/**"
|
|
12
14
|
],
|
|
13
15
|
"scripts": {
|
|
14
16
|
"sync:from-repo": "node ./scripts/sync-from-repo.mjs",
|
|
15
|
-
"prepack": "npm run sync:from-repo"
|
|
17
|
+
"prepack": "npm run sync:from-repo",
|
|
18
|
+
"test": "tsx --test tests/**/*.test.ts",
|
|
19
|
+
"smoke": "node scripts/smoke-test.mjs"
|
|
16
20
|
},
|
|
17
21
|
"peerDependencies": {
|
|
18
22
|
"convex": "^1.14.0"
|
|
19
23
|
},
|
|
24
|
+
"peerDependenciesMeta": {
|
|
25
|
+
"convex": { "optional": true }
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"convex": "^1.14.0",
|
|
29
|
+
"tsx": "^4.20.6"
|
|
30
|
+
},
|
|
20
31
|
"publishConfig": {
|
|
21
32
|
"access": "restricted"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"zod": "^4.3.6"
|
|
22
36
|
}
|
|
23
37
|
}
|