@sendinel/mcp-server 1.0.10 → 1.0.12
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/README.md +35 -22
- package/build/anthropic-model.d.ts +30 -0
- package/build/anthropic-model.js +26 -1
- package/build/db.d.ts +17 -0
- package/build/db.js +15 -1
- package/build/index.js +6 -0
- package/build/lib/action-approvals.js +28 -0
- package/build/lib/brand-asset-persist.d.ts +1 -0
- package/build/lib/brand-asset-persist.js +20 -1
- package/build/lib/composer-blocks.d.ts +25 -25
- package/build/lib/composer-blocks.js +25 -5
- package/build/lib/connector-table-discovery.d.ts +12 -0
- package/build/lib/connector-table-discovery.js +117 -0
- package/build/lib/custom-properties-diff.d.ts +16 -0
- package/build/lib/custom-properties-diff.js +22 -0
- package/build/lib/schedule.d.ts +7 -2
- package/build/lib/schedule.js +8 -0
- package/build/lib/v1-client.d.ts +3 -0
- package/build/lib/v1-client.js +45 -0
- package/build/lib/voice.d.ts +7 -30
- package/build/lib/voice.js +6 -138
- package/build/prompts.d.ts +6 -0
- package/build/prompts.js +18 -0
- package/build/scoped-token.d.ts +1 -1
- package/build/server.d.ts +1 -0
- package/build/server.js +65 -6
- package/build/tools/advisor.js +3 -3
- package/build/tools/analytics.d.ts +1 -0
- package/build/tools/analytics.js +105 -8
- package/build/tools/brand-kit.js +29 -30
- package/build/tools/campaigns.js +349 -60
- package/build/tools/cloud-import.d.ts +2 -0
- package/build/tools/cloud-import.js +76 -0
- package/build/tools/compound.d.ts +11 -0
- package/build/tools/compound.js +37 -7
- package/build/tools/connector.d.ts +2 -0
- package/build/tools/connector.js +280 -0
- package/build/tools/contacts.js +744 -16
- package/build/tools/content.js +228 -0
- package/build/tools/data-proposals.js +1 -1
- package/build/tools/data.js +448 -4
- package/build/tools/delivery-ops.js +525 -7
- package/build/tools/forms.js +7 -6
- package/build/tools/gateway.d.ts +6 -0
- package/build/tools/gateway.js +92 -0
- package/build/tools/gdpr.js +185 -0
- package/build/tools/landing-pages.d.ts +2 -0
- package/build/tools/landing-pages.js +202 -0
- package/build/tools/lists.d.ts +2 -0
- package/build/tools/lists.js +428 -0
- package/build/tools/newsletters.js +1 -8
- package/build/tools/org.js +565 -35
- package/build/tools/segments.js +35 -184
- package/build/tools/signatures.d.ts +2 -0
- package/build/tools/signatures.js +168 -0
- package/build/tools/sites.js +3 -1
- package/build/tools/skills.d.ts +2 -0
- package/build/tools/skills.js +29 -0
- package/build/tools/templates.js +133 -7
- package/package.json +13 -12
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { applySegmentRules, buildSegmentBaseQuery, evaluateSegmentRules, normalizeSegmentRulesToTree, } from '@sendinel/domain';
|
|
3
|
+
import { db } from '../db.js';
|
|
4
|
+
import { getProjectId } from '../project.js';
|
|
5
|
+
import { logAudit } from '../audit.js';
|
|
6
|
+
import { assertChannelCanExecute, checkApproval, currentChannelContext, requestApproval, } from '../lib/action-approvals.js';
|
|
7
|
+
const LIST_MEMBER_BATCH_SIZE = 500;
|
|
8
|
+
const txt = (text) => ({ content: [{ type: 'text', text }] });
|
|
9
|
+
const j = (data) => txt(JSON.stringify(data, null, 2));
|
|
10
|
+
async function loadFieldTypes(projectId) {
|
|
11
|
+
const client = db();
|
|
12
|
+
const scoped = typeof client.schema === 'function' ? client.schema('email') : client;
|
|
13
|
+
const { data, error } = await scoped
|
|
14
|
+
.from('custom_field_definitions')
|
|
15
|
+
.select('key, type, options')
|
|
16
|
+
.eq('project_id', projectId);
|
|
17
|
+
if (error) {
|
|
18
|
+
console.warn('[lists] custom field definition load failed:', error.message ?? error);
|
|
19
|
+
return {};
|
|
20
|
+
}
|
|
21
|
+
const fields = {};
|
|
22
|
+
for (const row of data ?? []) {
|
|
23
|
+
if (typeof row.key !== 'string' || typeof row.type !== 'string')
|
|
24
|
+
continue;
|
|
25
|
+
fields[row.key] = { type: row.type, options: Array.isArray(row.options) ? row.options : null };
|
|
26
|
+
}
|
|
27
|
+
return fields;
|
|
28
|
+
}
|
|
29
|
+
async function getSegmentContactIds(rules) {
|
|
30
|
+
const projectId = getProjectId();
|
|
31
|
+
const tree = normalizeSegmentRulesToTree(rules);
|
|
32
|
+
const fieldTypes = rules.custom_property ? await loadFieldTypes(projectId) : {};
|
|
33
|
+
const q = buildSegmentBaseQuery(db(), projectId);
|
|
34
|
+
const { query: filtered, siteContactIds } = await applySegmentRules(q, rules, db(), projectId, fieldTypes);
|
|
35
|
+
if (siteContactIds && siteContactIds.length === 0)
|
|
36
|
+
return [];
|
|
37
|
+
const { data, error } = await filtered;
|
|
38
|
+
if (error || !data)
|
|
39
|
+
return [];
|
|
40
|
+
const contacts = data.filter((contact) => evaluateSegmentRules(contact, tree, fieldTypes));
|
|
41
|
+
let ids = contacts.map((contact) => String(contact.id));
|
|
42
|
+
if (tree.exclude_suppressed !== false && ids.length > 0) {
|
|
43
|
+
const { data: suppressed } = await db()
|
|
44
|
+
.from('suppressions')
|
|
45
|
+
.select('email')
|
|
46
|
+
.eq('project_id', projectId);
|
|
47
|
+
if (suppressed?.length) {
|
|
48
|
+
const suppressedEmails = new Set(suppressed.map((row) => row.email));
|
|
49
|
+
ids = contacts
|
|
50
|
+
.filter((contact) => !suppressedEmails.has(String(contact.email)))
|
|
51
|
+
.map((contact) => String(contact.id));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return ids;
|
|
55
|
+
}
|
|
56
|
+
async function readValidContactIds(projectId, contactIds) {
|
|
57
|
+
const { data, error } = await db()
|
|
58
|
+
.from('subscribers')
|
|
59
|
+
.select('id')
|
|
60
|
+
.eq('project_id', projectId)
|
|
61
|
+
.in('id', contactIds);
|
|
62
|
+
return { ids: (data ?? []).map((row) => row.id), error };
|
|
63
|
+
}
|
|
64
|
+
async function readMemberIds(listId, contactIds) {
|
|
65
|
+
if (contactIds.length === 0)
|
|
66
|
+
return { ids: [], error: null };
|
|
67
|
+
const { data, error } = await db()
|
|
68
|
+
.from('contact_list_members')
|
|
69
|
+
.select('contact_id')
|
|
70
|
+
.eq('list_id', listId)
|
|
71
|
+
.in('contact_id', contactIds);
|
|
72
|
+
return { ids: (data ?? []).map((row) => row.contact_id), error };
|
|
73
|
+
}
|
|
74
|
+
function difference(ids, excludedIds) {
|
|
75
|
+
const excluded = new Set(excludedIds);
|
|
76
|
+
return ids.filter((id) => !excluded.has(id));
|
|
77
|
+
}
|
|
78
|
+
export function registerListTools(server) {
|
|
79
|
+
server.registerTool('list_lists', {
|
|
80
|
+
title: 'List Contact Lists',
|
|
81
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
82
|
+
description: 'List static contact lists for the current project, including grouped member counts.',
|
|
83
|
+
inputSchema: {
|
|
84
|
+
search: z.string().optional(),
|
|
85
|
+
limit: z.number().int().min(1).max(200).optional().default(50),
|
|
86
|
+
},
|
|
87
|
+
}, async ({ search, limit }) => {
|
|
88
|
+
const projectId = getProjectId();
|
|
89
|
+
let query = db()
|
|
90
|
+
.from('contact_lists')
|
|
91
|
+
.select('id, name, description, created_at, updated_at')
|
|
92
|
+
.eq('project_id', projectId);
|
|
93
|
+
const trimmedSearch = search?.trim();
|
|
94
|
+
if (trimmedSearch)
|
|
95
|
+
query = query.ilike('name', `%${trimmedSearch}%`);
|
|
96
|
+
const { data, error } = await query
|
|
97
|
+
.order('created_at', { ascending: false })
|
|
98
|
+
.limit(limit);
|
|
99
|
+
if (error)
|
|
100
|
+
return txt(`Error: ${error.message}`);
|
|
101
|
+
const lists = data ?? [];
|
|
102
|
+
const listIds = lists.map((list) => list.id);
|
|
103
|
+
const countMap = new Map();
|
|
104
|
+
if (listIds.length > 0) {
|
|
105
|
+
const { data: counts, error: countError } = await db().rpc('get_list_member_counts', { p_list_ids: listIds });
|
|
106
|
+
if (countError)
|
|
107
|
+
return txt(`Error: ${countError.message}`);
|
|
108
|
+
for (const row of counts ?? []) {
|
|
109
|
+
countMap.set(row.list_id, Number(row.member_count));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return j(lists.map((list) => ({
|
|
113
|
+
...list,
|
|
114
|
+
member_count: countMap.get(list.id) ?? 0,
|
|
115
|
+
})));
|
|
116
|
+
});
|
|
117
|
+
server.registerTool('create_list', {
|
|
118
|
+
title: 'Create Contact List',
|
|
119
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
120
|
+
description: 'Create a static contact list for the current project.',
|
|
121
|
+
inputSchema: {
|
|
122
|
+
name: z.string().min(1).max(200),
|
|
123
|
+
description: z.string().optional(),
|
|
124
|
+
},
|
|
125
|
+
}, async ({ name, description }) => {
|
|
126
|
+
const projectId = getProjectId();
|
|
127
|
+
const normalizedName = name.trim();
|
|
128
|
+
const { data, error } = await db()
|
|
129
|
+
.from('contact_lists')
|
|
130
|
+
.insert({
|
|
131
|
+
project_id: projectId,
|
|
132
|
+
name: normalizedName,
|
|
133
|
+
description: description?.trim() ?? null,
|
|
134
|
+
})
|
|
135
|
+
.select('id, name, description, created_at')
|
|
136
|
+
.single();
|
|
137
|
+
if (error)
|
|
138
|
+
return txt(`Error: ${error.message}`);
|
|
139
|
+
await logAudit({
|
|
140
|
+
action: 'list.created',
|
|
141
|
+
resourceType: 'list',
|
|
142
|
+
resourceId: data.id,
|
|
143
|
+
details: { name: normalizedName },
|
|
144
|
+
});
|
|
145
|
+
return j(data);
|
|
146
|
+
});
|
|
147
|
+
server.registerTool('update_list', {
|
|
148
|
+
title: 'Update Contact List',
|
|
149
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
150
|
+
description: 'Update the name or description of a static contact list.',
|
|
151
|
+
inputSchema: {
|
|
152
|
+
list_id: z.string().uuid(),
|
|
153
|
+
name: z.string().min(1).max(200).optional(),
|
|
154
|
+
description: z.string().optional(),
|
|
155
|
+
},
|
|
156
|
+
}, async ({ list_id, name, description }) => {
|
|
157
|
+
if (name === undefined && description === undefined)
|
|
158
|
+
return txt('Nothing to update');
|
|
159
|
+
const projectId = getProjectId();
|
|
160
|
+
const updates = { updated_at: new Date().toISOString() };
|
|
161
|
+
if (name !== undefined)
|
|
162
|
+
updates.name = name.trim();
|
|
163
|
+
if (description !== undefined)
|
|
164
|
+
updates.description = description.trim();
|
|
165
|
+
const { data, error } = await db()
|
|
166
|
+
.from('contact_lists')
|
|
167
|
+
.update(updates)
|
|
168
|
+
.eq('id', list_id)
|
|
169
|
+
.eq('project_id', projectId)
|
|
170
|
+
.select('id, name, description, updated_at')
|
|
171
|
+
.maybeSingle();
|
|
172
|
+
if (error)
|
|
173
|
+
return txt(`Error: ${error.message}`);
|
|
174
|
+
if (!data)
|
|
175
|
+
return txt('List not found');
|
|
176
|
+
await logAudit({
|
|
177
|
+
action: 'list.updated',
|
|
178
|
+
resourceType: 'list',
|
|
179
|
+
resourceId: list_id,
|
|
180
|
+
details: { name: name?.trim(), description: description?.trim() ?? null },
|
|
181
|
+
});
|
|
182
|
+
return j(data);
|
|
183
|
+
});
|
|
184
|
+
server.registerTool('delete_list', {
|
|
185
|
+
title: 'Delete Contact List',
|
|
186
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
|
|
187
|
+
description: 'Permanently delete a static contact list and its memberships.',
|
|
188
|
+
inputSchema: { list_id: z.string().uuid() },
|
|
189
|
+
}, async ({ list_id }) => {
|
|
190
|
+
const projectId = getProjectId();
|
|
191
|
+
const { error } = await db()
|
|
192
|
+
.from('contact_lists')
|
|
193
|
+
.delete()
|
|
194
|
+
.eq('id', list_id)
|
|
195
|
+
.eq('project_id', projectId);
|
|
196
|
+
if (error)
|
|
197
|
+
return txt(`Error: ${error.message}`);
|
|
198
|
+
await logAudit({ action: 'list.deleted', resourceType: 'list', resourceId: list_id });
|
|
199
|
+
return j({ deleted: true, list_id });
|
|
200
|
+
});
|
|
201
|
+
server.registerTool('add_list_members', {
|
|
202
|
+
title: 'Add List Members',
|
|
203
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
204
|
+
description: 'Add contacts to a static list after previewing the affected count and completing the standard bulk-action approval flow.',
|
|
205
|
+
inputSchema: {
|
|
206
|
+
list_id: z.string().uuid(),
|
|
207
|
+
contact_ids: z.array(z.string().uuid()).min(1),
|
|
208
|
+
confirmed: z.boolean().optional().default(false),
|
|
209
|
+
approval_id: z.string().uuid().optional(),
|
|
210
|
+
},
|
|
211
|
+
}, async ({ list_id, contact_ids, confirmed, approval_id }) => {
|
|
212
|
+
const projectId = getProjectId();
|
|
213
|
+
const approvalParams = { list_id, contact_ids };
|
|
214
|
+
const { data: list, error: listError } = await db()
|
|
215
|
+
.from('contact_lists')
|
|
216
|
+
.select('id, name')
|
|
217
|
+
.eq('id', list_id)
|
|
218
|
+
.eq('project_id', projectId)
|
|
219
|
+
.maybeSingle();
|
|
220
|
+
if (listError)
|
|
221
|
+
return j({ error: listError.message });
|
|
222
|
+
if (!list)
|
|
223
|
+
return j({ error: 'List not found', code: 'LIST_NOT_FOUND' });
|
|
224
|
+
const validResult = await readValidContactIds(projectId, contact_ids);
|
|
225
|
+
if (validResult.error)
|
|
226
|
+
return j({ error: validResult.error.message });
|
|
227
|
+
const memberResult = await readMemberIds(list_id, validResult.ids);
|
|
228
|
+
if (memberResult.error)
|
|
229
|
+
return j({ error: memberResult.error.message });
|
|
230
|
+
const wouldAdd = difference(validResult.ids, memberResult.ids);
|
|
231
|
+
if (!confirmed) {
|
|
232
|
+
return j({
|
|
233
|
+
confirmation_required: true,
|
|
234
|
+
tool: 'add_list_members',
|
|
235
|
+
list: { id: list.id, name: list.name },
|
|
236
|
+
requested: contact_ids.length,
|
|
237
|
+
valid_contacts: validResult.ids.length,
|
|
238
|
+
already_members: memberResult.ids.length,
|
|
239
|
+
would_add: wouldAdd.length,
|
|
240
|
+
message: `Adding ${contact_ids.length} contact(s) to list "${list.name}" will add ${wouldAdd.length} new member(s) (${memberResult.ids.length} already members). Call again with confirmed=true to proceed.`,
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
const channelContext = currentChannelContext();
|
|
244
|
+
const decision = assertChannelCanExecute('add_list_members', approvalParams, channelContext);
|
|
245
|
+
if (!decision.allowed)
|
|
246
|
+
return txt(decision.reason ?? 'This channel cannot request this action.');
|
|
247
|
+
if (decision.tier !== 'autonomous') {
|
|
248
|
+
if (!approval_id)
|
|
249
|
+
return j(await requestApproval('add_list_members', approvalParams, undefined, channelContext));
|
|
250
|
+
const check = await checkApproval(approval_id, { consume: true, action: 'add_list_members', params: approvalParams });
|
|
251
|
+
if (!check.approved) {
|
|
252
|
+
return txt(`Approval ${approval_id} is ${check.status}. ${check.status === 'pending' ? 'Waiting for human approval via dashboard.' : 'Request a new approval.'}`);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
const freshValidResult = await readValidContactIds(projectId, contact_ids);
|
|
256
|
+
if (freshValidResult.error)
|
|
257
|
+
return j({ error: freshValidResult.error.message });
|
|
258
|
+
const freshMemberResult = await readMemberIds(list_id, freshValidResult.ids);
|
|
259
|
+
if (freshMemberResult.error)
|
|
260
|
+
return j({ error: freshMemberResult.error.message });
|
|
261
|
+
const freshWouldAdd = difference(freshValidResult.ids, freshMemberResult.ids);
|
|
262
|
+
let added = 0;
|
|
263
|
+
if (freshWouldAdd.length > 0) {
|
|
264
|
+
const { data: inserted, error } = await db()
|
|
265
|
+
.from('contact_list_members')
|
|
266
|
+
.upsert(freshWouldAdd.map((contact_id) => ({ list_id, contact_id })), {
|
|
267
|
+
onConflict: 'list_id,contact_id',
|
|
268
|
+
ignoreDuplicates: true,
|
|
269
|
+
})
|
|
270
|
+
.select('id');
|
|
271
|
+
if (error)
|
|
272
|
+
return j({ error: error.message });
|
|
273
|
+
added = inserted?.length ?? 0;
|
|
274
|
+
}
|
|
275
|
+
await logAudit({
|
|
276
|
+
action: 'list.member_added',
|
|
277
|
+
resourceType: 'list',
|
|
278
|
+
resourceId: list_id,
|
|
279
|
+
details: { contact_ids: added },
|
|
280
|
+
});
|
|
281
|
+
return j({ ok: true, added, list_id });
|
|
282
|
+
});
|
|
283
|
+
server.registerTool('remove_list_members', {
|
|
284
|
+
title: 'Remove List Members',
|
|
285
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
|
|
286
|
+
description: 'Remove contacts from a static list after previewing the affected count and completing the standard bulk-action approval flow.',
|
|
287
|
+
inputSchema: {
|
|
288
|
+
list_id: z.string().uuid(),
|
|
289
|
+
contact_ids: z.array(z.string().uuid()).min(1),
|
|
290
|
+
confirmed: z.boolean().optional().default(false),
|
|
291
|
+
approval_id: z.string().uuid().optional(),
|
|
292
|
+
},
|
|
293
|
+
}, async ({ list_id, contact_ids, confirmed, approval_id }) => {
|
|
294
|
+
const projectId = getProjectId();
|
|
295
|
+
const approvalParams = { list_id, contact_ids };
|
|
296
|
+
const { data: list, error: listError } = await db()
|
|
297
|
+
.from('contact_lists')
|
|
298
|
+
.select('id, name')
|
|
299
|
+
.eq('id', list_id)
|
|
300
|
+
.eq('project_id', projectId)
|
|
301
|
+
.maybeSingle();
|
|
302
|
+
if (listError)
|
|
303
|
+
return j({ error: listError.message });
|
|
304
|
+
if (!list)
|
|
305
|
+
return j({ error: 'List not found', code: 'LIST_NOT_FOUND' });
|
|
306
|
+
const memberResult = await readMemberIds(list_id, contact_ids);
|
|
307
|
+
if (memberResult.error)
|
|
308
|
+
return j({ error: memberResult.error.message });
|
|
309
|
+
if (!confirmed) {
|
|
310
|
+
return j({
|
|
311
|
+
confirmation_required: true,
|
|
312
|
+
tool: 'remove_list_members',
|
|
313
|
+
list: { id: list.id, name: list.name },
|
|
314
|
+
requested: contact_ids.length,
|
|
315
|
+
would_remove: memberResult.ids.length,
|
|
316
|
+
message: `Removing ${contact_ids.length} contact(s) from list "${list.name}" will remove ${memberResult.ids.length} current member(s). Call again with confirmed=true to proceed.`,
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
const channelContext = currentChannelContext();
|
|
320
|
+
const decision = assertChannelCanExecute('remove_list_members', approvalParams, channelContext);
|
|
321
|
+
if (!decision.allowed)
|
|
322
|
+
return txt(decision.reason ?? 'This channel cannot request this action.');
|
|
323
|
+
if (decision.tier !== 'autonomous') {
|
|
324
|
+
if (!approval_id)
|
|
325
|
+
return j(await requestApproval('remove_list_members', approvalParams, undefined, channelContext));
|
|
326
|
+
const check = await checkApproval(approval_id, { consume: true, action: 'remove_list_members', params: approvalParams });
|
|
327
|
+
if (!check.approved) {
|
|
328
|
+
return txt(`Approval ${approval_id} is ${check.status}. ${check.status === 'pending' ? 'Waiting for human approval via dashboard.' : 'Request a new approval.'}`);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
const freshMemberResult = await readMemberIds(list_id, contact_ids);
|
|
332
|
+
if (freshMemberResult.error)
|
|
333
|
+
return j({ error: freshMemberResult.error.message });
|
|
334
|
+
const removed = freshMemberResult.ids.length;
|
|
335
|
+
if (removed > 0) {
|
|
336
|
+
const { error } = await db()
|
|
337
|
+
.from('contact_list_members')
|
|
338
|
+
.delete()
|
|
339
|
+
.eq('list_id', list_id)
|
|
340
|
+
.in('contact_id', freshMemberResult.ids);
|
|
341
|
+
if (error)
|
|
342
|
+
return j({ error: error.message });
|
|
343
|
+
}
|
|
344
|
+
await logAudit({
|
|
345
|
+
action: 'list.member_removed',
|
|
346
|
+
resourceType: 'list',
|
|
347
|
+
resourceId: list_id,
|
|
348
|
+
details: { contact_ids: removed },
|
|
349
|
+
});
|
|
350
|
+
return j({ ok: true, removed, list_id });
|
|
351
|
+
});
|
|
352
|
+
server.registerTool('add_list_members_by_filter', {
|
|
353
|
+
title: 'Add List Members By Filter',
|
|
354
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
355
|
+
description: 'Add every eligible contact matching segment rules after previewing the affected count and completing the standard bulk-action approval flow.',
|
|
356
|
+
inputSchema: {
|
|
357
|
+
list_id: z.string().uuid(),
|
|
358
|
+
rules: z.record(z.string(), z.unknown()),
|
|
359
|
+
confirmed: z.boolean().optional().default(false),
|
|
360
|
+
approval_id: z.string().uuid().optional(),
|
|
361
|
+
},
|
|
362
|
+
}, async ({ list_id, rules, confirmed, approval_id }) => {
|
|
363
|
+
const projectId = getProjectId();
|
|
364
|
+
const normalizedRules = normalizeSegmentRulesToTree(rules);
|
|
365
|
+
const approvalParams = { list_id, rules: normalizedRules };
|
|
366
|
+
const { data: list, error: listError } = await db()
|
|
367
|
+
.from('contact_lists')
|
|
368
|
+
.select('id, name')
|
|
369
|
+
.eq('id', list_id)
|
|
370
|
+
.eq('project_id', projectId)
|
|
371
|
+
.maybeSingle();
|
|
372
|
+
if (listError)
|
|
373
|
+
return j({ error: listError.message });
|
|
374
|
+
if (!list)
|
|
375
|
+
return j({ error: 'List not found', code: 'LIST_NOT_FOUND' });
|
|
376
|
+
const matchedIds = await getSegmentContactIds(normalizedRules);
|
|
377
|
+
const memberResult = await readMemberIds(list_id, matchedIds);
|
|
378
|
+
if (memberResult.error)
|
|
379
|
+
return j({ error: memberResult.error.message });
|
|
380
|
+
const wouldAdd = difference(matchedIds, memberResult.ids);
|
|
381
|
+
if (!confirmed) {
|
|
382
|
+
return j({
|
|
383
|
+
confirmation_required: true,
|
|
384
|
+
tool: 'add_list_members_by_filter',
|
|
385
|
+
list: { id: list.id, name: list.name },
|
|
386
|
+
matched: matchedIds.length,
|
|
387
|
+
already_members: memberResult.ids.length,
|
|
388
|
+
would_add: wouldAdd.length,
|
|
389
|
+
message: `Filter matches ${matchedIds.length} contact(s); ${wouldAdd.length} would be newly added to list "${list.name}". Call again with confirmed=true to proceed.`,
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
const channelContext = currentChannelContext();
|
|
393
|
+
const decision = assertChannelCanExecute('add_list_members_by_filter', approvalParams, channelContext);
|
|
394
|
+
if (!decision.allowed)
|
|
395
|
+
return txt(decision.reason ?? 'This channel cannot request this action.');
|
|
396
|
+
if (decision.tier !== 'autonomous') {
|
|
397
|
+
if (!approval_id)
|
|
398
|
+
return j(await requestApproval('add_list_members_by_filter', approvalParams, undefined, channelContext));
|
|
399
|
+
const check = await checkApproval(approval_id, { consume: true, action: 'add_list_members_by_filter', params: approvalParams });
|
|
400
|
+
if (!check.approved) {
|
|
401
|
+
return txt(`Approval ${approval_id} is ${check.status}. ${check.status === 'pending' ? 'Waiting for human approval via dashboard.' : 'Request a new approval.'}`);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
const freshMatchedIds = await getSegmentContactIds(normalizedRules);
|
|
405
|
+
const freshMemberResult = await readMemberIds(list_id, freshMatchedIds);
|
|
406
|
+
if (freshMemberResult.error)
|
|
407
|
+
return j({ error: freshMemberResult.error.message });
|
|
408
|
+
const freshWouldAdd = difference(freshMatchedIds, freshMemberResult.ids);
|
|
409
|
+
let added = 0;
|
|
410
|
+
for (let index = 0; index < freshWouldAdd.length; index += LIST_MEMBER_BATCH_SIZE) {
|
|
411
|
+
const rows = freshWouldAdd.slice(index, index + LIST_MEMBER_BATCH_SIZE).map((contact_id) => ({ list_id, contact_id }));
|
|
412
|
+
const { data: inserted, error } = await db()
|
|
413
|
+
.from('contact_list_members')
|
|
414
|
+
.upsert(rows, { onConflict: 'list_id,contact_id', ignoreDuplicates: true })
|
|
415
|
+
.select('id');
|
|
416
|
+
if (error)
|
|
417
|
+
return j({ error: error.message });
|
|
418
|
+
added += inserted?.length ?? 0;
|
|
419
|
+
}
|
|
420
|
+
await logAudit({
|
|
421
|
+
action: 'list.members_added_by_filter',
|
|
422
|
+
resourceType: 'list',
|
|
423
|
+
resourceId: list_id,
|
|
424
|
+
details: { added, matched: freshMatchedIds.length, source: 'filter' },
|
|
425
|
+
});
|
|
426
|
+
return j({ ok: true, added, matched: freshMatchedIds.length, list_id });
|
|
427
|
+
});
|
|
428
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { db } from '../db.js';
|
|
3
3
|
import { getProjectId } from '../project.js';
|
|
4
|
+
import { parseFutureDate } from '../lib/schedule.js';
|
|
4
5
|
const txt = (text) => ({ content: [{ type: 'text', text }] });
|
|
5
6
|
const j = (data) => txt(JSON.stringify(data, null, 2));
|
|
6
7
|
const NEWSLETTER_COLUMNS = 'id, org_id, project_id, site_id, name, slug, description, sender_name, sender_email, reply_to, cadence, branding, public_signup_enabled, double_opt_in, status, subscriber_count, source, source_payload, created_at, updated_at';
|
|
@@ -144,14 +145,6 @@ async function ensureNewsletterCampaign(newsletter, issue, projectId, scheduledA
|
|
|
144
145
|
return { error: statusError.message };
|
|
145
146
|
return { campaignId, enrolled: rows.length };
|
|
146
147
|
}
|
|
147
|
-
function parseFutureDate(value) {
|
|
148
|
-
const date = new Date(value);
|
|
149
|
-
if (Number.isNaN(date.getTime()))
|
|
150
|
-
return { error: 'scheduled_at must be a valid ISO timestamp' };
|
|
151
|
-
if (date.getTime() <= Date.now())
|
|
152
|
-
return { error: 'scheduled_at must be in the future' };
|
|
153
|
-
return { scheduledAt: date.toISOString() };
|
|
154
|
-
}
|
|
155
148
|
export function registerNewsletterTools(server) {
|
|
156
149
|
server.registerTool('list_newsletters', {
|
|
157
150
|
title: 'List Newsletters',
|