@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.
Files changed (44) hide show
  1. package/CHANGELOG.md +115 -0
  2. package/package.json +25 -0
  3. package/src/config.test.ts +19 -0
  4. package/src/config.ts +358 -0
  5. package/src/errors/ValidationError.test.ts +38 -0
  6. package/src/errors/ValidationError.ts +68 -0
  7. package/src/errors/index.ts +1 -0
  8. package/src/index.ts +21 -0
  9. package/src/schemas/agentTemplates.ts +100 -0
  10. package/src/schemas/apiKeys.test.ts +38 -0
  11. package/src/schemas/apiKeys.ts +28 -0
  12. package/src/schemas/auth.ts +76 -0
  13. package/src/schemas/campaigns.ts +88 -0
  14. package/src/schemas/contactLog.ts +96 -0
  15. package/src/schemas/dispatch.ts +115 -0
  16. package/src/schemas/email.ts +37 -0
  17. package/src/schemas/index.ts +15 -0
  18. package/src/schemas/insight.ts +20 -0
  19. package/src/schemas/portfolios.ts +49 -0
  20. package/src/schemas/userSettings.ts +18 -0
  21. package/src/schemas/users.ts +7 -0
  22. package/src/schemas/voiceEvent.ts +45 -0
  23. package/src/schemas/whatsApp.ts +101 -0
  24. package/src/schemas/workspaceSettings.ts +20 -0
  25. package/src/schemas/workspaces.ts +53 -0
  26. package/src/types/agentTemplates.ts +104 -0
  27. package/src/types/campaigns.ts +210 -0
  28. package/src/types/dispatch.ts +160 -0
  29. package/src/types/email.ts +66 -0
  30. package/src/types/engine.ts +73 -0
  31. package/src/types/index.ts +11 -0
  32. package/src/types/insight.ts +20 -0
  33. package/src/types/portfolios.ts +128 -0
  34. package/src/types/userSettings.ts +21 -0
  35. package/src/types/voiceApplication.ts +29 -0
  36. package/src/types/whatsApp.ts +82 -0
  37. package/src/types/workspaceSettings.ts +22 -0
  38. package/src/utils/index.ts +14 -0
  39. package/src/utils/outreach.test.ts +83 -0
  40. package/src/utils/outreach.ts +57 -0
  41. package/src/utils/time.ts +66 -0
  42. package/src/utils/withErrorHandlingAndValidation.test.ts +33 -0
  43. package/src/utils/withErrorHandlingAndValidation.ts +32 -0
  44. 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>;