@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 +61 -0
- package/convex/_generated/api.ts +4 -0
- package/convex/_generated/dataModel.ts +2 -0
- package/convex/_generated/server.ts +19 -0
- package/convex/campaigns.ts +22 -0
- package/convex/components/campaigns/domain/stateMachine.ts +21 -0
- package/convex/components/campaigns/domain/types.ts +109 -0
- package/convex/components/campaigns/functions/actions.ts +95 -0
- package/convex/components/campaigns/functions/authzInternal.ts +60 -0
- package/convex/components/campaigns/functions/ingestionInternal.ts +157 -0
- package/convex/components/campaigns/functions/internal.ts +508 -0
- package/convex/components/campaigns/functions/mutations.ts +225 -0
- package/convex/components/campaigns/functions/queries.ts +131 -0
- package/convex/components/campaigns/index.ts +4 -0
- package/convex/components/campaigns/permissions.ts +112 -0
- package/convex/components/campaigns/ports/callCenter.ts +47 -0
- package/convex/components/campaigns/ports/consent.ts +37 -0
- package/convex/components/campaigns/ports/messaging.ts +64 -0
- package/convex/components/campaigns/ports/patientData.ts +273 -0
- package/convex/components/campaigns/schema.ts +154 -0
- package/convex.config.ts +6 -0
- package/package.json +23 -0
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,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
|
+
});
|