mirror of
https://github.com/melgarafael/DeskcommCRM.git
synced 2026-10-02 09:34:46 +08:00
feat(followup): schema Zod do grafo de fluxo [onda 2]
TDD: 80 tests (RED → GREEN). Implementa discriminatedUnions para nodes, edges e graph completo. Validações: node types, configs, limits. Refine no union level (Zod v3 compat). Exports: NODE_TYPES, NodeType, 6 config schemas, flowNode/Edge/Graph schemas, tipos FlowNode/Edge/Graph. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0143T3gYRomX8kFxK3KhJK1U
This commit is contained in:
co-authored by
Claude Fable 5
parent
fda59f2f5c
commit
e86649726a
@@ -0,0 +1,905 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
NODE_TYPES,
|
||||
NodeType,
|
||||
waitConfigSchema,
|
||||
aiClassifyConfigSchema,
|
||||
actionConfigSchema,
|
||||
conditionConfigSchema,
|
||||
endConfigSchema,
|
||||
flowNodeSchema,
|
||||
flowEdgeSchema,
|
||||
flowGraphSchema,
|
||||
FlowGraph,
|
||||
FlowNode,
|
||||
FlowEdge,
|
||||
} from './graph-schema';
|
||||
|
||||
describe('graph-schema', () => {
|
||||
describe('NODE_TYPES & NodeType', () => {
|
||||
it('exports NODE_TYPES constant', () => {
|
||||
expect(NODE_TYPES).toEqual([
|
||||
'trigger',
|
||||
'wait',
|
||||
'condition',
|
||||
'ai_classify',
|
||||
'action',
|
||||
'end',
|
||||
]);
|
||||
});
|
||||
|
||||
it('NodeType type matches NODE_TYPES', () => {
|
||||
const nt: NodeType = 'trigger';
|
||||
expect(nt).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('waitConfigSchema', () => {
|
||||
describe('fixed mode', () => {
|
||||
it('accepts valid fixed duration', () => {
|
||||
const result = waitConfigSchema.safeParse({
|
||||
mode: 'fixed',
|
||||
duration_ms: 300_000, // 5 min
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts max duration', () => {
|
||||
const result = waitConfigSchema.safeParse({
|
||||
mode: 'fixed',
|
||||
duration_ms: 7_776_000_000, // 90 days
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects duration below 5 min', () => {
|
||||
const result = waitConfigSchema.safeParse({
|
||||
mode: 'fixed',
|
||||
duration_ms: 299_999,
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects duration above 90 days', () => {
|
||||
const result = waitConfigSchema.safeParse({
|
||||
mode: 'fixed',
|
||||
duration_ms: 7_776_000_001,
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects extra keys', () => {
|
||||
const result = waitConfigSchema.safeParse({
|
||||
mode: 'fixed',
|
||||
duration_ms: 300_000,
|
||||
extra_key: 'should reject',
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects non-integer duration', () => {
|
||||
const result = waitConfigSchema.safeParse({
|
||||
mode: 'fixed',
|
||||
duration_ms: 300_000.5,
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('smart mode', () => {
|
||||
it('accepts valid smart range', () => {
|
||||
const result = waitConfigSchema.safeParse({
|
||||
mode: 'smart',
|
||||
min_ms: 300_000,
|
||||
max_ms: 7_776_000_000,
|
||||
guidance: 'wait for reply',
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts smart without guidance', () => {
|
||||
const result = waitConfigSchema.safeParse({
|
||||
mode: 'smart',
|
||||
min_ms: 300_000,
|
||||
max_ms: 7_776_000_000,
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects when min > max', () => {
|
||||
const result = waitConfigSchema.safeParse({
|
||||
mode: 'smart',
|
||||
min_ms: 7_776_000_000,
|
||||
max_ms: 300_000,
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects when min below 5 min', () => {
|
||||
const result = waitConfigSchema.safeParse({
|
||||
mode: 'smart',
|
||||
min_ms: 299_999,
|
||||
max_ms: 7_776_000_000,
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects when max above 90 days', () => {
|
||||
const result = waitConfigSchema.safeParse({
|
||||
mode: 'smart',
|
||||
min_ms: 300_000,
|
||||
max_ms: 7_776_000_001,
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects guidance over 500 chars', () => {
|
||||
const result = waitConfigSchema.safeParse({
|
||||
mode: 'smart',
|
||||
min_ms: 300_000,
|
||||
max_ms: 7_776_000_000,
|
||||
guidance: 'a'.repeat(501),
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects extra keys', () => {
|
||||
const result = waitConfigSchema.safeParse({
|
||||
mode: 'smart',
|
||||
min_ms: 300_000,
|
||||
max_ms: 7_776_000_000,
|
||||
extra_key: 'should reject',
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('aiClassifyConfigSchema', () => {
|
||||
it('accepts valid config', () => {
|
||||
const result = aiClassifyConfigSchema.safeParse({
|
||||
classes: ['hot', 'warm', 'cold'],
|
||||
grace_timeout_ms: 900_000,
|
||||
target: 'last_reply',
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts config with hint', () => {
|
||||
const result = aiClassifyConfigSchema.safeParse({
|
||||
classes: ['yes', 'no'],
|
||||
grace_timeout_ms: 900_000,
|
||||
hint: 'classify as yes or no',
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('defaults target to last_reply', () => {
|
||||
const result = aiClassifyConfigSchema.safeParse({
|
||||
classes: ['a', 'b'],
|
||||
grace_timeout_ms: 900_000,
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.target).toBe('last_reply');
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects empty classes array', () => {
|
||||
const result = aiClassifyConfigSchema.safeParse({
|
||||
classes: [],
|
||||
grace_timeout_ms: 900_000,
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects more than 8 classes', () => {
|
||||
const result = aiClassifyConfigSchema.safeParse({
|
||||
classes: Array.from({ length: 9 }, (_, i) => `class${i}`),
|
||||
grace_timeout_ms: 900_000,
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects class string over 40 chars', () => {
|
||||
const result = aiClassifyConfigSchema.safeParse({
|
||||
classes: ['a'.repeat(41)],
|
||||
grace_timeout_ms: 900_000,
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects grace_timeout below 15 min', () => {
|
||||
const result = aiClassifyConfigSchema.safeParse({
|
||||
classes: ['a'],
|
||||
grace_timeout_ms: 899_999,
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects hint over 500 chars', () => {
|
||||
const result = aiClassifyConfigSchema.safeParse({
|
||||
classes: ['a'],
|
||||
grace_timeout_ms: 900_000,
|
||||
hint: 'a'.repeat(501),
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects extra keys', () => {
|
||||
const result = aiClassifyConfigSchema.safeParse({
|
||||
classes: ['a'],
|
||||
grace_timeout_ms: 900_000,
|
||||
extra_key: 'should reject',
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('actionConfigSchema', () => {
|
||||
describe('ai_message mode', () => {
|
||||
it('accepts valid ai_message config', () => {
|
||||
const result = actionConfigSchema.safeParse({
|
||||
mode: 'ai_message',
|
||||
prompt_hint: 'suggest best next action',
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts with fallback_template_id', () => {
|
||||
const result = actionConfigSchema.safeParse({
|
||||
mode: 'ai_message',
|
||||
prompt_hint: 'suggest',
|
||||
fallback_template_id: '550e8400-e29b-41d4-a716-446655440000',
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects ai_message without prompt_hint', () => {
|
||||
const result = actionConfigSchema.safeParse({
|
||||
mode: 'ai_message',
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects empty prompt_hint', () => {
|
||||
const result = actionConfigSchema.safeParse({
|
||||
mode: 'ai_message',
|
||||
prompt_hint: '',
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects prompt_hint over 1000 chars', () => {
|
||||
const result = actionConfigSchema.safeParse({
|
||||
mode: 'ai_message',
|
||||
prompt_hint: 'a'.repeat(1001),
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects invalid UUID for fallback_template_id', () => {
|
||||
const result = actionConfigSchema.safeParse({
|
||||
mode: 'ai_message',
|
||||
prompt_hint: 'suggest',
|
||||
fallback_template_id: 'not-a-uuid',
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects extra keys', () => {
|
||||
const result = actionConfigSchema.safeParse({
|
||||
mode: 'ai_message',
|
||||
prompt_hint: 'suggest',
|
||||
extra_key: 'should reject',
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('template mode', () => {
|
||||
it('accepts valid template config', () => {
|
||||
const result = actionConfigSchema.safeParse({
|
||||
mode: 'template',
|
||||
template_id: '550e8400-e29b-41d4-a716-446655440000',
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects template without template_id', () => {
|
||||
const result = actionConfigSchema.safeParse({
|
||||
mode: 'template',
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects invalid UUID for template_id', () => {
|
||||
const result = actionConfigSchema.safeParse({
|
||||
mode: 'template',
|
||||
template_id: 'not-a-uuid',
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects extra keys', () => {
|
||||
const result = actionConfigSchema.safeParse({
|
||||
mode: 'template',
|
||||
template_id: '550e8400-e29b-41d4-a716-446655440000',
|
||||
extra_key: 'should reject',
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('conditionConfigSchema', () => {
|
||||
it('accepts valid condition', () => {
|
||||
const result = conditionConfigSchema.safeParse({
|
||||
combinator: 'and',
|
||||
checks: [
|
||||
{ field: 'lead_stage', op: 'eq', value: 'qualified' },
|
||||
{ field: 'last_outcome', op: 'neq', value: 'lost' },
|
||||
],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('defaults combinator to and', () => {
|
||||
const result = conditionConfigSchema.safeParse({
|
||||
checks: [{ field: 'tag', op: 'contains', value: 'vip' }],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.combinator).toBe('and');
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts or combinator', () => {
|
||||
const result = conditionConfigSchema.safeParse({
|
||||
combinator: 'or',
|
||||
checks: [{ field: 'lead_stage', op: 'eq', value: 'a' }],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts numeric value', () => {
|
||||
const result = conditionConfigSchema.safeParse({
|
||||
checks: [{ field: 'steps_taken', op: 'gte', value: 5 }],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects empty checks', () => {
|
||||
const result = conditionConfigSchema.safeParse({
|
||||
checks: [],
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects more than 10 checks', () => {
|
||||
const result = conditionConfigSchema.safeParse({
|
||||
checks: Array.from({ length: 11 }, () => ({
|
||||
field: 'lead_stage' as const,
|
||||
op: 'eq' as const,
|
||||
value: 'a',
|
||||
})),
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects invalid field', () => {
|
||||
const result = conditionConfigSchema.safeParse({
|
||||
checks: [
|
||||
{ field: 'invalid_field', op: 'eq', value: 'a' },
|
||||
],
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects invalid operator', () => {
|
||||
const result = conditionConfigSchema.safeParse({
|
||||
checks: [
|
||||
{ field: 'lead_stage', op: 'invalid_op', value: 'a' },
|
||||
],
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects extra keys', () => {
|
||||
const result = conditionConfigSchema.safeParse({
|
||||
checks: [{ field: 'lead_stage', op: 'eq', value: 'a' }],
|
||||
extra_key: 'should reject',
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('endConfigSchema', () => {
|
||||
it('accepts converted outcome', () => {
|
||||
const result = endConfigSchema.safeParse({
|
||||
outcome: 'converted',
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts with optional note', () => {
|
||||
const result = endConfigSchema.safeParse({
|
||||
outcome: 'exhausted',
|
||||
note: 'too many retries',
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects note over 200 chars', () => {
|
||||
const result = endConfigSchema.safeParse({
|
||||
outcome: 'converted',
|
||||
note: 'a'.repeat(201),
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects invalid outcome', () => {
|
||||
const result = endConfigSchema.safeParse({
|
||||
outcome: 'invalid',
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects extra keys', () => {
|
||||
const result = endConfigSchema.safeParse({
|
||||
outcome: 'converted',
|
||||
extra_key: 'should reject',
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('flowEdgeSchema', () => {
|
||||
it('accepts always condition', () => {
|
||||
const result = flowEdgeSchema.safeParse({
|
||||
id: 'edge1',
|
||||
source: 'trigger',
|
||||
target: 'wait',
|
||||
condition: { type: 'always' },
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts class_match condition', () => {
|
||||
const result = flowEdgeSchema.safeParse({
|
||||
id: 'edge2',
|
||||
source: 'classify',
|
||||
target: 'action',
|
||||
condition: { type: 'class_match', value: 'hot' },
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts no_reply as class_match value', () => {
|
||||
const result = flowEdgeSchema.safeParse({
|
||||
id: 'edge3',
|
||||
source: 'classify',
|
||||
target: 'end',
|
||||
condition: { type: 'class_match', value: 'no_reply' },
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts cond_result condition', () => {
|
||||
const result = flowEdgeSchema.safeParse({
|
||||
id: 'edge4',
|
||||
source: 'condition',
|
||||
target: 'action',
|
||||
condition: { type: 'cond_result', value: true },
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('defaults priority to 0', () => {
|
||||
const result = flowEdgeSchema.safeParse({
|
||||
id: 'edge5',
|
||||
source: 'a',
|
||||
target: 'b',
|
||||
condition: { type: 'always' },
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.priority).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts custom priority', () => {
|
||||
const result = flowEdgeSchema.safeParse({
|
||||
id: 'edge6',
|
||||
source: 'a',
|
||||
target: 'b',
|
||||
priority: 10,
|
||||
condition: { type: 'always' },
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.priority).toBe(10);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects non-integer priority', () => {
|
||||
const result = flowEdgeSchema.safeParse({
|
||||
id: 'edge7',
|
||||
source: 'a',
|
||||
target: 'b',
|
||||
priority: 10.5,
|
||||
condition: { type: 'always' },
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects extra keys', () => {
|
||||
const result = flowEdgeSchema.safeParse({
|
||||
id: 'edge8',
|
||||
source: 'a',
|
||||
target: 'b',
|
||||
condition: { type: 'always' },
|
||||
extra_key: 'should reject',
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('flowNodeSchema (discriminated union)', () => {
|
||||
it('accepts trigger node', () => {
|
||||
const result = flowNodeSchema.safeParse({
|
||||
id: 'trigger-1',
|
||||
type: 'trigger',
|
||||
label: 'Start',
|
||||
position: { x: 0, y: 0 },
|
||||
config: {},
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts wait node with fixed config', () => {
|
||||
const result = flowNodeSchema.safeParse({
|
||||
id: 'wait-1',
|
||||
type: 'wait',
|
||||
label: 'Wait 5 min',
|
||||
position: { x: 100, y: 100 },
|
||||
config: {
|
||||
mode: 'fixed',
|
||||
duration_ms: 300_000,
|
||||
},
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts condition node', () => {
|
||||
const result = flowNodeSchema.safeParse({
|
||||
id: 'cond-1',
|
||||
type: 'condition',
|
||||
label: 'Is VIP?',
|
||||
position: { x: 200, y: 200 },
|
||||
config: {
|
||||
checks: [{ field: 'tag', op: 'contains', value: 'vip' }],
|
||||
},
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts ai_classify node', () => {
|
||||
const result = flowNodeSchema.safeParse({
|
||||
id: 'classify-1',
|
||||
type: 'ai_classify',
|
||||
label: 'Classify Interest',
|
||||
position: { x: 300, y: 300 },
|
||||
config: {
|
||||
classes: ['high', 'low'],
|
||||
grace_timeout_ms: 900_000,
|
||||
},
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts action node', () => {
|
||||
const result = flowNodeSchema.safeParse({
|
||||
id: 'action-1',
|
||||
type: 'action',
|
||||
label: 'Send Offer',
|
||||
position: { x: 400, y: 400 },
|
||||
config: {
|
||||
mode: 'ai_message',
|
||||
prompt_hint: 'send best offer',
|
||||
},
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts end node', () => {
|
||||
const result = flowNodeSchema.safeParse({
|
||||
id: 'end-1',
|
||||
type: 'end',
|
||||
label: 'Finished',
|
||||
position: { x: 500, y: 500 },
|
||||
config: {
|
||||
outcome: 'converted',
|
||||
note: 'successful',
|
||||
},
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects node without id', () => {
|
||||
const result = flowNodeSchema.safeParse({
|
||||
type: 'trigger',
|
||||
label: 'Start',
|
||||
position: { x: 0, y: 0 },
|
||||
config: {},
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects empty id', () => {
|
||||
const result = flowNodeSchema.safeParse({
|
||||
id: '',
|
||||
type: 'trigger',
|
||||
label: 'Start',
|
||||
position: { x: 0, y: 0 },
|
||||
config: {},
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects empty label', () => {
|
||||
const result = flowNodeSchema.safeParse({
|
||||
id: 'n1',
|
||||
type: 'trigger',
|
||||
label: '',
|
||||
position: { x: 0, y: 0 },
|
||||
config: {},
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects label over 60 chars', () => {
|
||||
const result = flowNodeSchema.safeParse({
|
||||
id: 'n1',
|
||||
type: 'trigger',
|
||||
label: 'a'.repeat(61),
|
||||
position: { x: 0, y: 0 },
|
||||
config: {},
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects invalid position shape', () => {
|
||||
const result = flowNodeSchema.safeParse({
|
||||
id: 'n1',
|
||||
type: 'trigger',
|
||||
label: 'Start',
|
||||
position: { x: 'invalid', y: 0 },
|
||||
config: {},
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects mismatched config for node type', () => {
|
||||
const result = flowNodeSchema.safeParse({
|
||||
id: 'wait-1',
|
||||
type: 'wait',
|
||||
label: 'Wait',
|
||||
position: { x: 0, y: 0 },
|
||||
config: { classes: ['a'] }, // ai_classify config, not wait
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects trigger node with non-empty config', () => {
|
||||
const result = flowNodeSchema.safeParse({
|
||||
id: 'trigger-1',
|
||||
type: 'trigger',
|
||||
label: 'Start',
|
||||
position: { x: 0, y: 0 },
|
||||
config: { something: 'invalid' },
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects extra keys in node', () => {
|
||||
const result = flowNodeSchema.safeParse({
|
||||
id: 'n1',
|
||||
type: 'trigger',
|
||||
label: 'Start',
|
||||
position: { x: 0, y: 0 },
|
||||
config: {},
|
||||
extra_key: 'should reject',
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('flowGraphSchema', () => {
|
||||
it('accepts valid graph with 2 nodes', () => {
|
||||
const result = flowGraphSchema.safeParse({
|
||||
nodes: [
|
||||
{
|
||||
id: 'trigger-1',
|
||||
type: 'trigger',
|
||||
label: 'Start',
|
||||
position: { x: 0, y: 0 },
|
||||
config: {},
|
||||
},
|
||||
{
|
||||
id: 'end-1',
|
||||
type: 'end',
|
||||
label: 'End',
|
||||
position: { x: 100, y: 100 },
|
||||
config: { outcome: 'converted' },
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{
|
||||
id: 'edge-1',
|
||||
source: 'trigger-1',
|
||||
target: 'end-1',
|
||||
condition: { type: 'always' },
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects graph with 1 node', () => {
|
||||
const result = flowGraphSchema.safeParse({
|
||||
nodes: [
|
||||
{
|
||||
id: 'trigger-1',
|
||||
type: 'trigger',
|
||||
label: 'Start',
|
||||
position: { x: 0, y: 0 },
|
||||
config: {},
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects graph with > 60 nodes', () => {
|
||||
const nodes = Array.from({ length: 61 }, (_, i) => ({
|
||||
id: `n${i}`,
|
||||
type: 'trigger' as const,
|
||||
label: `Node ${i}`,
|
||||
position: { x: i * 10, y: i * 10 },
|
||||
config: {} as {},
|
||||
}));
|
||||
const result = flowGraphSchema.safeParse({
|
||||
nodes,
|
||||
edges: [],
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects graph with > 120 edges', () => {
|
||||
const nodes = [
|
||||
{
|
||||
id: 'trigger-1',
|
||||
type: 'trigger' as const,
|
||||
label: 'Start',
|
||||
position: { x: 0, y: 0 },
|
||||
config: {} as {},
|
||||
},
|
||||
{
|
||||
id: 'end-1',
|
||||
type: 'end' as const,
|
||||
label: 'End',
|
||||
position: { x: 100, y: 100 },
|
||||
config: { outcome: 'converted' as const },
|
||||
},
|
||||
];
|
||||
const edges = Array.from({ length: 121 }, (_, i) => ({
|
||||
id: `edge${i}`,
|
||||
source: 'trigger-1',
|
||||
target: 'end-1',
|
||||
condition: { type: 'always' as const },
|
||||
}));
|
||||
const result = flowGraphSchema.safeParse({
|
||||
nodes,
|
||||
edges,
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects graph with no edges', () => {
|
||||
// This should pass since edges array can be empty
|
||||
const result = flowGraphSchema.safeParse({
|
||||
nodes: [
|
||||
{
|
||||
id: 'trigger-1',
|
||||
type: 'trigger',
|
||||
label: 'Start',
|
||||
position: { x: 0, y: 0 },
|
||||
config: {},
|
||||
},
|
||||
{
|
||||
id: 'end-1',
|
||||
type: 'end',
|
||||
label: 'End',
|
||||
position: { x: 100, y: 100 },
|
||||
config: { outcome: 'converted' },
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
});
|
||||
expect(result.success).toBe(true); // Edges can be empty
|
||||
});
|
||||
|
||||
it('rejects extra keys in graph', () => {
|
||||
const result = flowGraphSchema.safeParse({
|
||||
nodes: [
|
||||
{
|
||||
id: 'trigger-1',
|
||||
type: 'trigger',
|
||||
label: 'Start',
|
||||
position: { x: 0, y: 0 },
|
||||
config: {},
|
||||
},
|
||||
{
|
||||
id: 'end-1',
|
||||
type: 'end',
|
||||
label: 'End',
|
||||
position: { x: 100, y: 100 },
|
||||
config: { outcome: 'converted' },
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
extra_key: 'should reject',
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('type inference', () => {
|
||||
it('FlowGraph type is inferred correctly', () => {
|
||||
const graph: FlowGraph = {
|
||||
nodes: [
|
||||
{
|
||||
id: 'trigger-1',
|
||||
type: 'trigger',
|
||||
label: 'Start',
|
||||
position: { x: 0, y: 0 },
|
||||
config: {},
|
||||
},
|
||||
{
|
||||
id: 'end-1',
|
||||
type: 'end',
|
||||
label: 'End',
|
||||
position: { x: 100, y: 100 },
|
||||
config: { outcome: 'converted' },
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
};
|
||||
expect(graph).toBeTruthy();
|
||||
});
|
||||
|
||||
it('FlowNode type is inferred correctly', () => {
|
||||
const node: FlowNode = {
|
||||
id: 'wait-1',
|
||||
type: 'wait',
|
||||
label: 'Wait',
|
||||
position: { x: 0, y: 0 },
|
||||
config: { mode: 'fixed', duration_ms: 300_000 },
|
||||
};
|
||||
expect(node).toBeTruthy();
|
||||
});
|
||||
|
||||
it('FlowEdge type is inferred correctly', () => {
|
||||
const edge: FlowEdge = {
|
||||
id: 'e1',
|
||||
source: 'trigger-1',
|
||||
target: 'wait-1',
|
||||
priority: 0,
|
||||
condition: { type: 'always' },
|
||||
};
|
||||
expect(edge).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,214 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Flow graph schema for the follow-up automation system.
|
||||
* Defines types and Zod validators for nodes, edges, and complete graphs.
|
||||
*/
|
||||
|
||||
export const NODE_TYPES = [
|
||||
'trigger',
|
||||
'wait',
|
||||
'condition',
|
||||
'ai_classify',
|
||||
'action',
|
||||
'end',
|
||||
] as const;
|
||||
export type NodeType = (typeof NODE_TYPES)[number];
|
||||
|
||||
/**
|
||||
* Wait node configuration schema.
|
||||
* Supports two modes:
|
||||
* - fixed: absolute wait duration in milliseconds (5 min to 90 days)
|
||||
* - smart: adaptive wait with min/max range and optional guidance
|
||||
*/
|
||||
export const waitConfigSchema = z
|
||||
.discriminatedUnion('mode', [
|
||||
z.strictObject({
|
||||
mode: z.literal('fixed'),
|
||||
duration_ms: z.number().int().min(300_000).max(7_776_000_000),
|
||||
}),
|
||||
z.strictObject({
|
||||
mode: z.literal('smart'),
|
||||
min_ms: z.number().int().min(300_000),
|
||||
max_ms: z.number().int().max(7_776_000_000),
|
||||
guidance: z.string().max(500).optional(),
|
||||
}),
|
||||
])
|
||||
.refine((c) => c.mode !== 'smart' || c.min_ms <= c.max_ms, {
|
||||
message: 'min_ms must be <= max_ms',
|
||||
path: ['min_ms'],
|
||||
});
|
||||
|
||||
/**
|
||||
* AI classification node configuration.
|
||||
* Classifies incoming messages into one of several predefined classes.
|
||||
*/
|
||||
export const aiClassifyConfigSchema = z.strictObject({
|
||||
classes: z
|
||||
.array(z.string().min(1).max(40))
|
||||
.min(1)
|
||||
.max(8),
|
||||
grace_timeout_ms: z.number().int().min(900_000), // 15 min minimum
|
||||
target: z.enum(['last_reply', 'summary']).default('last_reply'),
|
||||
hint: z.string().max(500).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Action node configuration schema.
|
||||
* Supports two modes:
|
||||
* - ai_message: generate a message using AI with a prompt hint
|
||||
* - template: send a predefined template message
|
||||
*/
|
||||
export const actionConfigSchema = z.discriminatedUnion('mode', [
|
||||
z.strictObject({
|
||||
mode: z.literal('ai_message'),
|
||||
prompt_hint: z.string().min(1).max(1000),
|
||||
fallback_template_id: z.string().uuid().optional(),
|
||||
}),
|
||||
z.strictObject({
|
||||
mode: z.literal('template'),
|
||||
template_id: z.string().uuid(),
|
||||
}),
|
||||
]);
|
||||
|
||||
/**
|
||||
* Condition node configuration.
|
||||
* Evaluates multiple checks against lead state using boolean logic.
|
||||
*/
|
||||
export const conditionConfigSchema = z.strictObject({
|
||||
combinator: z.enum(['and', 'or']).default('and'),
|
||||
checks: z
|
||||
.array(
|
||||
z.strictObject({
|
||||
field: z.enum([
|
||||
'lead_stage',
|
||||
'tag',
|
||||
'steps_taken',
|
||||
'last_outcome',
|
||||
]),
|
||||
op: z.enum(['eq', 'neq', 'gte', 'lte', 'contains']),
|
||||
value: z.union([z.string(), z.number()]),
|
||||
})
|
||||
)
|
||||
.min(1)
|
||||
.max(10),
|
||||
});
|
||||
|
||||
/**
|
||||
* End node configuration.
|
||||
* Marks the conclusion of a flow with an outcome.
|
||||
*/
|
||||
export const endConfigSchema = z.strictObject({
|
||||
outcome: z.enum(['converted', 'exhausted', 'custom']),
|
||||
note: z.string().max(200).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Flow node schema — discriminated union based on node type.
|
||||
* Each node type has its specific config schema.
|
||||
*/
|
||||
export const flowNodeSchema = z.discriminatedUnion('type', [
|
||||
// Trigger node: entry point, no config
|
||||
z.strictObject({
|
||||
id: z.string().min(1),
|
||||
type: z.literal('trigger'),
|
||||
label: z.string().min(1).max(60),
|
||||
position: z.strictObject({
|
||||
x: z.number(),
|
||||
y: z.number(),
|
||||
}),
|
||||
config: z.strictObject({}),
|
||||
}),
|
||||
// Wait node: pauses flow for a duration
|
||||
z.strictObject({
|
||||
id: z.string().min(1),
|
||||
type: z.literal('wait'),
|
||||
label: z.string().min(1).max(60),
|
||||
position: z.strictObject({
|
||||
x: z.number(),
|
||||
y: z.number(),
|
||||
}),
|
||||
config: waitConfigSchema,
|
||||
}),
|
||||
// Condition node: branches flow based on criteria
|
||||
z.strictObject({
|
||||
id: z.string().min(1),
|
||||
type: z.literal('condition'),
|
||||
label: z.string().min(1).max(60),
|
||||
position: z.strictObject({
|
||||
x: z.number(),
|
||||
y: z.number(),
|
||||
}),
|
||||
config: conditionConfigSchema,
|
||||
}),
|
||||
// AI Classify node: classifies messages
|
||||
z.strictObject({
|
||||
id: z.string().min(1),
|
||||
type: z.literal('ai_classify'),
|
||||
label: z.string().min(1).max(60),
|
||||
position: z.strictObject({
|
||||
x: z.number(),
|
||||
y: z.number(),
|
||||
}),
|
||||
config: aiClassifyConfigSchema,
|
||||
}),
|
||||
// Action node: sends a message
|
||||
z.strictObject({
|
||||
id: z.string().min(1),
|
||||
type: z.literal('action'),
|
||||
label: z.string().min(1).max(60),
|
||||
position: z.strictObject({
|
||||
x: z.number(),
|
||||
y: z.number(),
|
||||
}),
|
||||
config: actionConfigSchema,
|
||||
}),
|
||||
// End node: terminal state
|
||||
z.strictObject({
|
||||
id: z.string().min(1),
|
||||
type: z.literal('end'),
|
||||
label: z.string().min(1).max(60),
|
||||
position: z.strictObject({
|
||||
x: z.number(),
|
||||
y: z.number(),
|
||||
}),
|
||||
config: endConfigSchema,
|
||||
}),
|
||||
]);
|
||||
|
||||
export type FlowNode = z.infer<typeof flowNodeSchema>;
|
||||
|
||||
/**
|
||||
* Flow edge schema — connection between nodes.
|
||||
* Includes condition to determine when edge is traversed.
|
||||
*/
|
||||
export const flowEdgeSchema = z.strictObject({
|
||||
id: z.string(),
|
||||
source: z.string(),
|
||||
target: z.string(),
|
||||
priority: z.number().int().default(0),
|
||||
condition: z.discriminatedUnion('type', [
|
||||
z.strictObject({ type: z.literal('always') }),
|
||||
z.strictObject({
|
||||
type: z.literal('class_match'),
|
||||
value: z.string(), // e.g., 'hot', 'cold', 'no_reply'
|
||||
}),
|
||||
z.strictObject({
|
||||
type: z.literal('cond_result'),
|
||||
value: z.boolean(),
|
||||
}),
|
||||
]),
|
||||
});
|
||||
|
||||
export type FlowEdge = z.infer<typeof flowEdgeSchema>;
|
||||
|
||||
/**
|
||||
* Complete flow graph schema.
|
||||
* Contains nodes and edges defining the flow automation.
|
||||
*/
|
||||
export const flowGraphSchema = z.strictObject({
|
||||
nodes: z.array(flowNodeSchema).min(2).max(60),
|
||||
edges: z.array(flowEdgeSchema).max(120),
|
||||
});
|
||||
|
||||
export type FlowGraph = z.infer<typeof flowGraphSchema>;
|
||||
Reference in New Issue
Block a user