@qcobro/common 1.11.3
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 +115 -0
- package/package.json +25 -0
- package/src/config.test.ts +19 -0
- package/src/config.ts +358 -0
- package/src/errors/ValidationError.test.ts +38 -0
- package/src/errors/ValidationError.ts +68 -0
- package/src/errors/index.ts +1 -0
- package/src/index.ts +21 -0
- package/src/schemas/agentTemplates.ts +100 -0
- package/src/schemas/apiKeys.test.ts +38 -0
- package/src/schemas/apiKeys.ts +28 -0
- package/src/schemas/auth.ts +76 -0
- package/src/schemas/campaigns.ts +88 -0
- package/src/schemas/contactLog.ts +96 -0
- package/src/schemas/dispatch.ts +115 -0
- package/src/schemas/email.ts +37 -0
- package/src/schemas/index.ts +15 -0
- package/src/schemas/insight.ts +20 -0
- package/src/schemas/portfolios.ts +49 -0
- package/src/schemas/userSettings.ts +18 -0
- package/src/schemas/users.ts +7 -0
- package/src/schemas/voiceEvent.ts +45 -0
- package/src/schemas/whatsApp.ts +101 -0
- package/src/schemas/workspaceSettings.ts +20 -0
- package/src/schemas/workspaces.ts +53 -0
- package/src/types/agentTemplates.ts +104 -0
- package/src/types/campaigns.ts +210 -0
- package/src/types/dispatch.ts +160 -0
- package/src/types/email.ts +66 -0
- package/src/types/engine.ts +73 -0
- package/src/types/index.ts +11 -0
- package/src/types/insight.ts +20 -0
- package/src/types/portfolios.ts +128 -0
- package/src/types/userSettings.ts +21 -0
- package/src/types/voiceApplication.ts +29 -0
- package/src/types/whatsApp.ts +82 -0
- package/src/types/workspaceSettings.ts +22 -0
- package/src/utils/index.ts +14 -0
- package/src/utils/outreach.test.ts +83 -0
- package/src/utils/outreach.ts +57 -0
- package/src/utils/time.ts +66 -0
- package/src/utils/withErrorHandlingAndValidation.test.ts +33 -0
- package/src/utils/withErrorHandlingAndValidation.ts +32 -0
- package/tsconfig.json +9 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { renderTemplate, buildOutreachContext, pickRandomNumber } from "./outreach.js";
|
|
4
|
+
import type { PortfolioAccountRecord } from "../types/portfolios.js";
|
|
5
|
+
|
|
6
|
+
function makeAccount(overrides: Partial<PortfolioAccountRecord> = {}): PortfolioAccountRecord {
|
|
7
|
+
return {
|
|
8
|
+
id: "acc-1",
|
|
9
|
+
portfolioId: "pf-1",
|
|
10
|
+
externalId: "EXT-1",
|
|
11
|
+
fullName: "María López",
|
|
12
|
+
phone: "+50670000000",
|
|
13
|
+
preferredLanguage: "es",
|
|
14
|
+
bestTimeToCall: null,
|
|
15
|
+
customerSegment: null,
|
|
16
|
+
principalAmount: 1000,
|
|
17
|
+
termsAmount: 0,
|
|
18
|
+
termsFrequency: null,
|
|
19
|
+
termsLength: 0,
|
|
20
|
+
outstandingBalance: 1500,
|
|
21
|
+
daysPastDue: 30,
|
|
22
|
+
missedInstallments: 2,
|
|
23
|
+
lastPaymentDate: null,
|
|
24
|
+
lastPaymentAmount: null,
|
|
25
|
+
negotiationOptions: null,
|
|
26
|
+
archivedAt: null,
|
|
27
|
+
createdAt: new Date(),
|
|
28
|
+
updatedAt: new Date(),
|
|
29
|
+
...overrides
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe("renderTemplate + buildOutreachContext", () => {
|
|
34
|
+
it("personalizes a body with account data and derived fields", () => {
|
|
35
|
+
const ctx = buildOutreachContext(makeAccount(), { currency: "CRC" });
|
|
36
|
+
const out = renderTemplate(
|
|
37
|
+
"Hola {{firstName}}, su saldo es {{outstandingBalance}} {{currency}}",
|
|
38
|
+
ctx
|
|
39
|
+
);
|
|
40
|
+
assert.equal(out, "Hola María, su saldo es 1500 CRC");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("renders a missing field as empty without throwing", () => {
|
|
44
|
+
const ctx = buildOutreachContext(makeAccount(), { currency: "CRC" });
|
|
45
|
+
const out = renderTemplate("Hola {{firstName}} {{unknownField}}!", ctx);
|
|
46
|
+
assert.equal(out, "Hola María !");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("derives firstName from the first token of fullName", () => {
|
|
50
|
+
const ctx = buildOutreachContext(makeAccount({ fullName: "Juan Carlos Pérez" }), {
|
|
51
|
+
currency: "USD"
|
|
52
|
+
});
|
|
53
|
+
assert.equal(ctx.firstName, "Juan");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("derives isDue from daysPastDue", () => {
|
|
57
|
+
const overdue = buildOutreachContext(makeAccount({ daysPastDue: 30 }), { currency: "CRC" });
|
|
58
|
+
const current = buildOutreachContext(makeAccount({ daysPastDue: 0 }), { currency: "CRC" });
|
|
59
|
+
assert.equal(overdue.isDue, true);
|
|
60
|
+
assert.equal(current.isDue, false);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("branches a template on the isDue conditional", () => {
|
|
64
|
+
const tpl = "{{#if isDue}}Su pago está vencido{{else}}Gracias por estar al día{{/if}}";
|
|
65
|
+
const overdue = renderTemplate(
|
|
66
|
+
tpl,
|
|
67
|
+
buildOutreachContext(makeAccount({ daysPastDue: 5 }), { currency: "CRC" })
|
|
68
|
+
);
|
|
69
|
+
const current = renderTemplate(
|
|
70
|
+
tpl,
|
|
71
|
+
buildOutreachContext(makeAccount({ daysPastDue: 0 }), { currency: "CRC" })
|
|
72
|
+
);
|
|
73
|
+
assert.equal(overdue, "Su pago está vencido");
|
|
74
|
+
assert.equal(current, "Gracias por estar al día");
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("pickRandomNumber", () => {
|
|
79
|
+
it("returns a number from the pool", () => {
|
|
80
|
+
const pool = ["+1", "+2", "+3"];
|
|
81
|
+
assert.ok(pool.includes(pickRandomNumber(pool)));
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import Handlebars from "handlebars";
|
|
2
|
+
import type { PortfolioAccountRecord } from "../types/portfolios.js";
|
|
3
|
+
import type { NumberSelector } from "../types/dispatch.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Renders a Handlebars template against a context. Bodies are plain text (voice
|
|
7
|
+
* script / SMS), never HTML, so escaping is disabled. A missing `{{field}}`
|
|
8
|
+
* renders as empty rather than throwing, so a sparse account never aborts a
|
|
9
|
+
* dispatch mid-flight.
|
|
10
|
+
*/
|
|
11
|
+
export function renderTemplate(template: string, context: Record<string, unknown>): string {
|
|
12
|
+
const compiled = Handlebars.compile(template, { noEscape: true });
|
|
13
|
+
return compiled(context);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Extracts the distinct simple placeholder names from a template, in first-seen order
|
|
18
|
+
* (e.g. `"Hola {{firstName}}, saldo {{outstandingBalance}}"` → `["firstName",
|
|
19
|
+
* "outstandingBalance"]`). Block helpers like `{{#if}}`/`{{/if}}` are ignored. Used to
|
|
20
|
+
* turn a WhatsApp template body into Meta **named** parameters: each token becomes a
|
|
21
|
+
* `{ parameter_name, text }` pair rendered against the customer context.
|
|
22
|
+
*/
|
|
23
|
+
export function extractTemplateTokens(template: string): string[] {
|
|
24
|
+
const tokens: string[] = [];
|
|
25
|
+
const re = /\{\{\s*([A-Za-z_][\w.]*)\s*\}\}/g;
|
|
26
|
+
let match: RegExpExecArray | null;
|
|
27
|
+
while ((match = re.exec(template)) !== null) {
|
|
28
|
+
if (!tokens.includes(match[1])) tokens.push(match[1]);
|
|
29
|
+
}
|
|
30
|
+
return tokens;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Builds the render context exposed to outreach templates: every account field
|
|
35
|
+
* plus derived `firstName` (first token of `fullName`), `currency` (the
|
|
36
|
+
* workspace's currency from WorkspaceSettings), and `isDue` (whether the account
|
|
37
|
+
* is past due, i.e. `daysPastDue > 0`). `isDue` is a boolean so templates can
|
|
38
|
+
* branch on it with Handlebars conditionals, e.g.
|
|
39
|
+
* `{{#if isDue}}su pago está vencido{{else}}gracias por estar al día{{/if}}`.
|
|
40
|
+
* These are the variables documented in the agent console.
|
|
41
|
+
*/
|
|
42
|
+
export function buildOutreachContext(
|
|
43
|
+
account: PortfolioAccountRecord,
|
|
44
|
+
opts: { currency: string }
|
|
45
|
+
): Record<string, unknown> {
|
|
46
|
+
const firstName = account.fullName.trim().split(/\s+/)[0] ?? "";
|
|
47
|
+
return {
|
|
48
|
+
...account,
|
|
49
|
+
firstName,
|
|
50
|
+
currency: opts.currency,
|
|
51
|
+
isDue: account.daysPastDue > 0
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Default number selector: a uniform random pick from the pool. */
|
|
56
|
+
export const pickRandomNumber: NumberSelector = (numbers) =>
|
|
57
|
+
numbers[Math.floor(Math.random() * numbers.length)];
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Timezone-aware wall-clock helpers. The campaigns engine evaluates schedule
|
|
3
|
+
* windows and daily caps in the deployment timezone (from `qcobro.json`), not UTC.
|
|
4
|
+
* All functions take an IANA timezone string and use `Intl` so DST is handled.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
interface LocalParts {
|
|
8
|
+
/** Local calendar date as `YYYY-MM-DD`. */
|
|
9
|
+
date: string;
|
|
10
|
+
/** ISO weekday: 1 = Monday … 7 = Sunday. */
|
|
11
|
+
weekday: number;
|
|
12
|
+
/** Local time as `HH:MM` (24h, zero-padded). */
|
|
13
|
+
time: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const WEEKDAY_TO_ISO: Record<string, number> = {
|
|
17
|
+
Mon: 1,
|
|
18
|
+
Tue: 2,
|
|
19
|
+
Wed: 3,
|
|
20
|
+
Thu: 4,
|
|
21
|
+
Fri: 5,
|
|
22
|
+
Sat: 6,
|
|
23
|
+
Sun: 7
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** Decompose an instant into local date/weekday/time parts for the given timezone. */
|
|
27
|
+
export function localParts(date: Date, timeZone: string): LocalParts {
|
|
28
|
+
const fmt = new Intl.DateTimeFormat("en-US", {
|
|
29
|
+
timeZone,
|
|
30
|
+
weekday: "short",
|
|
31
|
+
year: "numeric",
|
|
32
|
+
month: "2-digit",
|
|
33
|
+
day: "2-digit",
|
|
34
|
+
hour: "2-digit",
|
|
35
|
+
minute: "2-digit",
|
|
36
|
+
hour12: false
|
|
37
|
+
});
|
|
38
|
+
const parts = Object.fromEntries(fmt.formatToParts(date).map((p) => [p.type, p.value]));
|
|
39
|
+
// `hour` can come back as "24" at midnight under hour12:false; normalize to "00".
|
|
40
|
+
const hour = parts.hour === "24" ? "00" : parts.hour;
|
|
41
|
+
return {
|
|
42
|
+
date: `${parts.year}-${parts.month}-${parts.day}`,
|
|
43
|
+
weekday: WEEKDAY_TO_ISO[parts.weekday] ?? 0,
|
|
44
|
+
time: `${hour}:${parts.minute}`
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Local calendar date (`YYYY-MM-DD`) for an instant in the given timezone. */
|
|
49
|
+
export function localDateString(date: Date, timeZone: string): string {
|
|
50
|
+
return localParts(date, timeZone).date;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** True when two instants fall on the same local calendar day in the timezone. */
|
|
54
|
+
export function isSameLocalDay(a: Date, b: Date, timeZone: string): boolean {
|
|
55
|
+
return localDateString(a, timeZone) === localDateString(b, timeZone);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** ISO weekday (1=Mon … 7=Sun) for an instant in the timezone. */
|
|
59
|
+
export function localWeekdayISO(date: Date, timeZone: string): number {
|
|
60
|
+
return localParts(date, timeZone).weekday;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Local `HH:MM` (24h) for an instant in the timezone. */
|
|
64
|
+
export function localTimeHHMM(date: Date, timeZone: string): string {
|
|
65
|
+
return localParts(date, timeZone).time;
|
|
66
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { withErrorHandlingAndValidation } from "./withErrorHandlingAndValidation.js";
|
|
5
|
+
import { ValidationError } from "../errors/ValidationError.js";
|
|
6
|
+
|
|
7
|
+
const schema = z.object({ name: z.string().min(1) });
|
|
8
|
+
|
|
9
|
+
describe("withErrorHandlingAndValidation", () => {
|
|
10
|
+
it("passes parsed, typed input to the wrapped function and returns its result", async () => {
|
|
11
|
+
let received: { name: string } | null = null;
|
|
12
|
+
const wrapped = withErrorHandlingAndValidation(async (params: { name: string }) => {
|
|
13
|
+
received = params;
|
|
14
|
+
return `hello ${params.name}`;
|
|
15
|
+
}, schema);
|
|
16
|
+
|
|
17
|
+
const result = await wrapped({ name: "Ada" });
|
|
18
|
+
|
|
19
|
+
assert.equal(result, "hello Ada");
|
|
20
|
+
assert.deepEqual(received, { name: "Ada" });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("throws ValidationError and never calls the inner function on invalid input", async () => {
|
|
24
|
+
let called = false;
|
|
25
|
+
const wrapped = withErrorHandlingAndValidation(async (params: { name: string }) => {
|
|
26
|
+
called = true;
|
|
27
|
+
return params.name;
|
|
28
|
+
}, schema);
|
|
29
|
+
|
|
30
|
+
await assert.rejects(() => wrapped({ name: "" }), ValidationError);
|
|
31
|
+
assert.equal(called, false);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { ValidationError } from "../errors/ValidationError.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Wraps an async function with Zod schema validation.
|
|
6
|
+
*
|
|
7
|
+
* The returned function validates its input against `schema` before calling
|
|
8
|
+
* `fn`. Valid input is passed through as parsed, typed data; invalid input
|
|
9
|
+
* throws a {@link ValidationError} and `fn` is never invoked.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* ```typescript
|
|
13
|
+
* const createCustomer = withErrorHandlingAndValidation(
|
|
14
|
+
* async (params: CreateCustomerInput) => client.customer.create({ data: params }),
|
|
15
|
+
* createCustomerSchema
|
|
16
|
+
* );
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
export function withErrorHandlingAndValidation<TSchema extends z.ZodType, TResult>(
|
|
20
|
+
fn: (params: z.infer<TSchema>) => Promise<TResult>,
|
|
21
|
+
schema: TSchema
|
|
22
|
+
): (params: unknown) => Promise<TResult> {
|
|
23
|
+
return async (params: unknown) => {
|
|
24
|
+
const result = schema.safeParse(params);
|
|
25
|
+
|
|
26
|
+
if (!result.success) {
|
|
27
|
+
throw new ValidationError(result.error);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return fn(result.data);
|
|
31
|
+
};
|
|
32
|
+
}
|