conduyt-mcp 4.67.0 → 4.68.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/tools/advanced-filter-compilability.d.ts +6 -0
- package/dist/tools/advanced-filter-compilability.js +127 -0
- package/dist/tools/advanced-filter-rules.d.ts +37 -0
- package/dist/tools/advanced-filter-rules.js +207 -0
- package/dist/tools/lead-pool.d.ts +14 -0
- package/dist/tools/lead-pool.js +84 -0
- package/package.json +1 -1
- package/scripts/coverage-matrix.json +33 -5
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* (Codex mcp/cli r6 h1) DOES THIS RULE COMPILE? — the null-returning paths of advancedRuleToWhere / ruleToWhere /
|
|
3
|
+
* customFieldRuleToWhere / masterStatusRuleToWhere in lib/smart-view-filters.ts, as ONE pure predicate with no Prisma and no
|
|
4
|
+
* imports, so the MCP server and the CLI can carry it VERBATIM and refuse exactly what the app's validator refuses (the app's
|
|
5
|
+
* validator runs the real compiler; lib/advanced-filter-compilability.test.ts proves this predicate agrees with it over an
|
|
6
|
+
* adversarial value matrix for every field, operator and custom-field operator). Keep in sync with the compiler.
|
|
7
|
+
*/
|
|
8
|
+
const CF_FIELD_PREFIX = "cf:";
|
|
9
|
+
const CF_OPERATORS = new Set(["equals", "not_equals", "contains", "is_any_of", "is", "is_not", "num_equals", "num_not_equals", "gt", "gte", "lt", "lte", "is_true", "is_false", "has_any_of", "has_all_of", "has_none_of"]);
|
|
10
|
+
const SCALAR_DATE_FIELDS = new Set(["lastContactedAt", "lastActionAt", "lastContactAttemptAt", "createdAt", "updatedAt"]);
|
|
11
|
+
const LIST_FIELDS = new Set(["tags", "pipeline", "pipelineStage", "dealStatus", "email", "phone", "source", "assignedTo"]);
|
|
12
|
+
const STRING_FIELDS = new Set(["city", "state", "jobTitle"]);
|
|
13
|
+
function cfKeyOf(field) {
|
|
14
|
+
if (!field.startsWith(CF_FIELD_PREFIX))
|
|
15
|
+
return null;
|
|
16
|
+
const key = field.slice(CF_FIELD_PREFIX.length).trim();
|
|
17
|
+
return key.length > 0 ? key : null;
|
|
18
|
+
}
|
|
19
|
+
function isValidIsoDate(value) {
|
|
20
|
+
const ts = Date.parse(value);
|
|
21
|
+
return !isNaN(ts) && new Date(ts).toISOString() !== "Invalid Date";
|
|
22
|
+
}
|
|
23
|
+
const has = (set, op) => set.includes(op);
|
|
24
|
+
/** the exact compile verdict of the app for one rule: true when advancedRuleToWhere would return a condition */
|
|
25
|
+
export function advancedRuleCompiles(rule) {
|
|
26
|
+
const { field, operator: rawOp, value } = rule;
|
|
27
|
+
const operator = rawOp === "is" ? "equals" : rawOp === "is_not" ? "not_equals" : rawOp;
|
|
28
|
+
const strValue = Array.isArray(value) ? value.join(",") : value;
|
|
29
|
+
// String(undefined) is "undefined" in the compiler's own coercions: an absent value there is a one-element list, verbatim
|
|
30
|
+
const listOf = (v) => (Array.isArray(v) ? v : String(v).split(",")).map((x) => String(x).trim()).filter(Boolean);
|
|
31
|
+
const cfKey = cfKeyOf(field);
|
|
32
|
+
if (cfKey) {
|
|
33
|
+
const op = CF_OPERATORS.has(rawOp) ? rawOp : operator;
|
|
34
|
+
switch (op) {
|
|
35
|
+
case "equals":
|
|
36
|
+
case "is":
|
|
37
|
+
case "not_equals":
|
|
38
|
+
case "is_not":
|
|
39
|
+
case "contains":
|
|
40
|
+
case "is_true":
|
|
41
|
+
case "is_false": return true;
|
|
42
|
+
case "is_any_of": return (Array.isArray(value) ? value : String(value).split(",").map((v) => v.trim()).filter(Boolean)).length > 0;
|
|
43
|
+
case "num_equals":
|
|
44
|
+
case "num_not_equals":
|
|
45
|
+
case "gt":
|
|
46
|
+
case "gte":
|
|
47
|
+
case "lt":
|
|
48
|
+
case "lte": {
|
|
49
|
+
const s = strValue ?? "";
|
|
50
|
+
return s.trim() !== "" && Number.isFinite(Number(s));
|
|
51
|
+
}
|
|
52
|
+
case "has_any_of":
|
|
53
|
+
case "has_all_of":
|
|
54
|
+
case "has_none_of": return listOf(value).length > 0;
|
|
55
|
+
default: return false;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (field === "masterStatus") {
|
|
59
|
+
const list = (Array.isArray(value) ? value : String(value ?? "").split(",")).map((v) => String(v).trim().toLowerCase()).filter(Boolean);
|
|
60
|
+
if (list.length === 0)
|
|
61
|
+
return false;
|
|
62
|
+
if (rawOp === "is_any_of" || rawOp === "is_none_of")
|
|
63
|
+
return true;
|
|
64
|
+
return operator === "equals" || operator === "not_equals";
|
|
65
|
+
}
|
|
66
|
+
if (rawOp === "is_any_of") {
|
|
67
|
+
const values = Array.isArray(value) ? value : String(value ?? "").split(",").map((v) => v.trim()).filter(Boolean);
|
|
68
|
+
return values.length > 0 && LIST_FIELDS.has(field);
|
|
69
|
+
}
|
|
70
|
+
if (field === "company")
|
|
71
|
+
return has(["is_empty", "is_not_empty", "equals", "not_equals", "contains"], operator);
|
|
72
|
+
// (Codex pool r28 h1) a name-bearing tag rule needs at least one name (lib/tag-rule-names.ts: an array is one name per
|
|
73
|
+
// element, a scalar splits on commas; blanks dropped)
|
|
74
|
+
if (field === "tags" && has(["contains", "not_contains", "equals", "not_equals"], operator)) {
|
|
75
|
+
const names = Array.isArray(value) ? value.filter((x) => typeof x === "string").map((x) => x.trim()).filter(Boolean) : typeof value === "string" ? value.split(",").map((x) => x.trim()).filter(Boolean) : [];
|
|
76
|
+
return names.length > 0;
|
|
77
|
+
}
|
|
78
|
+
// ruleToWhere on the flattened value
|
|
79
|
+
const v = strValue ?? "";
|
|
80
|
+
// a duration is parsed with String.prototype.match: an ABSENT value throws inside the compiler (dropped), any string parses
|
|
81
|
+
const hasString = typeof strValue === "string";
|
|
82
|
+
if (SCALAR_DATE_FIELDS.has(field)) {
|
|
83
|
+
if (operator === "is_empty" || operator === "is_not_empty")
|
|
84
|
+
return true;
|
|
85
|
+
if (operator === "within" || operator === "not_within")
|
|
86
|
+
return hasString;
|
|
87
|
+
if (operator === "before" || operator === "after")
|
|
88
|
+
return isValidIsoDate(v);
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
if (field === "lastEmailAt" || field === "lastSmsAt") {
|
|
92
|
+
if (operator === "is_empty" || operator === "is_not_empty")
|
|
93
|
+
return true;
|
|
94
|
+
if (operator === "within" || operator === "not_within")
|
|
95
|
+
return hasString;
|
|
96
|
+
if (operator === "before" || operator === "after")
|
|
97
|
+
return isValidIsoDate(v);
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
if (field === "lastCallAt")
|
|
101
|
+
return operator === "is_empty" || operator === "is_not_empty" || (hasString && (operator === "within" || operator === "not_within"));
|
|
102
|
+
if (field === "callAttempts")
|
|
103
|
+
return true; // an unsupported boundary compiles to the impossible filter (the validator gates the value)
|
|
104
|
+
if (field === "nextAppointmentAt")
|
|
105
|
+
return operator === "is_empty" || operator === "is_not_empty" || (hasString && (operator === "within" || operator === "not_within"));
|
|
106
|
+
if (field === "appointmentStatus")
|
|
107
|
+
return has(["equals", "not_equals"], operator);
|
|
108
|
+
if (STRING_FIELDS.has(field))
|
|
109
|
+
return has(["is_empty", "is_not_empty", "equals", "not_equals", "contains"], operator);
|
|
110
|
+
if (field === "country")
|
|
111
|
+
return has(["equals", "not_equals"], operator);
|
|
112
|
+
if (field === "optedOutSms" || field === "optedOutEmail")
|
|
113
|
+
return operator === "equals";
|
|
114
|
+
if (field === "leadScore")
|
|
115
|
+
return v.trim() !== "" && Number.isFinite(Number(v.trim())) && has(["gt", "lt", "gte", "lte", "equals"], operator); // (Codex pool r28 m1) the whole value
|
|
116
|
+
if (field === "email" || field === "phone")
|
|
117
|
+
return has(["is_empty", "is_not_empty", "contains", "equals", "not_equals"], operator);
|
|
118
|
+
if (field === "source")
|
|
119
|
+
return has(["equals", "not_equals", "contains", "is_empty", "is_not_empty"], operator);
|
|
120
|
+
if (field === "assignedTo")
|
|
121
|
+
return has(["equals", "not_equals", "is_empty", "is_not_empty"], operator);
|
|
122
|
+
if (field === "tags")
|
|
123
|
+
return has(["is_empty", "is_not_empty"], operator);
|
|
124
|
+
if (field === "pipeline" || field === "pipelineStage" || field === "dealStatus")
|
|
125
|
+
return has(["equals", "not_equals", "is_empty", "is_not_empty"], operator);
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PORTED VERBATIM from conduyt-app lib/smart-view-filters.ts @ slice B r28 (bfba9b0ef + the non-string array element refusal; the app's validator additionally compiles every rule — see validateAdvancedFiltersEffective below) — validateAdvancedFilters, its tables and its
|
|
3
|
+
* value rules — so a call this surface accepts is never a predictable 400 from the API (Codex mcp r2 m1 / cli r2 m1).
|
|
4
|
+
* Keep in sync with the app: when validateAdvancedFilters, VALID_FIELDS, VALID_OPERATORS, ADVANCED_OPERATORS,
|
|
5
|
+
* CF_OPERATORS, VALUELESS_OPERATORS, DATE_FILTER_FIELDS or isSupportedCallAttemptsFilter change there, re-port here.
|
|
6
|
+
*/
|
|
7
|
+
export declare const CF_FIELD_PREFIX = "cf:";
|
|
8
|
+
export declare const MAX_FILTER_GROUPS = 10;
|
|
9
|
+
export declare const MAX_RULES_PER_GROUP = 20;
|
|
10
|
+
export declare const VALID_FIELDS: Set<string>;
|
|
11
|
+
export declare const ADVANCED_OPERATORS: Record<string, Set<string>>;
|
|
12
|
+
export declare const CF_OPERATORS: Set<string>;
|
|
13
|
+
export declare function customFieldKey(field: string): string | null;
|
|
14
|
+
export type FilterGroupPayload = {
|
|
15
|
+
conjunction: "AND" | "OR";
|
|
16
|
+
rules: Array<{
|
|
17
|
+
field: string;
|
|
18
|
+
operator: string;
|
|
19
|
+
value?: string | string[];
|
|
20
|
+
}>;
|
|
21
|
+
};
|
|
22
|
+
/** the API's validateAdvancedFilters, verbatim: the same verdict and the same message for the same payload */
|
|
23
|
+
export declare function validateAdvancedFilters(payload: unknown): {
|
|
24
|
+
valid: true;
|
|
25
|
+
groups: FilterGroupPayload[];
|
|
26
|
+
} | {
|
|
27
|
+
valid: false;
|
|
28
|
+
error: string;
|
|
29
|
+
};
|
|
30
|
+
/** validateAdvancedFilters (the port) PLUS the compile verdict per rule: the same answer the API gives a write */
|
|
31
|
+
export declare function validateAdvancedFiltersEffective(payload: unknown): {
|
|
32
|
+
valid: true;
|
|
33
|
+
groups: FilterGroupPayload[];
|
|
34
|
+
} | {
|
|
35
|
+
valid: false;
|
|
36
|
+
error: string;
|
|
37
|
+
};
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PORTED VERBATIM from conduyt-app lib/smart-view-filters.ts @ slice B r28 (bfba9b0ef + the non-string array element refusal; the app's validator additionally compiles every rule — see validateAdvancedFiltersEffective below) — validateAdvancedFilters, its tables and its
|
|
3
|
+
* value rules — so a call this surface accepts is never a predictable 400 from the API (Codex mcp r2 m1 / cli r2 m1).
|
|
4
|
+
* Keep in sync with the app: when validateAdvancedFilters, VALID_FIELDS, VALID_OPERATORS, ADVANCED_OPERATORS,
|
|
5
|
+
* CF_OPERATORS, VALUELESS_OPERATORS, DATE_FILTER_FIELDS or isSupportedCallAttemptsFilter change there, re-port here.
|
|
6
|
+
*/
|
|
7
|
+
export const CF_FIELD_PREFIX = "cf:";
|
|
8
|
+
export const MAX_FILTER_GROUPS = 10;
|
|
9
|
+
export const MAX_RULES_PER_GROUP = 20;
|
|
10
|
+
export const VALID_FIELDS = new Set([
|
|
11
|
+
"lastContactedAt", "lastActionAt", "lastContactAttemptAt", "createdAt", "updatedAt", "lastEmailAt", "lastSmsAt",
|
|
12
|
+
"lastCallAt", "callAttempts", "nextAppointmentAt", "appointmentStatus",
|
|
13
|
+
"city", "state", "country", "jobTitle", "optedOutSms", "optedOutEmail",
|
|
14
|
+
"leadScore", "email", "phone", "source", "assignedTo", "tags",
|
|
15
|
+
"pipeline", "pipelineStage", "dealStatus", "company",
|
|
16
|
+
"masterStatus",
|
|
17
|
+
]);
|
|
18
|
+
const VALID_OPERATORS = {
|
|
19
|
+
lastContactedAt: new Set(["within", "not_within", "is_empty", "is_not_empty"]),
|
|
20
|
+
lastActionAt: new Set(["within", "not_within", "is_empty", "is_not_empty"]),
|
|
21
|
+
lastContactAttemptAt: new Set(["within", "not_within", "is_empty", "is_not_empty"]),
|
|
22
|
+
createdAt: new Set(["within", "not_within", "before", "after"]),
|
|
23
|
+
updatedAt: new Set(["within", "not_within", "before", "after"]),
|
|
24
|
+
lastEmailAt: new Set(["within", "not_within", "is_empty", "is_not_empty"]),
|
|
25
|
+
lastSmsAt: new Set(["within", "not_within", "is_empty", "is_not_empty"]),
|
|
26
|
+
lastCallAt: new Set(["within", "not_within", "is_empty", "is_not_empty"]),
|
|
27
|
+
callAttempts: new Set(["gt", "lt", "gte", "lte", "equals"]),
|
|
28
|
+
nextAppointmentAt: new Set(["within", "not_within", "is_empty", "is_not_empty"]),
|
|
29
|
+
appointmentStatus: new Set(["equals", "not_equals"]),
|
|
30
|
+
leadScore: new Set(["gt", "lt", "gte", "lte", "equals"]),
|
|
31
|
+
email: new Set(["is_empty", "is_not_empty", "contains"]),
|
|
32
|
+
phone: new Set(["is_empty", "is_not_empty", "contains"]),
|
|
33
|
+
source: new Set(["equals", "not_equals", "contains"]),
|
|
34
|
+
assignedTo: new Set(["equals", "not_equals", "is_empty"]),
|
|
35
|
+
tags: new Set(["contains", "not_contains", "is_empty", "is_not_empty"]),
|
|
36
|
+
pipeline: new Set(["equals", "not_equals", "is_empty", "is_not_empty"]),
|
|
37
|
+
pipelineStage: new Set(["equals", "not_equals", "is_empty", "is_not_empty"]),
|
|
38
|
+
dealStatus: new Set(["equals", "not_equals", "is_empty", "is_not_empty"]),
|
|
39
|
+
masterStatus: new Set(["equals", "not_equals", "is_any_of", "is_none_of"]),
|
|
40
|
+
city: new Set(["equals", "not_equals", "contains", "is_empty", "is_not_empty"]),
|
|
41
|
+
state: new Set(["equals", "not_equals", "contains", "is_empty", "is_not_empty"]),
|
|
42
|
+
country: new Set(["equals", "not_equals"]),
|
|
43
|
+
jobTitle: new Set(["equals", "not_equals", "contains", "is_empty", "is_not_empty"]),
|
|
44
|
+
optedOutSms: new Set(["equals"]),
|
|
45
|
+
optedOutEmail: new Set(["equals"]),
|
|
46
|
+
company: new Set(["equals", "not_equals", "contains", "is_empty", "is_not_empty"]),
|
|
47
|
+
};
|
|
48
|
+
export const ADVANCED_OPERATORS = {
|
|
49
|
+
lastContactedAt: new Set([...VALID_OPERATORS.lastContactedAt, "before", "after"]),
|
|
50
|
+
lastActionAt: new Set([...VALID_OPERATORS.lastActionAt, "before", "after"]),
|
|
51
|
+
lastContactAttemptAt: new Set([...VALID_OPERATORS.lastContactAttemptAt, "before", "after"]),
|
|
52
|
+
createdAt: new Set([...VALID_OPERATORS.createdAt, "is_empty", "is_not_empty"]),
|
|
53
|
+
updatedAt: new Set([...VALID_OPERATORS.updatedAt, "is_empty", "is_not_empty"]),
|
|
54
|
+
lastEmailAt: new Set([...VALID_OPERATORS.lastEmailAt, "is_not_empty", "before", "after"]),
|
|
55
|
+
lastSmsAt: new Set([...VALID_OPERATORS.lastSmsAt, "is_not_empty", "before", "after"]),
|
|
56
|
+
lastCallAt: new Set([...VALID_OPERATORS.lastCallAt, "before", "after"]),
|
|
57
|
+
callAttempts: new Set([...VALID_OPERATORS.callAttempts]),
|
|
58
|
+
nextAppointmentAt: new Set([...VALID_OPERATORS.nextAppointmentAt]),
|
|
59
|
+
appointmentStatus: new Set([...VALID_OPERATORS.appointmentStatus, "is", "is_not", "is_any_of"]),
|
|
60
|
+
leadScore: new Set([...VALID_OPERATORS.leadScore]),
|
|
61
|
+
email: new Set([...VALID_OPERATORS.email, "is", "is_not", "is_any_of"]),
|
|
62
|
+
phone: new Set([...VALID_OPERATORS.phone, "is", "is_not", "is_any_of"]),
|
|
63
|
+
source: new Set([...VALID_OPERATORS.source, "is", "is_not", "is_empty", "is_not_empty", "is_any_of"]),
|
|
64
|
+
assignedTo: new Set([...VALID_OPERATORS.assignedTo, "is", "is_not", "is_not_empty"]),
|
|
65
|
+
tags: new Set([...VALID_OPERATORS.tags, "is", "is_not", "is_any_of"]),
|
|
66
|
+
pipeline: new Set([...VALID_OPERATORS.pipeline, "is", "is_not", "is_any_of"]),
|
|
67
|
+
pipelineStage: new Set([...VALID_OPERATORS.pipelineStage, "is", "is_not", "is_empty", "is_not_empty", "is_any_of"]),
|
|
68
|
+
dealStatus: new Set([...VALID_OPERATORS.dealStatus, "is", "is_not", "is_empty", "is_not_empty", "is_any_of"]),
|
|
69
|
+
masterStatus: new Set([...VALID_OPERATORS.masterStatus, "is", "is_not"]),
|
|
70
|
+
city: new Set([...VALID_OPERATORS.city, "is", "is_not", "is_any_of"]),
|
|
71
|
+
state: new Set([...VALID_OPERATORS.state, "is", "is_not", "is_any_of"]),
|
|
72
|
+
country: new Set([...VALID_OPERATORS.country, "is", "is_not", "is_any_of"]),
|
|
73
|
+
jobTitle: new Set([...VALID_OPERATORS.jobTitle, "is", "is_not", "is_any_of"]),
|
|
74
|
+
optedOutSms: new Set([...VALID_OPERATORS.optedOutSms]),
|
|
75
|
+
optedOutEmail: new Set([...VALID_OPERATORS.optedOutEmail]),
|
|
76
|
+
company: new Set(["is", "is_not", "contains", "is_empty", "is_not_empty"]),
|
|
77
|
+
};
|
|
78
|
+
export const CF_OPERATORS = new Set([
|
|
79
|
+
"equals", "not_equals", "contains", "is_any_of", "is", "is_not",
|
|
80
|
+
"num_equals", "num_not_equals", "gt", "gte", "lt", "lte", "is_true", "is_false",
|
|
81
|
+
"has_any_of", "has_all_of", "has_none_of",
|
|
82
|
+
]);
|
|
83
|
+
const VALUELESS_OPERATORS = new Set(["is_empty", "is_not_empty", "is_true", "is_false"]);
|
|
84
|
+
const DATE_FILTER_FIELDS = new Set([
|
|
85
|
+
"createdAt", "updatedAt", "lastContactedAt", "lastActionAt", "lastContactAttemptAt", "lastEmailAt", "lastSmsAt", "lastCallAt", "nextAppointmentAt",
|
|
86
|
+
]);
|
|
87
|
+
export function customFieldKey(field) {
|
|
88
|
+
if (!field.startsWith(CF_FIELD_PREFIX))
|
|
89
|
+
return null;
|
|
90
|
+
const key = field.slice(CF_FIELD_PREFIX.length).trim();
|
|
91
|
+
return key.length > 0 ? key : null;
|
|
92
|
+
}
|
|
93
|
+
function normalizeOperator(_field, operator) {
|
|
94
|
+
if (operator === "is")
|
|
95
|
+
return "equals";
|
|
96
|
+
if (operator === "is_not")
|
|
97
|
+
return "not_equals";
|
|
98
|
+
return operator;
|
|
99
|
+
}
|
|
100
|
+
function isSupportedCallAttemptsFilter(operator, value) {
|
|
101
|
+
const num = Number(value);
|
|
102
|
+
if (!Number.isInteger(num) || num < 0)
|
|
103
|
+
return false;
|
|
104
|
+
return ((operator === "equals" && num === 0) ||
|
|
105
|
+
(operator === "gt" && num === 0) ||
|
|
106
|
+
(operator === "gte" && num === 1) ||
|
|
107
|
+
(operator === "lt" && num === 1) ||
|
|
108
|
+
(operator === "lte" && num === 0));
|
|
109
|
+
}
|
|
110
|
+
function validateCommonRuleValue(field, operator, value, label) {
|
|
111
|
+
if (!VALUELESS_OPERATORS.has(operator)) {
|
|
112
|
+
if (typeof value !== "string" || value.trim() === "")
|
|
113
|
+
return `${label}: value is required for ${field} ${operator}`;
|
|
114
|
+
}
|
|
115
|
+
if (field === "callAttempts" && typeof value === "string") {
|
|
116
|
+
if (!isSupportedCallAttemptsFilter(operator, value))
|
|
117
|
+
return `${label}: callAttempts only supports exact zero-call or has-call boundary filters`;
|
|
118
|
+
}
|
|
119
|
+
if (DATE_FILTER_FIELDS.has(field) && typeof value === "string" && value.trim() !== "") {
|
|
120
|
+
if (operator === "before" || operator === "after") {
|
|
121
|
+
if (isNaN(Date.parse(value)))
|
|
122
|
+
return `${label}: invalid date value "${value}" for ${operator}`;
|
|
123
|
+
}
|
|
124
|
+
else if (operator === "within" || operator === "not_within") {
|
|
125
|
+
if (!/^\d+(h|d|m)$/.test(value))
|
|
126
|
+
return `${label}: invalid duration value "${value}" — expected format like "7d", "24h", or "3m"`;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
/** the API's validateAdvancedFilters, verbatim: the same verdict and the same message for the same payload */
|
|
132
|
+
export function validateAdvancedFilters(payload) {
|
|
133
|
+
if (!Array.isArray(payload))
|
|
134
|
+
return { valid: false, error: "Filter payload must be an array of groups" };
|
|
135
|
+
if (payload.length > MAX_FILTER_GROUPS)
|
|
136
|
+
return { valid: false, error: `Too many filter groups (max ${MAX_FILTER_GROUPS})` };
|
|
137
|
+
for (let gi = 0; gi < payload.length; gi++) {
|
|
138
|
+
const group = payload[gi];
|
|
139
|
+
if (!group || typeof group !== "object")
|
|
140
|
+
return { valid: false, error: `Group ${gi}: invalid object` };
|
|
141
|
+
const { conjunction, rules } = group;
|
|
142
|
+
if (conjunction !== "AND" && conjunction !== "OR")
|
|
143
|
+
return { valid: false, error: `Group ${gi}: conjunction must be "AND" or "OR"` };
|
|
144
|
+
if (!Array.isArray(rules))
|
|
145
|
+
return { valid: false, error: `Group ${gi}: rules must be an array` };
|
|
146
|
+
if (rules.length > MAX_RULES_PER_GROUP)
|
|
147
|
+
return { valid: false, error: `Group ${gi}: too many rules (max ${MAX_RULES_PER_GROUP})` };
|
|
148
|
+
for (let ri = 0; ri < rules.length; ri++) {
|
|
149
|
+
const rule = rules[ri];
|
|
150
|
+
if (!rule || typeof rule !== "object")
|
|
151
|
+
return { valid: false, error: `Group ${gi} rule ${ri}: invalid object` };
|
|
152
|
+
const { field, operator, value } = rule;
|
|
153
|
+
if (typeof field !== "string")
|
|
154
|
+
return { valid: false, error: `Group ${gi} rule ${ri}: field must be a string` };
|
|
155
|
+
const cfKey = customFieldKey(field);
|
|
156
|
+
if (cfKey) {
|
|
157
|
+
if (typeof operator !== "string")
|
|
158
|
+
return { valid: false, error: `Group ${gi} rule ${ri}: operator must be a string` };
|
|
159
|
+
if (!CF_OPERATORS.has(operator))
|
|
160
|
+
return { valid: false, error: `Group ${gi} rule ${ri}: invalid operator "${operator}" for custom field "${field}"` };
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
if (!VALID_FIELDS.has(field))
|
|
164
|
+
return { valid: false, error: `Group ${gi} rule ${ri}: unknown field "${field}"` };
|
|
165
|
+
if (typeof operator !== "string")
|
|
166
|
+
return { valid: false, error: `Group ${gi} rule ${ri}: operator must be a string` };
|
|
167
|
+
const validOps = ADVANCED_OPERATORS[field];
|
|
168
|
+
if (!validOps?.has(operator))
|
|
169
|
+
return { valid: false, error: `Group ${gi} rule ${ri}: invalid operator "${operator}" for field "${field}"` };
|
|
170
|
+
}
|
|
171
|
+
// (Codex cli r3 m1) an array element that is not a string is refused here, as the app now does (the is_any_of branch trims each element)
|
|
172
|
+
if (value !== undefined && typeof value !== "string" && !(Array.isArray(value) && value.every((v) => typeof v === "string")))
|
|
173
|
+
return { valid: false, error: `Group ${gi} rule ${ri}: value must be a string or string[]` };
|
|
174
|
+
if (operator === "is_any_of") {
|
|
175
|
+
const values = Array.isArray(value) ? value : typeof value === "string" ? value.split(",") : [];
|
|
176
|
+
if (values.map((v) => v.trim()).filter(Boolean).length === 0)
|
|
177
|
+
return { valid: false, error: `Group ${gi} rule ${ri}: at least one value is required` };
|
|
178
|
+
}
|
|
179
|
+
else {
|
|
180
|
+
const scalarValue = Array.isArray(value) ? value.join(",") : value;
|
|
181
|
+
const valueError = validateCommonRuleValue(field, normalizeOperator(field, operator), scalarValue, `Group ${gi} rule ${ri}`);
|
|
182
|
+
if (valueError)
|
|
183
|
+
return { valid: false, error: valueError };
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return { valid: true, groups: payload };
|
|
188
|
+
}
|
|
189
|
+
// ─── (Codex mcp/cli r5 h1 → r6 h1) VALID MEANS COMPILES ────────────────────────────────────────────────────────────────────────
|
|
190
|
+
// The app's validateAdvancedFilters runs every rule through its strict compiler and refuses what the compiler would drop. This
|
|
191
|
+
// surface carries the compiler's EXACT null-returning paths as one pure predicate — ./advanced-filter-compilability.ts, ported
|
|
192
|
+
// verbatim from conduyt-app lib/advanced-filter-compilability.ts (slice B, on 0dab73baf), which the app proves equal to its real
|
|
193
|
+
// compiler over an adversarial value matrix for every field, operator and custom-field operator
|
|
194
|
+
// (lib/advanced-filter-compilability.test.ts) — so a rule accepted here is one the API accepts and the compiler keeps.
|
|
195
|
+
import { advancedRuleCompiles } from "./advanced-filter-compilability.js";
|
|
196
|
+
/** validateAdvancedFilters (the port) PLUS the compile verdict per rule: the same answer the API gives a write */
|
|
197
|
+
export function validateAdvancedFiltersEffective(payload) {
|
|
198
|
+
const v = validateAdvancedFilters(payload);
|
|
199
|
+
if (!v.valid)
|
|
200
|
+
return v;
|
|
201
|
+
for (const [gi, group] of v.groups.entries())
|
|
202
|
+
for (const [ri, rule] of group.rules.entries()) {
|
|
203
|
+
if (!advancedRuleCompiles(rule))
|
|
204
|
+
return { valid: false, error: `Group ${gi} rule ${ri}: ${rule.field} ${rule.operator} cannot be applied to this value` };
|
|
205
|
+
}
|
|
206
|
+
return v;
|
|
207
|
+
}
|
|
@@ -24,4 +24,18 @@ export type TagRef = {
|
|
|
24
24
|
/** the same plain-English criteria the Up for Grabs page prints under its header (lib/pool-filter-summary.ts in the app):
|
|
25
25
|
* a deleted tag (name null) stays visible because its id still filters every lead out; a failed lookup is said so */
|
|
26
26
|
export declare function poolCriteriaPhrases(filter: PoolFilterShape, tags: TagRef[], tagLookupFailed?: boolean): string[];
|
|
27
|
+
/** (Paul's signed concept 2026-09-25 §2–§3: pool filter sets, slice D parity) the two pool pages */
|
|
28
|
+
export declare const POOL_PAGES: readonly ["up_for_grabs", "lead_pool"];
|
|
29
|
+
export type PoolPage = (typeof POOL_PAGES)[number];
|
|
30
|
+
/** the API's list row (lib/pool-page.ts toPoolFilterSetRow), validated in full: a stored payload that no longer compiles is answered
|
|
31
|
+
* as filters: [] with valid: false (it matches nothing until repaired), so `valid` is safety-critical and every field is checked */
|
|
32
|
+
export type PoolFilterSetRow = {
|
|
33
|
+
id: string;
|
|
34
|
+
name: string;
|
|
35
|
+
filters: unknown[];
|
|
36
|
+
isEnabled: boolean;
|
|
37
|
+
sortOrder: number;
|
|
38
|
+
valid: boolean;
|
|
39
|
+
};
|
|
40
|
+
export declare function isPoolFilterSetRow(r: unknown): r is PoolFilterSetRow;
|
|
27
41
|
export declare function registerLeadPoolTools(server: McpServer, client: ConduytClient): void;
|
package/dist/tools/lead-pool.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { formatResult } from "../client.js";
|
|
3
3
|
import { patchGeneralSettings } from "./lifecycle.js";
|
|
4
|
+
import { validateAdvancedFiltersEffective } from "./advanced-filter-rules.js";
|
|
4
5
|
const EMPTY = { enabled: false, tagIds: [], sources: [], minScore: null, maxScore: null, createdWithinDays: null };
|
|
5
6
|
function intOrNull(v) {
|
|
6
7
|
return typeof v === "number" && Number.isInteger(v) ? v : null;
|
|
@@ -67,7 +68,90 @@ async function tagNamesFor(client, tagIds) {
|
|
|
67
68
|
}
|
|
68
69
|
}
|
|
69
70
|
const score = z.number().int().min(0).max(1000);
|
|
71
|
+
/** (Paul's signed concept 2026-09-25 §2–§3: pool filter sets, slice D parity) the two pool pages */
|
|
72
|
+
export const POOL_PAGES = ["up_for_grabs", "lead_pool"];
|
|
73
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
74
|
+
export function isPoolFilterSetRow(r) {
|
|
75
|
+
if (!r || typeof r !== "object" || Array.isArray(r))
|
|
76
|
+
return false;
|
|
77
|
+
const x = r;
|
|
78
|
+
if (typeof x.id !== "string" || !UUID_RE.test(x.id))
|
|
79
|
+
return false;
|
|
80
|
+
if (typeof x.name !== "string" || typeof x.isEnabled !== "boolean" || typeof x.valid !== "boolean")
|
|
81
|
+
return false;
|
|
82
|
+
if (typeof x.sortOrder !== "number" || !Number.isInteger(x.sortOrder) || x.sortOrder < 0 || x.sortOrder > 10_000)
|
|
83
|
+
return false;
|
|
84
|
+
if (!Array.isArray(x.filters))
|
|
85
|
+
return false;
|
|
86
|
+
// (Codex mcp r7 m1) a row flagged valid must COMPILE by the same verdict the writes use: a version-skewed or malformed answer is never surfaced as a valid enabled set
|
|
87
|
+
return x.valid ? validateAdvancedFiltersEffective(x.filters).valid : x.filters.length === 0;
|
|
88
|
+
}
|
|
89
|
+
const poolPage = z.enum(POOL_PAGES).describe("Which page: up_for_grabs (claim offers) or lead_pool (pull pool)");
|
|
90
|
+
/** the API's validateAdvancedFilters, ported verbatim (./advanced-filter-rules.ts): the structural shape as a zod schema, then the
|
|
91
|
+
* field / operator / value rules as a refinement — a schema-valid call is never a predictable 400 */
|
|
92
|
+
const filtersSchema = z.array(z.object({
|
|
93
|
+
conjunction: z.enum(["AND", "OR"]),
|
|
94
|
+
rules: z.array(z.object({ field: z.string().min(1), operator: z.string().min(1), value: z.union([z.string(), z.array(z.string())]).optional() })).max(20),
|
|
95
|
+
})).max(10).superRefine((groups, ctx) => {
|
|
96
|
+
const verdict = validateAdvancedFiltersEffective(groups); // (Codex mcp r5 h1) valid means COMPILES, as the API now judges
|
|
97
|
+
if (!verdict.valid)
|
|
98
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: verdict.error });
|
|
99
|
+
}).describe("The Contacts Advanced Filters payload, validated exactly like a Smart View's: up to 10 groups, each { conjunction: AND|OR, rules: [...] } with up to 20 rules of { field, operator, value }; fields and operators are the API's (unknown fields, operators a field does not take, a missing value for a value-bearing operator, an empty is_any_of, a bad date or duration are refused here). Tag rules take an ARRAY of tag names (one name per element), e.g. [{\"conjunction\":\"AND\",\"rules\":[{\"field\":\"tags\",\"operator\":\"not_contains\",\"value\":[\"VIP\"]}]}]");
|
|
100
|
+
const setName = z.string().trim().min(1).max(80).describe("The set's name (1-80 characters, unique on its page)");
|
|
101
|
+
/** (Codex mcp r1 h1) the page version is the CALLER's: the one its conduyt_list_pool_filter_sets read answered, so the API's
|
|
102
|
+
* 409 page_version_stale protects the state the agent decided on — never a version this tool would read for it */
|
|
103
|
+
const pageVersion = z.string().min(1).describe("The pageVersion your conduyt_list_pool_filter_sets read answered (list first; a stale version is 409 page_version_stale: re-list, re-read the sets, retry)");
|
|
70
104
|
export function registerLeadPoolTools(server, client) {
|
|
105
|
+
server.tool("conduyt_list_pool_filter_sets", "The named filter sets behind the gear on Up for Grabs or Lead Pool (admin only): each set's name, Advanced Filters payload, enabled flag and order, plus the page's current pageVersion — every write must name it, so LIST FIRST. The page shows the UNION of the enabled sets; a page with an enabled rule-less set — or no set once the legacy filter is retired — is OPEN to every unassigned lead.", { page: poolPage }, async ({ page }) => {
|
|
106
|
+
// the API's envelope: jsonResponse({ data: sets, pageVersion }) is { data: { data: sets, pageVersion } } on the wire
|
|
107
|
+
const result = (await client.get(`/api/v1/pool-filter-sets?page=${page}`));
|
|
108
|
+
// (Codex mcp r3 m1) FAIL CLOSED on a malformed answer: a missing or non-array set collection, or a set without its essential
|
|
109
|
+
// fields, is never presented as an empty page (an admin would create and enable a replacement against a valid version)
|
|
110
|
+
const rows = result?.data?.data;
|
|
111
|
+
const version = typeof result?.data?.pageVersion === "string" && result.data.pageVersion.trim() ? result.data.pageVersion : "";
|
|
112
|
+
if (!version)
|
|
113
|
+
throw new Error("GET /api/v1/pool-filter-sets answered no pageVersion — the API is older than this tool");
|
|
114
|
+
// (Codex mcp r4 m1) the WHOLE row contract: a row missing or mistyping any field (filters, sortOrder, the valid flag too) fails the call
|
|
115
|
+
if (!Array.isArray(rows) || !rows.every(isPoolFilterSetRow)) {
|
|
116
|
+
throw new Error("GET /api/v1/pool-filter-sets answered a malformed set collection — refusing to report an empty page (the API and this tool disagree on the response shape)");
|
|
117
|
+
}
|
|
118
|
+
return formatResult({ data: { sets: rows, pageVersion: version } });
|
|
119
|
+
});
|
|
120
|
+
server.tool("conduyt_create_pool_filter_set", "Add a named filter set to a pool page (admin only). A new set is a DRAFT (off) unless isEnabled is true. Enabling a set that would OPEN the whole page (no rule) is refused with 409 pool_opens unless acknowledgeOpensPool is true. Every tag name a rule carries must be a live tag (422 tag_missing); a custom field must exist (422 custom_field_missing). pageVersion is the one your list read answered (409 page_version_stale when the page moved since: re-list and retry).", {
|
|
121
|
+
page: poolPage,
|
|
122
|
+
name: setName,
|
|
123
|
+
filters: filtersSchema,
|
|
124
|
+
isEnabled: z.boolean().optional().describe("true to enable at once (default: a draft)"),
|
|
125
|
+
acknowledgeOpensPool: z.boolean().optional().describe("true to confirm a change that opens the whole page to every unassigned lead — for the page state your pageVersion names"),
|
|
126
|
+
pageVersion,
|
|
127
|
+
}, async ({ page, name, filters, isEnabled, acknowledgeOpensPool, pageVersion: version }) => formatResult(await client.post("/api/v1/pool-filter-sets", {
|
|
128
|
+
page, name, filters, pageVersion: version,
|
|
129
|
+
...(isEnabled !== undefined ? { isEnabled } : {}),
|
|
130
|
+
...(acknowledgeOpensPool !== undefined ? { acknowledgeOpensPool } : {}),
|
|
131
|
+
})));
|
|
132
|
+
server.tool("conduyt_update_pool_filter_set", "Rename, refilter, enable / disable or reorder a pool filter set (admin only). Pass only what changes. Switching a set on or changing its rules reconciles the open offers the page no longer shows; a change that OPENS the whole page needs acknowledgeOpensPool. pageVersion is the one your list read answered for the set's page (409 page_version_stale when the page moved since: re-list and retry).", {
|
|
133
|
+
id: z.string().uuid().describe("The set's UUID"),
|
|
134
|
+
name: setName.optional(),
|
|
135
|
+
filters: filtersSchema.optional(),
|
|
136
|
+
isEnabled: z.boolean().optional(),
|
|
137
|
+
sortOrder: z.number().int().min(0).max(10_000).optional().describe("The set's position on its page (0-10000)"),
|
|
138
|
+
acknowledgeOpensPool: z.boolean().optional(),
|
|
139
|
+
pageVersion,
|
|
140
|
+
}, async ({ id, pageVersion: version, ...updates }) => {
|
|
141
|
+
if (!Object.entries(updates).some(([k, v]) => k !== "acknowledgeOpensPool" && v !== undefined))
|
|
142
|
+
throw new Error("nothing to update: pass name, filters, isEnabled, sortOrder (and acknowledgeOpensPool when the change opens the page)");
|
|
143
|
+
return formatResult(await client.patch(`/api/v1/pool-filter-sets/${id}`, { pageVersion: version, ...updates }));
|
|
144
|
+
});
|
|
145
|
+
server.tool("conduyt_delete_pool_filter_set", "Remove a pool filter set (admin only). Removing the LAST enabled set once the legacy filter is retired OPENS the page to every unassigned lead: refused with 409 pool_opens unless acknowledgeOpensPool is true. pageVersion is the one your list read answered for the set's page.", {
|
|
146
|
+
id: z.string().uuid().describe("The set's UUID"),
|
|
147
|
+
acknowledgeOpensPool: z.boolean().optional(),
|
|
148
|
+
pageVersion,
|
|
149
|
+
}, async ({ id, acknowledgeOpensPool, pageVersion: version }) => {
|
|
150
|
+
const params = new URLSearchParams({ pageVersion: version });
|
|
151
|
+
if (acknowledgeOpensPool)
|
|
152
|
+
params.set("acknowledgeOpensPool", "true");
|
|
153
|
+
return formatResult(await client.del(`/api/v1/pool-filter-sets/${id}?${params}`));
|
|
154
|
+
});
|
|
71
155
|
server.tool("conduyt_get_lead_pool_settings", "Read the admin-curated lead pool behind Up for Grabs / Lead Pool (settings.poolFilter + poolClaimDailyLimit). When enabled, an unassigned lead is offered only if it matches EVERY configured criterion: tagIds (any of), sources (any of), minScore (lead score >=), maxScore (lead score <=; min = max keeps one exact score), createdWithinDays (added to the CRM in the past N days: the contact's creation time — import, webhook, form, API or by hand; a re-import into an existing contact does not make it new). An empty criterion is skipped; enabled with nothing configured reads as disabled. Claims are gated by the same rules. tags carries the resolved tag names; criteria is the plain-English line the Up for Grabs page shows. Requires the settings scope.", {}, async () => {
|
|
72
156
|
const result = (await client.get("/api/v1/settings"));
|
|
73
157
|
const raw = result?.data ?? {};
|
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
|
-
"generatedAt": "2026-09-
|
|
3
|
-
"toolCount":
|
|
4
|
-
"totalEndpoints":
|
|
2
|
+
"generatedAt": "2026-09-26T07:26:50.579Z",
|
|
3
|
+
"toolCount": 288,
|
|
4
|
+
"totalEndpoints": 1008,
|
|
5
5
|
"counts": {
|
|
6
|
-
"covered":
|
|
6
|
+
"covered": 264,
|
|
7
7
|
"excluded": 295,
|
|
8
|
-
"planned":
|
|
8
|
+
"planned": 449,
|
|
9
9
|
"uncategorized": 0
|
|
10
10
|
},
|
|
11
11
|
"matrix": {
|
|
@@ -3071,6 +3071,10 @@
|
|
|
3071
3071
|
"status": "planned",
|
|
3072
3072
|
"reason": "notification surfaces"
|
|
3073
3073
|
},
|
|
3074
|
+
"GET /api/v1/ops/capacity": {
|
|
3075
|
+
"status": "planned",
|
|
3076
|
+
"reason": "operator capacity dashboard (#51, shipped 2026-09) — read-only queue/park telemetry; a typed conduyt_capacity_snapshot tool is agent-relevant for throughput questions"
|
|
3077
|
+
},
|
|
3074
3078
|
"GET /api/v1/phone-numbers": {
|
|
3075
3079
|
"status": "planned",
|
|
3076
3080
|
"reason": "phone number inventory"
|
|
@@ -3165,6 +3169,30 @@
|
|
|
3165
3169
|
"status": "planned",
|
|
3166
3170
|
"reason": "playbook management"
|
|
3167
3171
|
},
|
|
3172
|
+
"GET /api/v1/pool-filter-sets": {
|
|
3173
|
+
"status": "covered",
|
|
3174
|
+
"tools": [
|
|
3175
|
+
"conduyt_list_pool_filter_sets"
|
|
3176
|
+
]
|
|
3177
|
+
},
|
|
3178
|
+
"POST /api/v1/pool-filter-sets": {
|
|
3179
|
+
"status": "covered",
|
|
3180
|
+
"tools": [
|
|
3181
|
+
"conduyt_create_pool_filter_set"
|
|
3182
|
+
]
|
|
3183
|
+
},
|
|
3184
|
+
"PATCH /api/v1/pool-filter-sets/:id": {
|
|
3185
|
+
"status": "covered",
|
|
3186
|
+
"tools": [
|
|
3187
|
+
"conduyt_update_pool_filter_set"
|
|
3188
|
+
]
|
|
3189
|
+
},
|
|
3190
|
+
"DELETE /api/v1/pool-filter-sets/:id": {
|
|
3191
|
+
"status": "covered",
|
|
3192
|
+
"tools": [
|
|
3193
|
+
"conduyt_delete_pool_filter_set"
|
|
3194
|
+
]
|
|
3195
|
+
},
|
|
3168
3196
|
"GET /api/v1/products": {
|
|
3169
3197
|
"status": "covered",
|
|
3170
3198
|
"tools": [
|