@primocaredentgroup/convex-campaigns-component 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,61 @@
1
+ # PrimoCore Convex Campaigns Component
2
+
3
+ Pacchetto npm per distribuire il componente Convex `campaigns` di PrimoCore.
4
+
5
+ ## Contenuto
6
+
7
+ - `convex.config.ts`: definizione componente Convex.
8
+ - `convex/components/campaigns/*`: funzioni, schema, ports, domain.
9
+ - `convex/campaigns.ts`: API pubblica stabile (`campaigns.*`).
10
+
11
+ ## Build del pacchetto
12
+
13
+ Dal root del monorepo:
14
+
15
+ ```bash
16
+ cd packages/convex-campaigns-component
17
+ npm run sync:from-repo
18
+ npm pack
19
+ ```
20
+
21
+ `npm pack` esegue anche `prepack`, quindi sincronizza automaticamente i file dal repo.
22
+
23
+ ## Pubblicazione su npmjs
24
+
25
+ ```bash
26
+ cd packages/convex-campaigns-component
27
+ npm login
28
+ npm publish
29
+ ```
30
+
31
+ ## Installazione su host PrimoCore
32
+
33
+ Nel repo host:
34
+
35
+ ```bash
36
+ npm install @primocaredentgroup/convex-campaigns-component
37
+ ```
38
+
39
+ Poi nel `convex.config.ts` dell'host installa il componente:
40
+
41
+ ```ts
42
+ import { defineApp } from "convex/server";
43
+ import campaignsComponent from "@primocaredentgroup/convex-campaigns-component/convex.config";
44
+
45
+ const app = defineApp();
46
+ app.use(campaignsComponent, { name: "campaigns" });
47
+
48
+ export default app;
49
+ ```
50
+
51
+ Infine esegui:
52
+
53
+ ```bash
54
+ npx convex dev
55
+ ```
56
+
57
+ ## Note
58
+
59
+ - Il componente espone logica backend Campaigns (no UI).
60
+ - L'autorizzazione è server-side (`assertAuthorized`) e va collegata al provider auth del deployment host in produzione.
61
+ - La port Consent resta stub fino a disponibilità del componente consensi ufficiale.
@@ -0,0 +1,4 @@
1
+ import { componentsGeneric } from "convex/server";
2
+
3
+ export const internal = componentsGeneric() as any;
4
+ export const api = {} as any;
@@ -0,0 +1,2 @@
1
+ export type Id<TableName extends string> = string & { __tableName: TableName };
2
+ export type Doc<TableName extends string> = any;
@@ -0,0 +1,19 @@
1
+ import {
2
+ actionGeneric,
3
+ internalActionGeneric,
4
+ internalMutationGeneric,
5
+ internalQueryGeneric,
6
+ mutationGeneric,
7
+ queryGeneric,
8
+ } from "convex/server";
9
+
10
+ export const query = queryGeneric;
11
+ export const internalQuery = internalQueryGeneric;
12
+ export const mutation = mutationGeneric;
13
+ export const internalMutation = internalMutationGeneric;
14
+ export const action = actionGeneric;
15
+ export const internalAction = internalActionGeneric;
16
+
17
+ export type QueryCtx = any;
18
+ export type MutationCtx = any;
19
+ export type ActionCtx = any;
@@ -0,0 +1,22 @@
1
+ export {
2
+ listCampaigns,
3
+ getCampaign,
4
+ getSnapshot,
5
+ getCampaignReport,
6
+ } from "./components/campaigns/functions/queries";
7
+
8
+ export {
9
+ createCampaign,
10
+ updateCampaign,
11
+ setCampaignStatus,
12
+ addOrUpdateSteps,
13
+ previewAudience,
14
+ buildAudienceSnapshot,
15
+ } from "./components/campaigns/functions/mutations";
16
+
17
+ export {
18
+ ingestCallOutcome,
19
+ ingestAppointmentBooked,
20
+ ingestDeliveryStatus,
21
+ runTick,
22
+ } from "./components/campaigns/functions/actions";
@@ -0,0 +1,21 @@
1
+ import type { RecipientState } from "./types";
2
+
3
+ const ALLOWED_TRANSITIONS: Record<RecipientState, RecipientState[]> = {
4
+ ELIGIBLE: ["SUPPRESSED", "QUEUED", "CONVERTED"],
5
+ SUPPRESSED: ["QUEUED", "CONVERTED"],
6
+ QUEUED: ["SENT", "TASK_CREATED", "FAILED", "CONVERTED"],
7
+ SENT: ["DELIVERED", "FAILED", "CONVERTED"],
8
+ TASK_CREATED: ["COMPLETED", "FAILED", "CONVERTED"],
9
+ DELIVERED: ["COMPLETED", "CONVERTED"],
10
+ FAILED: ["QUEUED", "CONVERTED"],
11
+ COMPLETED: ["CONVERTED"],
12
+ CONVERTED: [],
13
+ };
14
+
15
+ export function canTransitionRecipientState(
16
+ from: RecipientState,
17
+ to: RecipientState,
18
+ ): boolean {
19
+ if (from === to) return true;
20
+ return ALLOWED_TRANSITIONS[from].includes(to);
21
+ }
@@ -0,0 +1,109 @@
1
+ import { v } from "convex/values";
2
+
3
+ export const CAMPAIGN_SCOPE_TYPES = ["HQ", "CLINIC"] as const;
4
+ export const CAMPAIGN_STATUSES = ["DRAFT", "RUNNING", "PAUSED", "COMPLETED", "ARCHIVED"] as const;
5
+ export const STEP_TYPES = ["EMAIL", "SMS", "CALL_CLINIC", "CALL_CENTER"] as const;
6
+ export const SNAPSHOT_STATUSES = ["BUILDING", "READY", "FAILED"] as const;
7
+ export const SEGMENT_TYPES = ["RECALL", "QUOTE_FOLLOWUP", "NO_SHOW", "CUSTOM_IDS"] as const;
8
+ export const RECIPIENT_STATES = [
9
+ "ELIGIBLE",
10
+ "SUPPRESSED",
11
+ "QUEUED",
12
+ "SENT",
13
+ "TASK_CREATED",
14
+ "DELIVERED",
15
+ "FAILED",
16
+ "COMPLETED",
17
+ "CONVERTED",
18
+ ] as const;
19
+ export const SUPPRESSION_REASONS = [
20
+ "NO_CONSENT",
21
+ "BLACKLIST",
22
+ "INVALID_CONTACT",
23
+ "CAP",
24
+ "LOCKED",
25
+ "LOWER_PRIORITY",
26
+ ] as const;
27
+
28
+ export const scopeTypeValidator = v.union(v.literal("HQ"), v.literal("CLINIC"));
29
+ export const campaignStatusValidator = v.union(
30
+ v.literal("DRAFT"),
31
+ v.literal("RUNNING"),
32
+ v.literal("PAUSED"),
33
+ v.literal("COMPLETED"),
34
+ v.literal("ARCHIVED"),
35
+ );
36
+ export const stepTypeValidator = v.union(
37
+ v.literal("EMAIL"),
38
+ v.literal("SMS"),
39
+ v.literal("CALL_CLINIC"),
40
+ v.literal("CALL_CENTER"),
41
+ );
42
+ export const snapshotStatusValidator = v.union(
43
+ v.literal("BUILDING"),
44
+ v.literal("READY"),
45
+ v.literal("FAILED"),
46
+ );
47
+ export const segmentTypeValidator = v.union(
48
+ v.literal("RECALL"),
49
+ v.literal("QUOTE_FOLLOWUP"),
50
+ v.literal("NO_SHOW"),
51
+ v.literal("CUSTOM_IDS"),
52
+ );
53
+ export const recipientStateValidator = v.union(
54
+ v.literal("ELIGIBLE"),
55
+ v.literal("SUPPRESSED"),
56
+ v.literal("QUEUED"),
57
+ v.literal("SENT"),
58
+ v.literal("TASK_CREATED"),
59
+ v.literal("DELIVERED"),
60
+ v.literal("FAILED"),
61
+ v.literal("COMPLETED"),
62
+ v.literal("CONVERTED"),
63
+ );
64
+ export const suppressionReasonValidator = v.union(
65
+ v.literal("NO_CONSENT"),
66
+ v.literal("BLACKLIST"),
67
+ v.literal("INVALID_CONTACT"),
68
+ v.literal("CAP"),
69
+ v.literal("LOCKED"),
70
+ v.literal("LOWER_PRIORITY"),
71
+ );
72
+
73
+ export const segmentationRulesValidator = v.object({
74
+ segmentType: segmentTypeValidator,
75
+ params: v.optional(v.any()),
76
+ });
77
+
78
+ export const runConfigValidator = v.object({
79
+ capWindowDays: v.optional(v.number()),
80
+ capMaxContacts: v.optional(v.number()),
81
+ lockDays: v.optional(v.number()),
82
+ quietHours: v.optional(
83
+ v.object({
84
+ startHour: v.number(),
85
+ endHour: v.number(),
86
+ timezone: v.optional(v.string()),
87
+ }),
88
+ ),
89
+ });
90
+
91
+ export const scheduleSpecValidator = v.object({
92
+ mode: v.union(v.literal("IMMEDIATE"), v.literal("AT"), v.literal("DELAY")),
93
+ at: v.optional(v.number()),
94
+ delayMinutes: v.optional(v.number()),
95
+ });
96
+
97
+ export type CampaignScopeType = (typeof CAMPAIGN_SCOPE_TYPES)[number];
98
+ export type CampaignStatus = (typeof CAMPAIGN_STATUSES)[number];
99
+ export type StepType = (typeof STEP_TYPES)[number];
100
+ export type SnapshotStatus = (typeof SNAPSHOT_STATUSES)[number];
101
+ export type SegmentType = (typeof SEGMENT_TYPES)[number];
102
+ export type RecipientState = (typeof RECIPIENT_STATES)[number];
103
+ export type SuppressionReason = (typeof SUPPRESSION_REASONS)[number];
104
+
105
+ export const DEFAULT_RUN_CONFIG = {
106
+ capWindowDays: 7,
107
+ capMaxContacts: 1,
108
+ lockDays: 7,
109
+ };
@@ -0,0 +1,95 @@
1
+ import { v } from "convex/values";
2
+ import { action } from "../../../_generated/server";
3
+ import { internal } from "../../../_generated/api";
4
+ import { assertAuthorized } from "../permissions";
5
+
6
+ async function isDuplicateByIdempotencyKey(ctx: any, idempotencyKey?: string) {
7
+ if (!idempotencyKey) return false;
8
+ const existing = await ctx.runQuery(
9
+ (internal as any).components.campaigns.functions.ingestionInternal.findEventByIdempotencyKey,
10
+ { idempotencyKey },
11
+ );
12
+ return Boolean(existing);
13
+ }
14
+
15
+ export const ingestCallOutcome: any = action({
16
+ args: {
17
+ payload: v.object({
18
+ idempotencyKey: v.string(),
19
+ campaignId: v.id("campaigns"),
20
+ snapshotId: v.id("audience_snapshots"),
21
+ patientId: v.id("patients"),
22
+ stepId: v.id("campaign_steps"),
23
+ outcome: v.union(v.literal("COMPLETED"), v.literal("FAILED")),
24
+ notes: v.optional(v.string()),
25
+ }),
26
+ },
27
+ handler: async (ctx, args) => {
28
+ await assertAuthorized(ctx, ["System", "Admin", "Marketing"]);
29
+ if (await isDuplicateByIdempotencyKey(ctx, args.payload.idempotencyKey)) {
30
+ return { deduplicated: true };
31
+ }
32
+ return ctx.runMutation(
33
+ (internal as any).components.campaigns.functions.ingestionInternal.ingestCallOutcomeInternal,
34
+ args,
35
+ );
36
+ },
37
+ });
38
+
39
+ export const ingestAppointmentBooked: any = action({
40
+ args: {
41
+ payload: v.object({
42
+ idempotencyKey: v.string(),
43
+ appointmentId: v.optional(v.string()),
44
+ campaignId: v.optional(v.id("campaigns")),
45
+ snapshotId: v.optional(v.id("audience_snapshots")),
46
+ patientId: v.id("patients"),
47
+ releaseLock: v.optional(v.boolean()),
48
+ }),
49
+ },
50
+ handler: async (ctx, args) => {
51
+ await assertAuthorized(ctx, ["System", "Admin", "Marketing"]);
52
+ if (await isDuplicateByIdempotencyKey(ctx, args.payload.idempotencyKey)) {
53
+ return { deduplicated: true };
54
+ }
55
+ return ctx.runMutation(
56
+ (internal as any).components.campaigns.functions.ingestionInternal.ingestAppointmentBookedInternal,
57
+ args,
58
+ );
59
+ },
60
+ });
61
+
62
+ export const ingestDeliveryStatus: any = action({
63
+ args: {
64
+ payload: v.object({
65
+ idempotencyKey: v.string(),
66
+ campaignId: v.id("campaigns"),
67
+ snapshotId: v.id("audience_snapshots"),
68
+ patientId: v.id("patients"),
69
+ stepId: v.id("campaign_steps"),
70
+ status: v.union(v.literal("DELIVERED"), v.literal("FAILED")),
71
+ providerMessageId: v.optional(v.string()),
72
+ }),
73
+ },
74
+ handler: async (ctx, args) => {
75
+ await assertAuthorized(ctx, ["System", "Admin", "Marketing"]);
76
+ if (await isDuplicateByIdempotencyKey(ctx, args.payload.idempotencyKey)) {
77
+ return { deduplicated: true };
78
+ }
79
+ return ctx.runMutation(
80
+ (internal as any).components.campaigns.functions.ingestionInternal.ingestDeliveryStatusInternal,
81
+ args,
82
+ );
83
+ },
84
+ });
85
+
86
+ export const runTick: any = action({
87
+ args: {},
88
+ handler: async (ctx) => {
89
+ await assertAuthorized(ctx, ["System", "Admin", "Marketing"]);
90
+ return ctx.runMutation(
91
+ (internal as any).components.campaigns.functions.internal._runTickInternal,
92
+ {},
93
+ );
94
+ },
95
+ });
@@ -0,0 +1,60 @@
1
+ import { v } from "convex/values";
2
+ import { internalQuery } from "../../../_generated/server";
3
+
4
+ function normalizeRole(role: string | undefined): string | null {
5
+ const value = (role ?? "").trim().toLowerCase();
6
+ return value.length > 0 ? value : null;
7
+ }
8
+
9
+ export const resolveRolesForIdentity = internalQuery({
10
+ args: {
11
+ subject: v.optional(v.string()),
12
+ email: v.optional(v.string()),
13
+ },
14
+ handler: async (ctx, args) => {
15
+ let user = null;
16
+
17
+ if (args.subject) {
18
+ user = await ctx.db
19
+ .query("users")
20
+ .withIndex("by_auth0", (q) => q.eq("auth0Id", args.subject))
21
+ .first();
22
+ }
23
+
24
+ if (!user && args.email) {
25
+ user = await ctx.db
26
+ .query("users")
27
+ .withIndex("by_email", (q) => q.eq("email", args.email!))
28
+ .first();
29
+ }
30
+
31
+ if (!user || !user.isActive) {
32
+ return {
33
+ userId: null,
34
+ roles: [] as string[],
35
+ };
36
+ }
37
+
38
+ const roles = new Set<string>();
39
+ const directRole = normalizeRole(user.role);
40
+ if (directRole) roles.add(directRole);
41
+
42
+ const userClinicRoles = await ctx.db
43
+ .query("user_clinic_roles")
44
+ .withIndex("by_user", (q) => q.eq("userId", user._id))
45
+ .collect();
46
+
47
+ for (const userClinicRole of userClinicRoles) {
48
+ if (!userClinicRole.isActive) continue;
49
+ const roleDoc = await ctx.db.get(userClinicRole.roleId);
50
+ if (!roleDoc || !roleDoc.isActive) continue;
51
+ const code = normalizeRole(roleDoc.code);
52
+ if (code) roles.add(code);
53
+ }
54
+
55
+ return {
56
+ userId: user._id,
57
+ roles: [...roles],
58
+ };
59
+ },
60
+ });
@@ -0,0 +1,157 @@
1
+ import { v } from "convex/values";
2
+ import { internalMutation, internalQuery } from "../../../_generated/server";
3
+
4
+ export const findEventByIdempotencyKey = internalQuery({
5
+ args: { idempotencyKey: v.string() },
6
+ handler: async (ctx, args) => {
7
+ return ctx.db
8
+ .query("event_log")
9
+ .withIndex("by_idempotency_key", (q) => q.eq("idempotencyKey", args.idempotencyKey))
10
+ .first();
11
+ },
12
+ });
13
+
14
+ export const ingestCallOutcomeInternal = internalMutation({
15
+ args: {
16
+ payload: v.object({
17
+ idempotencyKey: v.string(),
18
+ campaignId: v.id("campaigns"),
19
+ snapshotId: v.id("audience_snapshots"),
20
+ patientId: v.id("patients"),
21
+ stepId: v.id("campaign_steps"),
22
+ outcome: v.union(v.literal("COMPLETED"), v.literal("FAILED")),
23
+ notes: v.optional(v.string()),
24
+ }),
25
+ },
26
+ handler: async (ctx, args) => {
27
+ const now = Date.now();
28
+ const state = await ctx.db
29
+ .query("recipient_states")
30
+ .withIndex("by_snapshot_patient_step", (q) =>
31
+ q
32
+ .eq("snapshotId", args.payload.snapshotId)
33
+ .eq("patientId", args.payload.patientId)
34
+ .eq("stepId", args.payload.stepId),
35
+ )
36
+ .first();
37
+ if (state) {
38
+ await ctx.db.patch(state._id, {
39
+ state: args.payload.outcome,
40
+ reason: args.payload.notes,
41
+ updatedAt: now,
42
+ lastEventAt: now,
43
+ });
44
+ }
45
+
46
+ await ctx.db.insert("event_log", {
47
+ campaignId: args.payload.campaignId,
48
+ snapshotId: args.payload.snapshotId,
49
+ patientId: args.payload.patientId,
50
+ type: "call_task_completed",
51
+ payload: { outcome: args.payload.outcome, notes: args.payload.notes },
52
+ idempotencyKey: args.payload.idempotencyKey,
53
+ createdAt: now,
54
+ });
55
+
56
+ return { ok: true };
57
+ },
58
+ });
59
+
60
+ export const ingestAppointmentBookedInternal = internalMutation({
61
+ args: {
62
+ payload: v.object({
63
+ idempotencyKey: v.string(),
64
+ appointmentId: v.optional(v.string()),
65
+ campaignId: v.optional(v.id("campaigns")),
66
+ snapshotId: v.optional(v.id("audience_snapshots")),
67
+ patientId: v.id("patients"),
68
+ releaseLock: v.optional(v.boolean()),
69
+ }),
70
+ },
71
+ handler: async (ctx, args) => {
72
+ const now = Date.now();
73
+ const allStates = await ctx.db.query("recipient_states").collect();
74
+ const toConvert = allStates.filter(
75
+ (s) =>
76
+ s.patientId === args.payload.patientId &&
77
+ (!args.payload.campaignId || s.campaignId === args.payload.campaignId) &&
78
+ (!args.payload.snapshotId || s.snapshotId === args.payload.snapshotId),
79
+ );
80
+
81
+ for (const rs of toConvert) {
82
+ await ctx.db.patch(rs._id, {
83
+ state: "CONVERTED",
84
+ reason: "APPOINTMENT_BOOKED",
85
+ updatedAt: now,
86
+ lastEventAt: now,
87
+ });
88
+ }
89
+
90
+ if (args.payload.releaseLock ?? true) {
91
+ const lock = await ctx.db
92
+ .query("contact_locks")
93
+ .withIndex("by_patient", (q) => q.eq("patientId", args.payload.patientId))
94
+ .first();
95
+ if (lock) {
96
+ await ctx.db.delete(lock._id);
97
+ }
98
+ }
99
+
100
+ await ctx.db.insert("event_log", {
101
+ campaignId: args.payload.campaignId,
102
+ snapshotId: args.payload.snapshotId,
103
+ patientId: args.payload.patientId,
104
+ type: "appointment_booked",
105
+ payload: { appointmentId: args.payload.appointmentId },
106
+ idempotencyKey: args.payload.idempotencyKey,
107
+ createdAt: now,
108
+ });
109
+
110
+ return { convertedStates: toConvert.length };
111
+ },
112
+ });
113
+
114
+ export const ingestDeliveryStatusInternal = internalMutation({
115
+ args: {
116
+ payload: v.object({
117
+ idempotencyKey: v.string(),
118
+ campaignId: v.id("campaigns"),
119
+ snapshotId: v.id("audience_snapshots"),
120
+ patientId: v.id("patients"),
121
+ stepId: v.id("campaign_steps"),
122
+ status: v.union(v.literal("DELIVERED"), v.literal("FAILED")),
123
+ providerMessageId: v.optional(v.string()),
124
+ }),
125
+ },
126
+ handler: async (ctx, args) => {
127
+ const now = Date.now();
128
+ const state = await ctx.db
129
+ .query("recipient_states")
130
+ .withIndex("by_snapshot_patient_step", (q) =>
131
+ q
132
+ .eq("snapshotId", args.payload.snapshotId)
133
+ .eq("patientId", args.payload.patientId)
134
+ .eq("stepId", args.payload.stepId),
135
+ )
136
+ .first();
137
+ if (state) {
138
+ await ctx.db.patch(state._id, {
139
+ state: args.payload.status,
140
+ updatedAt: now,
141
+ lastEventAt: now,
142
+ });
143
+ }
144
+
145
+ await ctx.db.insert("event_log", {
146
+ campaignId: args.payload.campaignId,
147
+ snapshotId: args.payload.snapshotId,
148
+ patientId: args.payload.patientId,
149
+ type: args.payload.status === "DELIVERED" ? "message_delivered" : "message_failed",
150
+ payload: { providerMessageId: args.payload.providerMessageId, stepId: args.payload.stepId },
151
+ idempotencyKey: args.payload.idempotencyKey,
152
+ createdAt: now,
153
+ });
154
+
155
+ return { ok: true };
156
+ },
157
+ });