@avakado.ai/schemas 1.1.17 → 1.1.19

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@avakado.ai/schemas",
3
- "version": "1.1.17",
3
+ "version": "1.1.19",
4
4
  "description": "Canonical Mongo schemas, models, enums and index declarations shared by the Avakado services.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -3,6 +3,12 @@
3
3
  // missing from a validator, a partial index filtering on a status list that had fallen behind.
4
4
  // Anything that validates or filters on these values must read them from here.
5
5
 
6
+ export const LEAD_STATUS = ["new", "contacted", "qualified", "converted", "lost", "spam"];
7
+ export const CONVERSATION_STATUS = ["open", "pending", "snoozed", "completed", "closed", "archived", "spam"];
8
+ export const CAMPAIGN_STATUS = ["pending", "active", "completed", "failed", "partial"];
9
+ export const TASK_STATUS = ["pending", "in-progress", "completed", "failed", "skipped"];
10
+ export const WORKFLOW_TRIGGERS = [...LEAD_STATUS.map(status => `lead_${status}`), ...CONVERSATION_STATUS.map(status => `conversation_${status}`), ...CAMPAIGN_STATUS.map(status => `campaign_${status}`), ...TASK_STATUS.map(status => `task_${status}`)];
11
+ export const CONVERSATION_PRIORITY = ["low", "normal", "high", "urgent"];
6
12
  export const SUBSCRIPTION_STATUS = [
7
13
  "created",
8
14
  "pending_payment",
@@ -55,11 +61,7 @@ export const LEDGER_CATEGORY = {
55
61
  THIRD_PARTY: "third_party",
56
62
  };
57
63
 
58
- export const CONVERSATION_STATUS = ["open", "pending", "snoozed", "completed", "closed", "archived", "spam"];
59
- export const CONVERSATION_PRIORITY = ["low", "normal", "high", "urgent"];
60
64
 
61
- export const CAMPAIGN_STATUS = ["pending", "active", "completed", "failed", "partial"];
62
- export const TASK_STATUS = ["pending", "in-progress", "completed", "failed", "skipped"];
63
65
  export const TASK_TYPES = ["quick", "webhook"];
64
66
  export const TASK_REFERENCE_TYPES = ["Message", "CallSession"];
65
67
 
package/src/media.js CHANGED
@@ -41,6 +41,7 @@ export const CONVERTERS = new Set([
41
41
  "identity",
42
42
  "pcm16:8000->pcm16:16000",
43
43
  "pcm16:8000->pcm16:24000",
44
+ "pcm16:16000->pcm16:24000",
44
45
  "pcm16:24000->pcm16:8000",
45
46
  "pcm16:24000->pcm16:16000",
46
47
  "pcm16:24000->g711_ulaw:8000",
@@ -73,9 +74,8 @@ export const CHANNEL_CATALOGS = {
73
74
  export const PROVIDER_CARDS = {
74
75
  openai: {
75
76
  symmetric: true,
77
+ // audio/pcm is 24 kHz only. audio/pcmu and audio/pcma carry no rate.
76
78
  wires: [
77
- wire("pcm16", 8000),
78
- wire("pcm16", 16000),
79
79
  wire("pcm16", 24000),
80
80
  wire("g711_ulaw", 8000),
81
81
  wire("g711_alaw", 8000),
package/src/media.test.js CHANGED
@@ -2,24 +2,25 @@ import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
3
  import { MediaPlanError, negotiateCall, selectChannelMedia } from "./media.js";
4
4
 
5
- test("exotel default and openai share pcm16 8 kHz", () => {
5
+ test("exotel 8 kHz pcm is resampled to openai 24 kHz", () => {
6
6
  const plan = negotiateCall({ channelName: "Exotel", provider: "openai" });
7
7
  assert.deepEqual(plan.channel, { encoding: "pcm16", rate: 8000 });
8
- assert.deepEqual(plan.provider.input, plan.channel);
9
- assert.deepEqual(plan.provider.output, plan.channel);
10
- assert.equal(plan.uplink, "identity");
11
- assert.equal(plan.downlink, "identity");
8
+ assert.deepEqual(plan.provider.input, { encoding: "pcm16", rate: 24000 });
9
+ assert.deepEqual(plan.provider.output, { encoding: "pcm16", rate: 24000 });
10
+ assert.equal(plan.uplink, "pcm16:8000->pcm16:24000");
11
+ assert.equal(plan.downlink, "pcm16:24000->pcm16:8000");
12
12
  });
13
13
 
14
- test("user can move exotel to 16 kHz and openai follows", () => {
14
+ test("exotel 16 kHz pcm is resampled to openai 24 kHz", () => {
15
15
  const plan = negotiateCall({
16
16
  channelName: "exotel",
17
17
  channelConfig: { media: { encoding: "pcm16", rate: 16000 } },
18
18
  provider: "openai",
19
19
  });
20
20
  assert.equal(plan.channel.rate, 16000);
21
- assert.equal(plan.uplink, "identity");
22
- assert.equal(plan.downlink, "identity");
21
+ assert.equal(plan.provider.input.rate, 24000);
22
+ assert.equal(plan.uplink, "pcm16:16000->pcm16:24000");
23
+ assert.equal(plan.downlink, "pcm16:24000->pcm16:16000");
23
24
  });
24
25
 
25
26
  test("exotel 8 kHz and gemini bridge 16 kHz in and 24 kHz out", () => {
@@ -1,6 +1,7 @@
1
1
  import { Schema } from "mongoose";
2
2
  import { defineModel } from "../runtime.js";
3
3
  import { CAMPAIGN_STATUS, TASK_REFERENCE_TYPES, TASK_STATUS, TASK_TYPES } from "../enums/index.js";
4
+ import { attachStatusChangeTrigger } from "../statusTrigger.js";
4
5
 
5
6
  const TaskSchema = new Schema({
6
7
  business: { type: Schema.Types.ObjectId, ref: 'Businesses', required: true },
@@ -62,6 +63,9 @@ CampaignSchema.methods.updateTimeLine = async function (timeLine, currentStatus)
62
63
  return this.save();
63
64
  };
64
65
 
66
+ attachStatusChangeTrigger(TaskSchema, "task");
67
+ attachStatusChangeTrigger(CampaignSchema, "campaign");
68
+
65
69
  export { CampaignSchema, TaskSchema };
66
70
  export const Campaign = defineModel('Campaign', CampaignSchema, 'Campaign');
67
71
  export const Task = defineModel('Task', TaskSchema, 'Tasks');
@@ -1,6 +1,7 @@
1
1
  import { Schema } from "mongoose";
2
2
  import { defineModel, requireDep } from "../runtime.js";
3
3
  import { CONVERSATION_PRIORITY, CONVERSATION_STATUS } from "../enums/index.js";
4
+ import { attachStatusChangeTrigger } from "../statusTrigger.js";
4
5
  const ConversationSchema = new Schema({
5
6
  agent: { type: Schema.Types.ObjectId, ref: 'Agent' }, // the AI agent
6
7
  business: { type: Schema.Types.ObjectId, ref: 'Businesses' },
@@ -76,20 +77,10 @@ ConversationSchema.methods.updateStatus = async function (status) {
76
77
  acks: -1
77
78
  });
78
79
  }
79
-
80
- // if (businessId) {
81
- // const trigger = await Trigger.findOne({ business: businessId, name: "conversationStatusUpdated", type: status });
82
- // for (const workflow of trigger?.workflows ?? []) {
83
- // await sendKafkaMessage({
84
- // topic: 'workflow-execution-trigger',
85
- // message: { key: workflow.toString(), value: JSON.stringify({ conversationId }) },
86
- // acks: -1
87
- // });
88
- // }
89
- // }
90
-
91
80
  return saved;
92
81
  };
93
82
 
83
+ attachStatusChangeTrigger(ConversationSchema, "conversation");
84
+
94
85
  export { ConversationSchema };
95
86
  export const Conversation = defineModel('Conversation', ConversationSchema, "Conversations");
@@ -1,6 +1,7 @@
1
1
  import { Schema } from "mongoose";
2
2
  import { defineModel } from "../runtime.js";
3
- const leadStatusEnum = ["new", "contacted", "qualified", "converted", "lost"];
3
+ import { LEAD_STATUS } from "../enums/index.js";
4
+ import { attachStatusChangeTrigger } from "../statusTrigger.js";
4
5
  const platformEntrySchema = new Schema({
5
6
  platform: { type: String, required: true }, // Phone,'Twitter', 'Instagram', 'Whatsapp', etc.
6
7
  handle: { type: String, trim: true },
@@ -49,7 +50,7 @@ const LeadSchema = new Schema({
49
50
  tags: [String],
50
51
  //evaluation
51
52
  leadScore: { type: Number, default: 0 },
52
- status: { type: String, enum: leadStatusEnum, default: 'new' },
53
+ status: { type: String, enum: LEAD_STATUS, default: 'new' },
53
54
  notes: { type: [String], trim: true },
54
55
  // operations
55
56
  lastInteractedAt: Date,
@@ -80,5 +81,7 @@ const LeadTemplateSchema = new Schema({
80
81
  isActive: { type: Boolean, default: true }
81
82
  }, { timestamps: true });
82
83
 
84
+ attachStatusChangeTrigger(LeadSchema, "lead");
85
+
83
86
  export const Lead = defineModel('Lead', LeadSchema, 'Leads');
84
87
  export const LeadTemplate = defineModel('LeadTemplate', LeadTemplateSchema, 'LeadTemplates');
@@ -1,9 +1,10 @@
1
1
  import { Schema } from "mongoose";
2
2
  import { defineModel } from "../runtime.js";
3
3
  import { Task_Tree } from "./Action.js";
4
+ import { WORKFLOW_TRIGGERS } from "../enums/index.js";
4
5
  const WorkflowSchema = new Schema({
5
6
  name: { type: String, default: "Untitled Workflow" },
6
- trigger: { type: String, required: true },
7
+ trigger: { type: String, required: true, enum: WORKFLOW_TRIGGERS },
7
8
  business: { type: Schema.Types.ObjectId, ref: "Businesses" },
8
9
  status: { type: String, enum: ["active", "disabled"], default: "active" },
9
10
  task: Task_Tree,
@@ -17,6 +18,12 @@ WorkflowSchema.methods.updateStatus = function (status) {
17
18
  this.status = status;
18
19
  return this.save();
19
20
  };
21
+ WorkflowSchema.methods.execute = function (payload) {
22
+ let trigger = this.trigger;
23
+ let businessId = this.business;
24
+
25
+ return { trigger, businessId, payload };
26
+ };
20
27
  // WorkflowSchema.methods.getNextNode = function ({ currentNode = "", outputPortsOfCurentNode = [] } = {}) {
21
28
  // const nextNodes = [];
22
29
  // if (!currentNode) return [Object.keys(this.nodes).find((ele) => this.nodes[ele].type === "trigger")];
@@ -0,0 +1,217 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { CAMPAIGN_STATUS, CONVERSATION_STATUS, LEAD_STATUS, TASK_STATUS } from "./enums/index.js";
3
+ import { requireDep } from "./runtime.js";
4
+
5
+ // Status is not written in one place. Conversations go through updateStatus, but also
6
+ // findOneAndUpdate. Leads are created and patched with findOneAndUpdate / findByIdAndUpdate.
7
+ // Campaigns and tasks change status inside updateTimeLine, and tasks are also insertMany'd
8
+ // and updateMany'd to "skipped". These hooks run after the write, so a status that fails
9
+ // validation never publishes a trigger for a change that did not persist.
10
+
11
+ export const WORKFLOW_TRIGGER_TOPIC = "workflow-execution-trigger";
12
+
13
+ const STATUS_BY_ENTITY = {
14
+ lead: new Set(LEAD_STATUS),
15
+ conversation: new Set(CONVERSATION_STATUS),
16
+ campaign: new Set(CAMPAIGN_STATUS),
17
+ task: new Set(TASK_STATUS),
18
+ };
19
+
20
+ const UPDATE_HOOKS = ["findOneAndUpdate", "updateOne", "updateMany", "replaceOne", "findOneAndReplace"];
21
+ const pendingWrites = new WeakMap();
22
+ const BATCH_SIZE = 50;
23
+
24
+ function allowed(entity, status) {
25
+ return typeof status === "string" && STATUS_BY_ENTITY[entity]?.has(status);
26
+ }
27
+
28
+ function businessIdOf(doc) {
29
+ const business = doc?.business;
30
+ if (business == null || business === "") return "";
31
+ if (typeof business === "object" && business._id != null) return String(business._id);
32
+ return String(business);
33
+ }
34
+
35
+ function serializeDocument(doc) {
36
+ if (doc == null) return null;
37
+ try {
38
+ const plain = typeof doc.toObject === "function" ? doc.toObject({ virtuals: false }) : doc;
39
+ const seen = new WeakSet();
40
+ return JSON.parse(JSON.stringify(plain, (_key, value) => {
41
+ if (typeof value === "bigint") return value.toString();
42
+ if (typeof value === "object" && value !== null) {
43
+ if (seen.has(value)) return undefined;
44
+ seen.add(value);
45
+ }
46
+ return value;
47
+ }));
48
+ } catch {
49
+ return {
50
+ _id: doc._id != null ? String(doc._id) : null,
51
+ status: doc.status ?? null,
52
+ business: businessIdOf(doc) || null,
53
+ };
54
+ }
55
+ }
56
+
57
+ function readNextStatus(entity, update) {
58
+ if (update == null || typeof update !== "object") return null;
59
+ let status;
60
+ // Timestamps middleware adds `$set.updatedAt` without moving the caller's fields,
61
+ // so a plain update arrives as `{ status, $set: { updatedAt } }`.
62
+ if (Array.isArray(update)) {
63
+ for (const stage of update) {
64
+ if (typeof stage?.$set?.status === "string") status = stage.$set.status;
65
+ }
66
+ } else if (typeof update.$set?.status === "string") {
67
+ status = update.$set.status;
68
+ } else if (typeof update.status === "string") {
69
+ status = update.status;
70
+ }
71
+ return allowed(entity, status) ? status : null;
72
+ }
73
+
74
+ function statusTriggerMessage({ entity, status, previousStatus, businessId, document }) {
75
+ if (!allowed(entity, status) || previousStatus === status) return null;
76
+ const eventId = randomUUID();
77
+ const entityId = document?._id != null ? String(document._id) : "";
78
+ const resolvedBusinessId = businessId ? String(businessId) : businessIdOf(document);
79
+ const trigger = `${entity}_${status}`;
80
+ const body = {
81
+ eventId,
82
+ businessId: resolvedBusinessId,
83
+ trigger,
84
+ payload: {
85
+ entity,
86
+ entityId,
87
+ status,
88
+ previousStatus: previousStatus ?? null,
89
+ changedAt: new Date().toISOString(),
90
+ document: serializeDocument(document),
91
+ },
92
+ };
93
+ return {
94
+ key: resolvedBusinessId || entityId || eventId,
95
+ value: JSON.stringify(body),
96
+ headers: {
97
+ eventId,
98
+ businessId: resolvedBusinessId,
99
+ trigger,
100
+ entity,
101
+ },
102
+ };
103
+ }
104
+
105
+ async function publishStatusChanges(changes) {
106
+ const messages = changes.map(statusTriggerMessage).filter(Boolean);
107
+ if (!messages.length) return;
108
+ const sendKafkaMessage = requireDep("sendKafkaMessage", "workflow status trigger");
109
+ for (let i = 0; i < messages.length; i += BATCH_SIZE) {
110
+ const batch = messages.slice(i, i + BATCH_SIZE);
111
+ try {
112
+ await sendKafkaMessage({ topic: WORKFLOW_TRIGGER_TOPIC, messages: batch, acks: -1 });
113
+ } catch (error) {
114
+ console.error(
115
+ `@avakado.ai/schemas: failed to publish ${batch.length} workflow status trigger(s) on ${WORKFLOW_TRIGGER_TOPIC}`,
116
+ error?.message || error
117
+ );
118
+ }
119
+ }
120
+ }
121
+
122
+ function writeApplied(result) {
123
+ if (result == null || typeof result !== "object") return false;
124
+ if ("matchedCount" in result || "modifiedCount" in result || "upsertedCount" in result) {
125
+ return (result.matchedCount || 0) > 0 || (result.modifiedCount || 0) > 0 || (result.upsertedCount || 0) > 0;
126
+ }
127
+ return result._id != null;
128
+ }
129
+
130
+ export function attachStatusChangeTrigger(schema, entity) {
131
+ schema.post("init", function recordLoadedStatus() {
132
+ this.$locals.loadedStatus = this.status;
133
+ });
134
+
135
+ schema.pre("save", function captureStatusChange() {
136
+ const status = this.status;
137
+ if (this.isNew) {
138
+ this.$locals.statusEmit = allowed(entity, status) ? { status, previousStatus: null } : null;
139
+ return;
140
+ }
141
+ if (!this.isModified("status") || !allowed(entity, status)) {
142
+ this.$locals.statusEmit = null;
143
+ return;
144
+ }
145
+ const previousStatus = Object.prototype.hasOwnProperty.call(this.$locals, "loadedStatus")
146
+ ? this.$locals.loadedStatus
147
+ : null;
148
+ this.$locals.statusEmit = previousStatus === status ? null : { status, previousStatus };
149
+ });
150
+
151
+ schema.post("save", async function publishSavedStatus() {
152
+ const emit = this.$locals.statusEmit;
153
+ this.$locals.loadedStatus = this.status;
154
+ this.$locals.statusEmit = null;
155
+ if (!emit) return;
156
+ await publishStatusChanges([{
157
+ entity,
158
+ status: emit.status,
159
+ previousStatus: emit.previousStatus,
160
+ businessId: businessIdOf(this),
161
+ document: this,
162
+ }]);
163
+ });
164
+
165
+ schema.pre(UPDATE_HOOKS, async function captureQueryStatusChange() {
166
+ const status = readNextStatus(entity, this.getUpdate());
167
+ if (!status) return;
168
+ const session = this.getOptions()?.session;
169
+ let finder = this.model.find(this.getFilter()).select("_id status");
170
+ if (session) finder = finder.session(session);
171
+ const rows = await finder.lean();
172
+ const changed = rows.filter((row) => row.status !== status);
173
+ if (!changed.length) return;
174
+ pendingWrites.set(this, {
175
+ status,
176
+ previousById: new Map(changed.map((row) => [String(row._id), row.status ?? null])),
177
+ });
178
+ });
179
+
180
+ schema.post(UPDATE_HOOKS, async function publishQueryStatusChange(result) {
181
+ const pending = pendingWrites.get(this);
182
+ pendingWrites.delete(this);
183
+ if (!pending || !writeApplied(result)) return;
184
+
185
+ let docs = [];
186
+ if (result?._id && pending.previousById.has(String(result._id)) && result.status === pending.status) {
187
+ docs = [result];
188
+ } else {
189
+ const session = this.getOptions()?.session;
190
+ let finder = this.model.find({
191
+ _id: { $in: [...pending.previousById.keys()] },
192
+ status: pending.status,
193
+ });
194
+ if (session) finder = finder.session(session);
195
+ docs = await finder.lean();
196
+ }
197
+
198
+ await publishStatusChanges(docs.map((doc) => ({
199
+ entity,
200
+ status: doc.status,
201
+ previousStatus: pending.previousById.get(String(doc._id)) ?? null,
202
+ businessId: businessIdOf(doc),
203
+ document: doc,
204
+ })));
205
+ });
206
+
207
+ schema.post("insertMany", async function publishInsertedStatus(docs) {
208
+ if (!Array.isArray(docs) || !docs.length) return;
209
+ await publishStatusChanges(docs.map((doc) => ({
210
+ entity,
211
+ status: doc?.status,
212
+ previousStatus: null,
213
+ businessId: businessIdOf(doc),
214
+ document: doc,
215
+ })));
216
+ });
217
+ }