@sendinel/mcp-server 1.0.139 → 1.0.141
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/build/lib/db-error.d.ts +16 -0
- package/build/lib/db-error.js +17 -0
- package/build/lib/db-errors.d.ts +12 -0
- package/build/lib/db-errors.js +20 -0
- package/build/tools/advisor.js +23 -19
- package/build/tools/analytics.d.ts +2 -1
- package/build/tools/analytics.js +20 -8
- package/build/tools/approvals.js +3 -2
- package/build/tools/campaigns.js +92 -48
- package/build/tools/contacts.js +422 -310
- package/build/tools/content.js +12 -2
- package/build/tools/delivery-ops.js +5 -4
- package/build/tools/landing-pages.js +86 -9
- package/build/tools/segments.js +60 -46
- package/package.json +1 -1
package/build/tools/contacts.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { dbErrorResult } from '../lib/db-errors.js';
|
|
1
2
|
import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
3
|
import { CONTACT_ENRICHMENT_PROVIDERS } from '@desler/models';
|
|
3
4
|
import { createContactEnrichmentAdapters, enrichContactFromConnections, } from '@desler/models/contact-enrichment';
|
|
@@ -6,6 +7,8 @@ import { z } from 'zod';
|
|
|
6
7
|
import { callSendinelV1 } from '../lib/v1-client.js';
|
|
7
8
|
import { db, projectClientForWriteback } from '../db.js';
|
|
8
9
|
import { getProjectId } from '../project.js';
|
|
10
|
+
import { dbToolError, toolError } from '../lib/db-error.js';
|
|
11
|
+
import { resolveSegmentContactIds } from '../lib/send-audience.js';
|
|
9
12
|
import { checkPlanLimit, contactLimitMessage } from '../plan-limits.js';
|
|
10
13
|
import { logConsent, logAudit } from '../audit.js';
|
|
11
14
|
import { assertChannelCanExecute, currentChannelContext, requestApproval, checkApproval, withApproval } from '../lib/action-approvals.js';
|
|
@@ -237,6 +240,49 @@ function normalizePhoneE164(value) {
|
|
|
237
240
|
function isPlainObject(value) {
|
|
238
241
|
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
239
242
|
}
|
|
243
|
+
const SUBSCRIBER_VIEW_COLUMNS = new Set([
|
|
244
|
+
'contact', 'status_strip', 'quick_send', 'site', 'company', 'status', 'last_activity',
|
|
245
|
+
'sms_opt_in', 'tags', 'score', 'stage', 'opens', 'clicks', 'joined', 'embed_actions',
|
|
246
|
+
'email', 'first_name', 'last_name', 'introduced_by',
|
|
247
|
+
]);
|
|
248
|
+
const SUBSCRIBER_VIEW_STAGES = new Set(['new', 'active', 'cooling', 'cold', 'churned', 'lead', 'trial', 'customer', 'win-back']);
|
|
249
|
+
function validateSubscriberViewInput(columns, filters) {
|
|
250
|
+
if (isPlainObject(filters)) {
|
|
251
|
+
for (const [key, value] of Object.entries(filters)) {
|
|
252
|
+
if (!SUBSCRIBER_VIEW_FILTER_KEYS.has(key))
|
|
253
|
+
return `Unknown filter "${key}". Valid keys: ${[...SUBSCRIBER_VIEW_FILTER_KEYS].join(', ')}`;
|
|
254
|
+
if (key === 'show_test' ? typeof value !== 'boolean' : typeof value !== 'string' || value.length > 500)
|
|
255
|
+
return `Invalid value for filter "${key}"`;
|
|
256
|
+
if (key === 'stage' && value !== '' && !SUBSCRIBER_VIEW_STAGES.has(value))
|
|
257
|
+
return `Invalid stage. Valid stages: ${[...SUBSCRIBER_VIEW_STAGES].join(', ')}`;
|
|
258
|
+
if (key === 'dir' && value !== 'asc' && value !== 'desc')
|
|
259
|
+
return 'dir must be asc or desc';
|
|
260
|
+
if (key === 'sort' && value !== '' && !SUBSCRIBER_VIEW_SORT_FIELDS.has(value))
|
|
261
|
+
return 'Invalid sort field';
|
|
262
|
+
if (key === 'status' && !['', 'all', 'active', 'unsubscribed', 'not_subscribed'].includes(value))
|
|
263
|
+
return 'Invalid subscription status';
|
|
264
|
+
if (key === 'cp_key' && value !== '' && !SUBSCRIBER_VIEW_CUSTOM_PROPERTY_KEY_RE.test(value))
|
|
265
|
+
return 'Invalid custom property key';
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
if (isPlainObject(filters) && Boolean(filters.cp_key) !== Boolean(filters.cp_value))
|
|
269
|
+
return 'cp_key and cp_value must be provided together';
|
|
270
|
+
if (isPlainObject(columns)) {
|
|
271
|
+
for (const list of [columns.order, columns.hidden]) {
|
|
272
|
+
if (list === undefined)
|
|
273
|
+
continue;
|
|
274
|
+
if (!Array.isArray(list) || list.length > SUBSCRIBER_VIEW_MAX_COLUMNS)
|
|
275
|
+
return 'Invalid column list';
|
|
276
|
+
for (const key of list) {
|
|
277
|
+
if (typeof key !== 'string' || !SUBSCRIBER_VIEW_KEY_RE.test(key) ||
|
|
278
|
+
(!SUBSCRIBER_VIEW_COLUMNS.has(key) && !(key.startsWith('prop:') && SUBSCRIBER_VIEW_CUSTOM_PROPERTY_KEY_RE.test(key.slice(5))))) {
|
|
279
|
+
return `Unknown column "${String(key)}". Use a subscriber column or prop:<custom property key>`;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
240
286
|
function normalizeSubscriberViewKeys(value) {
|
|
241
287
|
if (!Array.isArray(value))
|
|
242
288
|
return [];
|
|
@@ -1074,9 +1120,12 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1074
1120
|
filters: z.record(z.string(), z.unknown()).optional(),
|
|
1075
1121
|
},
|
|
1076
1122
|
}, async ({ name, view_mode, columns, filters }) => {
|
|
1123
|
+
const inputError = validateSubscriberViewInput(columns, filters);
|
|
1124
|
+
if (inputError)
|
|
1125
|
+
return toolError({ error: inputError, code: 'validation_failed' });
|
|
1077
1126
|
const normalizedName = name.trim().slice(0, SUBSCRIBER_VIEW_MAX_NAME_LENGTH);
|
|
1078
1127
|
if (!normalizedName)
|
|
1079
|
-
return
|
|
1128
|
+
return toolError({ error: 'name is required', code: 'validation_failed' });
|
|
1080
1129
|
const { data, error } = await db()
|
|
1081
1130
|
.from('subscriber_list_views')
|
|
1082
1131
|
.insert({
|
|
@@ -1090,9 +1139,10 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1090
1139
|
.select('id, name, view_mode, columns, filters, created_by, created_at, updated_at')
|
|
1091
1140
|
.maybeSingle();
|
|
1092
1141
|
if (error?.code === '23505')
|
|
1093
|
-
return
|
|
1142
|
+
return toolError({ error: `A view named "${normalizedName}" already exists`, code: 'conflict' });
|
|
1094
1143
|
if (error)
|
|
1095
|
-
return
|
|
1144
|
+
return toolError(dbToolError(error));
|
|
1145
|
+
await logAudit({ action: 'subscriber_view.saved', resourceType: 'subscriber_view', resourceId: data?.id });
|
|
1096
1146
|
return j({ view: data });
|
|
1097
1147
|
});
|
|
1098
1148
|
server.registerTool('update_subscriber_view', {
|
|
@@ -1115,11 +1165,14 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1115
1165
|
if (name === undefined && view_mode === undefined && columns === undefined && filters === undefined) {
|
|
1116
1166
|
return txt('Nothing to update');
|
|
1117
1167
|
}
|
|
1168
|
+
const inputError = validateSubscriberViewInput(columns, filters);
|
|
1169
|
+
if (inputError)
|
|
1170
|
+
return toolError({ error: inputError, code: 'validation_failed' });
|
|
1118
1171
|
const updates = {};
|
|
1119
1172
|
if (name !== undefined) {
|
|
1120
1173
|
const normalizedName = name.trim().slice(0, SUBSCRIBER_VIEW_MAX_NAME_LENGTH);
|
|
1121
1174
|
if (!normalizedName)
|
|
1122
|
-
return
|
|
1175
|
+
return toolError({ error: 'name is required', code: 'validation_failed' });
|
|
1123
1176
|
updates.name = normalizedName;
|
|
1124
1177
|
}
|
|
1125
1178
|
if (view_mode !== undefined)
|
|
@@ -1136,11 +1189,12 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1136
1189
|
.select('id, name, view_mode, columns, filters, created_by, created_at, updated_at')
|
|
1137
1190
|
.maybeSingle();
|
|
1138
1191
|
if (error?.code === '23505')
|
|
1139
|
-
return
|
|
1192
|
+
return toolError({ error: 'A view with that name already exists', code: 'conflict' });
|
|
1140
1193
|
if (error)
|
|
1141
|
-
return
|
|
1194
|
+
return toolError(dbToolError(error));
|
|
1142
1195
|
if (!data)
|
|
1143
|
-
return
|
|
1196
|
+
return toolError({ error: 'View not found', code: 'not_found' });
|
|
1197
|
+
await logAudit({ action: 'subscriber_view.saved', resourceType: 'subscriber_view', resourceId: data?.id });
|
|
1144
1198
|
return j({ view: data });
|
|
1145
1199
|
});
|
|
1146
1200
|
server.registerTool('delete_subscriber_view', {
|
|
@@ -1253,133 +1307,138 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1253
1307
|
utm_term: z.string().optional(),
|
|
1254
1308
|
},
|
|
1255
1309
|
}, async ({ email, site_id, first_name, last_name, company_id, company_name, company_domain, source, utm_source, utm_medium, utm_campaign, utm_content, utm_term }) => {
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
// Validate project ownership before the cap, and reuse the double opt-in lookup.
|
|
1266
|
-
const { data: siteInfo, error: siteError } = await db().from('sites')
|
|
1267
|
-
.select('double_opt_in').eq('id', site_id).eq('project_id', projectId).maybeSingle();
|
|
1268
|
-
if (siteError)
|
|
1269
|
-
throw new Error(`Site lookup failed: ${siteError.message}`);
|
|
1270
|
-
if (!siteInfo)
|
|
1271
|
-
return contactInputError('not_found', 'Site not found for this project.');
|
|
1272
|
-
const needsConfirm = siteInfo.double_opt_in === true;
|
|
1273
|
-
const limit = await checkPlanLimit('contacts');
|
|
1274
|
-
if (!limit.allowed) {
|
|
1275
|
-
return contactInputError('plan_limit', contactLimitMessage(limit));
|
|
1276
|
-
}
|
|
1277
|
-
const embedOk = await hasCompaniesEmbed(projectId);
|
|
1278
|
-
const resolvedCompanyId = await resolveCompanyId(projectId, { company_id, company_name, company_domain });
|
|
1279
|
-
let { data: existingContact, error: lookupError } = await db()
|
|
1280
|
-
.from('subscribers')
|
|
1281
|
-
.select('id, source, first_name, last_name, company_id, custom_properties, opted_in_at')
|
|
1282
|
-
.eq('project_id', projectId)
|
|
1283
|
-
.eq('email', email)
|
|
1284
|
-
.maybeSingle();
|
|
1285
|
-
if (lookupError)
|
|
1286
|
-
return txt(`Error looking up contact: ${lookupError.message}`);
|
|
1287
|
-
let customProperties;
|
|
1288
|
-
if (company_name || company_domain) {
|
|
1289
|
-
const orgId = await getProjectOrgId(projectId);
|
|
1290
|
-
customProperties = await normalizeMcpContactPropertyPatch({
|
|
1291
|
-
...(company_name ? { company_name } : {}),
|
|
1292
|
-
...(company_domain ? { company_domain: company_domain.trim().toLowerCase() } : {}),
|
|
1293
|
-
}, { orgId, projectId });
|
|
1294
|
-
}
|
|
1295
|
-
const selection = embedOk ? '*, company:companies(id, name, domain)' : '*';
|
|
1296
|
-
let contact;
|
|
1297
|
-
if (!existingContact) {
|
|
1298
|
-
const row = { project_id: projectId, email, first_name, last_name, company_id: resolvedCompanyId, source, source_site: site_id };
|
|
1299
|
-
if (customProperties)
|
|
1300
|
-
row.custom_properties = customProperties;
|
|
1301
|
-
if (!needsConfirm)
|
|
1302
|
-
row.opted_in_at = new Date().toISOString();
|
|
1303
|
-
if (utm_source)
|
|
1304
|
-
row.utm_source = utm_source;
|
|
1305
|
-
if (utm_medium)
|
|
1306
|
-
row.utm_medium = utm_medium;
|
|
1307
|
-
if (utm_campaign)
|
|
1308
|
-
row.utm_campaign = utm_campaign;
|
|
1309
|
-
if (utm_content)
|
|
1310
|
-
row.utm_content = utm_content;
|
|
1311
|
-
if (utm_term)
|
|
1312
|
-
row.utm_term = utm_term;
|
|
1313
|
-
const { data, error } = await db().from('subscribers')
|
|
1314
|
-
.upsert(row, { onConflict: 'project_id,email', ignoreDuplicates: true })
|
|
1315
|
-
.select(selection).maybeSingle();
|
|
1316
|
-
if (error)
|
|
1317
|
-
return txt(`Error creating contact: ${error.message}`);
|
|
1318
|
-
contact = data;
|
|
1319
|
-
if (!contact) {
|
|
1320
|
-
// A concurrent add won the unique key; its provenance belongs to that insert.
|
|
1321
|
-
const result = await db().from('subscribers')
|
|
1322
|
-
.select('id, source, first_name, last_name, company_id, custom_properties, opted_in_at')
|
|
1323
|
-
.eq('project_id', projectId).eq('email', email).single();
|
|
1324
|
-
if (result.error)
|
|
1325
|
-
return txt(`Error looking up contact: ${result.error.message}`);
|
|
1326
|
-
existingContact = result.data;
|
|
1310
|
+
try {
|
|
1311
|
+
email = email.trim().toLowerCase();
|
|
1312
|
+
const projectId = getProjectId();
|
|
1313
|
+
// Block distribution list / system addresses
|
|
1314
|
+
const DISTRO_LOCAL_PARTS = new Set(['all', 'everyone', 'distro', 'broadcast', 'announce', 'postmaster', 'abuse', 'mailer-daemon', 'bounce', 'bounces', 'noreply', 'no-reply', 'nobody', 'null', 'void']);
|
|
1315
|
+
const DISTRO_PATTERNS = [/^all[-+.]/, /[-+.]all$/, /^list[-+.]/, /[-+.]list$/, /[-+.]announce$/, /^announce[-+.]/, /[-+.]distro$/, /^distro[-+.]/, /[-+.]broadcast$/, /^broadcast[-+.]/];
|
|
1316
|
+
const local = email.trim().toLowerCase().split('@')[0] ?? '';
|
|
1317
|
+
if (DISTRO_LOCAL_PARTS.has(local) || DISTRO_PATTERNS.some(p => p.test(local))) {
|
|
1318
|
+
return contactInputError('validation_failed', `Blocked: "${email}" looks like a group or distribution list address. Sendinel sends to individual people only.`);
|
|
1327
1319
|
}
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1320
|
+
// Validate project ownership before the cap, and reuse the double opt-in lookup.
|
|
1321
|
+
const { data: siteInfo, error: siteError } = await db().from('sites')
|
|
1322
|
+
.select('double_opt_in').eq('id', site_id).eq('project_id', projectId).maybeSingle();
|
|
1323
|
+
if (siteError)
|
|
1324
|
+
return dbErrorResult(siteError);
|
|
1325
|
+
if (!siteInfo)
|
|
1326
|
+
return contactInputError('not_found', 'Site not found for this project.');
|
|
1327
|
+
const needsConfirm = siteInfo.double_opt_in === true;
|
|
1328
|
+
const limit = await checkPlanLimit('contacts');
|
|
1329
|
+
if (!limit.allowed) {
|
|
1330
|
+
return contactInputError('plan_limit', contactLimitMessage(limit));
|
|
1336
1331
|
}
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
.
|
|
1341
|
-
|
|
1342
|
-
|
|
1332
|
+
const embedOk = await hasCompaniesEmbed(projectId);
|
|
1333
|
+
const resolvedCompanyId = await resolveCompanyId(projectId, { company_id, company_name, company_domain });
|
|
1334
|
+
let { data: existingContact, error: lookupError } = await db()
|
|
1335
|
+
.from('subscribers')
|
|
1336
|
+
.select('id, source, first_name, last_name, company_id, custom_properties, opted_in_at')
|
|
1337
|
+
.eq('project_id', projectId)
|
|
1338
|
+
.eq('email', email)
|
|
1339
|
+
.maybeSingle();
|
|
1340
|
+
if (lookupError)
|
|
1341
|
+
return dbErrorResult(lookupError);
|
|
1342
|
+
let customProperties;
|
|
1343
|
+
if (company_name || company_domain) {
|
|
1344
|
+
const orgId = await getProjectOrgId(projectId);
|
|
1345
|
+
customProperties = await normalizeMcpContactPropertyPatch({
|
|
1346
|
+
...(company_name ? { company_name } : {}),
|
|
1347
|
+
...(company_domain ? { company_domain: company_domain.trim().toLowerCase() } : {}),
|
|
1348
|
+
}, { orgId, projectId });
|
|
1343
1349
|
}
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1350
|
+
const selection = embedOk ? '*, company:companies(id, name, domain)' : '*';
|
|
1351
|
+
let contact;
|
|
1352
|
+
if (!existingContact) {
|
|
1353
|
+
const row = { project_id: projectId, email, first_name, last_name, company_id: resolvedCompanyId, source, source_site: site_id };
|
|
1354
|
+
if (customProperties)
|
|
1355
|
+
row.custom_properties = customProperties;
|
|
1356
|
+
if (!needsConfirm)
|
|
1357
|
+
row.opted_in_at = new Date().toISOString();
|
|
1358
|
+
if (utm_source)
|
|
1359
|
+
row.utm_source = utm_source;
|
|
1360
|
+
if (utm_medium)
|
|
1361
|
+
row.utm_medium = utm_medium;
|
|
1362
|
+
if (utm_campaign)
|
|
1363
|
+
row.utm_campaign = utm_campaign;
|
|
1364
|
+
if (utm_content)
|
|
1365
|
+
row.utm_content = utm_content;
|
|
1366
|
+
if (utm_term)
|
|
1367
|
+
row.utm_term = utm_term;
|
|
1368
|
+
const { data, error } = await db().from('subscribers')
|
|
1369
|
+
.upsert(row, { onConflict: 'project_id,email', ignoreDuplicates: true })
|
|
1370
|
+
.select(selection).maybeSingle();
|
|
1354
1371
|
if (error)
|
|
1355
|
-
return
|
|
1372
|
+
return dbErrorResult(error);
|
|
1373
|
+
contact = data;
|
|
1374
|
+
if (!contact) {
|
|
1375
|
+
// A concurrent add won the unique key; its provenance belongs to that insert.
|
|
1376
|
+
const result = await db().from('subscribers')
|
|
1377
|
+
.select('id, source, first_name, last_name, company_id, custom_properties, opted_in_at')
|
|
1378
|
+
.eq('project_id', projectId).eq('email', email).single();
|
|
1379
|
+
if (result.error)
|
|
1380
|
+
return dbErrorResult(result.error);
|
|
1381
|
+
existingContact = result.data;
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
const existing = !!existingContact;
|
|
1385
|
+
if (existingContact) {
|
|
1386
|
+
const empty = (value) => value == null || value === '';
|
|
1387
|
+
const row = {};
|
|
1388
|
+
for (const [key, value] of Object.entries({ first_name, last_name, company_id: resolvedCompanyId })) {
|
|
1389
|
+
if (empty(existingContact[key]) && !empty(value))
|
|
1390
|
+
row[key] = value;
|
|
1391
|
+
}
|
|
1392
|
+
if (!needsConfirm && existingContact.opted_in_at == null)
|
|
1393
|
+
row.opted_in_at = new Date().toISOString();
|
|
1394
|
+
const missingProperties = Object.fromEntries(Object.entries(customProperties ?? {})
|
|
1395
|
+
.filter(([key]) => !Object.hasOwn(existingContact.custom_properties ?? {}, key)));
|
|
1396
|
+
if (Object.keys(missingProperties).length) {
|
|
1397
|
+
row.custom_properties = { ...(existingContact.custom_properties ?? {}), ...missingProperties };
|
|
1398
|
+
}
|
|
1399
|
+
if (Object.keys(row).length) {
|
|
1400
|
+
let update = db().from('subscribers').update(row)
|
|
1401
|
+
.eq('id', existingContact.id).eq('project_id', projectId);
|
|
1402
|
+
// The stored values must still match: a concurrent edit always wins over a re-add.
|
|
1403
|
+
for (const key of Object.keys(row)) {
|
|
1404
|
+
const previous = existingContact[key];
|
|
1405
|
+
update = previous == null ? update.is(key, null)
|
|
1406
|
+
: update.eq(key, key === 'custom_properties' ? JSON.stringify(previous) : previous);
|
|
1407
|
+
}
|
|
1408
|
+
const { error } = await update;
|
|
1409
|
+
if (error)
|
|
1410
|
+
return dbErrorResult(error);
|
|
1411
|
+
}
|
|
1412
|
+
const result = await db().from('subscribers').select(selection)
|
|
1413
|
+
.eq('id', existingContact.id).eq('project_id', projectId).single();
|
|
1414
|
+
if (result.error)
|
|
1415
|
+
return dbErrorResult(result.error);
|
|
1416
|
+
contact = result.data;
|
|
1417
|
+
}
|
|
1418
|
+
const existingMessage = existing ? { message: 'Contact already existed; source and existing fields were not changed.' } : {};
|
|
1419
|
+
const { error: subErr } = await db()
|
|
1420
|
+
.from('site_subscriptions')
|
|
1421
|
+
.upsert({ contact_id: contact.id, site_id, subscribed: !needsConfirm }, { onConflict: 'contact_id,site_id' });
|
|
1422
|
+
if (subErr)
|
|
1423
|
+
return dbErrorResult(subErr);
|
|
1424
|
+
if (existing)
|
|
1425
|
+
logAudit({ action: 'contact.readded', resourceType: 'contact', resourceId: contact.id, details: { email, requested_source: source, existing: true } });
|
|
1426
|
+
if (needsConfirm) {
|
|
1427
|
+
// Create opt-in token (confirmation email must be sent externally in MCP context)
|
|
1428
|
+
await db().from('opt_in_tokens').insert({ contact_id: contact.id, site_id });
|
|
1429
|
+
await writeBackMcpContact(projectId, contact, { email, first_name: contact.first_name ?? null, last_name: contact.last_name ?? null });
|
|
1430
|
+
logConsent({ contactId: contact.id, siteId: site_id, action: 'grant', consentType: 'email_marketing', source: 'mcp_pending_double_opt_in' });
|
|
1431
|
+
return { content: [{ type: 'text', text: JSON.stringify({ ...contact, existing, confirmation: 'pending', message: 'Double opt-in required. Confirmation token created.', ...existingMessage }, null, 2) }] };
|
|
1356
1432
|
}
|
|
1357
|
-
const result = await db().from('subscribers').select(selection)
|
|
1358
|
-
.eq('id', existingContact.id).eq('project_id', projectId).single();
|
|
1359
|
-
if (result.error)
|
|
1360
|
-
return txt(`Error reading contact: ${result.error.message}`);
|
|
1361
|
-
contact = result.data;
|
|
1362
|
-
}
|
|
1363
|
-
const existingMessage = existing ? { message: 'Contact already existed; source and existing fields were not changed.' } : {};
|
|
1364
|
-
const { error: subErr } = await db()
|
|
1365
|
-
.from('site_subscriptions')
|
|
1366
|
-
.upsert({ contact_id: contact.id, site_id, subscribed: !needsConfirm }, { onConflict: 'contact_id,site_id' });
|
|
1367
|
-
if (subErr)
|
|
1368
|
-
return { content: [{ type: 'text', text: `Contact created but subscription failed: ${subErr.message}` }] };
|
|
1369
|
-
if (existing)
|
|
1370
|
-
logAudit({ action: 'contact.readded', resourceType: 'contact', resourceId: contact.id, details: { email, requested_source: source, existing: true } });
|
|
1371
|
-
if (needsConfirm) {
|
|
1372
|
-
// Create opt-in token (confirmation email must be sent externally in MCP context)
|
|
1373
|
-
await db().from('opt_in_tokens').insert({ contact_id: contact.id, site_id });
|
|
1374
1433
|
await writeBackMcpContact(projectId, contact, { email, first_name: contact.first_name ?? null, last_name: contact.last_name ?? null });
|
|
1375
|
-
logConsent({ contactId: contact.id, siteId: site_id, action: 'grant', consentType: 'email_marketing', source: '
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1434
|
+
logConsent({ contactId: contact.id, siteId: site_id, action: 'grant', consentType: 'email_marketing', source: 'mcp_add_subscriber' });
|
|
1435
|
+
if (!existing)
|
|
1436
|
+
logAudit({ action: 'contact.created', resourceType: 'contact', resourceId: contact.id, details: { email, source } });
|
|
1437
|
+
return { content: [{ type: 'text', text: JSON.stringify({ ...contact, existing, ...existingMessage }, null, 2) }] };
|
|
1438
|
+
}
|
|
1439
|
+
catch (error) {
|
|
1440
|
+
return dbErrorResult(error);
|
|
1441
|
+
}
|
|
1383
1442
|
});
|
|
1384
1443
|
server.registerTool('import_subscribers', {
|
|
1385
1444
|
title: 'Import Subscribers',
|
|
@@ -1643,6 +1702,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1643
1702
|
},
|
|
1644
1703
|
description: 'List contacts filtered by engagement score range and/or lifecycle stage. Useful for building re-engagement campaigns or identifying high-value contacts.',
|
|
1645
1704
|
inputSchema: {
|
|
1705
|
+
segment_id: z.string().uuid().optional().describe('Saved segment; mutually exclusive with inline audience filters'),
|
|
1646
1706
|
lifecycle_stage: z.enum(['new', 'active', 'cooling', 'cold', 'churned', 'lead', 'trial', 'customer', 'win-back']).optional(),
|
|
1647
1707
|
min_score: z.number().int().min(0).max(100).optional(),
|
|
1648
1708
|
max_score: z.number().int().min(0).max(100).optional(),
|
|
@@ -1651,8 +1711,28 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1651
1711
|
company_domain: z.string().optional(),
|
|
1652
1712
|
limit: z.number().int().min(1).max(200).optional().default(50),
|
|
1653
1713
|
},
|
|
1654
|
-
}, async ({ lifecycle_stage, min_score, max_score, site_id, company_id, company_domain, limit }) => {
|
|
1714
|
+
}, async ({ segment_id, lifecycle_stage, min_score, max_score, site_id, company_id, company_domain, limit }) => {
|
|
1655
1715
|
const projectId = getProjectId();
|
|
1716
|
+
let segmentIds;
|
|
1717
|
+
if (segment_id) {
|
|
1718
|
+
if ([lifecycle_stage, min_score, max_score, site_id, company_id, company_domain].some(value => value !== undefined)) {
|
|
1719
|
+
return toolError({ error: 'Provide segment_id or inline filters, not both', code: 'validation_failed' });
|
|
1720
|
+
}
|
|
1721
|
+
const { data: segment, error } = await db().from('segments').select('rules')
|
|
1722
|
+
.eq('project_id', projectId).eq('id', segment_id).maybeSingle();
|
|
1723
|
+
if (error)
|
|
1724
|
+
return toolError(dbToolError(error));
|
|
1725
|
+
if (!segment)
|
|
1726
|
+
return toolError({ error: 'Segment not found', code: 'not_found' });
|
|
1727
|
+
try {
|
|
1728
|
+
segmentIds = await resolveSegmentContactIds(projectId, segment.rules);
|
|
1729
|
+
}
|
|
1730
|
+
catch (error) {
|
|
1731
|
+
return toolError(isSegmentRulesError(error) ? { error: error.message, code: 'validation_failed' } : { error: 'Unable to evaluate segment', code: 'internal_error' });
|
|
1732
|
+
}
|
|
1733
|
+
if (!segmentIds.length)
|
|
1734
|
+
return j([]);
|
|
1735
|
+
}
|
|
1656
1736
|
const embedOk = await hasCompaniesEmbed(projectId);
|
|
1657
1737
|
let q = db()
|
|
1658
1738
|
.from('subscribers')
|
|
@@ -1691,9 +1771,23 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1691
1771
|
return { content: [{ type: 'text', text: '[]' }] };
|
|
1692
1772
|
q = q.in('company_id', ids);
|
|
1693
1773
|
}
|
|
1774
|
+
if (segmentIds) {
|
|
1775
|
+
const rows = [];
|
|
1776
|
+
for (const ids of chunkIds(segmentIds)) {
|
|
1777
|
+
// A Supabase builder is mutable; build a fresh query for each chunk.
|
|
1778
|
+
const { data, error } = await db().from('subscribers')
|
|
1779
|
+
.select(embedOk ? SEGMENT_SUBSCRIBER_SELECT_WITH_COMPANY : SEGMENT_SUBSCRIBER_SELECT_NO_COMPANY)
|
|
1780
|
+
.eq('project_id', projectId).eq('global_unsubscribed', false).in('id', ids)
|
|
1781
|
+
.order('engagement_score', { ascending: false }).limit(limit ?? 50);
|
|
1782
|
+
if (error)
|
|
1783
|
+
return toolError(dbToolError(error));
|
|
1784
|
+
rows.push(...(data ?? []));
|
|
1785
|
+
}
|
|
1786
|
+
return j(rows.sort((a, b) => b.engagement_score - a.engagement_score).slice(0, limit ?? 50));
|
|
1787
|
+
}
|
|
1694
1788
|
const { data, error } = await q;
|
|
1695
1789
|
if (error)
|
|
1696
|
-
return
|
|
1790
|
+
return toolError(dbToolError(error));
|
|
1697
1791
|
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
1698
1792
|
});
|
|
1699
1793
|
server.registerTool('update_subscriber', {
|
|
@@ -1719,194 +1813,199 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1719
1813
|
timezone: z.string().nullable().optional().describe('IANA time zone identifier, e.g. America/Los_Angeles'),
|
|
1720
1814
|
},
|
|
1721
1815
|
}, async ({ contact_id, email, first_name, last_name, company_id, company_name, company_domain, tags, custom_properties, phone_e164, timezone }) => {
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
definitions = await registeredCustomFields(projectId);
|
|
1729
|
-
}
|
|
1730
|
-
catch {
|
|
1731
|
-
return j({ error: 'Custom field definitions unavailable; update not performed', code: 'INTERNAL_ERROR' });
|
|
1732
|
-
}
|
|
1733
|
-
fields = Object.fromEntries(definitions.map(field => [field.key, field]));
|
|
1734
|
-
const values = Object.fromEntries(Object.entries(custom_properties).filter(([, value]) => value !== null));
|
|
1735
|
-
const invalidShape = Object.entries(values).find(([key, value]) => {
|
|
1736
|
-
if (!Object.hasOwn(fields, key))
|
|
1737
|
-
return false;
|
|
1738
|
-
const type = fields[key].type;
|
|
1739
|
-
if (type === 'multi_select')
|
|
1740
|
-
return typeof value !== 'string' && (!Array.isArray(value) || value.some(item => typeof item !== 'string'));
|
|
1741
|
-
return typeof value === 'object' && type !== 'contact_reference';
|
|
1742
|
-
});
|
|
1743
|
-
const { anomalies } = normalizeContactProperties(values, { fields });
|
|
1744
|
-
if (invalidShape || anomalies.length) {
|
|
1745
|
-
const key = invalidShape?.[0] ?? anomalies[0].key;
|
|
1746
|
-
return contactInputError('validation_failed', `custom_properties.${key} does not match its registered ${fields[key].type} definition or options`);
|
|
1747
|
-
}
|
|
1748
|
-
propertyWarnings = unknownCustomPropertyWarnings(custom_properties, fields);
|
|
1749
|
-
}
|
|
1750
|
-
const embedOk = await hasCompaniesEmbed(projectId);
|
|
1751
|
-
const updates = {};
|
|
1752
|
-
let customPropertyChanges = [];
|
|
1753
|
-
if (email !== undefined)
|
|
1754
|
-
updates.email = email;
|
|
1755
|
-
if (first_name !== undefined)
|
|
1756
|
-
updates.first_name = first_name;
|
|
1757
|
-
if (last_name !== undefined)
|
|
1758
|
-
updates.last_name = last_name;
|
|
1759
|
-
if (company_id !== undefined) {
|
|
1760
|
-
updates.company_id = company_id;
|
|
1761
|
-
}
|
|
1762
|
-
else if (company_name || company_domain) {
|
|
1763
|
-
updates.company_id = await resolveCompanyId(projectId, {
|
|
1764
|
-
company_name: company_name ?? undefined,
|
|
1765
|
-
company_domain: company_domain ?? undefined,
|
|
1766
|
-
});
|
|
1767
|
-
}
|
|
1768
|
-
if (tags !== undefined)
|
|
1769
|
-
updates.tags = tags;
|
|
1770
|
-
if (custom_properties !== undefined) {
|
|
1771
|
-
const { data: existing, error: existingError } = await db()
|
|
1772
|
-
.from('subscribers')
|
|
1773
|
-
.select('custom_properties')
|
|
1774
|
-
.eq('id', contact_id)
|
|
1775
|
-
.eq('project_id', projectId)
|
|
1776
|
-
.maybeSingle();
|
|
1777
|
-
if (existingError)
|
|
1778
|
-
return j({ error: 'Contact properties unavailable; update not performed', code: 'INTERNAL_ERROR' });
|
|
1779
|
-
if (!existing)
|
|
1780
|
-
return contactInputError('not_found', 'Contact not found');
|
|
1781
|
-
const orgId = await getProjectOrgId(projectId);
|
|
1782
|
-
const normalizedPatch = await normalizeMcpContactPropertyPatch(custom_properties, {
|
|
1783
|
-
orgId,
|
|
1784
|
-
projectId,
|
|
1785
|
-
subscriberId: contact_id,
|
|
1786
|
-
preserveNullDeletions: true,
|
|
1787
|
-
fields,
|
|
1788
|
-
});
|
|
1789
|
-
customPropertyChanges = diffCustomProperties(existing?.custom_properties ?? null, normalizedPatch);
|
|
1790
|
-
updates.custom_properties = {
|
|
1791
|
-
...(existing?.custom_properties ?? {}),
|
|
1792
|
-
...normalizedPatch,
|
|
1793
|
-
...(company_name ? { company_name } : {}),
|
|
1794
|
-
...(company_domain ? { company_domain: normalizeCompanyDomain(company_domain) } : {}),
|
|
1795
|
-
};
|
|
1796
|
-
for (const [key, value] of Object.entries(normalizedPatch)) {
|
|
1797
|
-
if (value === null)
|
|
1798
|
-
delete updates.custom_properties[key];
|
|
1799
|
-
}
|
|
1800
|
-
}
|
|
1801
|
-
else if (company_name || company_domain) {
|
|
1802
|
-
const { data: existing } = await db()
|
|
1803
|
-
.from('subscribers')
|
|
1804
|
-
.select('custom_properties')
|
|
1805
|
-
.eq('id', contact_id)
|
|
1806
|
-
.eq('project_id', projectId)
|
|
1807
|
-
.maybeSingle();
|
|
1808
|
-
const orgId = await getProjectOrgId(projectId);
|
|
1809
|
-
const companyPatch = await normalizeMcpContactPropertyPatch({
|
|
1810
|
-
...(company_name ? { company_name } : {}),
|
|
1811
|
-
...(company_domain ? { company_domain: normalizeCompanyDomain(company_domain) } : {}),
|
|
1812
|
-
}, { orgId, projectId, subscriberId: contact_id });
|
|
1813
|
-
updates.custom_properties = {
|
|
1814
|
-
...(existing?.custom_properties ?? {}),
|
|
1815
|
-
...companyPatch,
|
|
1816
|
-
};
|
|
1817
|
-
}
|
|
1818
|
-
if (phone_e164 !== undefined) {
|
|
1819
|
-
if (phone_e164 === null) {
|
|
1820
|
-
updates.phone_e164 = null;
|
|
1821
|
-
}
|
|
1822
|
-
else {
|
|
1823
|
-
const normalized = normalizePhoneE164(phone_e164);
|
|
1824
|
-
if (!normalized)
|
|
1825
|
-
return contactInputError('validation_failed', 'Invalid phone_e164: must be a valid E.164 phone number (SMS is US-only for now).');
|
|
1826
|
-
updates.phone_e164 = normalized;
|
|
1827
|
-
}
|
|
1828
|
-
}
|
|
1829
|
-
if (timezone !== undefined) {
|
|
1830
|
-
if (timezone === null) {
|
|
1831
|
-
updates.timezone = null;
|
|
1832
|
-
}
|
|
1833
|
-
else {
|
|
1834
|
-
// Region form only; mirrors normalizeTimezoneField in the dashboard. Intl accepts UTC offsets
|
|
1835
|
-
// ("+05:00") and abbreviations ("EST"), which Postgres AT TIME ZONE reads differently.
|
|
1836
|
-
if (timezone !== 'UTC' && !/^[A-Za-z_]+(\/[A-Za-z0-9_+-]+)+$/.test(timezone)) {
|
|
1837
|
-
return contactInputError('validation_failed', 'Invalid timezone: must be an IANA region name such as America/Los_Angeles (not a UTC offset or abbreviation).');
|
|
1838
|
-
}
|
|
1816
|
+
try {
|
|
1817
|
+
const projectId = getProjectId();
|
|
1818
|
+
let propertyWarnings = [];
|
|
1819
|
+
let fields;
|
|
1820
|
+
if (custom_properties !== undefined) {
|
|
1821
|
+
let definitions;
|
|
1839
1822
|
try {
|
|
1840
|
-
|
|
1823
|
+
definitions = await registeredCustomFields(projectId);
|
|
1841
1824
|
}
|
|
1842
1825
|
catch {
|
|
1843
|
-
return
|
|
1826
|
+
return j({ error: 'Custom field definitions unavailable; update not performed', code: 'INTERNAL_ERROR' });
|
|
1844
1827
|
}
|
|
1845
|
-
|
|
1828
|
+
fields = Object.fromEntries(definitions.map(field => [field.key, field]));
|
|
1829
|
+
const values = Object.fromEntries(Object.entries(custom_properties).filter(([, value]) => value !== null));
|
|
1830
|
+
const invalidShape = Object.entries(values).find(([key, value]) => {
|
|
1831
|
+
if (!Object.hasOwn(fields, key))
|
|
1832
|
+
return false;
|
|
1833
|
+
const type = fields[key].type;
|
|
1834
|
+
if (type === 'multi_select')
|
|
1835
|
+
return typeof value !== 'string' && (!Array.isArray(value) || value.some(item => typeof item !== 'string'));
|
|
1836
|
+
return typeof value === 'object' && type !== 'contact_reference';
|
|
1837
|
+
});
|
|
1838
|
+
const { anomalies } = normalizeContactProperties(values, { fields });
|
|
1839
|
+
if (invalidShape || anomalies.length) {
|
|
1840
|
+
const key = invalidShape?.[0] ?? anomalies[0].key;
|
|
1841
|
+
return contactInputError('validation_failed', `custom_properties.${key} does not match its registered ${fields[key].type} definition or options`);
|
|
1842
|
+
}
|
|
1843
|
+
propertyWarnings = unknownCustomPropertyWarnings(custom_properties, fields);
|
|
1846
1844
|
}
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1845
|
+
const embedOk = await hasCompaniesEmbed(projectId);
|
|
1846
|
+
const updates = {};
|
|
1847
|
+
let customPropertyChanges = [];
|
|
1848
|
+
if (email !== undefined)
|
|
1849
|
+
updates.email = email;
|
|
1850
|
+
if (first_name !== undefined)
|
|
1851
|
+
updates.first_name = first_name;
|
|
1852
|
+
if (last_name !== undefined)
|
|
1853
|
+
updates.last_name = last_name;
|
|
1854
|
+
if (company_id !== undefined) {
|
|
1855
|
+
updates.company_id = company_id;
|
|
1856
|
+
}
|
|
1857
|
+
else if (company_name || company_domain) {
|
|
1858
|
+
updates.company_id = await resolveCompanyId(projectId, {
|
|
1859
|
+
company_name: company_name ?? undefined,
|
|
1860
|
+
company_domain: company_domain ?? undefined,
|
|
1861
|
+
});
|
|
1862
|
+
}
|
|
1863
|
+
if (tags !== undefined)
|
|
1864
|
+
updates.tags = tags;
|
|
1865
|
+
if (custom_properties !== undefined) {
|
|
1866
|
+
const { data: existing, error: existingError } = await db()
|
|
1855
1867
|
.from('subscribers')
|
|
1856
|
-
.select('
|
|
1868
|
+
.select('custom_properties')
|
|
1857
1869
|
.eq('id', contact_id)
|
|
1858
1870
|
.eq('project_id', projectId)
|
|
1859
1871
|
.maybeSingle();
|
|
1860
|
-
if (
|
|
1861
|
-
|
|
1872
|
+
if (existingError)
|
|
1873
|
+
return j({ error: 'Contact properties unavailable; update not performed', code: 'INTERNAL_ERROR' });
|
|
1874
|
+
if (!existing)
|
|
1875
|
+
return contactInputError('not_found', 'Contact not found');
|
|
1876
|
+
const orgId = await getProjectOrgId(projectId);
|
|
1877
|
+
const normalizedPatch = await normalizeMcpContactPropertyPatch(custom_properties, {
|
|
1878
|
+
orgId,
|
|
1879
|
+
projectId,
|
|
1880
|
+
subscriberId: contact_id,
|
|
1881
|
+
preserveNullDeletions: true,
|
|
1882
|
+
fields,
|
|
1883
|
+
});
|
|
1884
|
+
customPropertyChanges = diffCustomProperties(existing?.custom_properties ?? null, normalizedPatch);
|
|
1885
|
+
updates.custom_properties = {
|
|
1886
|
+
...(existing?.custom_properties ?? {}),
|
|
1887
|
+
...normalizedPatch,
|
|
1888
|
+
...(company_name ? { company_name } : {}),
|
|
1889
|
+
...(company_domain ? { company_domain: normalizeCompanyDomain(company_domain) } : {}),
|
|
1890
|
+
};
|
|
1891
|
+
for (const [key, value] of Object.entries(normalizedPatch)) {
|
|
1892
|
+
if (value === null)
|
|
1893
|
+
delete updates.custom_properties[key];
|
|
1862
1894
|
}
|
|
1863
1895
|
}
|
|
1864
|
-
|
|
1865
|
-
|
|
1896
|
+
else if (company_name || company_domain) {
|
|
1897
|
+
const { data: existing } = await db()
|
|
1898
|
+
.from('subscribers')
|
|
1899
|
+
.select('custom_properties')
|
|
1900
|
+
.eq('id', contact_id)
|
|
1901
|
+
.eq('project_id', projectId)
|
|
1902
|
+
.maybeSingle();
|
|
1903
|
+
const orgId = await getProjectOrgId(projectId);
|
|
1904
|
+
const companyPatch = await normalizeMcpContactPropertyPatch({
|
|
1905
|
+
...(company_name ? { company_name } : {}),
|
|
1906
|
+
...(company_domain ? { company_domain: normalizeCompanyDomain(company_domain) } : {}),
|
|
1907
|
+
}, { orgId, projectId, subscriberId: contact_id });
|
|
1908
|
+
updates.custom_properties = {
|
|
1909
|
+
...(existing?.custom_properties ?? {}),
|
|
1910
|
+
...companyPatch,
|
|
1911
|
+
};
|
|
1866
1912
|
}
|
|
1867
|
-
if (
|
|
1868
|
-
|
|
1913
|
+
if (phone_e164 !== undefined) {
|
|
1914
|
+
if (phone_e164 === null) {
|
|
1915
|
+
updates.phone_e164 = null;
|
|
1916
|
+
}
|
|
1917
|
+
else {
|
|
1918
|
+
const normalized = normalizePhoneE164(phone_e164);
|
|
1919
|
+
if (!normalized)
|
|
1920
|
+
return contactInputError('validation_failed', 'Invalid phone_e164: must be a valid E.164 phone number (SMS is US-only for now).');
|
|
1921
|
+
updates.phone_e164 = normalized;
|
|
1922
|
+
}
|
|
1869
1923
|
}
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1924
|
+
if (timezone !== undefined) {
|
|
1925
|
+
if (timezone === null) {
|
|
1926
|
+
updates.timezone = null;
|
|
1927
|
+
}
|
|
1928
|
+
else {
|
|
1929
|
+
// Region form only; mirrors normalizeTimezoneField in the dashboard. Intl accepts UTC offsets
|
|
1930
|
+
// ("+05:00") and abbreviations ("EST"), which Postgres AT TIME ZONE reads differently.
|
|
1931
|
+
if (timezone !== 'UTC' && !/^[A-Za-z_]+(\/[A-Za-z0-9_+-]+)+$/.test(timezone)) {
|
|
1932
|
+
return contactInputError('validation_failed', 'Invalid timezone: must be an IANA region name such as America/Los_Angeles (not a UTC offset or abbreviation).');
|
|
1933
|
+
}
|
|
1934
|
+
try {
|
|
1935
|
+
new Intl.DateTimeFormat('en-US', { timeZone: timezone });
|
|
1936
|
+
}
|
|
1937
|
+
catch {
|
|
1938
|
+
return contactInputError('validation_failed', 'Invalid timezone: must be a valid IANA time zone identifier.');
|
|
1939
|
+
}
|
|
1940
|
+
updates.timezone = timezone;
|
|
1877
1941
|
}
|
|
1878
1942
|
}
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1943
|
+
if (!Object.keys(updates).length)
|
|
1944
|
+
return contactInputError('validation_failed', 'No fields to update.');
|
|
1945
|
+
const tagResult = {};
|
|
1946
|
+
if (tags !== undefined) {
|
|
1947
|
+
let previousTags;
|
|
1948
|
+
try {
|
|
1949
|
+
const { data: existing, error: readError } = await db()
|
|
1950
|
+
.from('subscribers')
|
|
1951
|
+
.select('tags')
|
|
1952
|
+
.eq('id', contact_id)
|
|
1953
|
+
.eq('project_id', projectId)
|
|
1954
|
+
.maybeSingle();
|
|
1955
|
+
if (!readError && Array.isArray(existing?.tags) && existing.tags.every((tag) => typeof tag === 'string')) {
|
|
1956
|
+
previousTags = existing.tags;
|
|
1957
|
+
}
|
|
1958
|
+
}
|
|
1959
|
+
catch {
|
|
1960
|
+
// The replacement can still succeed, but the previous set is unknown.
|
|
1961
|
+
}
|
|
1962
|
+
if (previousTags === undefined) {
|
|
1963
|
+
tagResult.warnings = ['Tags were REPLACED, but previous tags could not be read; removed and added tags are unknown. Use set_subscriber_tags to add tags without replacing the whole set.'];
|
|
1964
|
+
}
|
|
1965
|
+
else {
|
|
1966
|
+
const previousSet = new Set(previousTags);
|
|
1967
|
+
const nextSet = new Set(tags);
|
|
1968
|
+
tagResult.tags_removed = [...previousSet].filter((tag) => !nextSet.has(tag));
|
|
1969
|
+
tagResult.tags_added = [...nextSet].filter((tag) => !previousSet.has(tag));
|
|
1970
|
+
if (tagResult.tags_removed.length > 0) {
|
|
1971
|
+
tagResult.warnings = ['Tags were REPLACED. Use set_subscriber_tags to add tags without replacing the whole set.'];
|
|
1972
|
+
}
|
|
1973
|
+
}
|
|
1974
|
+
}
|
|
1975
|
+
updates.updated_at = new Date().toISOString();
|
|
1976
|
+
const { data, error } = await db()
|
|
1977
|
+
.from('subscribers')
|
|
1978
|
+
.update(updates)
|
|
1979
|
+
.eq('id', contact_id)
|
|
1980
|
+
.eq('project_id', projectId)
|
|
1981
|
+
.select(embedOk ? UPDATE_SUBSCRIBER_SELECT_WITH_COMPANY : UPDATE_SUBSCRIBER_SELECT_NO_COMPANY)
|
|
1982
|
+
.single();
|
|
1983
|
+
if (error?.code === '23505' && updates.phone_e164 !== undefined) {
|
|
1984
|
+
return contactInputError('conflict', 'Another contact in this project already has this phone number.');
|
|
1985
|
+
}
|
|
1986
|
+
if (error)
|
|
1987
|
+
return dbErrorResult(error);
|
|
1988
|
+
if (customPropertyChanges.length > 0) {
|
|
1989
|
+
logAudit({
|
|
1990
|
+
action: 'contact.custom_properties_changed',
|
|
1991
|
+
resourceType: 'contact',
|
|
1992
|
+
resourceId: contact_id,
|
|
1993
|
+
details: { changes: customPropertyChanges },
|
|
1994
|
+
});
|
|
1995
|
+
}
|
|
1996
|
+
await writeBackMcpContact(projectId, data, {
|
|
1997
|
+
...(updates.email !== undefined ? { email: updates.email } : {}),
|
|
1998
|
+
...(updates.first_name !== undefined ? { first_name: updates.first_name } : {}),
|
|
1999
|
+
...(updates.last_name !== undefined ? { last_name: updates.last_name } : {}),
|
|
2000
|
+
...(updates.tags !== undefined ? { tags: updates.tags } : {}),
|
|
2001
|
+
...(updates.custom_properties !== undefined ? { custom_properties: updates.custom_properties } : {}),
|
|
1899
2002
|
});
|
|
2003
|
+
const mergedWarnings = [...(tagResult.warnings ?? []), ...propertyWarnings];
|
|
2004
|
+
return j({ ...data, ...tagResult, ...(mergedWarnings.length ? { warnings: mergedWarnings } : {}) });
|
|
2005
|
+
}
|
|
2006
|
+
catch (error) {
|
|
2007
|
+
return dbErrorResult(error);
|
|
1900
2008
|
}
|
|
1901
|
-
await writeBackMcpContact(projectId, data, {
|
|
1902
|
-
...(updates.email !== undefined ? { email: updates.email } : {}),
|
|
1903
|
-
...(updates.first_name !== undefined ? { first_name: updates.first_name } : {}),
|
|
1904
|
-
...(updates.last_name !== undefined ? { last_name: updates.last_name } : {}),
|
|
1905
|
-
...(updates.tags !== undefined ? { tags: updates.tags } : {}),
|
|
1906
|
-
...(updates.custom_properties !== undefined ? { custom_properties: updates.custom_properties } : {}),
|
|
1907
|
-
});
|
|
1908
|
-
const mergedWarnings = [...(tagResult.warnings ?? []), ...propertyWarnings];
|
|
1909
|
-
return j({ ...data, ...tagResult, ...(mergedWarnings.length ? { warnings: mergedWarnings } : {}) });
|
|
1910
2009
|
});
|
|
1911
2010
|
server.registerTool('get_subscriber', {
|
|
1912
2011
|
title: 'Get Subscriber',
|
|
@@ -2045,18 +2144,23 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
2045
2144
|
},
|
|
2046
2145
|
}, async ({ contact_ids, add_tags = [], remove_tags = [] }) => {
|
|
2047
2146
|
if (!add_tags.length && !remove_tags.length)
|
|
2048
|
-
return
|
|
2147
|
+
return toolError({ error: 'No tags to add or remove.', code: 'validation_failed' });
|
|
2049
2148
|
const projectId = getProjectId();
|
|
2050
2149
|
let updated = 0;
|
|
2150
|
+
const missing_contact_ids = [];
|
|
2051
2151
|
for (const cid of contact_ids) {
|
|
2052
|
-
const { data: contact } = await db()
|
|
2152
|
+
const { data: contact, error: readError } = await db()
|
|
2053
2153
|
.from('subscribers')
|
|
2054
2154
|
.select('id, email, first_name, last_name, custom_properties, tags')
|
|
2055
2155
|
.eq('id', cid)
|
|
2056
2156
|
.eq('project_id', projectId)
|
|
2057
2157
|
.maybeSingle();
|
|
2058
|
-
if (
|
|
2158
|
+
if (readError)
|
|
2159
|
+
return toolError({ ...dbToolError(readError), updated });
|
|
2160
|
+
if (!contact) {
|
|
2161
|
+
missing_contact_ids.push(cid);
|
|
2059
2162
|
continue;
|
|
2163
|
+
}
|
|
2060
2164
|
const currentTags = contact.tags ?? [];
|
|
2061
2165
|
const removeSet = new Set(remove_tags);
|
|
2062
2166
|
const merged = currentTags.filter((t) => !removeSet.has(t));
|
|
@@ -2064,17 +2168,25 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
2064
2168
|
if (!merged.includes(t))
|
|
2065
2169
|
merged.push(t);
|
|
2066
2170
|
}
|
|
2067
|
-
const { error } = await db()
|
|
2171
|
+
const { data: saved, error } = await db()
|
|
2068
2172
|
.from('subscribers')
|
|
2069
2173
|
.update({ tags: merged })
|
|
2070
2174
|
.eq('id', cid)
|
|
2071
|
-
.eq('project_id', projectId)
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2175
|
+
.eq('project_id', projectId)
|
|
2176
|
+
.select('id').maybeSingle();
|
|
2177
|
+
if (error)
|
|
2178
|
+
return toolError({ ...dbToolError(error), updated });
|
|
2179
|
+
if (!saved) {
|
|
2180
|
+
missing_contact_ids.push(cid);
|
|
2181
|
+
continue;
|
|
2075
2182
|
}
|
|
2183
|
+
updated++;
|
|
2184
|
+
await writeBackMcpContact(projectId, contact, { tags: merged });
|
|
2185
|
+
await logAudit({ action: 'subscriber.tags_updated', resourceType: 'subscriber', resourceId: cid, details: { add_tags, remove_tags } });
|
|
2076
2186
|
}
|
|
2077
|
-
|
|
2187
|
+
if (!updated)
|
|
2188
|
+
return toolError({ error: 'Subscriber not found', code: 'not_found', updated, missing_contact_ids });
|
|
2189
|
+
return j({ missing_contact_ids, updated, total: contact_ids.length, add_tags, remove_tags });
|
|
2078
2190
|
});
|
|
2079
2191
|
server.registerTool('bulk_update_subscribers', {
|
|
2080
2192
|
title: 'Bulk Update Subscribers',
|