@talkpilot/core-db 1.3.25 → 1.3.27

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.
Files changed (63) hide show
  1. package/README.md +38 -0
  2. package/dist/talkpilot/calls/calls.constants.d.ts +14 -0
  3. package/dist/talkpilot/calls/calls.constants.d.ts.map +1 -1
  4. package/dist/talkpilot/calls/calls.constants.js +15 -1
  5. package/dist/talkpilot/calls/calls.constants.js.map +1 -1
  6. package/dist/talkpilot/calls/calls.getters.d.ts +3 -0
  7. package/dist/talkpilot/calls/calls.getters.d.ts.map +1 -1
  8. package/dist/talkpilot/calls/calls.getters.js +12 -1
  9. package/dist/talkpilot/calls/calls.getters.js.map +1 -1
  10. package/dist/talkpilot/calls/calls.types.d.ts +11 -2
  11. package/dist/talkpilot/calls/calls.types.d.ts.map +1 -1
  12. package/dist/talkpilot/clients/clients.quota.getters.d.ts +7 -0
  13. package/dist/talkpilot/clients/clients.quota.getters.d.ts.map +1 -0
  14. package/dist/talkpilot/clients/clients.quota.getters.js +80 -0
  15. package/dist/talkpilot/clients/clients.quota.getters.js.map +1 -0
  16. package/dist/talkpilot/clients/clients.quota.types.d.ts +22 -0
  17. package/dist/talkpilot/clients/clients.quota.types.d.ts.map +1 -0
  18. package/dist/talkpilot/clients/clients.quota.types.js +3 -0
  19. package/dist/talkpilot/clients/clients.quota.types.js.map +1 -0
  20. package/dist/talkpilot/clients/clients.types.d.ts +3 -0
  21. package/dist/talkpilot/clients/clients.types.d.ts.map +1 -1
  22. package/dist/talkpilot/clients/index.d.ts +2 -0
  23. package/dist/talkpilot/clients/index.d.ts.map +1 -1
  24. package/dist/talkpilot/clients/index.js +2 -0
  25. package/dist/talkpilot/clients/index.js.map +1 -1
  26. package/dist/talkpilot/clientsConfig/clientsConfig.getters.d.ts +3 -0
  27. package/dist/talkpilot/clientsConfig/clientsConfig.getters.d.ts.map +1 -1
  28. package/dist/talkpilot/clientsConfig/clientsConfig.getters.js +23 -1
  29. package/dist/talkpilot/clientsConfig/clientsConfig.getters.js.map +1 -1
  30. package/dist/talkpilot/clientsConfig/clientsConfig.types.d.ts +2 -0
  31. package/dist/talkpilot/clientsConfig/clientsConfig.types.d.ts.map +1 -1
  32. package/package.json +1 -1
  33. package/src/talkpilot/calls/__tests__/calls.spec.ts +189 -1
  34. package/src/talkpilot/calls/calls.constants.ts +17 -0
  35. package/src/talkpilot/calls/calls.getters.ts +19 -0
  36. package/src/talkpilot/calls/calls.types.ts +13 -10
  37. package/src/talkpilot/clients/__tests__/clients.quota.spec.ts +110 -0
  38. package/src/talkpilot/clients/clients.quota.getters.ts +119 -0
  39. package/src/talkpilot/clients/clients.quota.types.ts +25 -0
  40. package/src/talkpilot/clients/clients.types.ts +4 -0
  41. package/src/talkpilot/clients/index.ts +2 -0
  42. package/src/talkpilot/clientsConfig/clientsConfig.getters.ts +36 -0
  43. package/src/talkpilot/clientsConfig/clientsConfig.types.ts +3 -0
  44. package/dist/talkpilot/flows/flows.setter.d.ts +0 -13
  45. package/dist/talkpilot/flows/flows.setter.d.ts.map +0 -1
  46. package/dist/talkpilot/flows/flows.setter.js +0 -90
  47. package/dist/talkpilot/flows/flows.setter.js.map +0 -1
  48. package/dist/websitalk/websitalk.setup.d.ts +0 -3
  49. package/dist/websitalk/websitalk.setup.d.ts.map +0 -1
  50. package/dist/websitalk/websitalk.setup.js +0 -34
  51. package/dist/websitalk/websitalk.setup.js.map +0 -1
  52. package/dist/websitalk/websiteUrls/websiteUrls.constants.d.ts +0 -2
  53. package/dist/websitalk/websiteUrls/websiteUrls.constants.d.ts.map +0 -1
  54. package/dist/websitalk/websiteUrls/websiteUrls.constants.js +0 -5
  55. package/dist/websitalk/websiteUrls/websiteUrls.constants.js.map +0 -1
  56. package/dist/websitalk/websiteUrls/websiteUrls.context.d.ts +0 -11
  57. package/dist/websitalk/websiteUrls/websiteUrls.context.d.ts.map +0 -1
  58. package/dist/websitalk/websiteUrls/websiteUrls.context.js +0 -35
  59. package/dist/websitalk/websiteUrls/websiteUrls.context.js.map +0 -1
  60. package/dist/websitalk/websiteUrls/websiteUrls.setters.d.ts +0 -7
  61. package/dist/websitalk/websiteUrls/websiteUrls.setters.d.ts.map +0 -1
  62. package/dist/websitalk/websiteUrls/websiteUrls.setters.js +0 -29
  63. package/dist/websitalk/websiteUrls/websiteUrls.setters.js.map +0 -1
