@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
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# Change Log
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
|
5
|
+
|
|
6
|
+
# [1.11.0](https://github.com/fonoster/qcobro/compare/v1.10.0...v1.11.0) (2026-06-30)
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
- **whatsapp:** inbound autopilot — reply, opt-out, payment promise (§7.3, §7.4, §9.4) ([ef37a91](https://github.com/fonoster/qcobro/commit/ef37a9109909cb9e86ff3a787b5af06ed199fd78))
|
|
11
|
+
- **whatsapp:** inbound event processing — opt-out suppression + quality-rating (§7.2, §9.3) ([974a9e3](https://github.com/fonoster/qcobro/commit/974a9e35d549e1baf6386ac6bcec40297c8ae703))
|
|
12
|
+
- **whatsapp:** wire engine tick, webhook handshake, and unit tests (§6.2, §7.1, §9.1–9.2) ([a46bb1f](https://github.com/fonoster/qcobro/commit/a46bb1fd0f786e5856281da48d07f2f3249081e7))
|
|
13
|
+
|
|
14
|
+
# [1.10.0](https://github.com/fonoster/qcobro/compare/v1.9.0...v1.10.0) (2026-06-30)
|
|
15
|
+
|
|
16
|
+
### Features
|
|
17
|
+
|
|
18
|
+
- **whatsapp:** add WhatsApp channel server foundation (§2–§6.1) ([51211f3](https://github.com/fonoster/qcobro/commit/51211f3b8af1cd7fb45e0b2ded40262915606548)), closes [#5](https://github.com/fonoster/qcobro/issues/5)
|
|
19
|
+
|
|
20
|
+
# [1.9.0](https://github.com/fonoster/qcobro/compare/v1.8.0...v1.9.0) (2026-06-29)
|
|
21
|
+
|
|
22
|
+
### Bug Fixes
|
|
23
|
+
|
|
24
|
+
- **api:** stop resending Voz IA system prompt as call metadata ([0d66d18](https://github.com/fonoster/qcobro/commit/0d66d18b34d0f353f02d96599d8784f0f372a547))
|
|
25
|
+
|
|
26
|
+
### Features
|
|
27
|
+
|
|
28
|
+
- **common:** add isDue outreach variable and document template variables ([ed7a511](https://github.com/fonoster/qcobro/commit/ed7a51161b758d2337055028f7adf52f22f7ae56))
|
|
29
|
+
|
|
30
|
+
## [1.5.4](https://github.com/fonoster/qcobro/compare/v1.5.3...v1.5.4) (2026-06-28)
|
|
31
|
+
|
|
32
|
+
### Bug Fixes
|
|
33
|
+
|
|
34
|
+
- **e2e:** supply fonoster voices in CI config; enable campaigns + console specs ([2ff90ab](https://github.com/fonoster/qcobro/commit/2ff90aba4da71f7e6d902aacff753ea5a9411950))
|
|
35
|
+
|
|
36
|
+
# [1.5.0](https://github.com/fonoster/qcobro/compare/v1.4.1...v1.5.0) (2026-06-28)
|
|
37
|
+
|
|
38
|
+
### Features
|
|
39
|
+
|
|
40
|
+
- **profile-language:** per-user language preference + i18n hygiene sweep ([dbddb9c](https://github.com/fonoster/qcobro/commit/dbddb9c7915103afc339ec9d695b27c97d7634ce))
|
|
41
|
+
|
|
42
|
+
# [1.4.0](https://github.com/fonoster/qcobro/compare/v1.3.2...v1.4.0) (2026-06-28)
|
|
43
|
+
|
|
44
|
+
### Features
|
|
45
|
+
|
|
46
|
+
- **workspace-settings:** collect currency + timezone at workspace creation ([682ae8f](https://github.com/fonoster/qcobro/commit/682ae8f5fcf2045564c0dd5ac0e8f38fa736ed4a))
|
|
47
|
+
|
|
48
|
+
## [1.3.2](https://github.com/fonoster/qcobro/compare/v1.3.1...v1.3.2) (2026-06-28)
|
|
49
|
+
|
|
50
|
+
**Note:** Version bump only for package @qcobro/common
|
|
51
|
+
|
|
52
|
+
## [1.3.1](https://github.com/fonoster/qcobro/compare/v1.3.0...v1.3.1) (2026-06-28)
|
|
53
|
+
|
|
54
|
+
### Bug Fixes
|
|
55
|
+
|
|
56
|
+
- **timezone:** contact-log REST uses workspace tz; default is a constant ([1b86419](https://github.com/fonoster/qcobro/commit/1b86419fc98722d0c1174ce9f1bdf19848b35dd5))
|
|
57
|
+
|
|
58
|
+
# [1.3.0](https://github.com/fonoster/qcobro/compare/v1.2.3...v1.3.0) (2026-06-28)
|
|
59
|
+
|
|
60
|
+
### Features
|
|
61
|
+
|
|
62
|
+
- **workspace-settings:** per-workspace currency + timezone (off Identity) ([c1516a3](https://github.com/fonoster/qcobro/commit/c1516a3d306e2b2a906d9ae476fb27f23887d5ae))
|
|
63
|
+
|
|
64
|
+
# [1.2.0](https://github.com/fonoster/qcobro/compare/v1.1.4...v1.2.0) (2026-06-28)
|
|
65
|
+
|
|
66
|
+
### Features
|
|
67
|
+
|
|
68
|
+
- **payment-promises:** outcomes + PaymentPromise worklist, agent-based outreach ([6c620f8](https://github.com/fonoster/qcobro/commit/6c620f8a80c65a7178b0716b825a7d4ebb4077f7))
|
|
69
|
+
|
|
70
|
+
# 1.1.0 (2026-06-28)
|
|
71
|
+
|
|
72
|
+
### Bug Fixes
|
|
73
|
+
|
|
74
|
+
- **channel-dispatch:** allow empty firstMessage for VOICE_AI; non-destructive engine test ([71e17a6](https://github.com/fonoster/qcobro/commit/71e17a6a32d8353e9d428e5763e937408df066df))
|
|
75
|
+
- **channel-dispatch:** pre-recorded voice has no firstMessage either ([ebc8e90](https://github.com/fonoster/qcobro/commit/ebc8e9049b765388e8e0e7505dccef22d4dde875))
|
|
76
|
+
- **email:** hydrate inbound body from received-emails api; strip quoted history ([ddcbdb1](https://github.com/fonoster/qcobro/commit/ddcbdb170953cb9428d92a0cc385d5b83eb43cfd))
|
|
77
|
+
- **voice:** provision AUTOPILOT apps with required conversation settings ([c92a8aa](https://github.com/fonoster/qcobro/commit/c92a8aa52c30e2c8f3f78145dec17a99b72282cd))
|
|
78
|
+
- **webapp:** drop unused fromName/fromEmail from EMAIL agent form; add Resend status badge ([625c3c8](https://github.com/fonoster/qcobro/commit/625c3c8300d275f9f1982353bdf87112a9f03fa2))
|
|
79
|
+
- **workspaces:** wire invite acceptance to Identity HTTP bridge ([5b9fc40](https://github.com/fonoster/qcobro/commit/5b9fc40c57cd8d692d2711c1483bbd158e01aa54))
|
|
80
|
+
|
|
81
|
+
### Features
|
|
82
|
+
|
|
83
|
+
- **agent-templates:** per-channel agents, voices-from-config, Fonoster Voz IA sync ([6a8065d](https://github.com/fonoster/qcobro/commit/6a8065d27f8954aa5c5faf7ab34553dccefda5fc))
|
|
84
|
+
- **ai-insights:** transcript-based AI analysis + Voz IA wiring ([4ed7d2e](https://github.com/fonoster/qcobro/commit/4ed7d2e0faf2af9d8ff7966c687c346183b05184))
|
|
85
|
+
- **api-keys:** workspace API key management ([30dd25d](https://github.com/fonoster/qcobro/commit/30dd25d52e1083afb66c7bd323b10d0ac193425a))
|
|
86
|
+
- **api,webapp:** delete-workspace — ownerProcedure and WorkspaceSettings UI ([2542443](https://github.com/fonoster/qcobro/commit/2542443d6cdf4c9a6b2587e3380de9ea3e9f8263))
|
|
87
|
+
- **api,webapp:** profile-management — profile router and Profile page ([5850ec6](https://github.com/fonoster/qcobro/commit/5850ec6e777a987c559f6ab94a15725bc998820f))
|
|
88
|
+
- **api:** add contact-verification and OAuth auth procedures ([b6b70c3](https://github.com/fonoster/qcobro/commit/b6b70c35dd8063ccbdc6e429ded21aa96154928d))
|
|
89
|
+
- **api:** complete auth-and-workspaces change — password reset, resend invite, accept-invite UI ([09c557b](https://github.com/fonoster/qcobro/commit/09c557b00dfee7de725e45cfc5f6e5f61e91f44d))
|
|
90
|
+
- **apiserver:** add auth router (signup, login, refresh, logout) ([9fd50e0](https://github.com/fonoster/qcobro/commit/9fd50e0a32c92ca6bcdf0c053f7050f16921b42b))
|
|
91
|
+
- **apiserver:** add workspace create/list/get (Group 5 core) ([99de39c](https://github.com/fonoster/qcobro/commit/99de39c29c692fd2760053c3bb7f196e11c1a05b))
|
|
92
|
+
- **campaigns-engine:** propose change + config/contracts (group 1) ([6ffd70d](https://github.com/fonoster/qcobro/commit/6ffd70df34cf0a3f33060243eadb1b7460e440eb))
|
|
93
|
+
- **campaigns-engine:** wiring + cleanup (groups 7/9) ([828056d](https://github.com/fonoster/qcobro/commit/828056d091f419bafc5d675a6f0bd92e67bf9eb9))
|
|
94
|
+
- **campaigns:** campaigns-core — lifecycle, days-of-week, edit modal, specs synced ([d1e75cd](https://github.com/fonoster/qcobro/commit/d1e75cd9e065a1556811ee0abf94c4e2ab569e20))
|
|
95
|
+
- **campaigns:** checkpoint campaigns-core WIP before refinement ([2b3e339](https://github.com/fonoster/qcobro/commit/2b3e339913ab4de37152ed04e5aaf0d90fb247c4))
|
|
96
|
+
- **channel-dispatch:** outreach trigger layer (Fonoster voice + Twilio SMS) ([56a4b9e](https://github.com/fonoster/qcobro/commit/56a4b9e4c7c9267f0bfbad42b4b37fff74b6b8fb))
|
|
97
|
+
- **common:** add validated-function utilities and conventions guide ([a60bab9](https://github.com/fonoster/qcobro/commit/a60bab99affd9290602512e5921632c4a1f9f70f))
|
|
98
|
+
- **console:** config-driven announcement banner; flag unimplemented data ([01d8977](https://github.com/fonoster/qcobro/commit/01d89775fa547766521c5be5c15ba429bf5a655c))
|
|
99
|
+
- **console:** refinement + cleanup pass ([78dc3e5](https://github.com/fonoster/qcobro/commit/78dc3e58f2a4eaede4bf10a9d2a551b3c426d9ee))
|
|
100
|
+
- **email-channel:** inbound autopilot — webhook, decision loop, reply cap ([67b6a85](https://github.com/fonoster/qcobro/commit/67b6a8515e75f8ca178293755bffa540a2a3d139))
|
|
101
|
+
- **email-channel:** outbound email + engine integration (Resend) ([391d3d0](https://github.com/fonoster/qcobro/commit/391d3d0caf3440d4f41568d0c48ec37c7ec76d36))
|
|
102
|
+
- **email-channel:** spec + contracts for bidirectional email (Resend autopilot) ([6c2461c](https://github.com/fonoster/qcobro/commit/6c2461ced535cc1b1eb4a7f9eb2a3dcd989dbbe5))
|
|
103
|
+
- **email:** bidirectional email channel end-to-end ([b49a442](https://github.com/fonoster/qcobro/commit/b49a442104841023f3507a6c33cf85dfe689bf12))
|
|
104
|
+
- **gestiones:** add voz IA channel webhook and rich detail panel ([824671f](https://github.com/fonoster/qcobro/commit/824671f0da22dcc9ec16a610618925f96e28d2c8))
|
|
105
|
+
- identity now from the published fonoster identity mod ([9a6eaea](https://github.com/fonoster/qcobro/commit/9a6eaeaed20f51ea7a4846fe116735aeecdcc6e5))
|
|
106
|
+
- **manual-outreach:** carteras reach-out modal + campaign-derived dispatch ([367db2d](https://github.com/fonoster/qcobro/commit/367db2d370c893317042b76239acb7d39c3e69f7))
|
|
107
|
+
- **portfolios:** portfolio management, status enums, currency, and row actions ([6b6bac9](https://github.com/fonoster/qcobro/commit/6b6bac914d85f5140da0abfaa84bd682686364e3))
|
|
108
|
+
- scaffold Qcobro app monorepo ([d5a7507](https://github.com/fonoster/qcobro/commit/d5a7507016d27cc2f76ece0c6aaeff33186d8da0))
|
|
109
|
+
- scaffold spec-driven monorepo foundation ([1a17d89](https://github.com/fonoster/qcobro/commit/1a17d89dffe686032caafe1c09be50053286e48b))
|
|
110
|
+
- **sdk:** add @qcobro/sdk with portfolios, API-key auth, and auto-refresh ([324405e](https://github.com/fonoster/qcobro/commit/324405e9922bf70ccd57088122098c6c2d8de2e7))
|
|
111
|
+
- **voice:** embedded Fonoster VoiceServer for pre-recorded (external) agents ([00e581e](https://github.com/fonoster/qcobro/commit/00e581e78c503509ad7af4cade0b29b226a78a60))
|
|
112
|
+
- **voice:** make pre-recorded audio permanent and spec the events-hook ([8b03fdb](https://github.com/fonoster/qcobro/commit/8b03fdbbbd84cc4233578ae94e37a9a364171584))
|
|
113
|
+
- **voice:** pre-recorded via shared external app ref + Say playback ([e156292](https://github.com/fonoster/qcobro/commit/e15629223914a1141025a1804aa184222c3f244e))
|
|
114
|
+
- **webapp:** implement Pencil UI — login brand panel, workspace picker, sidebar redesign ([e34ef12](https://github.com/fonoster/qcobro/commit/e34ef123b68941a007304316f3c0135f50a69cbd))
|
|
115
|
+
- **workspaces:** rename + console navigation (workspace-management) ([46acf86](https://github.com/fonoster/qcobro/commit/46acf86d32a09d3cf4474818f4950e35776bef01))
|
package/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@qcobro/common",
|
|
3
|
+
"version": "1.11.3",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"publishConfig": {
|
|
6
|
+
"access": "public"
|
|
7
|
+
},
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./dist/index.js"
|
|
10
|
+
},
|
|
11
|
+
"scripts": {
|
|
12
|
+
"build": "tsc -b --force",
|
|
13
|
+
"clean": "rm -rf dist *.tsbuildinfo",
|
|
14
|
+
"typecheck": "tsc --noEmit",
|
|
15
|
+
"test": "node --import tsx --test \"src/**/*.test.ts\""
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"handlebars": "^4.7.9",
|
|
19
|
+
"zod": "^4.0.0"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"tsx": "^4.0.0",
|
|
23
|
+
"typescript": "^5.9.0"
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { ttsProductRefForVoice, type VoiceCatalogEntry } from "./config.js";
|
|
4
|
+
|
|
5
|
+
const voices: VoiceCatalogEntry[] = [
|
|
6
|
+
{ id: "v-eleven", name: "Sofía", language: "es", gender: "female", provider: "elevenlabs" },
|
|
7
|
+
{ id: "v-google", name: "Andrés", language: "es", gender: "male", provider: "google" }
|
|
8
|
+
];
|
|
9
|
+
|
|
10
|
+
describe("ttsProductRefForVoice", () => {
|
|
11
|
+
it("derives the TTS product ref from the voice's provider", () => {
|
|
12
|
+
assert.equal(ttsProductRefForVoice("v-eleven", voices), "tts.elevenlabs");
|
|
13
|
+
assert.equal(ttsProductRefForVoice("v-google", voices), "tts.google");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("falls back to tts.elevenlabs for an unknown voice", () => {
|
|
17
|
+
assert.equal(ttsProductRefForVoice("missing", voices), "tts.elevenlabs");
|
|
18
|
+
});
|
|
19
|
+
});
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* QCobro service configuration — the shape of `qcobro.json`.
|
|
5
|
+
*
|
|
6
|
+
* This module exports the schema/type only (no filesystem access) so it stays
|
|
7
|
+
* safe to import from the browser. Server packages load the file and call
|
|
8
|
+
* `qcobroConfigSchema.parse(...)`.
|
|
9
|
+
*
|
|
10
|
+
* Identity runs as an external Fonoster Identity service; QCobro only needs the
|
|
11
|
+
* endpoint to reach it. All Identity service configuration (database, keys,
|
|
12
|
+
* issuer, SMTP, …) lives with that service, not here.
|
|
13
|
+
*/
|
|
14
|
+
export const identityConfigSchema = z.object({
|
|
15
|
+
/** host:port the apiserver uses to reach the external Identity gRPC service. */
|
|
16
|
+
endpoint: z.string().default("localhost:50051"),
|
|
17
|
+
/** Base URL of the Identity HTTP bridge (accepts invite tokens). */
|
|
18
|
+
httpBridgeUrl: z.string().default("http://localhost:9110")
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A selectable voice in the deployment's catalog. Voice agent templates pick a
|
|
23
|
+
* voice by `id` (the provider's voice identifier, e.g. an ElevenLabs voice id);
|
|
24
|
+
* the console renders the picker from this catalog rather than free text.
|
|
25
|
+
*/
|
|
26
|
+
export const voiceCatalogEntrySchema = z.object({
|
|
27
|
+
id: z.string().min(1),
|
|
28
|
+
name: z.string().min(1),
|
|
29
|
+
language: z.string().min(1),
|
|
30
|
+
gender: z.enum(["female", "male"]),
|
|
31
|
+
provider: z.string().min(1).default("elevenlabs")
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
export type VoiceCatalogEntry = z.infer<typeof voiceCatalogEntrySchema>;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Fonoster connection + Autopilot defaults. VOICE_AI agent templates are synced to
|
|
38
|
+
* Fonoster as AUTOPILOT applications; the apiserver authenticates with a workspace
|
|
39
|
+
* access key + API key/secret. Optional — when absent, voice templates save locally
|
|
40
|
+
* and stay unsynced (the console offers a manual re-sync).
|
|
41
|
+
*/
|
|
42
|
+
export const fonosterConfigSchema = z
|
|
43
|
+
.object({
|
|
44
|
+
accessKeyId: z.string().min(1),
|
|
45
|
+
apiKey: z.string().min(1),
|
|
46
|
+
apiSecret: z.string().min(1),
|
|
47
|
+
/** Optional override for the Fonoster API endpoint (host:port). */
|
|
48
|
+
endpoint: z.string().optional(),
|
|
49
|
+
/** Default products/model used when building the Autopilot application. The
|
|
50
|
+
* TTS product is NOT set here — it is derived per voice (see `voices`) since
|
|
51
|
+
* both Voz IA and pre-recorded voice use it. */
|
|
52
|
+
autopilot: z
|
|
53
|
+
.object({
|
|
54
|
+
sttProductRef: z.string().default("stt.deepgram"),
|
|
55
|
+
sttModel: z.string().default("nova-3"),
|
|
56
|
+
llmProductRef: z.string().default("llm.google"),
|
|
57
|
+
llmProvider: z.string().default("google"),
|
|
58
|
+
llmModel: z.string().default("gemini-2.0-flash"),
|
|
59
|
+
maxTokens: z.number().default(300),
|
|
60
|
+
temperature: z.number().default(0)
|
|
61
|
+
})
|
|
62
|
+
.default({
|
|
63
|
+
sttProductRef: "stt.deepgram",
|
|
64
|
+
sttModel: "nova-3",
|
|
65
|
+
llmProductRef: "llm.google",
|
|
66
|
+
llmProvider: "google",
|
|
67
|
+
llmModel: "gemini-2.0-flash",
|
|
68
|
+
maxTokens: 300,
|
|
69
|
+
temperature: 0
|
|
70
|
+
}),
|
|
71
|
+
/**
|
|
72
|
+
* Selectable voice catalog for voice agent templates (Voz IA + pre-recorded).
|
|
73
|
+
* Voices are Fonoster-only, so they live here. Seeded with three Spanish
|
|
74
|
+
* voices; deployments override in `qcobro.json`. The TTS product ref is
|
|
75
|
+
* derived from each voice's `provider` (see {@link ttsProductRefForVoice}).
|
|
76
|
+
*/
|
|
77
|
+
// Shape only — the actual catalog is supplied by qcobro.json (see config/*.json),
|
|
78
|
+
// not defaulted in the schema.
|
|
79
|
+
voices: z.array(voiceCatalogEntrySchema).optional(),
|
|
80
|
+
/**
|
|
81
|
+
* Caller-ID numbers outbound voice dispatch rotates through. Use the format the
|
|
82
|
+
* carrier expects — Fonoster passes the number through as given, so whether a
|
|
83
|
+
* leading "+" is required depends on the carrier (here, no "+", e.g.
|
|
84
|
+
* `18297340812`). Empty by default — voice dispatch fails clearly until set.
|
|
85
|
+
*/
|
|
86
|
+
numbers: z.array(z.string().min(1)).default([]),
|
|
87
|
+
/**
|
|
88
|
+
* The Fonoster EXTERNAL application ref used for ALL pre-recorded voice
|
|
89
|
+
* dispatch (one shared app pointing at the embedded VoiceServer). The
|
|
90
|
+
* per-customer script is passed as call metadata; this ref is deployment-wide,
|
|
91
|
+
* not per agent. Voz IA uses each template's own AUTOPILOT app ref instead.
|
|
92
|
+
*/
|
|
93
|
+
prerecordedAppRef: z.string().min(1).optional(),
|
|
94
|
+
/**
|
|
95
|
+
* Externally reachable base URL of the apiserver for Fonoster callbacks (e.g. an
|
|
96
|
+
* ngrok URL). When set, syncing a Voz IA agent registers the autopilot events-hook
|
|
97
|
+
* at `${webhookBaseUrl}/api/voice/events` so conversation events return as gestiones.
|
|
98
|
+
*/
|
|
99
|
+
webhookBaseUrl: z.string().url().optional(),
|
|
100
|
+
/**
|
|
101
|
+
* Campaigns-engine pacing: the maximum number of voice calls the engine will
|
|
102
|
+
* originate per minute, deployment-wide (the caller-ID pool is shared by all
|
|
103
|
+
* workspaces). A conservative value bounds in-flight concurrency given the pool
|
|
104
|
+
* size and typical call duration. Reserve `0` to pause voice dispatch.
|
|
105
|
+
*/
|
|
106
|
+
maxCallsPerMinute: z.number().int().nonnegative().default(6)
|
|
107
|
+
})
|
|
108
|
+
.optional();
|
|
109
|
+
|
|
110
|
+
export type FonosterConfig = z.infer<typeof fonosterConfigSchema>;
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Derives the TTS product ref for a voice from its provider (e.g. an `elevenlabs`
|
|
114
|
+
* voice → `tts.elevenlabs`). Used by both Voz IA (Autopilot) and pre-recorded
|
|
115
|
+
* voice. Falls back to `tts.elevenlabs` when the voice isn't in the catalog.
|
|
116
|
+
*/
|
|
117
|
+
export function ttsProductRefForVoice(voiceId: string, voices: VoiceCatalogEntry[]): string {
|
|
118
|
+
const voice = voices.find((v) => v.id === voiceId);
|
|
119
|
+
return voice ? `tts.${voice.provider}` : "tts.elevenlabs";
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Twilio connection for SMS dispatch. Optional — when absent, SMS dispatch fails
|
|
124
|
+
* with a clear error. `fromNumbers` (E.164) are rotated through per message.
|
|
125
|
+
*/
|
|
126
|
+
export const twilioConfigSchema = z
|
|
127
|
+
.object({
|
|
128
|
+
accountSid: z.string().min(1),
|
|
129
|
+
authToken: z.string().min(1),
|
|
130
|
+
fromNumbers: z.array(z.string().min(1)).default([]),
|
|
131
|
+
/**
|
|
132
|
+
* Campaigns-engine pacing: the maximum number of SMS messages the engine will
|
|
133
|
+
* send per minute, deployment-wide (the sender pool is shared by all workspaces).
|
|
134
|
+
* Reserve `0` to pause SMS dispatch.
|
|
135
|
+
*/
|
|
136
|
+
maxSmsPerMinute: z.number().int().nonnegative().default(60)
|
|
137
|
+
})
|
|
138
|
+
.optional();
|
|
139
|
+
|
|
140
|
+
export type TwilioConfig = z.infer<typeof twilioConfigSchema>;
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Resend connection for the bidirectional EMAIL channel. Optional — when absent, EMAIL
|
|
144
|
+
* dispatch is inert (the engine reports EMAIL campaigns as not-configured) and the inbound
|
|
145
|
+
* webhook rejects everything. Outbound uses `apiKey` + `fromEmail`/`fromName`; inbound
|
|
146
|
+
* replies arrive at `reply+<token>@<inboundDomain>` and are verified with
|
|
147
|
+
* `inboundSigningSecret`. The per-attempt reply cap defaults to `maxRepliesDefault`.
|
|
148
|
+
*/
|
|
149
|
+
export const resendConfigSchema = z
|
|
150
|
+
.object({
|
|
151
|
+
apiKey: z.string().min(1),
|
|
152
|
+
fromEmail: z.string().email(),
|
|
153
|
+
fromName: z.string().min(1).optional(),
|
|
154
|
+
/** Domain the per-attempt reply-to token addresses are minted on (inbound). */
|
|
155
|
+
inboundDomain: z.string().min(1),
|
|
156
|
+
/** Shared secret used to verify inbound webhook signatures. */
|
|
157
|
+
inboundSigningSecret: z.string().min(1).optional(),
|
|
158
|
+
/**
|
|
159
|
+
* Campaigns-engine pacing: the maximum number of emails the engine will send per
|
|
160
|
+
* minute, deployment-wide. Reserve `0` to pause email dispatch.
|
|
161
|
+
*/
|
|
162
|
+
maxEmailsPerMinute: z.number().int().nonnegative().default(60),
|
|
163
|
+
/**
|
|
164
|
+
* Default cap on autopilot replies per collection attempt (per gestión) when an EMAIL
|
|
165
|
+
* agent does not set its own `maxReplies`. Bounds the back-and-forth so a debtor can't
|
|
166
|
+
* keep the AI talking indefinitely.
|
|
167
|
+
*/
|
|
168
|
+
maxRepliesDefault: z.number().int().nonnegative().default(3)
|
|
169
|
+
})
|
|
170
|
+
.optional();
|
|
171
|
+
|
|
172
|
+
export type ResendConfig = z.infer<typeof resendConfigSchema>;
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* AI-insight generation. Produces a gestión's structured analysis from its
|
|
176
|
+
* conversation transcript. Optional — when absent or `enabled:false`, no LLM is
|
|
177
|
+
* called and gestiones keep their unanalyzed / generic-insight state.
|
|
178
|
+
*
|
|
179
|
+
* Providers mirror the Fonoster autopilot / Mikro vendor set, reached over each
|
|
180
|
+
* vendor's REST API (no SDK dependency). `mock` is an offline provider that
|
|
181
|
+
* synthesizes a deterministic analysis from the transcript — for local dev,
|
|
182
|
+
* demos, and tests, with no key and no network/cost.
|
|
183
|
+
*/
|
|
184
|
+
export const aiProviderSchema = z.enum(["mock", "google", "openai", "anthropic"]);
|
|
185
|
+
export type AiProvider = z.infer<typeof aiProviderSchema>;
|
|
186
|
+
|
|
187
|
+
/** Valid models per provider; used to reject misconfiguration at load. */
|
|
188
|
+
export const AI_MODELS: Record<AiProvider, string[]> = {
|
|
189
|
+
mock: ["mock"],
|
|
190
|
+
google: ["gemini-2.5-flash", "gemini-2.5-flash-lite", "gemini-2.5-pro", "gemini-2.0-flash"],
|
|
191
|
+
openai: ["gpt-4o", "gpt-4o-mini", "gpt-4.1"],
|
|
192
|
+
anthropic: ["claude-3-5-sonnet-latest", "claude-3-5-haiku-latest"]
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
export const aiConfigSchema = z
|
|
196
|
+
.object({
|
|
197
|
+
enabled: z.boolean().default(false),
|
|
198
|
+
provider: aiProviderSchema.default("mock"),
|
|
199
|
+
apiKey: z.string().optional(),
|
|
200
|
+
model: z.string().default("gemini-2.5-flash"),
|
|
201
|
+
temperature: z.number().min(0).max(2).default(0),
|
|
202
|
+
maxTokens: z.number().int().positive().default(600),
|
|
203
|
+
/** When the analysis is produced. `onDemand` = on first detail open (then
|
|
204
|
+
* cached); `onIngestion` = when the transcript is first stored. */
|
|
205
|
+
generation: z.enum(["onDemand", "onIngestion"]).default("onDemand")
|
|
206
|
+
})
|
|
207
|
+
.superRefine((cfg, ctx) => {
|
|
208
|
+
if (!AI_MODELS[cfg.provider].includes(cfg.model)) {
|
|
209
|
+
ctx.addIssue({
|
|
210
|
+
code: z.ZodIssueCode.custom,
|
|
211
|
+
path: ["model"],
|
|
212
|
+
message: `Invalid model "${cfg.model}" for provider "${cfg.provider}". Valid: ${AI_MODELS[cfg.provider].join(", ")}`
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
// apiKey is optional here: it may be omitted and supplied via an environment
|
|
216
|
+
// variable instead (the adapter resolves it and errors clearly if none is found
|
|
217
|
+
// at call time).
|
|
218
|
+
})
|
|
219
|
+
.optional();
|
|
220
|
+
|
|
221
|
+
export type AiConfig = z.infer<typeof aiConfigSchema>;
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Text-to-speech for previewing pre-recorded agent scripts in the console (the
|
|
225
|
+
* Pre-grabada gestión detail plays the script as audio). Optional — when absent, the
|
|
226
|
+
* apiKey falls back to `ELEVENLABS_API_KEY`, and if no key resolves the player is simply
|
|
227
|
+
* unavailable. Voices come from `fonoster.voices`.
|
|
228
|
+
*/
|
|
229
|
+
export const ttsConfigSchema = z
|
|
230
|
+
.object({
|
|
231
|
+
provider: z.literal("elevenlabs").default("elevenlabs"),
|
|
232
|
+
apiKey: z.string().optional(),
|
|
233
|
+
model: z.string().default("eleven_multilingual_v2")
|
|
234
|
+
})
|
|
235
|
+
.optional();
|
|
236
|
+
|
|
237
|
+
export type TtsConfig = z.infer<typeof ttsConfigSchema>;
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* A piece of user-facing copy that may be localized. Either a single string
|
|
241
|
+
* (same text for every language) or a map of language code → string
|
|
242
|
+
* (e.g. `{ "en": "Early access", "es": "Acceso temprano" }`). The console
|
|
243
|
+
* resolves it against the active UI language, falling back to any available value.
|
|
244
|
+
*/
|
|
245
|
+
export const localizedStringSchema = z.union([
|
|
246
|
+
z.string().min(1),
|
|
247
|
+
z.record(z.string(), z.string().min(1))
|
|
248
|
+
]);
|
|
249
|
+
export type LocalizedString = z.infer<typeof localizedStringSchema>;
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* A deployment-wide announcement rendered as a dismissible banner across the
|
|
253
|
+
* console (and the workspace picker). Optional — when absent, no banner shows.
|
|
254
|
+
*
|
|
255
|
+
* `variant` selects the color scheme and `icon` the leading glyph, so a
|
|
256
|
+
* deployment can style it as a neutral announcement, an amber alert, etc.
|
|
257
|
+
* `title` and `message` are localizable (see {@link localizedStringSchema}).
|
|
258
|
+
*/
|
|
259
|
+
export const announcementConfigSchema = z
|
|
260
|
+
.object({
|
|
261
|
+
enabled: z.boolean().default(true),
|
|
262
|
+
/** Color scheme: `announcement` (blue), `alert` (amber), `success` (green), `danger` (red). */
|
|
263
|
+
variant: z.enum(["announcement", "alert", "success", "danger"]).default("announcement"),
|
|
264
|
+
/** Leading icon from a curated set. */
|
|
265
|
+
icon: z
|
|
266
|
+
.enum(["megaphone", "info", "alert-triangle", "sparkles", "rocket", "bell"])
|
|
267
|
+
.default("megaphone"),
|
|
268
|
+
/** Whether the user can dismiss the banner. */
|
|
269
|
+
dismissible: z.boolean().default(true),
|
|
270
|
+
title: localizedStringSchema.optional(),
|
|
271
|
+
message: localizedStringSchema
|
|
272
|
+
})
|
|
273
|
+
.optional();
|
|
274
|
+
|
|
275
|
+
export type AnnouncementConfig = z.infer<typeof announcementConfigSchema>;
|
|
276
|
+
|
|
277
|
+
export const qcobroConfigSchema = z.object({
|
|
278
|
+
/** Application (apiserver) database. */
|
|
279
|
+
database: z.object({ url: z.string().min(1) }),
|
|
280
|
+
identity: identityConfigSchema,
|
|
281
|
+
apiserver: z
|
|
282
|
+
.object({
|
|
283
|
+
port: z.number().default(3000),
|
|
284
|
+
/**
|
|
285
|
+
* Port for the embedded Fonoster VoiceServer (external voice application).
|
|
286
|
+
* Pre-recorded voice agents are EXTERNAL Fonoster apps that call back into
|
|
287
|
+
* this server; it answers and plays the rendered script via the Say verb.
|
|
288
|
+
*/
|
|
289
|
+
voicePort: z.number().default(50061),
|
|
290
|
+
/** External contact-log ingress (`POST /api/contact-logs`) auth gate. */
|
|
291
|
+
contactLogAuth: z.object({ enabled: z.boolean().default(false) }).default({ enabled: false })
|
|
292
|
+
})
|
|
293
|
+
.default({
|
|
294
|
+
port: 3000,
|
|
295
|
+
voicePort: 50061,
|
|
296
|
+
contactLogAuth: { enabled: false }
|
|
297
|
+
}),
|
|
298
|
+
fonoster: fonosterConfigSchema,
|
|
299
|
+
twilio: twilioConfigSchema,
|
|
300
|
+
resend: resendConfigSchema,
|
|
301
|
+
ai: aiConfigSchema,
|
|
302
|
+
tts: ttsConfigSchema,
|
|
303
|
+
announcement: announcementConfigSchema,
|
|
304
|
+
/**
|
|
305
|
+
* Secret-at-rest. `cloakEncryptionKey` is a versioned AES-GCM-256 key
|
|
306
|
+
* (`k1.aesgcm256.<base64-32-byte>`) used by `prisma-field-encryption` (the Fonoster/Routr
|
|
307
|
+
* "cloak" pattern) to encrypt tenant-provided secrets — today the WhatsApp WABA access
|
|
308
|
+
* token. Optional: when absent, features that store tenant secrets (the Workspace
|
|
309
|
+
* Integrations area) are disabled rather than crashing boot. Only the *key* is global; the
|
|
310
|
+
* *secret* is per-workspace in the DB.
|
|
311
|
+
*/
|
|
312
|
+
security: z.object({ cloakEncryptionKey: z.string().min(1).optional() }).optional(),
|
|
313
|
+
/**
|
|
314
|
+
* WhatsApp (Meta Cloud API) connection defaults. Per-workspace credentials (WABA id +
|
|
315
|
+
* access token) live in the DB, not here — only the shared Graph API base/version are
|
|
316
|
+
* deployment config.
|
|
317
|
+
*/
|
|
318
|
+
whatsapp: z
|
|
319
|
+
.object({
|
|
320
|
+
apiBaseUrl: z.string().url().default("https://graph.facebook.com"),
|
|
321
|
+
apiVersion: z.string().default("v18.0"),
|
|
322
|
+
/** Engine pacing: max template messages the engine may dispatch per minute. */
|
|
323
|
+
maxMessagesPerMinute: z.number().int().positive().default(60),
|
|
324
|
+
/**
|
|
325
|
+
* Meta App Secret — used to verify the `X-Hub-Signature-256` on inbound webhook
|
|
326
|
+
* events. Optional: when absent, signature verification is skipped (not recommended
|
|
327
|
+
* in production).
|
|
328
|
+
*/
|
|
329
|
+
appSecret: z.string().min(1).optional(),
|
|
330
|
+
/**
|
|
331
|
+
* Default cap on autopilot replies per collection attempt (per gestión) when a
|
|
332
|
+
* WHATSAPP agent does not set its own `maxReplies`. Mirrors `resend.maxRepliesDefault`.
|
|
333
|
+
*/
|
|
334
|
+
maxRepliesDefault: z.number().int().nonnegative().default(3)
|
|
335
|
+
})
|
|
336
|
+
.default({
|
|
337
|
+
apiBaseUrl: "https://graph.facebook.com",
|
|
338
|
+
apiVersion: "v18.0",
|
|
339
|
+
maxMessagesPerMinute: 60,
|
|
340
|
+
maxRepliesDefault: 3
|
|
341
|
+
}),
|
|
342
|
+
/**
|
|
343
|
+
* Campaigns engine. The autonomous in-process loop that originates campaign
|
|
344
|
+
* outreach. Disabled by default so it never auto-dials in local development;
|
|
345
|
+
* enable it in production. Per-channel pacing lives in the `fonoster`/`twilio`
|
|
346
|
+
* blocks (the provider pools they configure are deployment-wide).
|
|
347
|
+
*/
|
|
348
|
+
engine: z
|
|
349
|
+
.object({
|
|
350
|
+
enabled: z.boolean().default(false),
|
|
351
|
+
/** Seconds between engine ticks. */
|
|
352
|
+
tickSeconds: z.number().int().positive().default(60)
|
|
353
|
+
})
|
|
354
|
+
.default({ enabled: false, tickSeconds: 60 })
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
export type IdentityConfig = z.infer<typeof identityConfigSchema>;
|
|
358
|
+
export type QCobroConfig = z.infer<typeof qcobroConfigSchema>;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { ValidationError } from "./ValidationError.js";
|
|
5
|
+
|
|
6
|
+
const schema = z.object({ name: z.string().min(1), age: z.number() });
|
|
7
|
+
|
|
8
|
+
function zodErrorFor(input: unknown): z.ZodError {
|
|
9
|
+
const result = schema.safeParse(input);
|
|
10
|
+
assert.equal(result.success, false);
|
|
11
|
+
return (result as { success: false; error: z.ZodError }).error;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe("ValidationError", () => {
|
|
15
|
+
it("exposes a stable code and field-level errors", () => {
|
|
16
|
+
const error = new ValidationError(zodErrorFor({ name: "", age: "nope" }));
|
|
17
|
+
|
|
18
|
+
assert.equal(error.code, "VALIDATION_ERROR");
|
|
19
|
+
assert.equal(error.name, "ValidationError");
|
|
20
|
+
assert.ok(error instanceof Error);
|
|
21
|
+
|
|
22
|
+
const fields = error.fieldErrors.map((f) => f.field);
|
|
23
|
+
assert.ok(fields.includes("name"));
|
|
24
|
+
assert.ok(fields.includes("age"));
|
|
25
|
+
for (const fieldError of error.fieldErrors) {
|
|
26
|
+
assert.equal(typeof fieldError.message, "string");
|
|
27
|
+
assert.equal(typeof fieldError.code, "string");
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("serializes to a JSON shape for API responses", () => {
|
|
32
|
+
const error = new ValidationError(zodErrorFor({ name: "", age: 1 }));
|
|
33
|
+
const json = error.toJSON();
|
|
34
|
+
|
|
35
|
+
assert.deepEqual(Object.keys(json).sort(), ["code", "fieldErrors", "message"]);
|
|
36
|
+
assert.equal(json.code, "VALIDATION_ERROR");
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { z } from "zod";
|
|
2
|
+
|
|
3
|
+
export interface FieldError {
|
|
4
|
+
field: string;
|
|
5
|
+
message: string;
|
|
6
|
+
code: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Wraps a Zod validation error with structured, field-level details suitable
|
|
11
|
+
* for API responses.
|
|
12
|
+
*/
|
|
13
|
+
export class ValidationError extends Error {
|
|
14
|
+
public readonly code = "VALIDATION_ERROR";
|
|
15
|
+
public readonly fieldErrors: FieldError[];
|
|
16
|
+
public readonly zodError: z.ZodError;
|
|
17
|
+
|
|
18
|
+
constructor(zodError: z.ZodError) {
|
|
19
|
+
const fieldErrors = ValidationError.extractFieldErrors(zodError);
|
|
20
|
+
const message = ValidationError.formatMessage(fieldErrors);
|
|
21
|
+
|
|
22
|
+
super(message);
|
|
23
|
+
this.name = "ValidationError";
|
|
24
|
+
this.zodError = zodError;
|
|
25
|
+
this.fieldErrors = fieldErrors;
|
|
26
|
+
|
|
27
|
+
// Maintains proper stack trace for where the error was thrown (V8 engines).
|
|
28
|
+
if (Error.captureStackTrace) {
|
|
29
|
+
Error.captureStackTrace(this, ValidationError);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
private static extractFieldErrors(zodError: z.ZodError): FieldError[] {
|
|
34
|
+
return zodError.issues.map((issue) => ({
|
|
35
|
+
field: issue.path.join(".") || "root",
|
|
36
|
+
message: issue.message,
|
|
37
|
+
code: issue.code
|
|
38
|
+
}));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
private static formatMessage(fieldErrors: FieldError[]): string {
|
|
42
|
+
if (fieldErrors.length === 0) {
|
|
43
|
+
return "Validation failed";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (fieldErrors.length === 1) {
|
|
47
|
+
const { field, message } = fieldErrors[0];
|
|
48
|
+
return field === "root" ? message : `${field}: ${message}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const details = fieldErrors
|
|
52
|
+
.map(({ field, message }) => (field === "root" ? message : `${field}: ${message}`))
|
|
53
|
+
.join("; ");
|
|
54
|
+
|
|
55
|
+
return `Validation failed: ${details}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Returns a JSON-serializable representation for API responses.
|
|
60
|
+
*/
|
|
61
|
+
toJSON() {
|
|
62
|
+
return {
|
|
63
|
+
code: this.code,
|
|
64
|
+
message: this.message,
|
|
65
|
+
fieldErrors: this.fieldErrors
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { ValidationError, type FieldError } from "./ValidationError.js";
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
export * from "./errors/index.js";
|
|
4
|
+
export * from "./utils/index.js";
|
|
5
|
+
export * from "./schemas/index.js";
|
|
6
|
+
export * from "./types/index.js";
|
|
7
|
+
export * from "./config.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Placeholder contract proving the shared-schema pattern.
|
|
11
|
+
*
|
|
12
|
+
* `common` is the single source of truth for domain types and Zod schemas,
|
|
13
|
+
* imported by both the apiserver (input validation) and the webapp (forms/types).
|
|
14
|
+
* Real domain schemas (portfolios, accounts, Objectives, ...) arrive in a later
|
|
15
|
+
* change; this entry exists so the contract pattern is wired end to end.
|
|
16
|
+
*/
|
|
17
|
+
export const PingInput = z.object({
|
|
18
|
+
message: z.string().min(1).max(280)
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
export type PingInput = z.infer<typeof PingInput>;
|