@sendinel/mcp-server 1.0.138 → 1.0.140
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 +2 -2
- package/build/lib/db-errors.d.ts +12 -0
- package/build/lib/db-errors.js +20 -0
- package/build/lib/url-validation.d.ts +3 -0
- package/build/lib/url-validation.js +15 -0
- package/build/tools/approvals.js +3 -2
- package/build/tools/campaigns.d.ts +2 -1
- package/build/tools/campaigns.js +122 -44
- package/build/tools/contacts.js +375 -267
- package/build/tools/content.js +9 -0
- package/build/tools/delivery-ops.js +6 -2
- package/build/tools/forms.js +30 -3
- package/build/tools/newsletters.js +41 -9
- package/build/tools/segments.js +57 -46
- package/build/tools/short-links.js +20 -14
- package/build/tools/surveys.js +41 -4
- package/build/tools/tool-search.js +1 -0
- package/build/tools/webhooks.js +2 -2
- 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';
|
|
@@ -24,6 +25,40 @@ const CUSTOM_FIELD_TYPES = [
|
|
|
24
25
|
'multi_select', 'contact_reference',
|
|
25
26
|
];
|
|
26
27
|
const CUSTOM_FIELD_KEY_RE = /^[a-z][a-z0-9_]{0,49}$/;
|
|
28
|
+
// Core contact columns from 001_core_schema.sql and subsequent subscriber migrations.
|
|
29
|
+
const RESERVED_CUSTOM_FIELD_KEYS = new Set([
|
|
30
|
+
'email', 'first_name', 'last_name', 'id', 'phone', 'status', 'tags', 'project_id', 'org_id', 'user_id',
|
|
31
|
+
'created_at', 'updated_at', 'clerk_user_id', 'global_unsubscribed', 'global_unsubscribed_at',
|
|
32
|
+
'source', 'source_site', 'ip_address', 'opted_in_at', 'last_emailed_at', 'last_opened_at',
|
|
33
|
+
'last_clicked_at', 'total_emails_sent', 'total_opens', 'total_clicks', 'engagement_score',
|
|
34
|
+
'lifecycle_stage', 'score_updated_at', 'custom_properties', 'timezone', 'last_engaged_at',
|
|
35
|
+
'utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term', 'best_send_hour',
|
|
36
|
+
'best_send_hour_confidence', 'best_send_hour_updated_at', 'notes', 'birthday', 'anniversary',
|
|
37
|
+
'external_id', 'imported_from', 'phone_e164', 'sms_opt_in_status', 'sms_opt_in_at',
|
|
38
|
+
'sms_opt_out_at', 'is_test', 'company_id', 'source_deleted_at', 'cooled_at', 'cooled_reason',
|
|
39
|
+
'introduced_by_subscriber_id', 'ccpa_opted_out', 'last_transactional_at',
|
|
40
|
+
'total_transactional_sent', 'last_activity_at',
|
|
41
|
+
]);
|
|
42
|
+
const CUSTOM_FIELD_COLUMNS = 'id, key, label, type, required, options, display_order';
|
|
43
|
+
async function registeredCustomFields(projectId) {
|
|
44
|
+
return readAllRows(db().from('custom_field_definitions').select(CUSTOM_FIELD_COLUMNS).eq('project_id', projectId));
|
|
45
|
+
}
|
|
46
|
+
function keyDistance(left, right) {
|
|
47
|
+
let row = Array.from({ length: right.length + 1 }, (_, index) => index);
|
|
48
|
+
for (let i = 0; i < left.length; i++) {
|
|
49
|
+
const next = [i + 1];
|
|
50
|
+
for (let j = 0; j < right.length; j++)
|
|
51
|
+
next.push(Math.min(next[j] + 1, row[j + 1] + 1, row[j] + (left[i] === right[j] ? 0 : 1)));
|
|
52
|
+
row = next;
|
|
53
|
+
}
|
|
54
|
+
return row[right.length];
|
|
55
|
+
}
|
|
56
|
+
function unknownCustomPropertyWarnings(patch, fields) {
|
|
57
|
+
return Object.keys(patch).filter(key => !Object.hasOwn(fields, key)).map(key => {
|
|
58
|
+
const near = Object.keys(fields).filter(known => keyDistance(key, known) <= 2);
|
|
59
|
+
return `Unknown custom property "${key}"${near.length ? `; did you mean ${near.map(value => `"${value}"`).join(' or ')}?` : '; no registered definition'}`;
|
|
60
|
+
});
|
|
61
|
+
}
|
|
27
62
|
const SUBSCRIBER_VIEW_MAX_NAME_LENGTH = 100;
|
|
28
63
|
const SUBSCRIBER_VIEW_MAX_COLUMNS = 150;
|
|
29
64
|
const SUBSCRIBER_VIEW_KEY_RE = /^[a-zA-Z0-9_.:-]{1,160}$/;
|
|
@@ -1219,133 +1254,138 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1219
1254
|
utm_term: z.string().optional(),
|
|
1220
1255
|
},
|
|
1221
1256
|
}, 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 }) => {
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
// Validate project ownership before the cap, and reuse the double opt-in lookup.
|
|
1232
|
-
const { data: siteInfo, error: siteError } = await db().from('sites')
|
|
1233
|
-
.select('double_opt_in').eq('id', site_id).eq('project_id', projectId).maybeSingle();
|
|
1234
|
-
if (siteError)
|
|
1235
|
-
throw new Error(`Site lookup failed: ${siteError.message}`);
|
|
1236
|
-
if (!siteInfo)
|
|
1237
|
-
return contactInputError('not_found', 'Site not found for this project.');
|
|
1238
|
-
const needsConfirm = siteInfo.double_opt_in === true;
|
|
1239
|
-
const limit = await checkPlanLimit('contacts');
|
|
1240
|
-
if (!limit.allowed) {
|
|
1241
|
-
return contactInputError('plan_limit', contactLimitMessage(limit));
|
|
1242
|
-
}
|
|
1243
|
-
const embedOk = await hasCompaniesEmbed(projectId);
|
|
1244
|
-
const resolvedCompanyId = await resolveCompanyId(projectId, { company_id, company_name, company_domain });
|
|
1245
|
-
let { data: existingContact, error: lookupError } = await db()
|
|
1246
|
-
.from('subscribers')
|
|
1247
|
-
.select('id, source, first_name, last_name, company_id, custom_properties, opted_in_at')
|
|
1248
|
-
.eq('project_id', projectId)
|
|
1249
|
-
.eq('email', email)
|
|
1250
|
-
.maybeSingle();
|
|
1251
|
-
if (lookupError)
|
|
1252
|
-
return txt(`Error looking up contact: ${lookupError.message}`);
|
|
1253
|
-
let customProperties;
|
|
1254
|
-
if (company_name || company_domain) {
|
|
1255
|
-
const orgId = await getProjectOrgId(projectId);
|
|
1256
|
-
customProperties = await normalizeMcpContactPropertyPatch({
|
|
1257
|
-
...(company_name ? { company_name } : {}),
|
|
1258
|
-
...(company_domain ? { company_domain: company_domain.trim().toLowerCase() } : {}),
|
|
1259
|
-
}, { orgId, projectId });
|
|
1260
|
-
}
|
|
1261
|
-
const selection = embedOk ? '*, company:companies(id, name, domain)' : '*';
|
|
1262
|
-
let contact;
|
|
1263
|
-
if (!existingContact) {
|
|
1264
|
-
const row = { project_id: projectId, email, first_name, last_name, company_id: resolvedCompanyId, source, source_site: site_id };
|
|
1265
|
-
if (customProperties)
|
|
1266
|
-
row.custom_properties = customProperties;
|
|
1267
|
-
if (!needsConfirm)
|
|
1268
|
-
row.opted_in_at = new Date().toISOString();
|
|
1269
|
-
if (utm_source)
|
|
1270
|
-
row.utm_source = utm_source;
|
|
1271
|
-
if (utm_medium)
|
|
1272
|
-
row.utm_medium = utm_medium;
|
|
1273
|
-
if (utm_campaign)
|
|
1274
|
-
row.utm_campaign = utm_campaign;
|
|
1275
|
-
if (utm_content)
|
|
1276
|
-
row.utm_content = utm_content;
|
|
1277
|
-
if (utm_term)
|
|
1278
|
-
row.utm_term = utm_term;
|
|
1279
|
-
const { data, error } = await db().from('subscribers')
|
|
1280
|
-
.upsert(row, { onConflict: 'project_id,email', ignoreDuplicates: true })
|
|
1281
|
-
.select(selection).maybeSingle();
|
|
1282
|
-
if (error)
|
|
1283
|
-
return txt(`Error creating contact: ${error.message}`);
|
|
1284
|
-
contact = data;
|
|
1285
|
-
if (!contact) {
|
|
1286
|
-
// A concurrent add won the unique key; its provenance belongs to that insert.
|
|
1287
|
-
const result = await db().from('subscribers')
|
|
1288
|
-
.select('id, source, first_name, last_name, company_id, custom_properties, opted_in_at')
|
|
1289
|
-
.eq('project_id', projectId).eq('email', email).single();
|
|
1290
|
-
if (result.error)
|
|
1291
|
-
return txt(`Error looking up contact: ${result.error.message}`);
|
|
1292
|
-
existingContact = result.data;
|
|
1257
|
+
try {
|
|
1258
|
+
email = email.trim().toLowerCase();
|
|
1259
|
+
const projectId = getProjectId();
|
|
1260
|
+
// Block distribution list / system addresses
|
|
1261
|
+
const DISTRO_LOCAL_PARTS = new Set(['all', 'everyone', 'distro', 'broadcast', 'announce', 'postmaster', 'abuse', 'mailer-daemon', 'bounce', 'bounces', 'noreply', 'no-reply', 'nobody', 'null', 'void']);
|
|
1262
|
+
const DISTRO_PATTERNS = [/^all[-+.]/, /[-+.]all$/, /^list[-+.]/, /[-+.]list$/, /[-+.]announce$/, /^announce[-+.]/, /[-+.]distro$/, /^distro[-+.]/, /[-+.]broadcast$/, /^broadcast[-+.]/];
|
|
1263
|
+
const local = email.trim().toLowerCase().split('@')[0] ?? '';
|
|
1264
|
+
if (DISTRO_LOCAL_PARTS.has(local) || DISTRO_PATTERNS.some(p => p.test(local))) {
|
|
1265
|
+
return contactInputError('validation_failed', `Blocked: "${email}" looks like a group or distribution list address. Sendinel sends to individual people only.`);
|
|
1293
1266
|
}
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1267
|
+
// Validate project ownership before the cap, and reuse the double opt-in lookup.
|
|
1268
|
+
const { data: siteInfo, error: siteError } = await db().from('sites')
|
|
1269
|
+
.select('double_opt_in').eq('id', site_id).eq('project_id', projectId).maybeSingle();
|
|
1270
|
+
if (siteError)
|
|
1271
|
+
return dbErrorResult(siteError);
|
|
1272
|
+
if (!siteInfo)
|
|
1273
|
+
return contactInputError('not_found', 'Site not found for this project.');
|
|
1274
|
+
const needsConfirm = siteInfo.double_opt_in === true;
|
|
1275
|
+
const limit = await checkPlanLimit('contacts');
|
|
1276
|
+
if (!limit.allowed) {
|
|
1277
|
+
return contactInputError('plan_limit', contactLimitMessage(limit));
|
|
1302
1278
|
}
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
.
|
|
1307
|
-
|
|
1308
|
-
|
|
1279
|
+
const embedOk = await hasCompaniesEmbed(projectId);
|
|
1280
|
+
const resolvedCompanyId = await resolveCompanyId(projectId, { company_id, company_name, company_domain });
|
|
1281
|
+
let { data: existingContact, error: lookupError } = await db()
|
|
1282
|
+
.from('subscribers')
|
|
1283
|
+
.select('id, source, first_name, last_name, company_id, custom_properties, opted_in_at')
|
|
1284
|
+
.eq('project_id', projectId)
|
|
1285
|
+
.eq('email', email)
|
|
1286
|
+
.maybeSingle();
|
|
1287
|
+
if (lookupError)
|
|
1288
|
+
return dbErrorResult(lookupError);
|
|
1289
|
+
let customProperties;
|
|
1290
|
+
if (company_name || company_domain) {
|
|
1291
|
+
const orgId = await getProjectOrgId(projectId);
|
|
1292
|
+
customProperties = await normalizeMcpContactPropertyPatch({
|
|
1293
|
+
...(company_name ? { company_name } : {}),
|
|
1294
|
+
...(company_domain ? { company_domain: company_domain.trim().toLowerCase() } : {}),
|
|
1295
|
+
}, { orgId, projectId });
|
|
1309
1296
|
}
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1297
|
+
const selection = embedOk ? '*, company:companies(id, name, domain)' : '*';
|
|
1298
|
+
let contact;
|
|
1299
|
+
if (!existingContact) {
|
|
1300
|
+
const row = { project_id: projectId, email, first_name, last_name, company_id: resolvedCompanyId, source, source_site: site_id };
|
|
1301
|
+
if (customProperties)
|
|
1302
|
+
row.custom_properties = customProperties;
|
|
1303
|
+
if (!needsConfirm)
|
|
1304
|
+
row.opted_in_at = new Date().toISOString();
|
|
1305
|
+
if (utm_source)
|
|
1306
|
+
row.utm_source = utm_source;
|
|
1307
|
+
if (utm_medium)
|
|
1308
|
+
row.utm_medium = utm_medium;
|
|
1309
|
+
if (utm_campaign)
|
|
1310
|
+
row.utm_campaign = utm_campaign;
|
|
1311
|
+
if (utm_content)
|
|
1312
|
+
row.utm_content = utm_content;
|
|
1313
|
+
if (utm_term)
|
|
1314
|
+
row.utm_term = utm_term;
|
|
1315
|
+
const { data, error } = await db().from('subscribers')
|
|
1316
|
+
.upsert(row, { onConflict: 'project_id,email', ignoreDuplicates: true })
|
|
1317
|
+
.select(selection).maybeSingle();
|
|
1320
1318
|
if (error)
|
|
1321
|
-
return
|
|
1319
|
+
return dbErrorResult(error);
|
|
1320
|
+
contact = data;
|
|
1321
|
+
if (!contact) {
|
|
1322
|
+
// A concurrent add won the unique key; its provenance belongs to that insert.
|
|
1323
|
+
const result = await db().from('subscribers')
|
|
1324
|
+
.select('id, source, first_name, last_name, company_id, custom_properties, opted_in_at')
|
|
1325
|
+
.eq('project_id', projectId).eq('email', email).single();
|
|
1326
|
+
if (result.error)
|
|
1327
|
+
return dbErrorResult(result.error);
|
|
1328
|
+
existingContact = result.data;
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
const existing = !!existingContact;
|
|
1332
|
+
if (existingContact) {
|
|
1333
|
+
const empty = (value) => value == null || value === '';
|
|
1334
|
+
const row = {};
|
|
1335
|
+
for (const [key, value] of Object.entries({ first_name, last_name, company_id: resolvedCompanyId })) {
|
|
1336
|
+
if (empty(existingContact[key]) && !empty(value))
|
|
1337
|
+
row[key] = value;
|
|
1338
|
+
}
|
|
1339
|
+
if (!needsConfirm && existingContact.opted_in_at == null)
|
|
1340
|
+
row.opted_in_at = new Date().toISOString();
|
|
1341
|
+
const missingProperties = Object.fromEntries(Object.entries(customProperties ?? {})
|
|
1342
|
+
.filter(([key]) => !Object.hasOwn(existingContact.custom_properties ?? {}, key)));
|
|
1343
|
+
if (Object.keys(missingProperties).length) {
|
|
1344
|
+
row.custom_properties = { ...(existingContact.custom_properties ?? {}), ...missingProperties };
|
|
1345
|
+
}
|
|
1346
|
+
if (Object.keys(row).length) {
|
|
1347
|
+
let update = db().from('subscribers').update(row)
|
|
1348
|
+
.eq('id', existingContact.id).eq('project_id', projectId);
|
|
1349
|
+
// The stored values must still match: a concurrent edit always wins over a re-add.
|
|
1350
|
+
for (const key of Object.keys(row)) {
|
|
1351
|
+
const previous = existingContact[key];
|
|
1352
|
+
update = previous == null ? update.is(key, null)
|
|
1353
|
+
: update.eq(key, key === 'custom_properties' ? JSON.stringify(previous) : previous);
|
|
1354
|
+
}
|
|
1355
|
+
const { error } = await update;
|
|
1356
|
+
if (error)
|
|
1357
|
+
return dbErrorResult(error);
|
|
1358
|
+
}
|
|
1359
|
+
const result = await db().from('subscribers').select(selection)
|
|
1360
|
+
.eq('id', existingContact.id).eq('project_id', projectId).single();
|
|
1361
|
+
if (result.error)
|
|
1362
|
+
return dbErrorResult(result.error);
|
|
1363
|
+
contact = result.data;
|
|
1364
|
+
}
|
|
1365
|
+
const existingMessage = existing ? { message: 'Contact already existed; source and existing fields were not changed.' } : {};
|
|
1366
|
+
const { error: subErr } = await db()
|
|
1367
|
+
.from('site_subscriptions')
|
|
1368
|
+
.upsert({ contact_id: contact.id, site_id, subscribed: !needsConfirm }, { onConflict: 'contact_id,site_id' });
|
|
1369
|
+
if (subErr)
|
|
1370
|
+
return dbErrorResult(subErr);
|
|
1371
|
+
if (existing)
|
|
1372
|
+
logAudit({ action: 'contact.readded', resourceType: 'contact', resourceId: contact.id, details: { email, requested_source: source, existing: true } });
|
|
1373
|
+
if (needsConfirm) {
|
|
1374
|
+
// Create opt-in token (confirmation email must be sent externally in MCP context)
|
|
1375
|
+
await db().from('opt_in_tokens').insert({ contact_id: contact.id, site_id });
|
|
1376
|
+
await writeBackMcpContact(projectId, contact, { email, first_name: contact.first_name ?? null, last_name: contact.last_name ?? null });
|
|
1377
|
+
logConsent({ contactId: contact.id, siteId: site_id, action: 'grant', consentType: 'email_marketing', source: 'mcp_pending_double_opt_in' });
|
|
1378
|
+
return { content: [{ type: 'text', text: JSON.stringify({ ...contact, existing, confirmation: 'pending', message: 'Double opt-in required. Confirmation token created.', ...existingMessage }, null, 2) }] };
|
|
1322
1379
|
}
|
|
1323
|
-
const result = await db().from('subscribers').select(selection)
|
|
1324
|
-
.eq('id', existingContact.id).eq('project_id', projectId).single();
|
|
1325
|
-
if (result.error)
|
|
1326
|
-
return txt(`Error reading contact: ${result.error.message}`);
|
|
1327
|
-
contact = result.data;
|
|
1328
|
-
}
|
|
1329
|
-
const existingMessage = existing ? { message: 'Contact already existed; source and existing fields were not changed.' } : {};
|
|
1330
|
-
const { error: subErr } = await db()
|
|
1331
|
-
.from('site_subscriptions')
|
|
1332
|
-
.upsert({ contact_id: contact.id, site_id, subscribed: !needsConfirm }, { onConflict: 'contact_id,site_id' });
|
|
1333
|
-
if (subErr)
|
|
1334
|
-
return { content: [{ type: 'text', text: `Contact created but subscription failed: ${subErr.message}` }] };
|
|
1335
|
-
if (existing)
|
|
1336
|
-
logAudit({ action: 'contact.readded', resourceType: 'contact', resourceId: contact.id, details: { email, requested_source: source, existing: true } });
|
|
1337
|
-
if (needsConfirm) {
|
|
1338
|
-
// Create opt-in token (confirmation email must be sent externally in MCP context)
|
|
1339
|
-
await db().from('opt_in_tokens').insert({ contact_id: contact.id, site_id });
|
|
1340
1380
|
await writeBackMcpContact(projectId, contact, { email, first_name: contact.first_name ?? null, last_name: contact.last_name ?? null });
|
|
1341
|
-
logConsent({ contactId: contact.id, siteId: site_id, action: 'grant', consentType: 'email_marketing', source: '
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1381
|
+
logConsent({ contactId: contact.id, siteId: site_id, action: 'grant', consentType: 'email_marketing', source: 'mcp_add_subscriber' });
|
|
1382
|
+
if (!existing)
|
|
1383
|
+
logAudit({ action: 'contact.created', resourceType: 'contact', resourceId: contact.id, details: { email, source } });
|
|
1384
|
+
return { content: [{ type: 'text', text: JSON.stringify({ ...contact, existing, ...existingMessage }, null, 2) }] };
|
|
1385
|
+
}
|
|
1386
|
+
catch (error) {
|
|
1387
|
+
return dbErrorResult(error);
|
|
1388
|
+
}
|
|
1349
1389
|
});
|
|
1350
1390
|
server.registerTool('import_subscribers', {
|
|
1351
1391
|
title: 'Import Subscribers',
|
|
@@ -1670,7 +1710,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1670
1710
|
idempotentHint: true,
|
|
1671
1711
|
openWorldHint: false,
|
|
1672
1712
|
},
|
|
1673
|
-
description: 'Update a contact\'s email, name, phone, timezone, tags, or custom properties. tags replaces the whole tag set; use set_subscriber_tags to add tags.',
|
|
1713
|
+
description: 'Update a contact\'s email, name, phone, timezone, tags, or custom properties. tags replaces the whole tag set; use set_subscriber_tags to add tags. Registered custom properties must match their type/options; unknown keys remain allowed with warnings and near-miss suggestions.',
|
|
1674
1714
|
inputSchema: {
|
|
1675
1715
|
contact_id: z.string().uuid(),
|
|
1676
1716
|
email: z.string().email().optional(),
|
|
@@ -1685,161 +1725,199 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1685
1725
|
timezone: z.string().nullable().optional().describe('IANA time zone identifier, e.g. America/Los_Angeles'),
|
|
1686
1726
|
},
|
|
1687
1727
|
}, async ({ contact_id, email, first_name, last_name, company_id, company_name, company_domain, tags, custom_properties, phone_e164, timezone }) => {
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
if (first_name !== undefined)
|
|
1695
|
-
updates.first_name = first_name;
|
|
1696
|
-
if (last_name !== undefined)
|
|
1697
|
-
updates.last_name = last_name;
|
|
1698
|
-
if (company_id !== undefined) {
|
|
1699
|
-
updates.company_id = company_id;
|
|
1700
|
-
}
|
|
1701
|
-
else if (company_name || company_domain) {
|
|
1702
|
-
updates.company_id = await resolveCompanyId(projectId, {
|
|
1703
|
-
company_name: company_name ?? undefined,
|
|
1704
|
-
company_domain: company_domain ?? undefined,
|
|
1705
|
-
});
|
|
1706
|
-
}
|
|
1707
|
-
if (tags !== undefined)
|
|
1708
|
-
updates.tags = tags;
|
|
1709
|
-
if (custom_properties !== undefined) {
|
|
1710
|
-
const { data: existing } = await db()
|
|
1711
|
-
.from('subscribers')
|
|
1712
|
-
.select('custom_properties')
|
|
1713
|
-
.eq('id', contact_id)
|
|
1714
|
-
.eq('project_id', projectId)
|
|
1715
|
-
.maybeSingle();
|
|
1716
|
-
const orgId = await getProjectOrgId(projectId);
|
|
1717
|
-
const normalizedPatch = await normalizeMcpContactPropertyPatch(custom_properties, {
|
|
1718
|
-
orgId,
|
|
1719
|
-
projectId,
|
|
1720
|
-
subscriberId: contact_id,
|
|
1721
|
-
preserveNullDeletions: true,
|
|
1722
|
-
});
|
|
1723
|
-
customPropertyChanges = diffCustomProperties(existing?.custom_properties ?? null, normalizedPatch);
|
|
1724
|
-
updates.custom_properties = {
|
|
1725
|
-
...(existing?.custom_properties ?? {}),
|
|
1726
|
-
...normalizedPatch,
|
|
1727
|
-
...(company_name ? { company_name } : {}),
|
|
1728
|
-
...(company_domain ? { company_domain: normalizeCompanyDomain(company_domain) } : {}),
|
|
1729
|
-
};
|
|
1730
|
-
for (const [key, value] of Object.entries(normalizedPatch)) {
|
|
1731
|
-
if (value === null)
|
|
1732
|
-
delete updates.custom_properties[key];
|
|
1733
|
-
}
|
|
1734
|
-
}
|
|
1735
|
-
else if (company_name || company_domain) {
|
|
1736
|
-
const { data: existing } = await db()
|
|
1737
|
-
.from('subscribers')
|
|
1738
|
-
.select('custom_properties')
|
|
1739
|
-
.eq('id', contact_id)
|
|
1740
|
-
.eq('project_id', projectId)
|
|
1741
|
-
.maybeSingle();
|
|
1742
|
-
const orgId = await getProjectOrgId(projectId);
|
|
1743
|
-
const companyPatch = await normalizeMcpContactPropertyPatch({
|
|
1744
|
-
...(company_name ? { company_name } : {}),
|
|
1745
|
-
...(company_domain ? { company_domain: normalizeCompanyDomain(company_domain) } : {}),
|
|
1746
|
-
}, { orgId, projectId, subscriberId: contact_id });
|
|
1747
|
-
updates.custom_properties = {
|
|
1748
|
-
...(existing?.custom_properties ?? {}),
|
|
1749
|
-
...companyPatch,
|
|
1750
|
-
};
|
|
1751
|
-
}
|
|
1752
|
-
if (phone_e164 !== undefined) {
|
|
1753
|
-
if (phone_e164 === null) {
|
|
1754
|
-
updates.phone_e164 = null;
|
|
1755
|
-
}
|
|
1756
|
-
else {
|
|
1757
|
-
const normalized = normalizePhoneE164(phone_e164);
|
|
1758
|
-
if (!normalized)
|
|
1759
|
-
return contactInputError('validation_failed', 'Invalid phone_e164: must be a valid E.164 phone number (SMS is US-only for now).');
|
|
1760
|
-
updates.phone_e164 = normalized;
|
|
1761
|
-
}
|
|
1762
|
-
}
|
|
1763
|
-
if (timezone !== undefined) {
|
|
1764
|
-
if (timezone === null) {
|
|
1765
|
-
updates.timezone = null;
|
|
1766
|
-
}
|
|
1767
|
-
else {
|
|
1768
|
-
// Region form only; mirrors normalizeTimezoneField in the dashboard. Intl accepts UTC offsets
|
|
1769
|
-
// ("+05:00") and abbreviations ("EST"), which Postgres AT TIME ZONE reads differently.
|
|
1770
|
-
if (timezone !== 'UTC' && !/^[A-Za-z_]+(\/[A-Za-z0-9_+-]+)+$/.test(timezone)) {
|
|
1771
|
-
return contactInputError('validation_failed', 'Invalid timezone: must be an IANA region name such as America/Los_Angeles (not a UTC offset or abbreviation).');
|
|
1772
|
-
}
|
|
1728
|
+
try {
|
|
1729
|
+
const projectId = getProjectId();
|
|
1730
|
+
let propertyWarnings = [];
|
|
1731
|
+
let fields;
|
|
1732
|
+
if (custom_properties !== undefined) {
|
|
1733
|
+
let definitions;
|
|
1773
1734
|
try {
|
|
1774
|
-
|
|
1735
|
+
definitions = await registeredCustomFields(projectId);
|
|
1775
1736
|
}
|
|
1776
1737
|
catch {
|
|
1777
|
-
return
|
|
1738
|
+
return j({ error: 'Custom field definitions unavailable; update not performed', code: 'INTERNAL_ERROR' });
|
|
1778
1739
|
}
|
|
1779
|
-
|
|
1740
|
+
fields = Object.fromEntries(definitions.map(field => [field.key, field]));
|
|
1741
|
+
const values = Object.fromEntries(Object.entries(custom_properties).filter(([, value]) => value !== null));
|
|
1742
|
+
const invalidShape = Object.entries(values).find(([key, value]) => {
|
|
1743
|
+
if (!Object.hasOwn(fields, key))
|
|
1744
|
+
return false;
|
|
1745
|
+
const type = fields[key].type;
|
|
1746
|
+
if (type === 'multi_select')
|
|
1747
|
+
return typeof value !== 'string' && (!Array.isArray(value) || value.some(item => typeof item !== 'string'));
|
|
1748
|
+
return typeof value === 'object' && type !== 'contact_reference';
|
|
1749
|
+
});
|
|
1750
|
+
const { anomalies } = normalizeContactProperties(values, { fields });
|
|
1751
|
+
if (invalidShape || anomalies.length) {
|
|
1752
|
+
const key = invalidShape?.[0] ?? anomalies[0].key;
|
|
1753
|
+
return contactInputError('validation_failed', `custom_properties.${key} does not match its registered ${fields[key].type} definition or options`);
|
|
1754
|
+
}
|
|
1755
|
+
propertyWarnings = unknownCustomPropertyWarnings(custom_properties, fields);
|
|
1780
1756
|
}
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1757
|
+
const embedOk = await hasCompaniesEmbed(projectId);
|
|
1758
|
+
const updates = {};
|
|
1759
|
+
let customPropertyChanges = [];
|
|
1760
|
+
if (email !== undefined)
|
|
1761
|
+
updates.email = email;
|
|
1762
|
+
if (first_name !== undefined)
|
|
1763
|
+
updates.first_name = first_name;
|
|
1764
|
+
if (last_name !== undefined)
|
|
1765
|
+
updates.last_name = last_name;
|
|
1766
|
+
if (company_id !== undefined) {
|
|
1767
|
+
updates.company_id = company_id;
|
|
1768
|
+
}
|
|
1769
|
+
else if (company_name || company_domain) {
|
|
1770
|
+
updates.company_id = await resolveCompanyId(projectId, {
|
|
1771
|
+
company_name: company_name ?? undefined,
|
|
1772
|
+
company_domain: company_domain ?? undefined,
|
|
1773
|
+
});
|
|
1774
|
+
}
|
|
1775
|
+
if (tags !== undefined)
|
|
1776
|
+
updates.tags = tags;
|
|
1777
|
+
if (custom_properties !== undefined) {
|
|
1778
|
+
const { data: existing, error: existingError } = await db()
|
|
1789
1779
|
.from('subscribers')
|
|
1790
|
-
.select('
|
|
1780
|
+
.select('custom_properties')
|
|
1791
1781
|
.eq('id', contact_id)
|
|
1792
1782
|
.eq('project_id', projectId)
|
|
1793
1783
|
.maybeSingle();
|
|
1794
|
-
if (
|
|
1795
|
-
|
|
1784
|
+
if (existingError)
|
|
1785
|
+
return j({ error: 'Contact properties unavailable; update not performed', code: 'INTERNAL_ERROR' });
|
|
1786
|
+
if (!existing)
|
|
1787
|
+
return contactInputError('not_found', 'Contact not found');
|
|
1788
|
+
const orgId = await getProjectOrgId(projectId);
|
|
1789
|
+
const normalizedPatch = await normalizeMcpContactPropertyPatch(custom_properties, {
|
|
1790
|
+
orgId,
|
|
1791
|
+
projectId,
|
|
1792
|
+
subscriberId: contact_id,
|
|
1793
|
+
preserveNullDeletions: true,
|
|
1794
|
+
fields,
|
|
1795
|
+
});
|
|
1796
|
+
customPropertyChanges = diffCustomProperties(existing?.custom_properties ?? null, normalizedPatch);
|
|
1797
|
+
updates.custom_properties = {
|
|
1798
|
+
...(existing?.custom_properties ?? {}),
|
|
1799
|
+
...normalizedPatch,
|
|
1800
|
+
...(company_name ? { company_name } : {}),
|
|
1801
|
+
...(company_domain ? { company_domain: normalizeCompanyDomain(company_domain) } : {}),
|
|
1802
|
+
};
|
|
1803
|
+
for (const [key, value] of Object.entries(normalizedPatch)) {
|
|
1804
|
+
if (value === null)
|
|
1805
|
+
delete updates.custom_properties[key];
|
|
1796
1806
|
}
|
|
1797
1807
|
}
|
|
1798
|
-
|
|
1799
|
-
|
|
1808
|
+
else if (company_name || company_domain) {
|
|
1809
|
+
const { data: existing } = await db()
|
|
1810
|
+
.from('subscribers')
|
|
1811
|
+
.select('custom_properties')
|
|
1812
|
+
.eq('id', contact_id)
|
|
1813
|
+
.eq('project_id', projectId)
|
|
1814
|
+
.maybeSingle();
|
|
1815
|
+
const orgId = await getProjectOrgId(projectId);
|
|
1816
|
+
const companyPatch = await normalizeMcpContactPropertyPatch({
|
|
1817
|
+
...(company_name ? { company_name } : {}),
|
|
1818
|
+
...(company_domain ? { company_domain: normalizeCompanyDomain(company_domain) } : {}),
|
|
1819
|
+
}, { orgId, projectId, subscriberId: contact_id });
|
|
1820
|
+
updates.custom_properties = {
|
|
1821
|
+
...(existing?.custom_properties ?? {}),
|
|
1822
|
+
...companyPatch,
|
|
1823
|
+
};
|
|
1800
1824
|
}
|
|
1801
|
-
if (
|
|
1802
|
-
|
|
1825
|
+
if (phone_e164 !== undefined) {
|
|
1826
|
+
if (phone_e164 === null) {
|
|
1827
|
+
updates.phone_e164 = null;
|
|
1828
|
+
}
|
|
1829
|
+
else {
|
|
1830
|
+
const normalized = normalizePhoneE164(phone_e164);
|
|
1831
|
+
if (!normalized)
|
|
1832
|
+
return contactInputError('validation_failed', 'Invalid phone_e164: must be a valid E.164 phone number (SMS is US-only for now).');
|
|
1833
|
+
updates.phone_e164 = normalized;
|
|
1834
|
+
}
|
|
1803
1835
|
}
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1836
|
+
if (timezone !== undefined) {
|
|
1837
|
+
if (timezone === null) {
|
|
1838
|
+
updates.timezone = null;
|
|
1839
|
+
}
|
|
1840
|
+
else {
|
|
1841
|
+
// Region form only; mirrors normalizeTimezoneField in the dashboard. Intl accepts UTC offsets
|
|
1842
|
+
// ("+05:00") and abbreviations ("EST"), which Postgres AT TIME ZONE reads differently.
|
|
1843
|
+
if (timezone !== 'UTC' && !/^[A-Za-z_]+(\/[A-Za-z0-9_+-]+)+$/.test(timezone)) {
|
|
1844
|
+
return contactInputError('validation_failed', 'Invalid timezone: must be an IANA region name such as America/Los_Angeles (not a UTC offset or abbreviation).');
|
|
1845
|
+
}
|
|
1846
|
+
try {
|
|
1847
|
+
new Intl.DateTimeFormat('en-US', { timeZone: timezone });
|
|
1848
|
+
}
|
|
1849
|
+
catch {
|
|
1850
|
+
return contactInputError('validation_failed', 'Invalid timezone: must be a valid IANA time zone identifier.');
|
|
1851
|
+
}
|
|
1852
|
+
updates.timezone = timezone;
|
|
1811
1853
|
}
|
|
1812
1854
|
}
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1855
|
+
if (!Object.keys(updates).length)
|
|
1856
|
+
return contactInputError('validation_failed', 'No fields to update.');
|
|
1857
|
+
const tagResult = {};
|
|
1858
|
+
if (tags !== undefined) {
|
|
1859
|
+
let previousTags;
|
|
1860
|
+
try {
|
|
1861
|
+
const { data: existing, error: readError } = await db()
|
|
1862
|
+
.from('subscribers')
|
|
1863
|
+
.select('tags')
|
|
1864
|
+
.eq('id', contact_id)
|
|
1865
|
+
.eq('project_id', projectId)
|
|
1866
|
+
.maybeSingle();
|
|
1867
|
+
if (!readError && Array.isArray(existing?.tags) && existing.tags.every((tag) => typeof tag === 'string')) {
|
|
1868
|
+
previousTags = existing.tags;
|
|
1869
|
+
}
|
|
1870
|
+
}
|
|
1871
|
+
catch {
|
|
1872
|
+
// The replacement can still succeed, but the previous set is unknown.
|
|
1873
|
+
}
|
|
1874
|
+
if (previousTags === undefined) {
|
|
1875
|
+
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.'];
|
|
1876
|
+
}
|
|
1877
|
+
else {
|
|
1878
|
+
const previousSet = new Set(previousTags);
|
|
1879
|
+
const nextSet = new Set(tags);
|
|
1880
|
+
tagResult.tags_removed = [...previousSet].filter((tag) => !nextSet.has(tag));
|
|
1881
|
+
tagResult.tags_added = [...nextSet].filter((tag) => !previousSet.has(tag));
|
|
1882
|
+
if (tagResult.tags_removed.length > 0) {
|
|
1883
|
+
tagResult.warnings = ['Tags were REPLACED. Use set_subscriber_tags to add tags without replacing the whole set.'];
|
|
1884
|
+
}
|
|
1885
|
+
}
|
|
1886
|
+
}
|
|
1887
|
+
updates.updated_at = new Date().toISOString();
|
|
1888
|
+
const { data, error } = await db()
|
|
1889
|
+
.from('subscribers')
|
|
1890
|
+
.update(updates)
|
|
1891
|
+
.eq('id', contact_id)
|
|
1892
|
+
.eq('project_id', projectId)
|
|
1893
|
+
.select(embedOk ? UPDATE_SUBSCRIBER_SELECT_WITH_COMPANY : UPDATE_SUBSCRIBER_SELECT_NO_COMPANY)
|
|
1894
|
+
.single();
|
|
1895
|
+
if (error?.code === '23505' && updates.phone_e164 !== undefined) {
|
|
1896
|
+
return contactInputError('conflict', 'Another contact in this project already has this phone number.');
|
|
1897
|
+
}
|
|
1898
|
+
if (error)
|
|
1899
|
+
return dbErrorResult(error);
|
|
1900
|
+
if (customPropertyChanges.length > 0) {
|
|
1901
|
+
logAudit({
|
|
1902
|
+
action: 'contact.custom_properties_changed',
|
|
1903
|
+
resourceType: 'contact',
|
|
1904
|
+
resourceId: contact_id,
|
|
1905
|
+
details: { changes: customPropertyChanges },
|
|
1906
|
+
});
|
|
1907
|
+
}
|
|
1908
|
+
await writeBackMcpContact(projectId, data, {
|
|
1909
|
+
...(updates.email !== undefined ? { email: updates.email } : {}),
|
|
1910
|
+
...(updates.first_name !== undefined ? { first_name: updates.first_name } : {}),
|
|
1911
|
+
...(updates.last_name !== undefined ? { last_name: updates.last_name } : {}),
|
|
1912
|
+
...(updates.tags !== undefined ? { tags: updates.tags } : {}),
|
|
1913
|
+
...(updates.custom_properties !== undefined ? { custom_properties: updates.custom_properties } : {}),
|
|
1833
1914
|
});
|
|
1915
|
+
const mergedWarnings = [...(tagResult.warnings ?? []), ...propertyWarnings];
|
|
1916
|
+
return j({ ...data, ...tagResult, ...(mergedWarnings.length ? { warnings: mergedWarnings } : {}) });
|
|
1917
|
+
}
|
|
1918
|
+
catch (error) {
|
|
1919
|
+
return dbErrorResult(error);
|
|
1834
1920
|
}
|
|
1835
|
-
await writeBackMcpContact(projectId, data, {
|
|
1836
|
-
...(updates.email !== undefined ? { email: updates.email } : {}),
|
|
1837
|
-
...(updates.first_name !== undefined ? { first_name: updates.first_name } : {}),
|
|
1838
|
-
...(updates.last_name !== undefined ? { last_name: updates.last_name } : {}),
|
|
1839
|
-
...(updates.tags !== undefined ? { tags: updates.tags } : {}),
|
|
1840
|
-
...(updates.custom_properties !== undefined ? { custom_properties: updates.custom_properties } : {}),
|
|
1841
|
-
});
|
|
1842
|
-
return j({ ...data, ...tagResult });
|
|
1843
1921
|
});
|
|
1844
1922
|
server.registerTool('get_subscriber', {
|
|
1845
1923
|
title: 'Get Subscriber',
|
|
@@ -2515,7 +2593,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
2515
2593
|
idempotentHint: true,
|
|
2516
2594
|
openWorldHint: false,
|
|
2517
2595
|
},
|
|
2518
|
-
description: 'Discover all custom_properties keys used across contacts for a site. Returns the list of keys, example values, and merge tag syntax so you can use them in campaigns. Call this before generating emails to know what personalisation data is available.',
|
|
2596
|
+
description: 'Discover registered custom field definitions and all custom_properties keys used across contacts for a site, including unused definitions. Returns the list of keys, example values, and merge tag syntax so you can use them in campaigns. Call this before generating emails to know what personalisation data is available.',
|
|
2519
2597
|
inputSchema: {
|
|
2520
2598
|
site_id: z.string().uuid().describe('Site to inspect — only contacts belonging to this site are sampled'),
|
|
2521
2599
|
},
|
|
@@ -2539,20 +2617,34 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
2539
2617
|
// site-scoped; project scope is only for a site with no members at all.
|
|
2540
2618
|
let loaded = await loadSchemaKeys(site_id);
|
|
2541
2619
|
if ('error' in loaded)
|
|
2542
|
-
return
|
|
2620
|
+
return j({ error: loaded.error, code: 'INTERNAL_ERROR' });
|
|
2543
2621
|
let scope = 'site';
|
|
2544
2622
|
if (!loaded.has_membership) {
|
|
2545
2623
|
loaded = await loadSchemaKeys(null);
|
|
2546
2624
|
if ('error' in loaded)
|
|
2547
|
-
return
|
|
2625
|
+
return j({ error: loaded.error, code: 'INTERNAL_ERROR' });
|
|
2548
2626
|
scope = 'project';
|
|
2549
2627
|
}
|
|
2550
|
-
|
|
2628
|
+
let definitions;
|
|
2629
|
+
try {
|
|
2630
|
+
definitions = await registeredCustomFields(projectId);
|
|
2631
|
+
}
|
|
2632
|
+
catch {
|
|
2633
|
+
return j({ error: 'Custom field definitions unavailable', code: 'INTERNAL_ERROR' });
|
|
2634
|
+
}
|
|
2635
|
+
const byKey = new Map(definitions.map(field => [field.key, field]));
|
|
2636
|
+
const keys = [...loaded.keys];
|
|
2637
|
+
const observed = new Set(keys.map(row => row.key));
|
|
2638
|
+
for (const field of definitions)
|
|
2639
|
+
if (!observed.has(field.key))
|
|
2640
|
+
keys.push({ key: field.key, contacts_with_this_key: 0, example_values: [] });
|
|
2641
|
+
const schema = keys.map(({ key, contacts_with_this_key, example_values }) => ({
|
|
2551
2642
|
key,
|
|
2552
2643
|
merge_tag: `{{custom.${key}}}`,
|
|
2553
2644
|
merge_tag_with_fallback: `{{custom.${key}|fallback}}`,
|
|
2554
2645
|
example_values,
|
|
2555
2646
|
contacts_with_this_key,
|
|
2647
|
+
...(byKey.has(key) ? { definition: byKey.get(key) } : {}),
|
|
2556
2648
|
}));
|
|
2557
2649
|
return j({
|
|
2558
2650
|
site_id,
|
|
@@ -2564,6 +2656,20 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
2564
2656
|
: 'Use merge_tag syntax in email body/subject. For sensitive data, add a fallback: {{custom.plan|subscriber}}. ⚠️ custom_properties data passes through the Anthropic API — avoid storing sensitive PII (SSNs, passwords, financial account numbers).',
|
|
2565
2657
|
});
|
|
2566
2658
|
});
|
|
2659
|
+
server.registerTool('list_custom_fields', {
|
|
2660
|
+
title: 'List Custom Fields',
|
|
2661
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
2662
|
+
description: 'List all registered custom field definitions for the current project, including fields with no contact values yet.',
|
|
2663
|
+
inputSchema: {},
|
|
2664
|
+
}, async () => {
|
|
2665
|
+
try {
|
|
2666
|
+
const fields = await registeredCustomFields(getProjectId());
|
|
2667
|
+
return j({ fields, count: fields.length });
|
|
2668
|
+
}
|
|
2669
|
+
catch {
|
|
2670
|
+
return j({ error: 'Custom field definitions unavailable', code: 'INTERNAL_ERROR' });
|
|
2671
|
+
}
|
|
2672
|
+
});
|
|
2567
2673
|
server.registerTool('create_custom_field', {
|
|
2568
2674
|
title: 'Create Custom Field',
|
|
2569
2675
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
@@ -2577,9 +2683,11 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
2577
2683
|
display_order: z.number().int().optional().default(0),
|
|
2578
2684
|
},
|
|
2579
2685
|
}, async ({ key, label, type, required, options, display_order }) => {
|
|
2686
|
+
if (RESERVED_CUSTOM_FIELD_KEYS.has(key))
|
|
2687
|
+
return contactInputError('validation_failed', `Custom field key "${key}" is reserved for a core contact column`);
|
|
2580
2688
|
const optionsError = validateCustomFieldOptions(type, options);
|
|
2581
2689
|
if (optionsError)
|
|
2582
|
-
return
|
|
2690
|
+
return contactInputError('validation_failed', `Error: ${optionsError}`);
|
|
2583
2691
|
const normalizedOptions = type === 'select' || type === 'multi_select'
|
|
2584
2692
|
? normalizeCustomFieldOptions(options)
|
|
2585
2693
|
: null;
|
|
@@ -2601,8 +2709,8 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
2601
2709
|
.single();
|
|
2602
2710
|
if (error) {
|
|
2603
2711
|
if (error.code === '23505')
|
|
2604
|
-
return
|
|
2605
|
-
return
|
|
2712
|
+
return contactInputError('conflict', `Error: A custom field with key "${key}" already exists.`);
|
|
2713
|
+
return j({ error: `Error: ${error.message}`, code: 'INTERNAL_ERROR' });
|
|
2606
2714
|
}
|
|
2607
2715
|
await logAudit({
|
|
2608
2716
|
action: 'custom_field.created',
|