@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
@@ -0,0 +1,100 @@
1
+ import { z } from "zod";
2
+
3
+ export const agentTypeSchema = z.enum([
4
+ "SMS",
5
+ "VOICE_PRERECORDED",
6
+ "VOICE_AI",
7
+ "EMAIL",
8
+ "WHATSAPP"
9
+ ]);
10
+ export type AgentType = z.infer<typeof agentTypeSchema>;
11
+
12
+ const baseFields = {
13
+ name: z.string().min(1).max(120)
14
+ };
15
+
16
+ /**
17
+ * Creating an agent template is a discriminated union on `type`: each channel
18
+ * carries its own config fields, never mixed across types. `fonosterAppName` is
19
+ * optional on voice types — the create function defaults it to the template name.
20
+ */
21
+ export const createAgentTemplateSchema = z.discriminatedUnion("type", [
22
+ z.object({
23
+ ...baseFields,
24
+ type: z.literal("VOICE_AI"),
25
+ voice: z.string().min(1),
26
+ systemPrompt: z.string().min(1),
27
+ // Optional: a VOICE_AI agent may rely on its system prompt with no scripted opening line.
28
+ firstMessage: z.string().optional(),
29
+ language: z.string().min(1),
30
+ fonosterAppName: z.string().min(1).optional()
31
+ }),
32
+ z.object({
33
+ ...baseFields,
34
+ type: z.literal("VOICE_PRERECORDED"),
35
+ voice: z.string().min(1),
36
+ script: z.string().min(1),
37
+ language: z.string().min(1),
38
+ fonosterAppName: z.string().min(1).optional()
39
+ }),
40
+ z.object({
41
+ ...baseFields,
42
+ type: z.literal("SMS"),
43
+ messageBody: z.string().min(1),
44
+ senderId: z.string().min(1).optional()
45
+ }),
46
+ z.object({
47
+ ...baseFields,
48
+ type: z.literal("EMAIL"),
49
+ subject: z.string().min(1),
50
+ messageBody: z.string().min(1),
51
+ /** Autopilot decision brain: governs reply/ignore/resolve/escalate on each inbound reply. */
52
+ systemPrompt: z.string().min(1),
53
+ /** Per-agent cap on autopilot replies per collection attempt; falls back to the
54
+ * `resend.maxRepliesDefault` deployment default when omitted. */
55
+ maxReplies: z.number().int().nonnegative().optional()
56
+ }),
57
+ z.object({
58
+ ...baseFields,
59
+ type: z.literal("WHATSAPP"),
60
+ /** Meta template id the operator enters; QCobro resolves + previews the template from the WABA. */
61
+ templateId: z.string().min(1),
62
+ /** Resolved from `templateId` (read-only in the UI); the approved Meta template name to send. */
63
+ templateName: z.string().min(1),
64
+ /** Fetched template body (read-only preview); its `{{vars}}` are sent as named parameters. */
65
+ messageBody: z.string().min(1),
66
+ /** Smart-agent decision brain for replies after the customer responds (mirrors EMAIL). */
67
+ systemPrompt: z.string().min(1),
68
+ /** Per-agent cap on automated replies per gestión; falls back to the deployment default when omitted. */
69
+ maxReplies: z.number().int().nonnegative().optional()
70
+ })
71
+ ]);
72
+ export type CreateAgentTemplateInput = z.infer<typeof createAgentTemplateSchema>;
73
+
74
+ /**
75
+ * Updating an agent template: mutable base fields plus a loose `config` bag of
76
+ * type-specific fields applied to the stored child table. `type` is immutable —
77
+ * `.strict()` rejects any attempt to pass it (or other unknown keys).
78
+ */
79
+ export const updateAgentTemplateSchema = z
80
+ .object({
81
+ id: z.string().min(1),
82
+ name: z.string().min(1).max(120).optional(),
83
+ // `archived` toggles the template's archived state: true sets `archivedAt` to
84
+ // now, false clears it (restore). Templates have no status concept.
85
+ archived: z.boolean().optional(),
86
+ config: z.record(z.string(), z.unknown()).optional()
87
+ })
88
+ .strict();
89
+ export type UpdateAgentTemplateInput = z.infer<typeof updateAgentTemplateSchema>;
90
+
91
+ export const deleteAgentTemplateSchema = z.object({
92
+ id: z.string().min(1)
93
+ });
94
+ export type DeleteAgentTemplateInput = z.infer<typeof deleteAgentTemplateSchema>;
95
+
96
+ /** Manually re-attempt the Fonoster sync for a voice template. */
97
+ export const syncAgentTemplateSchema = z.object({
98
+ id: z.string().min(1)
99
+ });
100
+ export type SyncAgentTemplateInput = z.infer<typeof syncAgentTemplateSchema>;
@@ -0,0 +1,38 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { createApiKeySchema, apiKeyRefSchema } from "./apiKeys.js";
4
+
5
+ describe("createApiKeySchema", () => {
6
+ it("defaults the role to admin with no expiry", () => {
7
+ const parsed = createApiKeySchema.parse({});
8
+ assert.equal(parsed.role, "WORKSPACE_ADMIN");
9
+ assert.equal(parsed.expiresAt, undefined);
10
+ });
11
+
12
+ it("accepts an admin role with a future expiry", () => {
13
+ const future = Date.now() + 86_400_000;
14
+ const parsed = createApiKeySchema.parse({ role: "WORKSPACE_ADMIN", expiresAt: future });
15
+ assert.equal(parsed.role, "WORKSPACE_ADMIN");
16
+ assert.equal(parsed.expiresAt, future);
17
+ });
18
+
19
+ it("rejects member/owner roles — Identity only issues admin keys", () => {
20
+ assert.equal(createApiKeySchema.safeParse({ role: "WORKSPACE_MEMBER" }).success, false);
21
+ assert.equal(createApiKeySchema.safeParse({ role: "WORKSPACE_OWNER" }).success, false);
22
+ });
23
+
24
+ it("rejects an expiry in the past", () => {
25
+ const result = createApiKeySchema.safeParse({ expiresAt: Date.now() - 1000 });
26
+ assert.equal(result.success, false);
27
+ if (!result.success) {
28
+ assert.match(result.error.issues[0].message, /future/);
29
+ }
30
+ });
31
+ });
32
+
33
+ describe("apiKeyRefSchema", () => {
34
+ it("requires a non-empty ref", () => {
35
+ assert.equal(apiKeyRefSchema.safeParse({ ref: "" }).success, false);
36
+ assert.equal(apiKeyRefSchema.safeParse({ ref: "ak_1" }).success, true);
37
+ });
38
+ });
@@ -0,0 +1,28 @@
1
+ import { z } from "zod";
2
+
3
+ // API keys are workspace-scoped credentials for unattended, server-to-server
4
+ // integrations (e.g. @qcobro/sdk's loginWithApiKey). Unlike member invites,
5
+ // Fonoster Identity only permits WORKSPACE_ADMIN for an API key (its
6
+ // createApiKeyRequestSchema is `z.enum([WORKSPACE_ADMIN])`), so there is no role
7
+ // choice — every key is an admin-scoped key.
8
+ export const apiKeyRoleEnum = z.enum(["WORKSPACE_ADMIN"]);
9
+ export type ApiKeyRole = z.infer<typeof apiKeyRoleEnum>;
10
+
11
+ export const createApiKeySchema = z.object({
12
+ role: apiKeyRoleEnum.default("WORKSPACE_ADMIN"),
13
+ // Optional expiry as epoch milliseconds; must be in the future when present.
14
+ // Identity stores no expiry when this is omitted (the key never expires).
15
+ expiresAt: z
16
+ .number()
17
+ .int()
18
+ .positive()
19
+ .refine((ms) => ms > Date.now(), { message: "expiresAt must be in the future" })
20
+ .optional()
21
+ });
22
+ export type CreateApiKeyInput = z.infer<typeof createApiKeySchema>;
23
+
24
+ // Identifies a single key for regenerate/delete.
25
+ export const apiKeyRefSchema = z.object({
26
+ ref: z.string().min(1)
27
+ });
28
+ export type ApiKeyRefInput = z.infer<typeof apiKeyRefSchema>;
@@ -0,0 +1,76 @@
1
+ import { z } from "zod";
2
+
3
+ export const signUpSchema = z.object({
4
+ name: z.string().min(1).max(60),
5
+ email: z.email(),
6
+ password: z.string().min(8).max(128),
7
+ phone: z.string().max(20).optional(),
8
+ avatar: z.string().max(255).optional()
9
+ });
10
+ export type SignUpInput = z.infer<typeof signUpSchema>;
11
+
12
+ export const loginSchema = z.object({
13
+ email: z.email(),
14
+ password: z.string().min(1),
15
+ twoFactorCode: z.string().optional()
16
+ });
17
+ export type LoginInput = z.infer<typeof loginSchema>;
18
+
19
+ export const refreshTokenSchema = z.object({
20
+ refreshToken: z.string().min(1)
21
+ });
22
+ export type RefreshTokenInput = z.infer<typeof refreshTokenSchema>;
23
+
24
+ // Exchange a workspace API key (accessKeyId + accessKeySecret) for tokens.
25
+ // Used by unattended, server-to-server integrations (e.g. the SDK's
26
+ // loginWithApiKey) that cannot perform an interactive credentials login.
27
+ export const apiKeyLoginSchema = z.object({
28
+ accessKeyId: z.string().min(1),
29
+ accessKeySecret: z.string().min(1)
30
+ });
31
+ export type ApiKeyLoginInput = z.infer<typeof apiKeyLoginSchema>;
32
+
33
+ export const sendResetPasswordCodeSchema = z.object({
34
+ username: z.email(),
35
+ resetPasswordUrl: z.string().url()
36
+ });
37
+ export type SendResetPasswordCodeInput = z.infer<typeof sendResetPasswordCodeSchema>;
38
+
39
+ export const resetPasswordSchema = z.object({
40
+ username: z.email(),
41
+ password: z.string().min(8).max(128),
42
+ verificationCode: z.string().min(1)
43
+ });
44
+ export type ResetPasswordInput = z.infer<typeof resetPasswordSchema>;
45
+
46
+ export const contactTypeEnum = z.enum(["EMAIL", "PHONE"]);
47
+ export type ContactType = z.infer<typeof contactTypeEnum>;
48
+
49
+ export const sendVerificationCodeSchema = z.object({
50
+ contactType: contactTypeEnum,
51
+ value: z.string().min(1)
52
+ });
53
+ export type SendVerificationCodeInput = z.infer<typeof sendVerificationCodeSchema>;
54
+
55
+ export const verifyCodeSchema = z.object({
56
+ username: z.email(),
57
+ contactType: contactTypeEnum,
58
+ value: z.string().min(1),
59
+ verificationCode: z.string().min(1)
60
+ });
61
+ export type VerifyCodeInput = z.infer<typeof verifyCodeSchema>;
62
+
63
+ // Identity currently supports a single OAuth2 provider.
64
+ export const oauthProviderEnum = z.enum(["GITHUB"]);
65
+ export type OauthProvider = z.infer<typeof oauthProviderEnum>;
66
+
67
+ export const oauthSignInSchema = z.object({
68
+ provider: oauthProviderEnum,
69
+ code: z.string().min(1)
70
+ });
71
+ export type OauthSignInInput = z.infer<typeof oauthSignInSchema>;
72
+
73
+ export const oauthSignUpSchema = z.object({
74
+ code: z.string().min(1)
75
+ });
76
+ export type OauthSignUpInput = z.infer<typeof oauthSignUpSchema>;
@@ -0,0 +1,88 @@
1
+ import { z } from "zod";
2
+
3
+ export const campaignStatusSchema = z.enum(["PAUSED", "ACTIVE", "COMPLETED", "ARCHIVED"]);
4
+ export type CampaignStatus = z.infer<typeof campaignStatusSchema>;
5
+
6
+ /**
7
+ * Valid status transitions. A new campaign starts ACTIVE (dispatching immediately).
8
+ * COMPLETED is read-only. An ARCHIVED campaign can be restored to PAUSED — it never
9
+ * resumes dispatch without an explicit later activation. The UI offers only the
10
+ * transitions valid for the current status; the API enforces the same map.
11
+ */
12
+ export const campaignStatusTransitions: Record<CampaignStatus, CampaignStatus[]> = {
13
+ PAUSED: ["ACTIVE", "ARCHIVED"],
14
+ ACTIVE: ["PAUSED", "COMPLETED", "ARCHIVED"],
15
+ COMPLETED: ["ARCHIVED"],
16
+ ARCHIVED: ["PAUSED"]
17
+ };
18
+
19
+ const timeOfDay = z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/, "must be HH:MM 24h time");
20
+
21
+ /**
22
+ * Days of the week the campaign runs, as ISO weekday numbers (1 = Monday … 7 = Sunday).
23
+ * Any non-empty combination is allowed (e.g. Monday + Friday only).
24
+ */
25
+ const daysOfWeek = z
26
+ .array(z.number().int().min(1, "weekday must be 1–7").max(7, "weekday must be 1–7"))
27
+ .min(1, "select at least one day")
28
+ .refine((days) => new Set(days).size === days.length, {
29
+ message: "days of week must be unique"
30
+ });
31
+
32
+ export const createCampaignSchema = z
33
+ .object({
34
+ name: z.string().min(1).max(120),
35
+ agentTemplateId: z.string().min(1),
36
+ portfolioIds: z.array(z.string().min(1)).min(1),
37
+ /**
38
+ * The WhatsApp sender number to send from. Required when the agent template is
39
+ * `WHATSAPP` (quality rating and conversation continuity are per-number, so the
40
+ * sender identity is chosen per campaign, not pooled like voice/SMS) and must be
41
+ * omitted otherwise. The cross-field rule is enforced in `createCampaign` against the
42
+ * template's resolved type.
43
+ */
44
+ whatsAppSenderNumberId: z.string().min(1).optional(),
45
+ startDate: z.string().min(1),
46
+ endDate: z.string().min(1).optional(),
47
+ daysOfWeek,
48
+ startTime: timeOfDay,
49
+ endTime: timeOfDay,
50
+ maxAttemptsPerAccount: z.number().int().positive(),
51
+ maxAttemptsPerDay: z.number().int().positive()
52
+ })
53
+ .refine((c) => !c.endDate || new Date(c.endDate) > new Date(c.startDate), {
54
+ message: "endDate must be after startDate",
55
+ path: ["endDate"]
56
+ });
57
+ export type CreateCampaignInput = z.infer<typeof createCampaignSchema>;
58
+
59
+ /**
60
+ * Updating a campaign: mutable configuration only. `agentTemplateId` is immutable and
61
+ * `status` is changed through {@link updateCampaignStatusSchema} (guarded transitions),
62
+ * so `.strict()` rejects either being passed here, along with any unknown keys.
63
+ */
64
+ export const updateCampaignSchema = z
65
+ .object({
66
+ id: z.string().min(1),
67
+ name: z.string().min(1).max(120).optional(),
68
+ startDate: z.string().min(1).optional(),
69
+ endDate: z.string().min(1).optional(),
70
+ daysOfWeek: daysOfWeek.optional(),
71
+ startTime: timeOfDay.optional(),
72
+ endTime: timeOfDay.optional(),
73
+ maxAttemptsPerAccount: z.number().int().positive().optional(),
74
+ maxAttemptsPerDay: z.number().int().positive().optional()
75
+ })
76
+ .strict();
77
+ export type UpdateCampaignInput = z.infer<typeof updateCampaignSchema>;
78
+
79
+ export const updateCampaignStatusSchema = z.object({
80
+ id: z.string().min(1),
81
+ status: campaignStatusSchema
82
+ });
83
+ export type UpdateCampaignStatusInput = z.infer<typeof updateCampaignStatusSchema>;
84
+
85
+ export const deleteCampaignSchema = z.object({
86
+ id: z.string().min(1)
87
+ });
88
+ export type DeleteCampaignInput = z.infer<typeof deleteCampaignSchema>;
@@ -0,0 +1,96 @@
1
+ import { z } from "zod";
2
+ import { agentTypeSchema } from "./agentTemplates.js";
3
+
4
+ export const contactOutcomeSchema = z.enum([
5
+ "DELIVERED",
6
+ "NOT_DELIVERED",
7
+ "NO_ANSWER",
8
+ "PAYMENT_PROMISE",
9
+ "PARTIAL_PAYMENT_AGREED",
10
+ "NEW_TERMS",
11
+ "CALLBACK_REQUESTED",
12
+ "DISPUTE_RAISED",
13
+ "INFORMATION_REQUEST",
14
+ "RESOLVED",
15
+ "PAID",
16
+ "WRONG_NUMBER",
17
+ "OPT_OUT",
18
+ "REFUSED",
19
+ "OTHER"
20
+ ]);
21
+ export type ContactOutcome = z.infer<typeof contactOutcomeSchema>;
22
+
23
+ export const aiSentimentSchema = z.enum(["POSITIVE", "NEUTRAL", "NEGATIVE", "HOSTILE"]);
24
+ export type AiSentiment = z.infer<typeof aiSentimentSchema>;
25
+
26
+ /**
27
+ * PaymentPromise is the only outcome QCobro tracks with a lifecycle, because a payment
28
+ * is the only commitment it can verify. DUE is derived (PENDING past its dueDate), not a
29
+ * stored status. There is intentionally no "broken" status — an unpaid promise stays on
30
+ * the worklist until an operator resolves it. EXPIRED is set when the account leaves its
31
+ * portfolio.
32
+ */
33
+ export const paymentPromiseStatusSchema = z.enum(["PENDING", "MET", "EXPIRED", "CANCELLED"]);
34
+ export type PaymentPromiseStatus = z.infer<typeof paymentPromiseStatusSchema>;
35
+
36
+ export const createContactLogSchema = z.object({
37
+ portfolioAccountId: z.string().min(1),
38
+ campaignId: z.string().min(1).optional(),
39
+ /** Agent template used (campaign dispatch or ad-hoc follow-up). */
40
+ agentTemplateId: z.string().min(1).optional(),
41
+ /** Set when this gestión is an ad-hoc follow-up on a specific PaymentPromise. */
42
+ paymentPromiseId: z.string().min(1).optional(),
43
+ agentType: agentTypeSchema,
44
+ contactedAt: z.string().min(1),
45
+ durationSeconds: z.number().int().nonnegative().optional(),
46
+ outcome: contactOutcomeSchema,
47
+ notes: z.string().optional(),
48
+ debtAmountSnapshot: z.number().nonnegative().optional(),
49
+ aiSummary: z.string().optional(),
50
+ aiSentiment: aiSentimentSchema.optional(),
51
+ aiDebtReason: z.string().optional(),
52
+ aiResult: z.string().optional(),
53
+ aiNextStep: z.string().optional(),
54
+ intentMetadata: z.record(z.string(), z.unknown()).optional(),
55
+ channelData: z.record(z.string(), z.unknown()).optional(),
56
+ /**
57
+ * Provider call ref (voice) / message sid (sms) for the dispatch-time attempt.
58
+ * When present, `recordOutcome` upserts the gestión keyed by it (one row per
59
+ * attempt, enriched by the async callback) instead of inserting a duplicate.
60
+ */
61
+ providerRef: z.string().min(1).optional()
62
+ });
63
+ export type CreateContactLogInput = z.infer<typeof createContactLogSchema>;
64
+
65
+ /**
66
+ * Input to reserve a campaign attempt before the provider call (the engine's
67
+ * at-most-once step). Increments the attempt counters; writes no gestión.
68
+ */
69
+ export const reserveAttemptSchema = z.object({
70
+ campaignId: z.string().min(1).optional(),
71
+ portfolioAccountId: z.string().min(1),
72
+ /** When the attempt is being made (ISO). */
73
+ at: z.string().min(1)
74
+ });
75
+ export type ReserveAttemptInput = z.infer<typeof reserveAttemptSchema>;
76
+
77
+ /**
78
+ * Operator resolution of a payment promise. A promise leaves PENDING only by explicit
79
+ * action: `MET` (paid — v1 is manual-only, no trusted payment signal) or `CANCELLED`.
80
+ * `EXPIRED` is set by the system when the account leaves its portfolio, not here.
81
+ */
82
+ export const updatePaymentPromiseSchema = z.object({
83
+ id: z.string().min(1),
84
+ status: z.enum(["MET", "CANCELLED"])
85
+ });
86
+ export type UpdatePaymentPromiseInput = z.infer<typeof updatePaymentPromiseSchema>;
87
+
88
+ /**
89
+ * Follow up on a payment promise with an ad-hoc agent dispatch (no campaign). Writes a
90
+ * gestión with `campaignId` null, the chosen `agentTemplateId`, and a link to the promise.
91
+ */
92
+ export const followUpPaymentPromiseSchema = z.object({
93
+ paymentPromiseId: z.string().min(1),
94
+ agentTemplateId: z.string().min(1)
95
+ });
96
+ export type FollowUpPaymentPromiseInput = z.infer<typeof followUpPaymentPromiseSchema>;
@@ -0,0 +1,115 @@
1
+ import { z } from "zod";
2
+
3
+ /** The channels the dispatch layer triggers (subset of AgentType). */
4
+ export const dispatchChannelSchema = z.enum([
5
+ "VOICE_AI",
6
+ "VOICE_PRERECORDED",
7
+ "SMS",
8
+ "EMAIL",
9
+ "WHATSAPP"
10
+ ]);
11
+
12
+ /**
13
+ * A normalized dispatch request: a channel, a destination, the render context
14
+ * (the customer's account fields), and the raw template body fields for that
15
+ * channel. The dispatch function renders the bodies against `context` before
16
+ * sending, so callers pass raw templates — not pre-rendered strings.
17
+ */
18
+ export const dispatchOutreachSchema = z
19
+ .object({
20
+ channel: dispatchChannelSchema,
21
+ /** Destination number (E.164). */
22
+ to: z.string().min(1),
23
+ /** Render context — the customer's account fields plus derived values. */
24
+ context: z.record(z.string(), z.unknown()).default({}),
25
+ /** Optional explicit caller-ID/sender; otherwise picked from the pool. */
26
+ from: z.string().min(1).optional(),
27
+ /** Voice: the provider application ref to drive the call. */
28
+ appRef: z.string().min(1).optional(),
29
+ /** Voz IA: opening line template (the autopilot may also open silently). */
30
+ firstMessage: z.string().optional(),
31
+ /** Voz pregrabada: the whole spoken script template (locuted via TTS). */
32
+ script: z.string().optional(),
33
+ /** SMS / EMAIL / WHATSAPP: message body template. For WHATSAPP this is the fetched
34
+ * template body whose `{{vars}}` are extracted and sent as named parameters. */
35
+ body: z.string().optional(),
36
+ /** EMAIL: subject line template. */
37
+ subject: z.string().optional(),
38
+ /** WHATSAPP: Meta-approved template name to send. */
39
+ templateName: z.string().optional(),
40
+ /** WHATSAPP: Meta template-send language code (sourced from the workspace, e.g. `es_DO`). */
41
+ languageCode: z.string().optional()
42
+ })
43
+ .superRefine((value, ctx) => {
44
+ if (value.channel === "SMS" && (value.body ?? "").length === 0) {
45
+ ctx.addIssue({ code: "custom", path: ["body"], message: "SMS requires a message body" });
46
+ }
47
+ if (value.channel === "EMAIL") {
48
+ if ((value.subject ?? "").length === 0) {
49
+ ctx.addIssue({ code: "custom", path: ["subject"], message: "Email requires a subject" });
50
+ }
51
+ if ((value.body ?? "").length === 0) {
52
+ ctx.addIssue({ code: "custom", path: ["body"], message: "Email requires a body" });
53
+ }
54
+ }
55
+ if (value.channel === "WHATSAPP") {
56
+ if ((value.templateName ?? "").length === 0) {
57
+ ctx.addIssue({
58
+ code: "custom",
59
+ path: ["templateName"],
60
+ message: "WhatsApp requires a templateName"
61
+ });
62
+ }
63
+ if ((value.languageCode ?? "").length === 0) {
64
+ ctx.addIssue({
65
+ code: "custom",
66
+ path: ["languageCode"],
67
+ message: "WhatsApp requires a languageCode"
68
+ });
69
+ }
70
+ if ((value.body ?? "").length === 0) {
71
+ ctx.addIssue({
72
+ code: "custom",
73
+ path: ["body"],
74
+ message: "WhatsApp requires a template body for parameter extraction"
75
+ });
76
+ }
77
+ }
78
+ // Voice dispatch needs the synced application ref. Neither voice channel requires a
79
+ // `firstMessage`: VOICE_AI may open silently (the autopilot places the call and waits
80
+ // for the customer to speak first), and pre-recorded has no first message at all.
81
+ // EMAIL and SMS are not voice and need no appRef.
82
+ const isVoice = value.channel === "VOICE_AI" || value.channel === "VOICE_PRERECORDED";
83
+ if (isVoice && !value.appRef) {
84
+ ctx.addIssue({
85
+ code: "custom",
86
+ path: ["appRef"],
87
+ message: "Voice dispatch requires appRef"
88
+ });
89
+ }
90
+ });
91
+
92
+ export type DispatchOutreachInput = z.infer<typeof dispatchOutreachSchema>;
93
+
94
+ /**
95
+ * Input for the manual "Contactar manualmente" procedure: which customer and which
96
+ * campaign. A manual contact runs the campaign's agent against this one customer, so
97
+ * the campaign (required) determines the agent/channel; the server resolves these to
98
+ * a {@link dispatchOutreachSchema} request.
99
+ */
100
+ export const manualOutreachSchema = z.object({
101
+ portfolioAccountId: z.string().min(1),
102
+ // Manual outreach is agent-based, not campaign-based: it dispatches the chosen agent
103
+ // template ad-hoc and records a campaign-less gestión (no CampaignAccountState).
104
+ agentTemplateId: z.string().min(1),
105
+ /** Operator override for the email subject (rendered, replaces template value). */
106
+ subject: z.string().optional(),
107
+ /** Operator override for the message body — SMS or EMAIL (rendered). */
108
+ body: z.string().optional(),
109
+ /** Operator override for the Voz IA opening line (rendered). */
110
+ firstMessage: z.string().optional(),
111
+ /** Operator override for the Voz pregrabada spoken script (rendered). */
112
+ script: z.string().optional()
113
+ });
114
+
115
+ export type ManualOutreachInput = z.infer<typeof manualOutreachSchema>;
@@ -0,0 +1,37 @@
1
+ import { z } from "zod";
2
+
3
+ /**
4
+ * The structured decision the EMAIL autopilot returns for an inbound reply. Validated so a
5
+ * model can't drive the loop with malformed output. `outcome` mirrors the contact-log
6
+ * outcomes; `objective` carries promise details when the reply implies one.
7
+ */
8
+ export const emailAutopilotDecisionSchema = z.object({
9
+ action: z.enum(["reply", "ignore", "resolve", "escalate"]),
10
+ replyBody: z.string().optional(),
11
+ outcome: z.string().optional(),
12
+ objective: z
13
+ .object({
14
+ type: z.string(),
15
+ amount: z.number().optional(),
16
+ dueDate: z.string().optional(),
17
+ note: z.string().optional()
18
+ })
19
+ .nullish()
20
+ });
21
+
22
+ /**
23
+ * Normalized inbound email from the provider webhook. The provider payload is mapped to
24
+ * this before ingestion so the function stays provider-agnostic.
25
+ */
26
+ export const inboundEmailSchema = z.object({
27
+ from: z.string().min(1),
28
+ to: z.array(z.string()).default([]),
29
+ subject: z.string().optional(),
30
+ text: z.string().default(""),
31
+ messageId: z.string().optional(),
32
+ inReplyTo: z.string().optional(),
33
+ references: z.array(z.string()).optional(),
34
+ headers: z.record(z.string(), z.string()).optional()
35
+ });
36
+
37
+ export type InboundEmailInput = z.infer<typeof inboundEmailSchema>;
@@ -0,0 +1,15 @@
1
+ export * from "./auth.js";
2
+ export * from "./workspaces.js";
3
+ export * from "./apiKeys.js";
4
+ export * from "./users.js";
5
+ export * from "./portfolios.js";
6
+ export * from "./workspaceSettings.js";
7
+ export * from "./userSettings.js";
8
+ export * from "./agentTemplates.js";
9
+ export * from "./campaigns.js";
10
+ export * from "./contactLog.js";
11
+ export * from "./dispatch.js";
12
+ export * from "./email.js";
13
+ export * from "./whatsApp.js";
14
+ export * from "./voiceEvent.js";
15
+ export * from "./insight.js";
@@ -0,0 +1,20 @@
1
+ import { z } from "zod";
2
+ import { aiSentimentSchema } from "./contactLog.js";
3
+
4
+ /**
5
+ * The structured analysis an LLM must return for a gestión transcript. Mirrors the
6
+ * `ai*` fields on `AccountContactLog`; text fields are written in the call's language,
7
+ * `aiSentiment` is always one of the fixed enum values.
8
+ */
9
+ export const gestionInsightSchema = z.object({
10
+ aiSummary: z.string().min(1),
11
+ aiSentiment: aiSentimentSchema,
12
+ aiDebtReason: z.string().min(1),
13
+ aiResult: z.string().min(1),
14
+ aiNextStep: z.string().min(1)
15
+ });
16
+ export type GestionInsight = z.infer<typeof gestionInsightSchema>;
17
+
18
+ /** Input to the generate-insight operation — the gestión (contact-log) id. */
19
+ export const generateInsightInputSchema = z.object({ id: z.string().min(1) });
20
+ export type GenerateInsightInput = z.infer<typeof generateInsightInputSchema>;
@@ -0,0 +1,49 @@
1
+ import { z } from "zod";
2
+
3
+ export const createPortfolioSchema = z.object({
4
+ name: z.string().min(1).max(120),
5
+ clientId: z.string().min(1).max(120)
6
+ });
7
+ export type CreatePortfolioInput = z.infer<typeof createPortfolioSchema>;
8
+
9
+ export const updatePortfolioSchema = z.object({
10
+ id: z.string().min(1),
11
+ name: z.string().min(1).max(120).optional(),
12
+ // `archived` toggles the portfolio's archived state: true sets `archivedAt` to
13
+ // now, false clears it (restore). There is no separate status concept.
14
+ archived: z.boolean().optional()
15
+ });
16
+ export type UpdatePortfolioInput = z.infer<typeof updatePortfolioSchema>;
17
+
18
+ export const deletePortfolioSchema = z.object({
19
+ id: z.string().min(1)
20
+ });
21
+ export type DeletePortfolioInput = z.infer<typeof deletePortfolioSchema>;
22
+
23
+ export const accountRowSchema = z.object({
24
+ externalId: z.string().min(1),
25
+ fullName: z.string().min(1),
26
+ phone: z.string().optional(),
27
+ email: z.string().email().optional(),
28
+ preferredLanguage: z.string().optional(),
29
+ bestTimeToCall: z.string().optional(),
30
+ customerSegment: z.string().optional(),
31
+ principalAmount: z.number().nonnegative().default(0),
32
+ termsAmount: z.number().nonnegative().default(0),
33
+ termsFrequency: z.string().optional(),
34
+ termsLength: z.number().int().nonnegative().default(0),
35
+ outstandingBalance: z.number().nonnegative(),
36
+ daysPastDue: z.number().int().nonnegative().default(0),
37
+ missedInstallments: z.number().int().nonnegative().default(0),
38
+ lastPaymentDate: z.string().optional(),
39
+ lastPaymentAmount: z.number().nonnegative().optional(),
40
+ negotiationOptions: z.string().optional()
41
+ });
42
+ export type AccountRowInput = z.infer<typeof accountRowSchema>;
43
+
44
+ export const syncAccountsInputSchema = z.object({
45
+ portfolioId: z.string().min(1),
46
+ mode: z.enum(["APPEND_ONLY", "UPDATE_EXISTING", "REPLACE"]),
47
+ rows: z.array(accountRowSchema).min(1)
48
+ });
49
+ export type SyncAccountsInput = z.infer<typeof syncAccountsInputSchema>;