conduyt-mcp 4.66.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/dist/tools/saved-filters.js +9 -6
- package/dist/tools/smart-views.d.ts +14 -0
- package/dist/tools/smart-views.js +56 -4
- package/package.json +1 -1
- package/scripts/coverage-matrix.json +66 -27
|
@@ -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 ?? {};
|
|
@@ -37,17 +37,20 @@ export function registerSavedFilterTools(server, client) {
|
|
|
37
37
|
const result = await client.get(`/api/v1/saved-filters${qs ? `?${qs}` : ""}`);
|
|
38
38
|
return formatResult(result);
|
|
39
39
|
});
|
|
40
|
-
server.tool("conduyt_create_saved_filter", "Save a named contact filter for one-click re-use — e.g. \"save a filter for Heather's Facebook leads\".
|
|
40
|
+
server.tool("conduyt_create_saved_filter", "Save a named contact filter for one-click re-use — e.g. \"save a filter for Heather's Facebook leads\". Since 2026-09-24 a saved filter IS a personal Smart View (the Smart Views tab), private to the key's user for every role (max 50 per user). Requires the 'saved-filters' scope.", {
|
|
41
41
|
name: z.string().min(1).max(60).describe("Display name shown in the Saved dropdown (unique per user, max 60 chars)"),
|
|
42
42
|
filters: filtersSchema,
|
|
43
43
|
sortField: z.string().optional().describe("Sort field applied with the filter (default createdAt)"),
|
|
44
44
|
sortOrder: z.enum(["asc", "desc"]).optional().describe("Sort direction (default desc)"),
|
|
45
45
|
entityType: z.enum(["contact"]).optional().describe("Entity type (default 'contact')"),
|
|
46
46
|
}, async (params) => {
|
|
47
|
-
|
|
47
|
+
// (Smart Views 09-24) saved filters are personal Smart Views now: the same body creates one (personal for a
|
|
48
|
+
// rep key; an admin key creates a SHARED view, admins have no private filters); POST /saved-filters is retired (410)
|
|
49
|
+
// personal: true keeps the saved-filter contract for EVERY key (an admin key creates a private view, never a shared one)
|
|
50
|
+
const result = await client.post("/api/v1/smart-views", { name: params.name, filters: params.filters, sortField: params.sortField, sortOrder: params.sortOrder, personal: true });
|
|
48
51
|
return formatResult(result);
|
|
49
52
|
});
|
|
50
|
-
server.tool("conduyt_update_saved_filter", "Rename a saved filter or replace its filters/sort. Owner-only —
|
|
53
|
+
server.tool("conduyt_update_saved_filter", "Rename a saved filter (a personal Smart View) or replace its filters/sort. Owner-only — a personal view is its owner's for every role. Use conduyt_list_saved_filters or conduyt_list_smart_views to find the id. Requires the 'saved-filters' scope.", {
|
|
51
54
|
id: z.string().uuid().describe("Saved filter UUID (from conduyt_list_saved_filters)"),
|
|
52
55
|
name: z.string().min(1).max(60).optional().describe("New display name (unique per user)"),
|
|
53
56
|
filters: filtersSchema,
|
|
@@ -56,7 +59,7 @@ export function registerSavedFilterTools(server, client) {
|
|
|
56
59
|
}, async ({ id, name, filters, sortField, sortOrder }) => {
|
|
57
60
|
// Inline body (undefined fields drop out in JSON) — keeps the shape
|
|
58
61
|
// readable by the contract verifier's static parser.
|
|
59
|
-
const result = await client.patch(`/api/v1/
|
|
62
|
+
const result = await client.patch(`/api/v1/smart-views/${encodeURIComponent(id)}`, {
|
|
60
63
|
name,
|
|
61
64
|
filters,
|
|
62
65
|
sortField,
|
|
@@ -64,12 +67,12 @@ export function registerSavedFilterTools(server, client) {
|
|
|
64
67
|
});
|
|
65
68
|
return formatResult(result);
|
|
66
69
|
});
|
|
67
|
-
server.tool("conduyt_delete_saved_filter", "Delete one of YOUR saved filters by id. Owner-only. Requires the 'saved-filters' scope.", {
|
|
70
|
+
server.tool("conduyt_delete_saved_filter", "Delete one of YOUR saved filters (a personal Smart View) by id. Owner-only. Requires the 'saved-filters' scope.", {
|
|
68
71
|
// Strict UUID + encode — the id is interpolated into a DELETE path and
|
|
69
72
|
// must never traverse out of the saved-filters endpoint.
|
|
70
73
|
id: z.string().uuid().describe("Saved filter UUID (from conduyt_list_saved_filters)"),
|
|
71
74
|
}, async ({ id }) => {
|
|
72
|
-
const result = await client.del(`/api/v1/
|
|
75
|
+
const result = await client.del(`/api/v1/smart-views/${encodeURIComponent(id)}`);
|
|
73
76
|
return formatResult(result);
|
|
74
77
|
});
|
|
75
78
|
}
|
|
@@ -1,3 +1,17 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import { ConduytClient } from "../client.js";
|
|
3
|
+
/**
|
|
4
|
+
* (Smart Views Codex r5) GET /smart-views is paged, 100 a page, and says so (meta.total, meta.has_more and an order
|
|
5
|
+
* stamp that moves on every reorder, edit, create and delete). EVERY page is walked under the first page's stamp,
|
|
6
|
+
* counts only on the first page (the expensive part); a page read after the order shifted restarts the walk, so an
|
|
7
|
+
* AI caller never silently loses a view past row 100 and never sees one twice.
|
|
8
|
+
*/
|
|
9
|
+
export declare function listAllSmartViews(client: ConduytClient): Promise<{
|
|
10
|
+
data: Array<Record<string, unknown>>;
|
|
11
|
+
meta: {
|
|
12
|
+
total: number;
|
|
13
|
+
pages: number;
|
|
14
|
+
order_stamp: string;
|
|
15
|
+
};
|
|
16
|
+
}>;
|
|
3
17
|
export declare function registerSmartViewTools(server: McpServer, client: ConduytClient): void;
|
|
@@ -14,11 +14,34 @@ const scheduleStepSchema = z.object({
|
|
|
14
14
|
attemptNumber: z.number().int().min(1).max(20).describe("1 = the first call, 2 = the first retry, …"),
|
|
15
15
|
delayMinutes: z.number().int().min(0).describe("Minutes to wait after the previous attempt before this one (0 = immediately)"),
|
|
16
16
|
});
|
|
17
|
+
/**
|
|
18
|
+
* (Smart Views Codex r5) GET /smart-views is paged, 100 a page, and says so (meta.total, meta.has_more and an order
|
|
19
|
+
* stamp that moves on every reorder, edit, create and delete). EVERY page is walked under the first page's stamp,
|
|
20
|
+
* counts only on the first page (the expensive part); a page read after the order shifted restarts the walk, so an
|
|
21
|
+
* AI caller never silently loses a view past row 100 and never sees one twice.
|
|
22
|
+
*/
|
|
23
|
+
export async function listAllSmartViews(client) {
|
|
24
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
25
|
+
const first = (await client.get("/api/v1/smart-views"));
|
|
26
|
+
const rows = [...first.data.data];
|
|
27
|
+
let meta = first.data.meta;
|
|
28
|
+
let shifted = false;
|
|
29
|
+
for (let page = 2; meta.has_more && page <= 50; page += 1) {
|
|
30
|
+
const next = (await client.get(`/api/v1/smart-views?page=${page}&counts=false`));
|
|
31
|
+
if (next.data.meta.order_stamp !== first.data.meta.order_stamp) {
|
|
32
|
+
shifted = true;
|
|
33
|
+
break;
|
|
34
|
+
}
|
|
35
|
+
rows.push(...next.data.data);
|
|
36
|
+
meta = next.data.meta;
|
|
37
|
+
}
|
|
38
|
+
if (!shifted)
|
|
39
|
+
return { data: rows, meta: { total: first.data.meta.total, pages: meta.page, order_stamp: first.data.meta.order_stamp } };
|
|
40
|
+
}
|
|
41
|
+
throw new Error("Smart Views kept changing while they were listed (a reorder or edit landed three times in a row); try again");
|
|
42
|
+
}
|
|
17
43
|
export function registerSmartViewTools(server, client) {
|
|
18
|
-
server.tool("conduyt_list_smart_views", "List the account's Smart Views in sidebar order with their lead counts and Smart Dialing settings (dialEnabled = it is a dialing priority; sortPosition = its rank; maxDialAttempts, maxDialAttemptsPerDay, dialCooldownMinutes, dialSchedule). Use it to answer \"what does the team dial first?\" or before changing a priority.", {}, async () =>
|
|
19
|
-
const result = await client.get("/api/v1/smart-views");
|
|
20
|
-
return formatResult(result);
|
|
21
|
-
});
|
|
44
|
+
server.tool("conduyt_list_smart_views", "List the account's Smart Views in sidebar order with their lead counts, audience (groupId + group name, members, isPersonal) and Smart Dialing settings (dialEnabled = it is a dialing priority; sortPosition = its rank; maxDialAttempts, maxDialAttemptsPerDay, dialCooldownMinutes, dialSchedule). Use it to answer \"what does the team dial first?\" or before changing a priority.", {}, async () => formatResult(await listAllSmartViews(client)));
|
|
22
45
|
server.tool("conduyt_update_smart_view_dialing", "Change one Smart View's Smart Dialing settings (admin key): make it a priority or not, the lifetime attempt cap, the per-day cap (null = no daily cap, counted in the account timezone), the cooldown between calls to the same lead, the shared pool and when a pool lead is assigned, and the retry schedule. Only the fields you pass change. Use conduyt_reorder_smart_views to change WHICH priority it is.", {
|
|
23
46
|
id: z.string().uuid().describe("Smart View UUID (from conduyt_list_smart_views)"),
|
|
24
47
|
dialEnabled: z.boolean().optional().describe("true = this view is a Smart Dialing priority; false = agents no longer dial it"),
|
|
@@ -70,10 +93,39 @@ export function registerSmartViewTools(server, client) {
|
|
|
70
93
|
const result = await client.patch("/api/v1/smart-views/dial-order", { order });
|
|
71
94
|
return formatResult(result);
|
|
72
95
|
});
|
|
96
|
+
server.tool("conduyt_set_smart_view_audience", "Who a SHARED Smart View is for (admin key): everyone on the account, one Team group (its CURRENT members, resolved when the view is read, so a rep added to the group later sees it and dials from it), or picked reps. A group replaces picked reps and vice versa. Personal views (a rep's own) have no audience.", {
|
|
97
|
+
id: z.string().uuid().describe("Smart View UUID (from conduyt_list_smart_views)"),
|
|
98
|
+
audience: z.enum(["everyone", "group", "reps"]).describe("everyone | group (pass groupId) | reps (pass memberIds)"),
|
|
99
|
+
groupId: z.string().uuid().optional().describe("The Team group (conduyt_list_groups) when audience = group"),
|
|
100
|
+
memberIds: z.array(z.string().uuid()).min(1).max(200).optional().describe("The reps' user ids when audience = reps"),
|
|
101
|
+
}, async ({ id, audience, groupId, memberIds }) => {
|
|
102
|
+
if (audience === "group" && !groupId)
|
|
103
|
+
throw new Error("audience = group needs groupId");
|
|
104
|
+
if (audience === "reps" && (!memberIds || memberIds.length === 0))
|
|
105
|
+
throw new Error("audience = reps needs memberIds");
|
|
106
|
+
const result = await client.patch(`/api/v1/smart-views/${encodeURIComponent(id)}`, {
|
|
107
|
+
groupId: audience === "group" ? groupId : null,
|
|
108
|
+
members: audience === "reps" ? (memberIds ?? []).map((userId) => ({ userId })) : [],
|
|
109
|
+
});
|
|
110
|
+
return formatResult(result);
|
|
111
|
+
});
|
|
73
112
|
server.tool("conduyt_reorder_smart_views", "Set the SIDEBAR order of Smart Views (admin key). Pass EVERY view id from conduyt_list_smart_views in the wanted order (max 200, no duplicates) — views you leave out keep a stale position, so always send the full list. For the dialing priority use conduyt_set_smart_dialing_order, which only needs the dial-enabled ids.", {
|
|
74
113
|
order: z.array(z.string().uuid()).min(1).max(200).describe("Every Smart View UUID, first = highest priority"),
|
|
75
114
|
}, async ({ order }) => {
|
|
76
115
|
const result = await client.patch("/api/v1/smart-views/reorder", { order });
|
|
77
116
|
return formatResult(result);
|
|
78
117
|
});
|
|
118
|
+
server.tool("conduyt_move_smart_view", "Move ONE Smart View next to another in the sidebar order (admin key): pass the moved view's id and exactly one anchor, beforeId (it lands directly above that view) or afterId (directly below). Every other view keeps its relative position; no size limit, so use this on accounts with more than 200 views (conduyt_reorder_smart_views is capped at 200 ids). The order is the Smart Dialing priority for dial-enabled views.", {
|
|
119
|
+
id: z.string().uuid().describe("The Smart View to move"),
|
|
120
|
+
beforeId: z.string().uuid().optional().describe("Land directly ABOVE this view (pass this or afterId, not both)"),
|
|
121
|
+
afterId: z.string().uuid().optional().describe("Land directly BELOW this view (pass this or beforeId, not both)"),
|
|
122
|
+
}, async ({ id, beforeId, afterId }) => {
|
|
123
|
+
const anchors = [beforeId, afterId].filter(Boolean);
|
|
124
|
+
if (anchors.length !== 1)
|
|
125
|
+
throw new Error("Pass exactly one of beforeId or afterId.");
|
|
126
|
+
if (anchors[0] === id)
|
|
127
|
+
throw new Error("A view cannot be anchored to itself.");
|
|
128
|
+
const result = await client.patch("/api/v1/smart-views/reorder", { move: beforeId ? { id, beforeId } : { id, afterId } });
|
|
129
|
+
return formatResult(result);
|
|
130
|
+
});
|
|
79
131
|
}
|
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":
|
|
7
|
-
"excluded":
|
|
8
|
-
"planned":
|
|
6
|
+
"covered": 264,
|
|
7
|
+
"excluded": 295,
|
|
8
|
+
"planned": 449,
|
|
9
9
|
"uncategorized": 0
|
|
10
10
|
},
|
|
11
11
|
"matrix": {
|
|
@@ -97,6 +97,14 @@
|
|
|
97
97
|
"status": "excluded",
|
|
98
98
|
"reason": "public unauthenticated surfaces (forms, booking, unsubscribe) — no key semantics"
|
|
99
99
|
},
|
|
100
|
+
"POST /api/public/forms/:slug/upload-url": {
|
|
101
|
+
"status": "excluded",
|
|
102
|
+
"reason": "public unauthenticated surfaces (forms, booking, unsubscribe) — no key semantics"
|
|
103
|
+
},
|
|
104
|
+
"OPTIONS /api/public/forms/:slug/upload-url": {
|
|
105
|
+
"status": "excluded",
|
|
106
|
+
"reason": "public unauthenticated surfaces (forms, booking, unsubscribe) — no key semantics"
|
|
107
|
+
},
|
|
100
108
|
"GET /api/v1/forms": {
|
|
101
109
|
"status": "covered",
|
|
102
110
|
"tools": [
|
|
@@ -145,6 +153,10 @@
|
|
|
145
153
|
"status": "planned",
|
|
146
154
|
"reason": "form management"
|
|
147
155
|
},
|
|
156
|
+
"GET /api/v1/forms/:id/uploads": {
|
|
157
|
+
"status": "planned",
|
|
158
|
+
"reason": "form management"
|
|
159
|
+
},
|
|
148
160
|
"POST /api/public/invoices/:id/payment-link": {
|
|
149
161
|
"status": "excluded",
|
|
150
162
|
"reason": "public unauthenticated surfaces (forms, booking, unsubscribe) — no key semantics"
|
|
@@ -3157,6 +3169,30 @@
|
|
|
3157
3169
|
"status": "planned",
|
|
3158
3170
|
"reason": "playbook management"
|
|
3159
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
|
+
},
|
|
3160
3196
|
"GET /api/v1/products": {
|
|
3161
3197
|
"status": "covered",
|
|
3162
3198
|
"tools": [
|
|
@@ -3578,22 +3614,16 @@
|
|
|
3578
3614
|
"reason": "saved filter management"
|
|
3579
3615
|
},
|
|
3580
3616
|
"POST /api/v1/saved-filters": {
|
|
3581
|
-
"status": "
|
|
3582
|
-
"
|
|
3583
|
-
"conduyt_create_saved_filter"
|
|
3584
|
-
]
|
|
3617
|
+
"status": "planned",
|
|
3618
|
+
"reason": "saved filter management"
|
|
3585
3619
|
},
|
|
3586
3620
|
"PATCH /api/v1/saved-filters/:id": {
|
|
3587
|
-
"status": "
|
|
3588
|
-
"
|
|
3589
|
-
"conduyt_update_saved_filter"
|
|
3590
|
-
]
|
|
3621
|
+
"status": "planned",
|
|
3622
|
+
"reason": "saved filter management"
|
|
3591
3623
|
},
|
|
3592
3624
|
"DELETE /api/v1/saved-filters/:id": {
|
|
3593
|
-
"status": "
|
|
3594
|
-
"
|
|
3595
|
-
"conduyt_delete_saved_filter"
|
|
3596
|
-
]
|
|
3625
|
+
"status": "planned",
|
|
3626
|
+
"reason": "saved filter management"
|
|
3597
3627
|
},
|
|
3598
3628
|
"GET /api/v1/schema/api-catalog": {
|
|
3599
3629
|
"status": "covered",
|
|
@@ -3985,15 +4015,15 @@
|
|
|
3985
4015
|
"reason": "smart list management"
|
|
3986
4016
|
},
|
|
3987
4017
|
"GET /api/v1/smart-views": {
|
|
4018
|
+
"status": "planned",
|
|
4019
|
+
"reason": "smart view management beyond summaries"
|
|
4020
|
+
},
|
|
4021
|
+
"POST /api/v1/smart-views": {
|
|
3988
4022
|
"status": "covered",
|
|
3989
4023
|
"tools": [
|
|
3990
|
-
"
|
|
4024
|
+
"conduyt_create_saved_filter"
|
|
3991
4025
|
]
|
|
3992
4026
|
},
|
|
3993
|
-
"POST /api/v1/smart-views": {
|
|
3994
|
-
"status": "planned",
|
|
3995
|
-
"reason": "smart view management beyond summaries"
|
|
3996
|
-
},
|
|
3997
4027
|
"GET /api/v1/smart-views/:id": {
|
|
3998
4028
|
"status": "planned",
|
|
3999
4029
|
"reason": "smart view management beyond summaries"
|
|
@@ -4001,12 +4031,16 @@
|
|
|
4001
4031
|
"PATCH /api/v1/smart-views/:id": {
|
|
4002
4032
|
"status": "covered",
|
|
4003
4033
|
"tools": [
|
|
4004
|
-
"
|
|
4034
|
+
"conduyt_update_saved_filter",
|
|
4035
|
+
"conduyt_update_smart_view_dialing",
|
|
4036
|
+
"conduyt_set_smart_view_audience"
|
|
4005
4037
|
]
|
|
4006
4038
|
},
|
|
4007
4039
|
"DELETE /api/v1/smart-views/:id": {
|
|
4008
|
-
"status": "
|
|
4009
|
-
"
|
|
4040
|
+
"status": "covered",
|
|
4041
|
+
"tools": [
|
|
4042
|
+
"conduyt_delete_saved_filter"
|
|
4043
|
+
]
|
|
4010
4044
|
},
|
|
4011
4045
|
"GET /api/v1/smart-views/dial-order": {
|
|
4012
4046
|
"status": "covered",
|
|
@@ -4023,7 +4057,8 @@
|
|
|
4023
4057
|
"PATCH /api/v1/smart-views/reorder": {
|
|
4024
4058
|
"status": "covered",
|
|
4025
4059
|
"tools": [
|
|
4026
|
-
"conduyt_reorder_smart_views"
|
|
4060
|
+
"conduyt_reorder_smart_views",
|
|
4061
|
+
"conduyt_move_smart_view"
|
|
4027
4062
|
]
|
|
4028
4063
|
},
|
|
4029
4064
|
"GET /api/v1/sms-providers": {
|
|
@@ -4339,6 +4374,10 @@
|
|
|
4339
4374
|
"status": "planned",
|
|
4340
4375
|
"reason": "team management subroutes beyond list/invite"
|
|
4341
4376
|
},
|
|
4377
|
+
"POST /api/v1/users/me/welcomed": {
|
|
4378
|
+
"status": "planned",
|
|
4379
|
+
"reason": "team management subroutes beyond list/invite"
|
|
4380
|
+
},
|
|
4342
4381
|
"GET /api/v1/warmy-engine/health": {
|
|
4343
4382
|
"status": "planned",
|
|
4344
4383
|
"reason": "email warm-up engine surfaces"
|