@@ -9,9 +9,10 @@ import {
9
9
  getCallsByClientAndDateRange,
10
10
  getCallsCollection,
11
11
  pushToolExecution,
12
+ getToolExecutionsByCallSid,
12
13
  updateCallStatusByCallSid,
13
14
  } from "../calls.getters";
14
- import type { ToolExecution } from "../calls.types";
15
+ import type { ToolExecution, ToolExecutionStep } from "../calls.types";
15
16
  import { ObjectId } from "mongodb";
16
17
  import { createOutGoingCallDoc } from "../../../test-utils/factories";
17
18
 
@@ -242,6 +243,193 @@ describe("db.calls", () => {
242
243
  });
243
244
  });
244
245
 
246
+ describe("getToolExecutionsByCallSid()", () => {
247
+ const makeExecution = (
248
+ overrides?: Partial<ToolExecution>,
249
+ ): ToolExecution => ({
250
+ toolName: "testTool",
251
+ executedAt: new Date(),
252
+ durationMs: 100,
253
+ args: { query: "test" },
254
+ meta: { kind: "http", url: "https://example.com", method: "POST" },
255
+ status: "success",
256
+ httpStatus: 200,
257
+ ...overrides,
258
+ });
259
+
260
+ it("returns all executions for a call", async () => {
261
+ const call = createOutGoingCallDoc();
262
+ await createCallDoc(call);
263
+ await pushToolExecution(call.callSid, makeExecution({ toolName: "first" }));
264
+ await pushToolExecution(call.callSid, makeExecution({ toolName: "second" }));
265
+
266
+ const result = await getToolExecutionsByCallSid(call.callSid);
267
+
268
+ expect(result).toHaveLength(2);
269
+ expect(result.map((e) => e.toolName)).toEqual(["first", "second"]);
270
+ });
271
+
272
+ it("returns an empty array when the call has no executions", async () => {
273
+ const call = createOutGoingCallDoc();
274
+ await createCallDoc(call);
275
+
276
+ const result = await getToolExecutionsByCallSid(call.callSid);
277
+
278
+ expect(result).toEqual([]);
279
+ });
280
+
281
+ it("returns an empty array for an unknown callSid", async () => {
282
+ const result = await getToolExecutionsByCallSid("nonexistent-sid");
283
+
284
+ expect(result).toEqual([]);
285
+ });
286
+
287
+ it("respects skip and limit for pagination", async () => {
288
+ const call = createOutGoingCallDoc();
289
+ await createCallDoc(call);
290
+ for (const name of ["a", "b", "c", "d", "e"]) {
291
+ await pushToolExecution(call.callSid, makeExecution({ toolName: name }));
292
+ }
293
+
294
+ const page1 = await getToolExecutionsByCallSid(call.callSid, { skip: 0, limit: 2 });
295
+ const page2 = await getToolExecutionsByCallSid(call.callSid, { skip: 2, limit: 2 });
296
+ const page3 = await getToolExecutionsByCallSid(call.callSid, { skip: 4, limit: 2 });
297
+
298
+ expect(page1.map((e) => e.toolName)).toEqual(["a", "b"]);
299
+ expect(page2.map((e) => e.toolName)).toEqual(["c", "d"]);
300
+ expect(page3.map((e) => e.toolName)).toEqual(["e"]);
301
+ });
302
+
303
+ it("includes steps when present", async () => {
304
+ const call = createOutGoingCallDoc();
305
+ await createCallDoc(call);
306
+ const steps: ToolExecutionStep[] = [
307
+ { timestamp: new Date("2024-01-01T10:00:00Z"), label: "audio_enrichment", level: "info", data: { audioUsed: true } },
308
+ { timestamp: new Date("2024-01-01T10:00:01Z"), label: "subject_classification", level: "warn" },
309
+ ];
310
+ await pushToolExecution(call.callSid, makeExecution({ steps }));
311
+
312
+ const result = await getToolExecutionsByCallSid(call.callSid);
313
+
314
+ expect(result[0].steps).toHaveLength(2);
315
+ const s0 = result[0].steps![0];
316
+ expect(s0.label).toBe("audio_enrichment");
317
+ expect(s0.level).toBe("info");
318
+ expect(new Date(s0.timestamp).toISOString()).toBe("2024-01-01T10:00:00.000Z");
319
+ expect(s0.data).toEqual({ audioUsed: true });
320
+ const s1 = result[0].steps![1];
321
+ expect(s1.label).toBe("subject_classification");
322
+ expect(s1.level).toBe("warn");
323
+ expect(s1.data).toBeUndefined();
324
+ });
325
+
326
+ it("includes step payload when present", async () => {
327
+ const call = createOutGoingCallDoc();
328
+ await createCallDoc(call);
329
+ const steps: ToolExecutionStep[] = [
330
+ {
331
+ timestamp: new Date("2024-01-01T10:00:00Z"),
332
+ label: "subject_classification",
333
+ level: "info",
334
+ data: "Classified as: ניקיון / אשפה",
335
+ payload: { subject: "ניקיון", subSubject: "אשפה", ids: { subject: "abc", subSubject: "def" } },
336
+ },
337
+ ];
338
+ await pushToolExecution(call.callSid, makeExecution({ steps }));
339
+
340
+ const result = await getToolExecutionsByCallSid(call.callSid);
341
+
342
+ const s0 = result[0].steps![0];
343
+ expect(s0.data).toBe("Classified as: ניקיון / אשפה");
344
+ expect(s0.payload).toEqual({ subject: "ניקיון", subSubject: "אשפה", ids: { subject: "abc", subSubject: "def" } });
345
+ });
346
+
347
+ it("does not require step payload to be present", async () => {
348
+ const call = createOutGoingCallDoc();
349
+ await createCallDoc(call);
350
+ const steps: ToolExecutionStep[] = [
351
+ { timestamp: new Date(), label: "user_query", level: "info", data: "Query: הרצל" },
352
+ ];
353
+ await pushToolExecution(call.callSid, makeExecution({ steps }));
354
+
355
+ const result = await getToolExecutionsByCallSid(call.callSid);
356
+
357
+ expect(result[0].steps![0].payload).toBeUndefined();
358
+ });
359
+
360
+ it("does not require steps to be present", async () => {
361
+ const call = createOutGoingCallDoc();
362
+ await createCallDoc(call);
363
+ await pushToolExecution(call.callSid, makeExecution());
364
+
365
+ const result = await getToolExecutionsByCallSid(call.callSid);
366
+
367
+ expect(result[0].steps).toBeUndefined();
368
+ });
369
+
370
+ it("returns at most 50 executions by default", async () => {
371
+ const call = createOutGoingCallDoc();
372
+ await getCallsCollection().insertOne({
373
+ ...call,
374
+ createdAt: new Date(),
375
+ updatedAt: new Date(),
376
+ env: "test",
377
+ toolExecutions: Array.from({ length: 51 }, (_, i) =>
378
+ makeExecution({ toolName: `tool${i}` }),
379
+ ),
380
+ });
381
+
382
+ const result = await getToolExecutionsByCallSid(call.callSid);
383
+
384
+ expect(result).toHaveLength(50);
385
+ expect(result[0].toolName).toBe("tool0");
386
+ expect(result[49].toolName).toBe("tool49");
387
+ });
388
+
389
+ it("clamps negative skip to 0", async () => {
390
+ const call = createOutGoingCallDoc();
391
+ await createCallDoc(call);
392
+ await pushToolExecution(call.callSid, makeExecution({ toolName: "only" }));
393
+
394
+ const result = await getToolExecutionsByCallSid(call.callSid, { skip: -5 });
395
+
396
+ expect(result).toHaveLength(1);
397
+ expect(result[0].toolName).toBe("only");
398
+ });
399
+
400
+ it("clamps negative limit to 1", async () => {
401
+ const call = createOutGoingCallDoc();
402
+ await createCallDoc(call);
403
+ await pushToolExecution(call.callSid, makeExecution({ toolName: "first" }));
404
+ await pushToolExecution(call.callSid, makeExecution({ toolName: "second" }));
405
+
406
+ const result = await getToolExecutionsByCallSid(call.callSid, { limit: -1 });
407
+
408
+ expect(result).toHaveLength(1);
409
+ });
410
+
411
+ it("returns executions when clientId matches the call's client", async () => {
412
+ const call = createOutGoingCallDoc({ clientId: "clientA" });
413
+ await createCallDoc(call);
414
+ await pushToolExecution(call.callSid, makeExecution({ toolName: "secret" }));
415
+
416
+ const result = await getToolExecutionsByCallSid(call.callSid, { clientId: "clientA" });
417
+
418
+ expect(result).toHaveLength(1);
419
+ expect(result[0].toolName).toBe("secret");
420
+ });
421
+
422
+ it("returns empty when clientId does not match the call's client", async () => {
423
+ const call = createOutGoingCallDoc({ clientId: "clientA" });
424
+ await createCallDoc(call);
425
+ await pushToolExecution(call.callSid, makeExecution({ toolName: "secret" }));
426
+
427
+ const result = await getToolExecutionsByCallSid(call.callSid, { clientId: "clientB" });
428
+
429
+ expect(result).toEqual([]);
430
+ });
431
+ });
432
+
245
433
  describe("getCallsByClientAndDateRange()", () => {
246
434
  it("should return calls within date range", async () => {
247
435
  const clientId = "dateRangeClient";
@@ -30,3 +30,20 @@ export const STATS_MAX_TIME_MS = 30_000;
30
30
  */
31
31
  export const TREND_MONTH_BUCKET_MIN_MONTHS = 6;
32
32
  export const TREND_WEEK_BUCKET_MIN_MONTHS = 3;
33
+
34
+ export const TOOL_EXECUTIONS_DEFAULT_SKIP = 0;
35
+ export const TOOL_EXECUTIONS_DEFAULT_LIMIT = 50;
36
+
37
+ export const CallStatusValues = {
38
+ Initiated: "initiated",
39
+ Ringing: "ringing",
40
+ InProgress: "in-progress",
41
+ Answered: "answered",
42
+ Completed: "completed",
43
+ Busy: "busy",
44
+ NoAnswer: "no-answer",
45
+ Failed: "failed",
46
+ Canceled: "canceled",
47
+ } as const;
48
+
49
+ export const ALL_CALL_STATUSES = Object.values(CallStatusValues);
@@ -6,6 +6,10 @@ import {
6
6
  getDb,
7
7
  } from "../index";
8
8
  import type { CountOpts, DateRange, ToolExecution, CallStatus } from "./calls.types";
9
+ import {
10
+ TOOL_EXECUTIONS_DEFAULT_LIMIT,
11
+ TOOL_EXECUTIONS_DEFAULT_SKIP,
12
+ } from "./calls.constants";
9
13
  import { Filter, ObjectId } from "mongodb";
10
14
  import * as process from "node:process";
11
15
  import { applyQueryOptions } from "../utils/query.utils";
@@ -60,6 +64,21 @@ export const updateCallByCallSid = async (
60
64
  );
61
65
  };
62
66
 
67
+ export const getToolExecutionsByCallSid = async (
68
+ callSid: string,
69
+ opts?: Pick<CallQueryOptions, "skip" | "limit"> & { clientId?: string },
70
+ ): Promise<ToolExecution[]> => {
71
+ const skip = Math.max(0, opts?.skip ?? TOOL_EXECUTIONS_DEFAULT_SKIP);
72
+ const limit = Math.max(1, opts?.limit ?? TOOL_EXECUTIONS_DEFAULT_LIMIT);
73
+ const filter: Filter<Call> = { callSid };
74
+ if (opts?.clientId != null) filter.clientId = opts.clientId;
75
+ const call = await getCallsCollection().findOne(
76
+ filter,
77
+ { projection: { toolExecutions: { $slice: [skip, limit] } } },
78
+ );
79
+ return call?.toolExecutions ?? [];
80
+ };
81
+
63
82
  export const updateCallStatusByCallSid = async (
64
83
  callSid: string,
65
84
  status: CallStatus,
@@ -3,6 +3,7 @@ import { TranscriptionSegment } from "../results";
3
3
  import { LeadProperty } from "../leads";
4
4
 
5
5
  import {
6
+ CallStatusValues,
6
7
  CONFERENCE_ROLE_CUSTOMER,
7
8
  CONFERENCE_ROLE_SUPERVISOR,
8
9
  } from "./calls.constants";
@@ -12,16 +13,7 @@ export type ConferenceRole =
12
13
  | typeof CONFERENCE_ROLE_SUPERVISOR
13
14
  | null;
14
15
 
15
- export type CallStatus =
16
- | "initiated"
17
- | "ringing"
18
- | "in-progress"
19
- | "answered"
20
- | "completed"
21
- | "busy"
22
- | "no-answer"
23
- | "failed"
24
- | "canceled";
16
+ export type CallStatus = (typeof CallStatusValues)[keyof typeof CallStatusValues];
25
17
 
26
18
  export type Call = {
27
19
  callSid: string;
@@ -64,6 +56,17 @@ export type ToolExecution = {
64
56
  status: "success" | "error";
65
57
  httpStatus?: number;
66
58
  response?: Record<string, unknown>;
59
+ steps?: ToolExecutionStep[];
60
+ };
61
+
62
+ export type ToolExecutionStepLevel = "info" | "warn" | "error";
63
+
64
+ export type ToolExecutionStep = {
65
+ timestamp: Date;
66
+ label: string;
67
+ level: ToolExecutionStepLevel;
68
+ data?: Record<string, unknown> | string;
69
+ payload?: Record<string, unknown>;
67
70
  };
68
71
 
69
72
  export type ToolExecutionMeta =
@@ -0,0 +1,110 @@
1
+ import {
2
+ getClientsConfigCollection,
3
+ incrementClientQuotaUsage,
4
+ resetClientQuotaUsage,
5
+ } from "../../clientsConfig/clientsConfig.getters";
6
+ import { getClientsCollection } from "../clients.getters";
7
+ import {
8
+ markQuotaAlertSent,
9
+ markQuotaExpiredSent,
10
+ updateQuotaAlertsConfig,
11
+ } from "../clients.quota.getters";
12
+
13
+ describe("quota getters", () => {
14
+ beforeEach(async () => {
15
+ await getClientsConfigCollection().deleteMany({});
16
+ await getClientsCollection().deleteMany({});
17
+ });
18
+
19
+ const seed = async (clientId: string, configQuota: object, clientQuota?: object) => {
20
+ await getClientsConfigCollection().insertOne({
21
+ clientId,
22
+ products: {},
23
+ quota: configQuota,
24
+ } as any);
25
+ if (clientQuota !== undefined) {
26
+ await getClientsCollection().insertOne({
27
+ clientId,
28
+ createdAt: new Date(),
29
+ updatedAt: new Date(),
30
+ credits: 0,
31
+ quota: clientQuota,
32
+ } as any);
33
+ }
34
+ };
35
+
36
+ it("updateQuotaAlertsConfig stores alerts on clients", async () => {
37
+ const clientId = "quota-alerts";
38
+ await seed(clientId, { totalQuota: 100, usedQuota: 5 });
39
+
40
+ await updateQuotaAlertsConfig(clientId, [
41
+ { thresholdPct: 50, recipientEmails: ["a@example.com"] },
42
+ { thresholdPct: 80, recipientEmails: ["b@example.com", "c@example.com"] },
43
+ ]);
44
+
45
+ const client = await getClientsCollection().findOne({ clientId });
46
+ expect(client?.quota?.alerts).toEqual([
47
+ { thresholdPct: 50, recipientEmails: ["a@example.com"] },
48
+ { thresholdPct: 80, recipientEmails: ["b@example.com", "c@example.com"] },
49
+ ]);
50
+
51
+ const config = await getClientsConfigCollection().findOne({ clientId });
52
+ expect(config?.quota).toEqual({ totalQuota: 100, usedQuota: 5 });
53
+ });
54
+
55
+ it("markQuotaAlertSent returns true only on first transition", async () => {
56
+ const clientId = "quota-alert-sent";
57
+ await seed(
58
+ clientId,
59
+ { totalQuota: 100, usedQuota: 0 },
60
+ { alerts: [{ thresholdPct: 50, recipientEmails: ["a@b.com"] }] },
61
+ );
62
+
63
+ expect(await markQuotaAlertSent(clientId, 0)).toBe(true);
64
+ expect(await markQuotaAlertSent(clientId, 0)).toBe(false);
65
+
66
+ const client = await getClientsCollection().findOne({ clientId });
67
+ expect(client?.quota?.alerts?.[0]?.thresholdSent).toBe(true);
68
+ });
69
+
70
+ it("markQuotaExpiredSent returns true only on first transition", async () => {
71
+ const clientId = "quota-notify";
72
+ await seed(clientId, { totalQuota: 10, usedQuota: 10 });
73
+
74
+ expect(await markQuotaExpiredSent(clientId)).toBe(true);
75
+ expect(await markQuotaExpiredSent(clientId)).toBe(false);
76
+
77
+ const client = await getClientsCollection().findOne({ clientId });
78
+ expect(client?.quota?.expiredSent).toBe(true);
79
+ });
80
+
81
+ it("incrementClientQuotaUsage increments usedQuota on clientsConfig", async () => {
82
+ const clientId = "quota-inc";
83
+ await seed(clientId, { totalQuota: 100, usedQuota: 3 });
84
+
85
+ await incrementClientQuotaUsage(clientId, 2);
86
+ const config = await getClientsConfigCollection().findOne({ clientId });
87
+ expect(config?.quota?.usedQuota).toBe(5);
88
+ });
89
+
90
+ it("resetClientQuotaUsage zeros usedQuota and clears client quota flags", async () => {
91
+ const clientId = "quota-reset";
92
+ await seed(
93
+ clientId,
94
+ { totalQuota: 100, usedQuota: 50 },
95
+ {
96
+ expiredSent: true,
97
+ alerts: [{ thresholdPct: 50, recipientEmails: ["a@b.com"], thresholdSent: true }],
98
+ },
99
+ );
100
+
101
+ await resetClientQuotaUsage(clientId);
102
+
103
+ const config = await getClientsConfigCollection().findOne({ clientId });
104
+ expect(config?.quota?.usedQuota).toBe(0);
105
+
106
+ const client = await getClientsCollection().findOne({ clientId });
107
+ expect(client?.quota?.expiredSent).toBeUndefined();
108
+ expect(client?.quota?.alerts?.[0]?.thresholdSent).toBeUndefined();
109
+ });
110
+ });
@@ -0,0 +1,119 @@
1
+ import { getClientConfig } from "../clientsConfig/clientsConfig.getters";
2
+ import { getClientsCollection } from "./clients.getters";
3
+ import type {
4
+ ClientQuotaState,
5
+ QuotaAlertInput,
6
+ } from "./clients.quota.types";
7
+
8
+ export async function getClientQuotaState(
9
+ clientId: string,
10
+ ): Promise<ClientQuotaState | null> {
11
+ const [config, client] = await Promise.all([
12
+ getClientConfig(clientId),
13
+ getClientsCollection().findOne({ clientId }),
14
+ ]);
15
+
16
+ const usage = config?.quota;
17
+ if (usage?.totalQuota === undefined || usage?.totalQuota === null) return null;
18
+
19
+ const settings = client?.quota;
20
+
21
+ return {
22
+ totalQuota: usage.totalQuota,
23
+ usedQuota: usage.usedQuota ?? 0,
24
+ expiresAt: usage.expiresAt,
25
+ alerts: settings?.alerts,
26
+ expiredSent: settings?.expiredSent,
27
+ };
28
+ }
29
+
30
+ const ensureClientDoc = async (clientId: string): Promise<void> => {
31
+ const now = new Date();
32
+ await getClientsCollection().updateOne(
33
+ { clientId },
34
+ { $setOnInsert: { clientId, createdAt: now, updatedAt: now, credits: 0 } },
35
+ { upsert: true },
36
+ );
37
+ await getClientsCollection().updateOne({ clientId }, { $set: { updatedAt: now } });
38
+ };
39
+
40
+ export async function updateQuotaAlertsConfig(
41
+ clientId: string,
42
+ alerts: QuotaAlertInput[],
43
+ ): Promise<void> {
44
+ const config = await getClientConfig(clientId);
45
+ if (!config) {
46
+ throw new Error(`No client config found for clientId: ${clientId}`);
47
+ }
48
+
49
+ await ensureClientDoc(clientId);
50
+ const state = await getClientQuotaState(clientId);
51
+ const existing = state?.alerts ?? [];
52
+ const sentByThreshold = new Map(
53
+ existing.filter((a) => a.thresholdSent).map((a) => [a.thresholdPct, true]),
54
+ );
55
+ const merged = alerts.map((alert) => ({
56
+ thresholdPct: alert.thresholdPct,
57
+ recipientEmails: alert.recipientEmails,
58
+ ...(sentByThreshold.get(alert.thresholdPct) ? { thresholdSent: true as const } : {}),
59
+ }));
60
+
61
+ const result = await getClientsCollection().updateOne(
62
+ { clientId },
63
+ { $set: { "quota.alerts": merged, updatedAt: new Date() } },
64
+ );
65
+
66
+ if (result.matchedCount === 0) {
67
+ throw new Error(`No client found for clientId: ${clientId}`);
68
+ }
69
+ }
70
+
71
+ export async function markQuotaAlertSent(
72
+ clientId: string,
73
+ alertIndex: number,
74
+ ): Promise<boolean> {
75
+ await ensureClientDoc(clientId);
76
+ const result = await getClientsCollection().findOneAndUpdate(
77
+ {
78
+ clientId,
79
+ [`quota.alerts.${alertIndex}.thresholdSent`]: { $ne: true },
80
+ },
81
+ { $set: { [`quota.alerts.${alertIndex}.thresholdSent`]: true, updatedAt: new Date() } },
82
+ { returnDocument: "after" },
83
+ );
84
+
85
+ return result !== null;
86
+ }
87
+
88
+ export async function markQuotaExpiredSent(clientId: string): Promise<boolean> {
89
+ await ensureClientDoc(clientId);
90
+ const result = await getClientsCollection().findOneAndUpdate(
91
+ {
92
+ clientId,
93
+ "quota.expiredSent": { $ne: true },
94
+ },
95
+ { $set: { "quota.expiredSent": true, updatedAt: new Date() } },
96
+ { returnDocument: "after" },
97
+ );
98
+
99
+ return result !== null;
100
+ }
101
+
102
+ export async function resetClientQuotaSettings(clientId: string): Promise<void> {
103
+ const client = await getClientsCollection().findOne({ clientId });
104
+ const alerts =
105
+ client?.quota?.alerts?.map(({ thresholdSent: _s, ...alert }) => alert) ?? [];
106
+
107
+ if (client) {
108
+ await getClientsCollection().updateOne(
109
+ { clientId },
110
+ {
111
+ $set: {
112
+ ...(alerts.length ? { "quota.alerts": alerts } : {}),
113
+ updatedAt: new Date(),
114
+ },
115
+ $unset: { "quota.expiredSent": "" },
116
+ },
117
+ );
118
+ }
119
+ }
@@ -0,0 +1,25 @@
1
+ export type QuotaThresholdAlert = {
2
+ thresholdPct: number;
3
+ recipientEmails: string[];
4
+ thresholdSent?: boolean;
5
+ };
6
+
7
+ export type ClientQuotaSettings = {
8
+ /** Up to 3 threshold alerts, each with up to 3 recipient emails. */
9
+ alerts?: QuotaThresholdAlert[];
10
+ /** Set after expired-quota email is sent successfully. */
11
+ expiredSent?: boolean;
12
+ };
13
+
14
+ export type QuotaAlertInput = {
15
+ thresholdPct: number;
16
+ recipientEmails: string[];
17
+ };
18
+
19
+ export type ClientQuotaUsage = {
20
+ totalQuota: number;
21
+ usedQuota: number;
22
+ expiresAt?: Date;
23
+ };
24
+
25
+ export type ClientQuotaState = ClientQuotaUsage & ClientQuotaSettings;
@@ -1,4 +1,7 @@
1
1
  import { ObjectId } from "mongodb";
2
+ import { ClientQuotaSettings } from "./clients.quota.types";
3
+
4
+ export type { QuotaThresholdAlert, ClientQuotaSettings, QuotaAlertInput, ClientQuotaUsage, ClientQuotaState } from "./clients.quota.types";
2
5
 
3
6
  export type Client = {
4
7
  _id?: ObjectId;
@@ -9,6 +12,7 @@ export type Client = {
9
12
  apiKey?: string | null;
10
13
  credits: number;
11
14
  managedBy?: string;
15
+ quota?: ClientQuotaSettings;
12
16
  };
13
17
 
14
18
  export type ClientDoc = Client & { _id: ObjectId };
@@ -1,2 +1,4 @@
1
1
  export * from "./clients.getters";
2
2
  export * from "./clients.types";
3
+ export * from "./clients.quota.getters";
4
+ export * from "./clients.quota.types";
@@ -7,6 +7,7 @@ import {
7
7
  KnownProductKey,
8
8
  } from "../index";
9
9
  import { Collection } from "mongodb";
10
+ import { resetClientQuotaSettings } from "../clients/clients.quota.getters";
10
11
 
11
12
  export const getClientsConfigCollection = (): Collection<ClientConfigDoc> => {
12
13
  return getDb().collection<ClientConfigDoc>("clientsConfig");
@@ -77,3 +78,38 @@ export const clearActiveScanId = async (clientId: string): Promise<void> => {
77
78
  throw new Error(`No client config found for clientId: ${clientId}`);
78
79
  }
79
80
  };
81
+
82
+ export async function incrementClientQuotaUsage(
83
+ clientId: string,
84
+ amount: number = 1,
85
+ ): Promise<void> {
86
+ const result = await getClientsConfigCollection().updateOne(
87
+ { clientId, "quota.totalQuota": { $exists: true } },
88
+ { $inc: { "quota.usedQuota": amount } },
89
+ );
90
+
91
+ if (result.matchedCount === 0) {
92
+ return;
93
+ }
94
+ }
95
+
96
+ export async function resetClientQuotaUsage(clientId: string): Promise<void> {
97
+ const result = await getClientsConfigCollection().updateOne(
98
+ { clientId },
99
+ { $set: { "quota.usedQuota": 0 } },
100
+ );
101
+
102
+ if (result.matchedCount === 0) {
103
+ throw new Error(`No client config found for clientId: ${clientId}`);
104
+ }
105
+
106
+ await resetClientQuotaSettings(clientId);
107
+ }
108
+
109
+ export {
110
+ getClientQuotaState,
111
+ updateQuotaAlertsConfig,
112
+ markQuotaAlertSent,
113
+ markQuotaExpiredSent,
114
+ resetClientQuotaSettings,
115
+ } from "../clients/clients.quota.getters";
@@ -111,6 +111,8 @@ export type CommunicationsConfig = {
111
111
  whatsapp?: WhatsappConfig;
112
112
  };
113
113
 
114
+ import type { ClientQuotaUsage } from "../clients/clients.quota.types";
115
+
114
116
  export type ClientConfig<P extends Products = Products> = {
115
117
  clientId: string;
116
118
  phoneNumber?: string;
@@ -119,6 +121,7 @@ export type ClientConfig<P extends Products = Products> = {
119
121
  timezone?: string;
120
122
  communications?: CommunicationsConfig;
121
123
  callLengthThresholds?: CallLengthThresholds;
124
+ quota?: ClientQuotaUsage;
122
125
  };
123
126
 
124
127
  export type ClientConfigDoc<P extends Products = Products> = WithId<
@@ -1,13 +0,0 @@
1
- import type { Flow } from "./flows.types";
2
- export type FlowTemplateParams = {
3
- companyName: string;
4
- companyInfo: string;
5
- language: string;
6
- wtisBaseUrl: string;
7
- };
8
- export declare const extractCompanyInfoFromFlow: (flow: Flow) => string;
9
- type FlowTemplateFields = Omit<Flow, "_id" | "clientId" | "createdAt" | "updatedAt">;
10
- export declare const applyFlowClientParams: (flow: Flow, params: FlowTemplateParams) => FlowTemplateFields;
11
- export declare const substituteFlowTemplate: (template: Flow, params: FlowTemplateParams) => FlowTemplateFields;
12
- export {};
13
- //# sourceMappingURL=flows.setter.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"flows.setter.d.ts","sourceRoot":"","sources":["../../../src/talkpilot/flows/flows.setter.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,eAAe,CAAC;AAE1C,MAAM,MAAM,kBAAkB,GAAG;IAC/B,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,CAAC;IACpB,QAAQ,EAAE,MAAM,CAAC;IACjB,WAAW,EAAE,MAAM,CAAC;CACrB,CAAC;AA2BF,eAAO,MAAM,0BAA0B,GAAI,MAAM,IAAI,KAAG,MAUvD,CAAC;AAgBF,KAAK,kBAAkB,GAAG,IAAI,CAC5B,IAAI,EACJ,KAAK,GAAG,UAAU,GAAG,WAAW,GAAG,WAAW,CAC/C,CAAC;AAKF,eAAO,MAAM,qBAAqB,GAChC,MAAM,IAAI,EACV,QAAQ,kBAAkB,KACzB,kBA+CF,CAAC;AAEF,eAAO,MAAM,sBAAsB,GACjC,UAAU,IAAI,EACd,QAAQ,kBAAkB,KACzB,kBAkCF,CAAC"}