@sendinel/mcp-server 1.0.173 → 1.0.175
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/action-approvals.js +4 -4
- package/build/lib/mcp-created-items.d.ts +28 -0
- package/build/lib/mcp-created-items.js +101 -0
- package/build/lib/sandbox-approvals.d.ts +2 -2
- package/build/lib/sandbox-approvals.js +12 -4
- package/build/tools/advisor.js +3 -2
- package/build/tools/campaign-sets.js +3 -2
- package/build/tools/campaigns.js +33 -29
- package/build/tools/compound.js +9 -8
- package/build/tools/contacts.js +91 -87
- package/build/tools/content.js +3 -2
- package/build/tools/drafts.js +5 -4
- package/build/tools/landing-pages.js +3 -2
- package/build/tools/lists.js +3 -2
- package/build/tools/newsletters.js +11 -4
- package/build/tools/segments.js +7 -6
- package/build/tools/signatures.js +3 -2
- package/build/tools/sms.js +5 -4
- package/build/tools/social-posts.js +3 -2
- package/build/tools/templates.js +5 -4
- package/build/tools/webhooks.js +3 -2
- package/package.json +1 -1
|
@@ -382,7 +382,7 @@ export async function requestApproval(action, params, apiKeyId, channelContext,
|
|
|
382
382
|
throw new Error(`Failed to create approval request: ${error.message}`);
|
|
383
383
|
let projectName;
|
|
384
384
|
let sandboxDisplayRead = false;
|
|
385
|
-
const sandboxApproved = !(effective.applied && (effective.level === 'ask_every_time' || effective.class === 'F'
|
|
385
|
+
const sandboxApproved = !(effective.applied && (effective.level === 'ask_every_time' || (effective.class === 'F' && !action.startsWith('delete_')))) && await trySandboxApproval(data.id, getProjectId(), action, approvalPayloadHash(action, frozenParams), name => { projectName = name; sandboxDisplayRead = true; }, frozenParams);
|
|
386
386
|
if (isTesterRequest(action))
|
|
387
387
|
await logTesterRequestAudit('tester_request', action, data.id, null);
|
|
388
388
|
fireWebhookEvent('approval.requested', {
|
|
@@ -534,8 +534,8 @@ async function checkApprovalState(approvalId, options = {}) {
|
|
|
534
534
|
return { approved: false, status: 'human_decision_required' };
|
|
535
535
|
}
|
|
536
536
|
const sandboxDecision = data.decided_via === 'sandbox';
|
|
537
|
-
if (data.status === 'approved' && sandboxDecision && (data.project_id !== getProjectId() || data.tier
|
|
538
|
-
!await sandboxApprovalEligible(getProjectId(), data.action))) {
|
|
537
|
+
if (data.status === 'approved' && sandboxDecision && (data.project_id !== getProjectId() || !['gated', 'protected'].includes(data.tier) || (data.tier === 'protected' && !['delete_segment', 'delete_subscriber_data'].includes(data.action)) ||
|
|
538
|
+
!await sandboxApprovalEligible(getProjectId(), data.action, undefined, data.params))) {
|
|
539
539
|
return { approved: false, status: 'sandbox_decision_no_longer_eligible' };
|
|
540
540
|
}
|
|
541
541
|
if (data.status === 'approved' && data.params?.__approval_snapshot?.approval_channel_rule === 'human_only' && !['dashboard', 'chat_confirm'].includes(data.decided_via)) {
|
|
@@ -598,7 +598,7 @@ async function checkApprovalState(approvalId, options = {}) {
|
|
|
598
598
|
approved: true,
|
|
599
599
|
status: 'approved',
|
|
600
600
|
decided_by: claimed.decided_by,
|
|
601
|
-
...(options.action && ['send_test_email', 'identify_subscriber', 'emit_contact_event'].includes(options.action) ? { consumed_at: consumedAt } : {}),
|
|
601
|
+
...(options.action && ['send_test_email', 'identify_subscriber', 'emit_contact_event', 'delete_campaign'].includes(options.action) ? { consumed_at: consumedAt } : {}),
|
|
602
602
|
...(sandboxDecision ? { decision_channel: 'sandbox' } : {}),
|
|
603
603
|
};
|
|
604
604
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** Table names are the entity types in the service-only provenance ledger. */
|
|
2
|
+
export declare const DELETE_ITEM_TARGETS: {
|
|
3
|
+
readonly delete_campaign: readonly ['campaigns', 'campaign_id', 'campaign_ids'];
|
|
4
|
+
readonly delete_campaign_step: readonly ['campaign_steps', 'step_id', 'step_ids'];
|
|
5
|
+
readonly delete_content_block: readonly ['content_blocks', 'block_id', 'block_ids'];
|
|
6
|
+
readonly delete_custom_field: readonly ['custom_field_definitions', 'id', 'ids'];
|
|
7
|
+
readonly delete_landing_page: readonly ['landing_pages', 'page_id', 'page_ids'];
|
|
8
|
+
readonly delete_list: readonly ['contact_lists', 'list_id', 'list_ids'];
|
|
9
|
+
readonly delete_segment: readonly ['segments', 'segment_id', 'segment_ids'];
|
|
10
|
+
readonly delete_signature: readonly ['signature_blocks', 'id', 'ids'];
|
|
11
|
+
readonly delete_social_post: readonly ['social_posts', 'post_id', 'post_ids'];
|
|
12
|
+
readonly delete_subscriber_data: readonly ['subscribers', 'contact_id', 'contact_ids'];
|
|
13
|
+
readonly delete_subscriber_view: readonly ['subscriber_list_views', 'view_id', 'view_ids'];
|
|
14
|
+
readonly delete_template: readonly ['campaign_templates', 'id', 'ids'];
|
|
15
|
+
readonly delete_webhook_subscription: readonly ['webhook_subscriptions', 'subscription_id', 'subscription_ids'];
|
|
16
|
+
};
|
|
17
|
+
export type CreatedItemType = typeof DELETE_ITEM_TARGETS[keyof typeof DELETE_ITEM_TARGETS][0];
|
|
18
|
+
/** Only server-authenticated credential identity; never user IDs or tool arguments. */
|
|
19
|
+
export declare function requestingCreatorKey(): string | null;
|
|
20
|
+
/** Insert-only provenance: updates, retries and reconnects must never claim an existing item. */
|
|
21
|
+
export declare function recordCreatedItems(entityType: CreatedItemType, data: any): Promise<void>;
|
|
22
|
+
/** The query must return only newly inserted rows, never the result of an updating upsert. */
|
|
23
|
+
export declare function withCreatedItems<T extends {
|
|
24
|
+
data: any;
|
|
25
|
+
error: any;
|
|
26
|
+
}>(entityType: CreatedItemType, query: PromiseLike<T>): Promise<T>;
|
|
27
|
+
/** Unknown actions/targets and partial reads fail closed, including defensive bulk inputs. */
|
|
28
|
+
export declare function ownsDeleteTargets(projectId: string, action: string, params: Record<string, unknown>, creatorKey: string | null): Promise<boolean>;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { adminDb, requestContext } from '../db.js';
|
|
2
|
+
import { getProjectId } from '../project.js';
|
|
3
|
+
/** Table names are the entity types in the service-only provenance ledger. */
|
|
4
|
+
export const DELETE_ITEM_TARGETS = {
|
|
5
|
+
delete_campaign: ['campaigns', 'campaign_id', 'campaign_ids'],
|
|
6
|
+
delete_campaign_step: ['campaign_steps', 'step_id', 'step_ids'],
|
|
7
|
+
delete_content_block: ['content_blocks', 'block_id', 'block_ids'],
|
|
8
|
+
delete_custom_field: ['custom_field_definitions', 'id', 'ids'],
|
|
9
|
+
delete_landing_page: ['landing_pages', 'page_id', 'page_ids'],
|
|
10
|
+
delete_list: ['contact_lists', 'list_id', 'list_ids'],
|
|
11
|
+
delete_segment: ['segments', 'segment_id', 'segment_ids'],
|
|
12
|
+
delete_signature: ['signature_blocks', 'id', 'ids'],
|
|
13
|
+
delete_social_post: ['social_posts', 'post_id', 'post_ids'],
|
|
14
|
+
delete_subscriber_data: ['subscribers', 'contact_id', 'contact_ids'],
|
|
15
|
+
delete_subscriber_view: ['subscriber_list_views', 'view_id', 'view_ids'],
|
|
16
|
+
delete_template: ['campaign_templates', 'id', 'ids'],
|
|
17
|
+
delete_webhook_subscription: ['webhook_subscriptions', 'subscription_id', 'subscription_ids'],
|
|
18
|
+
};
|
|
19
|
+
/** Only server-authenticated credential identity; never user IDs or tool arguments. */
|
|
20
|
+
export function requestingCreatorKey() {
|
|
21
|
+
const ctx = requestContext();
|
|
22
|
+
if (ctx?.authKind === 'oauth' && ctx.oauthClientId)
|
|
23
|
+
return `oauth:${ctx.oauthClientId}`;
|
|
24
|
+
if (ctx?.authKind === 'api_key' && ctx.keyId)
|
|
25
|
+
return `apikey:${ctx.keyId}`;
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
/** Insert-only provenance: updates, retries and reconnects must never claim an existing item. */
|
|
29
|
+
export async function recordCreatedItems(entityType, data) {
|
|
30
|
+
const creatorKey = requestingCreatorKey();
|
|
31
|
+
if (!creatorKey)
|
|
32
|
+
return;
|
|
33
|
+
const rows = (Array.isArray(data) ? data : data ? [data] : [])
|
|
34
|
+
.filter(row => typeof row.id === 'string')
|
|
35
|
+
.map(row => ({ project_id: getProjectId(), entity_type: entityType, entity_id: row.id, creator_key: creatorKey }));
|
|
36
|
+
if (!rows.length)
|
|
37
|
+
return;
|
|
38
|
+
try {
|
|
39
|
+
const { error } = await adminDb().schema('email').from('mcp_created_items')
|
|
40
|
+
.upsert(rows, { onConflict: 'project_id,entity_type,entity_id', ignoreDuplicates: true });
|
|
41
|
+
if (error)
|
|
42
|
+
console.warn('MCP creation provenance unavailable; deletion will require human approval.');
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
// The item already exists. Unknown provenance must stay unknown, never inferred later.
|
|
46
|
+
console.warn('MCP creation provenance unavailable; deletion will require human approval.');
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/** The query must return only newly inserted rows, never the result of an updating upsert. */
|
|
50
|
+
export async function withCreatedItems(entityType, query) {
|
|
51
|
+
const result = await query;
|
|
52
|
+
if (!result.error)
|
|
53
|
+
await recordCreatedItems(entityType, result.data);
|
|
54
|
+
return result;
|
|
55
|
+
}
|
|
56
|
+
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
57
|
+
/** Unknown actions/targets and partial reads fail closed, including defensive bulk inputs. */
|
|
58
|
+
export async function ownsDeleteTargets(projectId, action, params, creatorKey) {
|
|
59
|
+
const target = DELETE_ITEM_TARGETS[action];
|
|
60
|
+
if (!creatorKey || !target)
|
|
61
|
+
return false;
|
|
62
|
+
const [entityType, single, bulk] = target;
|
|
63
|
+
const source = adminDb().schema('email');
|
|
64
|
+
let ids = [];
|
|
65
|
+
if (params[single] !== undefined)
|
|
66
|
+
ids.push(params[single]);
|
|
67
|
+
if (params[bulk] !== undefined) {
|
|
68
|
+
if (!Array.isArray(params[bulk]) || !params[bulk].length)
|
|
69
|
+
return false;
|
|
70
|
+
ids.push(...params[bulk]);
|
|
71
|
+
}
|
|
72
|
+
// GDPR snapshots carry both ID and email. Verify the address names that same target.
|
|
73
|
+
if (action === 'delete_subscriber_data' && params.email !== undefined) {
|
|
74
|
+
if (typeof params.email !== 'string' || !params.email.trim())
|
|
75
|
+
return false;
|
|
76
|
+
const { data, error } = await source.from('subscribers').select('id').eq('project_id', projectId)
|
|
77
|
+
.eq('email', params.email.trim().toLowerCase()).maybeSingle();
|
|
78
|
+
if (error || !data || (ids.length && (ids.length !== 1 || ids[0] !== data.id)))
|
|
79
|
+
return false;
|
|
80
|
+
ids.push(data.id);
|
|
81
|
+
}
|
|
82
|
+
if (!ids.length || ids.some(id => typeof id !== 'string' || !uuid.test(id)))
|
|
83
|
+
return false;
|
|
84
|
+
ids = [...new Set(ids)];
|
|
85
|
+
const owns = async (type, targets) => {
|
|
86
|
+
const { data, error } = await source.from('mcp_created_items').select('entity_id')
|
|
87
|
+
.eq('project_id', projectId).eq('entity_type', type).eq('creator_key', creatorKey).in('entity_id', targets);
|
|
88
|
+
return !error && Array.isArray(data) && targets.every(id => data.some(row => row.entity_id === id));
|
|
89
|
+
};
|
|
90
|
+
if (!await owns(entityType, ids))
|
|
91
|
+
return false;
|
|
92
|
+
// Deleting a campaign also deletes its steps; another client may have added one.
|
|
93
|
+
if (action === 'delete_campaign') {
|
|
94
|
+
const { data, error } = await source.from('campaign_steps').select('id').in('campaign_id', ids);
|
|
95
|
+
if (error || !Array.isArray(data))
|
|
96
|
+
return false;
|
|
97
|
+
if (data.length && !await owns('campaign_steps', data.map(row => row.id)))
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
@@ -27,5 +27,5 @@ export declare const SANDBOX_ADMIN_SCOPE: {
|
|
|
27
27
|
readonly tenant_export_full: 'project';
|
|
28
28
|
};
|
|
29
29
|
/** Never consult token claims, BYOD clients or cached display data for this authority. */
|
|
30
|
-
export declare function sandboxApprovalEligible(projectId: string, action: string, onProjectRead?: (name: string | undefined) => void): Promise<boolean>;
|
|
31
|
-
export declare function trySandboxApproval(id: string, projectId: string, action: string, payloadHash: string, onProjectRead?: (name: string | undefined) => void): Promise<boolean>;
|
|
30
|
+
export declare function sandboxApprovalEligible(projectId: string, action: string, onProjectRead?: (name: string | undefined) => void, params?: Record<string, unknown>): Promise<boolean>;
|
|
31
|
+
export declare function trySandboxApproval(id: string, projectId: string, action: string, payloadHash: string, onProjectRead?: (name: string | undefined) => void, params?: Record<string, unknown>): Promise<boolean>;
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { adminDb } from '../db.js';
|
|
2
|
+
import { DELETE_ITEM_TARGETS, ownsDeleteTargets, requestingCreatorKey } from './mcp-created-items.js';
|
|
3
|
+
import { approvalClass } from '@sendinel/domain';
|
|
2
4
|
import { PROTECTED_ACTIONS } from './approval-actions.js';
|
|
3
5
|
import { isHumanRequestableAdminTool } from './admin-tools.js';
|
|
4
6
|
/** Conservative classification: shared org credentials and grants require an all-sandbox org. */
|
|
@@ -14,8 +16,12 @@ export const SANDBOX_ADMIN_SCOPE = {
|
|
|
14
16
|
tenant_export_full: 'project',
|
|
15
17
|
};
|
|
16
18
|
/** Never consult token claims, BYOD clients or cached display data for this authority. */
|
|
17
|
-
export async function sandboxApprovalEligible(projectId, action, onProjectRead) {
|
|
18
|
-
|
|
19
|
+
export async function sandboxApprovalEligible(projectId, action, onProjectRead, params = {}) {
|
|
20
|
+
const deletion = approvalClass(action) === 'D' || action.startsWith('delete_');
|
|
21
|
+
// No exception for shared org credentials, unknown delete actions or other protected effects.
|
|
22
|
+
if (deletion && (!(action in DELETE_ITEM_TARGETS) || !requestingCreatorKey()))
|
|
23
|
+
return false;
|
|
24
|
+
if (action === 'delete_provider_credential' || (!deletion && PROTECTED_ACTIONS.has(action)))
|
|
19
25
|
return false;
|
|
20
26
|
try {
|
|
21
27
|
const source = adminDb().schema('email');
|
|
@@ -24,6 +30,8 @@ export async function sandboxApprovalEligible(projectId, action, onProjectRead)
|
|
|
24
30
|
onProjectRead?.(!error && typeof data?.name === 'string' ? data.name : undefined);
|
|
25
31
|
if (error || data?.id !== projectId || data.sandbox_mode !== true)
|
|
26
32
|
return false;
|
|
33
|
+
if (deletion)
|
|
34
|
+
return await ownsDeleteTargets(projectId, action, params, requestingCreatorKey());
|
|
27
35
|
if (!isHumanRequestableAdminTool(action))
|
|
28
36
|
return true;
|
|
29
37
|
const scope = SANDBOX_ADMIN_SCOPE[action];
|
|
@@ -39,8 +47,8 @@ export async function sandboxApprovalEligible(projectId, action, onProjectRead)
|
|
|
39
47
|
return false;
|
|
40
48
|
}
|
|
41
49
|
}
|
|
42
|
-
export async function trySandboxApproval(id, projectId, action, payloadHash, onProjectRead) {
|
|
43
|
-
if (!await sandboxApprovalEligible(projectId, action, onProjectRead))
|
|
50
|
+
export async function trySandboxApproval(id, projectId, action, payloadHash, onProjectRead, params = {}) {
|
|
51
|
+
if (!await sandboxApprovalEligible(projectId, action, onProjectRead, params))
|
|
44
52
|
return false;
|
|
45
53
|
try {
|
|
46
54
|
// The RPC performs one conditional UPDATE + audit INSERT in one transaction.
|
package/build/tools/advisor.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { withCreatedItems } from '../lib/mcp-created-items.js';
|
|
1
2
|
import { countEmailRows, groupMetricRows, percentRate, isBouncedRow, AI_MAX_TOKENS, outputConfig, anthropicCheckedText, readAllRows } from "@sendinel/domain";
|
|
2
3
|
import { z } from 'zod';
|
|
3
4
|
import Anthropic from '@anthropic-ai/sdk';
|
|
@@ -361,7 +362,7 @@ Each subject should be distinct in approach (urgency, curiosity, benefit, social
|
|
|
361
362
|
const labels = ['B', 'C'];
|
|
362
363
|
variantsCreated = [];
|
|
363
364
|
for (let i = 0; i < 2; i++) {
|
|
364
|
-
const { error: insertErr } = await db()
|
|
365
|
+
const { error: insertErr } = await withCreatedItems('campaign_steps', db()
|
|
365
366
|
.from('campaign_steps')
|
|
366
367
|
.insert({
|
|
367
368
|
campaign_id,
|
|
@@ -372,7 +373,7 @@ Each subject should be distinct in approach (urgency, curiosity, benefit, social
|
|
|
372
373
|
body_text: currentStep.text_body,
|
|
373
374
|
delay_days: currentStep.delay_days,
|
|
374
375
|
delay_hours: currentStep.delay_hours,
|
|
375
|
-
});
|
|
376
|
+
}).select('id'));
|
|
376
377
|
if (!insertErr)
|
|
377
378
|
variantsCreated.push(labels[i]);
|
|
378
379
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { withCreatedItems } from '../lib/mcp-created-items.js';
|
|
1
2
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
3
|
import { z } from 'zod';
|
|
3
4
|
import Anthropic from '@anthropic-ai/sdk';
|
|
@@ -149,7 +150,7 @@ export async function writeCampaignDrafts(projectId, siteId, drafts, source) {
|
|
|
149
150
|
}, projectId);
|
|
150
151
|
if (targetingError)
|
|
151
152
|
return { error: { error: targetingError, code: 'INVALID_FIELD', campaign: c.name, created_before_failure: createdIds } };
|
|
152
|
-
const { data: campaign, error } = await db().from('campaigns').insert(insertPayload).select('id').single();
|
|
153
|
+
const { data: campaign, error } = await withCreatedItems('campaigns', db().from('campaigns').insert(insertPayload).select('id').single());
|
|
153
154
|
if (error || !campaign)
|
|
154
155
|
return { error: { error: (error ? dbToolError(error).error : undefined) ?? 'Failed to create campaign', code: 'CAMPAIGN_CREATE_FAILED', campaign: c.name, created_before_failure: createdIds } };
|
|
155
156
|
createdIds.push(campaign.id);
|
|
@@ -164,7 +165,7 @@ export async function writeCampaignDrafts(projectId, siteId, drafts, source) {
|
|
|
164
165
|
delay_hours: e.delay_hours,
|
|
165
166
|
variant_label: 'A',
|
|
166
167
|
}));
|
|
167
|
-
const { error: stepError } = await db().from('campaign_steps').insert(rows);
|
|
168
|
+
const { error: stepError } = await withCreatedItems('campaign_steps', db().from('campaign_steps').insert(rows).select('id'));
|
|
168
169
|
if (stepError)
|
|
169
170
|
return { error: { ...dbToolError(stepError), code: 'STEP_CREATE_FAILED', campaign: c.name, created_before_failure: createdIds } };
|
|
170
171
|
}
|
package/build/tools/campaigns.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { withCreatedItems } from '../lib/mcp-created-items.js';
|
|
1
2
|
import { validateStepMultiEventRules, stepMultiEventRulesView, validateStepAbsenceRule, stepAbsenceRuleView } from "@sendinel/domain";
|
|
2
3
|
import { readCampaignMetricGroups, SENT_STATUSES } from "@sendinel/domain";
|
|
3
4
|
import { resolveCampaignReentry, readCampaignPeriods, campaignPeriodFields } from "@sendinel/domain";
|
|
@@ -19,7 +20,7 @@ import { dbErrorResult, dbToolError, toolError } from '../lib/db-error.js';
|
|
|
19
20
|
import { checkPlanLimit } from '../plan-limits.js';
|
|
20
21
|
import { buildVoicePrompt, buildEmailSystemPrompt, deriveTemplateVariables, validateTemplate } from '../lib/voice.js';
|
|
21
22
|
import { fireWebhookEvent } from '../lib/webhook-events.js';
|
|
22
|
-
import { approvalNext, approvalExcerpt, assertChannelCanExecute, currentChannelContext, requestApproval, checkApproval, withApproval } from '../lib/action-approvals.js';
|
|
23
|
+
import { approvalNext, approvalExcerpt, assertChannelCanExecute, currentChannelContext, requestApproval, checkApproval, withApproval, markNotExecuted } from '../lib/action-approvals.js';
|
|
23
24
|
import { sandboxAudience, broadcastApprovalNext } from '../lib/sandbox-audience.js';
|
|
24
25
|
import { resolveSegmentContactIds, resolveSendAudience } from '../lib/send-audience.js';
|
|
25
26
|
import { AudienceNotUnderstoodError, generateEmailFromBrief, parseAudienceRules, unsupportedAudienceReason } from '../lib/email-generation.js';
|
|
@@ -478,7 +479,7 @@ async function executeBroadcast(input) {
|
|
|
478
479
|
if (!limit.allowed) {
|
|
479
480
|
return { ok: false, error: `Campaign limit reached (${limit.current}/${limit.limit}). Upgrade your plan for more campaigns.`, code: 'PLAN_LIMIT' };
|
|
480
481
|
}
|
|
481
|
-
const { data: campaign, error: campaignErr } = await db()
|
|
482
|
+
const { data: campaign, error: campaignErr } = await withCreatedItems('campaigns', db()
|
|
482
483
|
.from('campaigns')
|
|
483
484
|
.insert({
|
|
484
485
|
project_id: input.projectId,
|
|
@@ -494,11 +495,11 @@ async function executeBroadcast(input) {
|
|
|
494
495
|
metadata: { source: input.source, ...(input.metadata ?? {}), content_key: contentKey },
|
|
495
496
|
})
|
|
496
497
|
.select('id')
|
|
497
|
-
.single();
|
|
498
|
+
.single());
|
|
498
499
|
if (campaignErr || !campaign) {
|
|
499
500
|
return { ok: false, error: (campaignErr ? dbToolError(campaignErr).error : undefined) ?? 'Failed to create campaign', code: 'CAMPAIGN_CREATE_FAILED' };
|
|
500
501
|
}
|
|
501
|
-
const { data: step, error: stepErr } = await db()
|
|
502
|
+
const { data: step, error: stepErr } = await withCreatedItems('campaign_steps', db()
|
|
502
503
|
.from('campaign_steps')
|
|
503
504
|
.insert({
|
|
504
505
|
campaign_id: campaign.id,
|
|
@@ -511,7 +512,7 @@ async function executeBroadcast(input) {
|
|
|
511
512
|
delay_hours: 0,
|
|
512
513
|
})
|
|
513
514
|
.select('id')
|
|
514
|
-
.single();
|
|
515
|
+
.single());
|
|
515
516
|
if (stepErr || !step) {
|
|
516
517
|
return { ok: false, error: (stepErr ? dbToolError(stepErr).error : undefined) ?? 'Campaign created but failed to add broadcast step', code: 'STEP_CREATE_FAILED', campaign_id: campaign.id };
|
|
517
518
|
}
|
|
@@ -899,11 +900,11 @@ export function registerCampaignTools(server) {
|
|
|
899
900
|
const targetingError = await applyCampaignTargetingFields(insertPayload, { trigger_event_name, trigger_tag, trigger_type, trigger_config, segment_id }, projectId);
|
|
900
901
|
if (targetingError)
|
|
901
902
|
return j({ error: targetingError, code: targetingError === 'Segment not found' ? 'NOT_FOUND' : 'validation_failed' });
|
|
902
|
-
const { data, error } = await db()
|
|
903
|
+
const { data, error } = await withCreatedItems('campaigns', db()
|
|
903
904
|
.from('campaigns')
|
|
904
905
|
.insert(insertPayload)
|
|
905
906
|
.select()
|
|
906
|
-
.single();
|
|
907
|
+
.single());
|
|
907
908
|
if (error)
|
|
908
909
|
return { content: [{ type: 'text', text: `Error: ${dbToolError(error).error}` }] };
|
|
909
910
|
const warnings = await campaignTimezoneWarnings(data);
|
|
@@ -983,11 +984,11 @@ export function registerCampaignTools(server) {
|
|
|
983
984
|
const renderedSubject = survey_id ? renderTemplateVars(subject, renderVars) : subject;
|
|
984
985
|
const renderedHtml = body_html !== undefined && survey_id ? renderTemplateVars(body_html, renderVars) : body_html ?? htmlFromBlocks;
|
|
985
986
|
const renderedText = body_text !== undefined && survey_id ? renderTemplateVars(body_text, renderVars) : body_text;
|
|
986
|
-
const { data, error } = await db()
|
|
987
|
+
const { data, error } = await withCreatedItems('campaign_steps', db()
|
|
987
988
|
.from('campaign_steps')
|
|
988
989
|
.insert({ campaign_id, step_order, subject: renderedSubject, body_html: renderedHtml, body_text: renderedText ?? (normalizedBlocks?.length ? blocksToText(normalizedBlocks) : undefined), blocks: normalizedBlocks ?? null, delay_days, delay_hours, variant_label: variant_label ?? 'A', skip_if_event: skip_if_event ?? null, send_only_if_event: send_only_if_event ?? null, ...events.rules, ...absence.rules, ...properties.rules })
|
|
989
990
|
.select()
|
|
990
|
-
.single();
|
|
991
|
+
.single());
|
|
991
992
|
if (error)
|
|
992
993
|
return dbErrorResult(error);
|
|
993
994
|
await recordBrandAssetUsage({
|
|
@@ -1722,7 +1723,7 @@ export function registerCampaignTools(server) {
|
|
|
1722
1723
|
const limit = await checkPlanLimit('active_campaigns');
|
|
1723
1724
|
if (!limit.allowed)
|
|
1724
1725
|
return toolError({ error: `Active campaign limit reached (${limit.current}/${limit.limit}).`, code: 'plan_limit' });
|
|
1725
|
-
const { data: newCampaign, error: createErr } = await db()
|
|
1726
|
+
const { data: newCampaign, error: createErr } = await withCreatedItems('campaigns', db()
|
|
1726
1727
|
.from('campaigns')
|
|
1727
1728
|
.insert({
|
|
1728
1729
|
project_id: projectId,
|
|
@@ -1735,7 +1736,7 @@ export function registerCampaignTools(server) {
|
|
|
1735
1736
|
status: 'draft',
|
|
1736
1737
|
})
|
|
1737
1738
|
.select()
|
|
1738
|
-
.single();
|
|
1739
|
+
.single());
|
|
1739
1740
|
if (createErr)
|
|
1740
1741
|
return toolError(dbToolError(createErr));
|
|
1741
1742
|
const steps = (original.campaign_steps ?? []).sort((a, b) => a.step_order - b.step_order);
|
|
@@ -1750,7 +1751,7 @@ export function registerCampaignTools(server) {
|
|
|
1750
1751
|
delay_days: s.delay_days,
|
|
1751
1752
|
delay_hours: s.delay_hours,
|
|
1752
1753
|
}));
|
|
1753
|
-
const { error: stepErr } = await db().from('campaign_steps').insert(stepRows);
|
|
1754
|
+
const { error: stepErr } = await withCreatedItems('campaign_steps', db().from('campaign_steps').insert(stepRows).select('id'));
|
|
1754
1755
|
if (stepErr)
|
|
1755
1756
|
return toolError({ ...dbToolError(stepErr, 'Campaign created but copying steps failed'), campaign_id: newCampaign.id });
|
|
1756
1757
|
}
|
|
@@ -1861,7 +1862,7 @@ export function registerCampaignTools(server) {
|
|
|
1861
1862
|
return txt('Error: A sending domain is required before cloning a template — create one with create_site first, or pass site_id.');
|
|
1862
1863
|
}
|
|
1863
1864
|
const cloneName = new_name?.trim() || `${template.name} (cloned)`;
|
|
1864
|
-
const { data: campaign, error: campaignError } = await db()
|
|
1865
|
+
const { data: campaign, error: campaignError } = await withCreatedItems('campaigns', db()
|
|
1865
1866
|
.from('campaigns')
|
|
1866
1867
|
.insert({
|
|
1867
1868
|
project_id: projectId,
|
|
@@ -1871,12 +1872,12 @@ export function registerCampaignTools(server) {
|
|
|
1871
1872
|
status: 'draft',
|
|
1872
1873
|
})
|
|
1873
1874
|
.select()
|
|
1874
|
-
.single();
|
|
1875
|
+
.single());
|
|
1875
1876
|
if (campaignError)
|
|
1876
1877
|
return txt(`Error creating campaign: ${dbToolError(campaignError).error}`);
|
|
1877
1878
|
const steps = Array.isArray(template.template?.steps) ? template.template.steps : [];
|
|
1878
1879
|
if (steps.length > 0) {
|
|
1879
|
-
const { error: stepsError } = await db()
|
|
1880
|
+
const { error: stepsError } = await withCreatedItems('campaign_steps', db()
|
|
1880
1881
|
.from('campaign_steps')
|
|
1881
1882
|
.insert(steps.map((step, index) => ({
|
|
1882
1883
|
campaign_id: campaign.id,
|
|
@@ -1888,7 +1889,7 @@ export function registerCampaignTools(server) {
|
|
|
1888
1889
|
delay_hours: step.delay_hours ?? 0,
|
|
1889
1890
|
condition_type: step.condition_type ?? null,
|
|
1890
1891
|
condition_lookback_hours: step.condition_lookback_hours ?? null,
|
|
1891
|
-
})));
|
|
1892
|
+
}))).select('id'));
|
|
1892
1893
|
if (stepsError)
|
|
1893
1894
|
return txt(`Campaign created but steps failed: ${dbToolError(stepsError).error}`);
|
|
1894
1895
|
}
|
|
@@ -2103,11 +2104,11 @@ Adapt your tone accordingly:
|
|
|
2103
2104
|
const rulesCheck = validateSegmentRules(plan.rules);
|
|
2104
2105
|
if (!rulesCheck.ok)
|
|
2105
2106
|
return txt(`Error: Could not use the audience rules. ${rulesCheck.errors[0]} Nothing was created.`);
|
|
2106
|
-
const { data: seg, error: segErr } = await db()
|
|
2107
|
+
const { data: seg, error: segErr } = await withCreatedItems('segments', db()
|
|
2107
2108
|
.from('segments')
|
|
2108
2109
|
.insert({ project_id: projectId, name: plan.segment_name, description: segment_description, rules: plan.rules })
|
|
2109
2110
|
.select('id')
|
|
2110
|
-
.single();
|
|
2111
|
+
.single());
|
|
2111
2112
|
if (segErr)
|
|
2112
2113
|
return txt(`Error creating segment: ${dbToolError(segErr).error}`);
|
|
2113
2114
|
segId = seg.id;
|
|
@@ -2115,17 +2116,17 @@ Adapt your tone accordingly:
|
|
|
2115
2116
|
const campLimit = await checkPlanLimit('active_campaigns');
|
|
2116
2117
|
if (!campLimit.allowed)
|
|
2117
2118
|
return txt(`Active campaign limit reached (${campLimit.current}/${campLimit.limit}).`);
|
|
2118
|
-
const { data: campaign, error: campErr } = await db()
|
|
2119
|
+
const { data: campaign, error: campErr } = await withCreatedItems('campaigns', db()
|
|
2119
2120
|
.from('campaigns')
|
|
2120
2121
|
.insert({ project_id: projectId, site_id, name, type: type ?? 'sequence', segment_id: segId, sends_per_hour: sends_per_hour ?? null, requires_approval: false })
|
|
2121
2122
|
.select('id')
|
|
2122
|
-
.single();
|
|
2123
|
+
.single());
|
|
2123
2124
|
if (campErr)
|
|
2124
2125
|
return txt(`Error creating campaign: ${dbToolError(campErr).error}`);
|
|
2125
2126
|
const steps = [];
|
|
2126
2127
|
for (let i = 0; i < plan.steps.length; i++) {
|
|
2127
2128
|
const planned = plan.steps[i];
|
|
2128
|
-
const { data: step, error: stepErr } = await db()
|
|
2129
|
+
const { data: step, error: stepErr } = await withCreatedItems('campaign_steps', db()
|
|
2129
2130
|
.from('campaign_steps')
|
|
2130
2131
|
.insert({
|
|
2131
2132
|
campaign_id: campaign.id, step_order: i,
|
|
@@ -2134,7 +2135,7 @@ Adapt your tone accordingly:
|
|
|
2134
2135
|
delay_days: planned.delay_days, delay_hours: planned.delay_hours,
|
|
2135
2136
|
})
|
|
2136
2137
|
.select('id, step_order, subject')
|
|
2137
|
-
.single();
|
|
2138
|
+
.single());
|
|
2138
2139
|
if (stepErr)
|
|
2139
2140
|
return txt(`Error adding step ${i}: ${dbToolError(stepErr).error}`);
|
|
2140
2141
|
steps.push(step);
|
|
@@ -3314,7 +3315,7 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3314
3315
|
}
|
|
3315
3316
|
// Execute mode (second call)
|
|
3316
3317
|
// 1. Create campaign
|
|
3317
|
-
const { data: campaign, error: campErr } = await db()
|
|
3318
|
+
const { data: campaign, error: campErr } = await withCreatedItems('campaigns', db()
|
|
3318
3319
|
.from('campaigns')
|
|
3319
3320
|
.insert({
|
|
3320
3321
|
project_id: getProjectId(),
|
|
@@ -3329,7 +3330,7 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3329
3330
|
sends_per_hour: sends_per_hour ?? null,
|
|
3330
3331
|
})
|
|
3331
3332
|
.select()
|
|
3332
|
-
.single();
|
|
3333
|
+
.single());
|
|
3333
3334
|
if (campErr)
|
|
3334
3335
|
return txt(`Failed to create campaign: ${dbToolError(campErr).error}`);
|
|
3335
3336
|
// 2. Add steps
|
|
@@ -3347,9 +3348,9 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3347
3348
|
...stepMultiEventRulesView(s),
|
|
3348
3349
|
...stepAbsenceRuleView(s),
|
|
3349
3350
|
}));
|
|
3350
|
-
const { error: stepErr } = await db()
|
|
3351
|
+
const { error: stepErr } = await withCreatedItems('campaign_steps', db()
|
|
3351
3352
|
.from('campaign_steps')
|
|
3352
|
-
.insert(stepInserts);
|
|
3353
|
+
.insert(stepInserts).select('id'));
|
|
3353
3354
|
if (stepErr)
|
|
3354
3355
|
return txt(`Campaign created (${campaign.id}) but failed to add steps: ${dbToolError(stepErr).error}`);
|
|
3355
3356
|
// 3. Enroll segment if provided
|
|
@@ -3599,10 +3600,13 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3599
3600
|
.eq('id', campaign_id)
|
|
3600
3601
|
.eq('project_id', projectId)
|
|
3601
3602
|
.maybeSingle();
|
|
3602
|
-
|
|
3603
|
-
|
|
3603
|
+
// No write has happened: release the claim so the same approval can be retried.
|
|
3604
|
+
if (fetchErr)
|
|
3605
|
+
return markNotExecuted(j(dbToolError(fetchErr)));
|
|
3606
|
+
if (!campaign)
|
|
3607
|
+
return markNotExecuted(j({ error: 'Campaign not found', code: 'NOT_FOUND' }));
|
|
3604
3608
|
if (campaign.status === 'active') {
|
|
3605
|
-
return j({ error: 'Pause the campaign before deleting', code: 'INVALID_STATUS' });
|
|
3609
|
+
return markNotExecuted(j({ error: 'Pause the campaign before deleting', code: 'INVALID_STATUS' }));
|
|
3606
3610
|
}
|
|
3607
3611
|
const { error } = await db()
|
|
3608
3612
|
.from('campaigns')
|
package/build/tools/compound.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { withCreatedItems } from '../lib/mcp-created-items.js';
|
|
1
2
|
import { anthropicRequiredText, outputConfig } from '@sendinel/domain';
|
|
2
3
|
import { z } from 'zod';
|
|
3
4
|
import Anthropic from '@anthropic-ai/sdk';
|
|
@@ -115,7 +116,7 @@ Respond with a JSON array.`,
|
|
|
115
116
|
catch (error) {
|
|
116
117
|
return j({ error: `AI generation failed: ${error instanceof Error ? error.message : String(error)}`, code: 'GENERATION_FAILED' });
|
|
117
118
|
}
|
|
118
|
-
const { data: template, error: templateErr } = await sb.from('campaign_templates')
|
|
119
|
+
const { data: template, error: templateErr } = await withCreatedItems('campaign_templates', sb.from('campaign_templates')
|
|
119
120
|
.insert({
|
|
120
121
|
project_id: projectId,
|
|
121
122
|
name: `${campaign_name} - Template`,
|
|
@@ -127,19 +128,19 @@ Respond with a JSON array.`,
|
|
|
127
128
|
description: `Base template generated for campaign "${campaign_name}"`,
|
|
128
129
|
})
|
|
129
130
|
.select('id')
|
|
130
|
-
.single();
|
|
131
|
+
.single());
|
|
131
132
|
if (templateErr)
|
|
132
133
|
return j({ error: `Template creation failed: ${dbToolError(templateErr).error}` });
|
|
133
|
-
const { data: campaign, error: campaignErr } = await sb.from('campaigns')
|
|
134
|
+
const { data: campaign, error: campaignErr } = await withCreatedItems('campaigns', sb.from('campaigns')
|
|
134
135
|
.insert({ name: campaign_name, site_id, project_id: projectId, type: campaign_type, status: 'draft' })
|
|
135
136
|
.select('id, name, status')
|
|
136
|
-
.single();
|
|
137
|
+
.single());
|
|
137
138
|
if (campaignErr)
|
|
138
139
|
return j({ error: `Campaign creation failed: ${dbToolError(campaignErr).error}` });
|
|
139
140
|
const createdSteps = [];
|
|
140
141
|
for (let i = 0; i < steps.length; i++) {
|
|
141
142
|
const step = steps[i];
|
|
142
|
-
const { data, error } = await sb.from('campaign_steps')
|
|
143
|
+
const { data, error } = await withCreatedItems('campaign_steps', sb.from('campaign_steps')
|
|
143
144
|
.insert({
|
|
144
145
|
campaign_id: campaign.id,
|
|
145
146
|
step_order: i,
|
|
@@ -152,7 +153,7 @@ Respond with a JSON array.`,
|
|
|
152
153
|
template_id: String(template.id),
|
|
153
154
|
})
|
|
154
155
|
.select('id, step_order, subject')
|
|
155
|
-
.single();
|
|
156
|
+
.single());
|
|
156
157
|
if (error)
|
|
157
158
|
return j({ error: `Step ${i + 1} creation failed: ${dbToolError(error).error}`, campaign_id: campaign.id });
|
|
158
159
|
createdSteps.push(data);
|
|
@@ -224,7 +225,7 @@ Respond with JSON: { "subject": "...", "blocks": [...], "body_text": "..." }`,
|
|
|
224
225
|
console.error("[compound] Generation failed:", error);
|
|
225
226
|
return toolError(dbToolError(error, "Generation failed: unable to generate template"));
|
|
226
227
|
}
|
|
227
|
-
const { data: template, error } = await db().from('campaign_templates')
|
|
228
|
+
const { data: template, error } = await withCreatedItems('campaign_templates', db().from('campaign_templates')
|
|
228
229
|
.insert({
|
|
229
230
|
project_id: projectId,
|
|
230
231
|
name,
|
|
@@ -236,7 +237,7 @@ Respond with JSON: { "subject": "...", "blocks": [...], "body_text": "..." }`,
|
|
|
236
237
|
description: 'Generated from MCP brief',
|
|
237
238
|
})
|
|
238
239
|
.select('id, name, subject_hint, created_at')
|
|
239
|
-
.single();
|
|
240
|
+
.single());
|
|
240
241
|
if (error)
|
|
241
242
|
return j(dbToolError(error));
|
|
242
243
|
return j({ ok: true, template, preview: generated });
|
package/build/tools/contacts.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { recordCreatedItems, withCreatedItems } from '../lib/mcp-created-items.js';
|
|
1
2
|
import { normalizeTagInput, tagInputWarnings } from '../lib/tags.js';
|
|
2
3
|
import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
3
4
|
import { CONTACT_ENRICHMENT_PROVIDERS } from '@desler/models';
|
|
@@ -671,8 +672,12 @@ export function registerContactTools(server) {
|
|
|
671
672
|
},
|
|
672
673
|
}, withApproval('identify_subscriber', async (params) => {
|
|
673
674
|
const result = await callSendinelV1('/api/v1/identify', { method: 'POST', body: params });
|
|
674
|
-
if (result.ok)
|
|
675
|
+
if (result.ok) {
|
|
676
|
+
const identified = result.data;
|
|
677
|
+
if (identified.created === true && identified.contact_id)
|
|
678
|
+
await recordCreatedItems('subscribers', { id: identified.contact_id });
|
|
675
679
|
return j(result.data);
|
|
680
|
+
}
|
|
676
681
|
const failed = err(JSON.stringify(result.error));
|
|
677
682
|
return isPreExecutionStatus(result.status) ? markNotExecuted(failed) : failed;
|
|
678
683
|
}));
|
|
@@ -1133,7 +1138,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1133
1138
|
const normalizedName = name.trim().slice(0, SUBSCRIBER_VIEW_MAX_NAME_LENGTH);
|
|
1134
1139
|
if (!normalizedName)
|
|
1135
1140
|
return toolError({ error: 'name is required', code: 'validation_failed' });
|
|
1136
|
-
const { data, error } = await db()
|
|
1141
|
+
const { data, error } = await withCreatedItems('subscriber_list_views', db()
|
|
1137
1142
|
.from('subscriber_list_views')
|
|
1138
1143
|
.insert({
|
|
1139
1144
|
project_id: getProjectId(),
|
|
@@ -1144,7 +1149,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1144
1149
|
filters: normalizeSubscriberViewFilters(filters),
|
|
1145
1150
|
})
|
|
1146
1151
|
.select('id, name, view_mode, columns, filters, created_by, created_at, updated_at')
|
|
1147
|
-
.maybeSingle();
|
|
1152
|
+
.maybeSingle());
|
|
1148
1153
|
if (error?.code === '23505')
|
|
1149
1154
|
return toolError({ error: `A view named "${normalizedName}" already exists`, code: 'conflict' });
|
|
1150
1155
|
if (error)
|
|
@@ -1378,9 +1383,9 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1378
1383
|
row.utm_content = utm_content;
|
|
1379
1384
|
if (utm_term)
|
|
1380
1385
|
row.utm_term = utm_term;
|
|
1381
|
-
const { data, error } = await db().from('subscribers')
|
|
1386
|
+
const { data, error } = await withCreatedItems('subscribers', db().from('subscribers')
|
|
1382
1387
|
.upsert(row, { onConflict: 'project_id,email', ignoreDuplicates: true })
|
|
1383
|
-
.select(selection).maybeSingle();
|
|
1388
|
+
.select(selection).maybeSingle());
|
|
1384
1389
|
if (error)
|
|
1385
1390
|
return dbErrorResult(error);
|
|
1386
1391
|
contact = data;
|
|
@@ -1532,6 +1537,12 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1532
1537
|
source: 'import',
|
|
1533
1538
|
opted_in_at: new Date().toISOString(),
|
|
1534
1539
|
}));
|
|
1540
|
+
// ON CONFLICT DO NOTHING returns only actual inserts, even under concurrent imports.
|
|
1541
|
+
// The updating upsert below must never claim a pre-existing subscriber.
|
|
1542
|
+
const { error: creationError } = await withCreatedItems('subscribers', db().from('subscribers')
|
|
1543
|
+
.upsert(rows, { onConflict: 'project_id,email', ignoreDuplicates: true }).select('id'));
|
|
1544
|
+
if (creationError)
|
|
1545
|
+
return j(dbToolError(creationError));
|
|
1535
1546
|
const { data: upserted, error: uErr } = await db()
|
|
1536
1547
|
.from('subscribers')
|
|
1537
1548
|
.upsert(rows, { onConflict: 'project_id,email', ignoreDuplicates: false })
|
|
@@ -2326,55 +2337,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
2326
2337
|
return toolError({ error: error.message, code: 'validation_failed' });
|
|
2327
2338
|
throw error;
|
|
2328
2339
|
}
|
|
2329
|
-
let
|
|
2330
|
-
.from('subscribers')
|
|
2331
|
-
.select('id, email, first_name, last_name, engagement_score, lifecycle_stage, source, created_at, last_opened_at, last_clicked_at')
|
|
2332
|
-
.eq('project_id', projectId)
|
|
2333
|
-
.eq('global_unsubscribed', false);
|
|
2334
|
-
if (params.lifecycle_stage)
|
|
2335
|
-
q = q.eq('lifecycle_stage', params.lifecycle_stage);
|
|
2336
|
-
if (params.min_score !== undefined)
|
|
2337
|
-
q = q.gte('engagement_score', params.min_score);
|
|
2338
|
-
if (params.max_score !== undefined)
|
|
2339
|
-
q = q.lte('engagement_score', params.max_score);
|
|
2340
|
-
if (params.source)
|
|
2341
|
-
q = q.eq('source', params.source);
|
|
2342
|
-
if (params.subscribed_after)
|
|
2343
|
-
q = q.gte('created_at', `${params.subscribed_after}T00:00:00Z`);
|
|
2344
|
-
if (params.subscribed_before)
|
|
2345
|
-
q = q.lte('created_at', `${params.subscribed_before}T23:59:59Z`);
|
|
2346
|
-
if (params.has_opened === true)
|
|
2347
|
-
q = q.not('last_opened_at', 'is', null);
|
|
2348
|
-
if (params.has_opened === false)
|
|
2349
|
-
q = q.is('last_opened_at', null);
|
|
2350
|
-
if (params.has_clicked === true)
|
|
2351
|
-
q = q.not('last_clicked_at', 'is', null);
|
|
2352
|
-
if (params.has_clicked === false)
|
|
2353
|
-
q = q.is('last_clicked_at', null);
|
|
2354
|
-
if (params.tag)
|
|
2355
|
-
q = q.contains('tags', [params.tag]);
|
|
2356
|
-
if (params.company_id)
|
|
2357
|
-
q = q.eq('company_id', params.company_id);
|
|
2358
|
-
const fieldTypes = params.custom_property ? await loadContactPropertyDefinitions(projectId) : {};
|
|
2359
|
-
if (params.custom_property) {
|
|
2360
|
-
try {
|
|
2361
|
-
// #4270: the query runs as-is, so a filter it cannot express is an error, not dropped.
|
|
2362
|
-
({ query: q } = await applySegmentRules(q, { custom_property: params.custom_property }, db(), projectId, fieldTypes, { pushDownOnly: true }));
|
|
2363
|
-
}
|
|
2364
|
-
catch (error) {
|
|
2365
|
-
if (isSegmentRulesError(error))
|
|
2366
|
-
return toolError({ error: error.message, code: 'validation_failed' });
|
|
2367
|
-
throw error;
|
|
2368
|
-
}
|
|
2369
|
-
}
|
|
2370
|
-
if (params.site_id) {
|
|
2371
|
-
// Shared site filter: an inner join, not an id list that overflows past ~350 contacts (#3674).
|
|
2372
|
-
// Deliberately everyone on the site, so allowEmpty.
|
|
2373
|
-
const siteFiltered = await applySegmentRules(q, { site_id: params.site_id }, db(), projectId, {}, { allowEmpty: true });
|
|
2374
|
-
if (siteFiltered.siteContactIds && siteFiltered.siteContactIds.length === 0)
|
|
2375
|
-
return j({ total: 0, sample: [] });
|
|
2376
|
-
q = siteFiltered.query;
|
|
2377
|
-
}
|
|
2340
|
+
let companyIds;
|
|
2378
2341
|
if (params.company_domain) {
|
|
2379
2342
|
const { data: companies, error } = await db()
|
|
2380
2343
|
.from('companies')
|
|
@@ -2386,45 +2349,86 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
2386
2349
|
const ids = (companies ?? []).map((company) => company.id);
|
|
2387
2350
|
if (!ids.length)
|
|
2388
2351
|
return j({ total: 0, sample: [] });
|
|
2389
|
-
|
|
2352
|
+
companyIds = ids;
|
|
2390
2353
|
}
|
|
2354
|
+
const fieldTypes = params.custom_property ? await loadContactPropertyDefinitions(projectId) : {};
|
|
2355
|
+
const applyEstimateFilters = async (baseQuery) => {
|
|
2356
|
+
let filtered = baseQuery;
|
|
2357
|
+
if (params.lifecycle_stage)
|
|
2358
|
+
filtered = filtered.eq('lifecycle_stage', params.lifecycle_stage);
|
|
2359
|
+
if (params.min_score !== undefined)
|
|
2360
|
+
filtered = filtered.gte('engagement_score', params.min_score);
|
|
2361
|
+
if (params.max_score !== undefined)
|
|
2362
|
+
filtered = filtered.lte('engagement_score', params.max_score);
|
|
2363
|
+
if (params.source)
|
|
2364
|
+
filtered = filtered.eq('source', params.source);
|
|
2365
|
+
if (params.subscribed_after)
|
|
2366
|
+
filtered = filtered.gte('created_at', `${params.subscribed_after}T00:00:00Z`);
|
|
2367
|
+
if (params.subscribed_before)
|
|
2368
|
+
filtered = filtered.lte('created_at', `${params.subscribed_before}T23:59:59Z`);
|
|
2369
|
+
if (params.has_opened === true)
|
|
2370
|
+
filtered = filtered.not('last_opened_at', 'is', null);
|
|
2371
|
+
if (params.has_opened === false)
|
|
2372
|
+
filtered = filtered.is('last_opened_at', null);
|
|
2373
|
+
if (params.has_clicked === true)
|
|
2374
|
+
filtered = filtered.not('last_clicked_at', 'is', null);
|
|
2375
|
+
if (params.has_clicked === false)
|
|
2376
|
+
filtered = filtered.is('last_clicked_at', null);
|
|
2377
|
+
if (params.tag)
|
|
2378
|
+
filtered = filtered.contains('tags', [params.tag]);
|
|
2379
|
+
if (params.company_id)
|
|
2380
|
+
filtered = filtered.eq('company_id', params.company_id);
|
|
2381
|
+
if (companyIds)
|
|
2382
|
+
filtered = filtered.in('company_id', companyIds);
|
|
2383
|
+
if (params.custom_property) {
|
|
2384
|
+
try {
|
|
2385
|
+
// #4270: the query runs as-is, so a filter it cannot express is an error, not dropped.
|
|
2386
|
+
const res = await applySegmentRules(filtered, { custom_property: params.custom_property }, db(), projectId, fieldTypes, { pushDownOnly: true });
|
|
2387
|
+
filtered = res.query;
|
|
2388
|
+
}
|
|
2389
|
+
catch (error) {
|
|
2390
|
+
if (isSegmentRulesError(error))
|
|
2391
|
+
return { query: filtered, error: toolError({ error: error.message, code: 'validation_failed' }) };
|
|
2392
|
+
throw error;
|
|
2393
|
+
}
|
|
2394
|
+
}
|
|
2395
|
+
if (params.site_id) {
|
|
2396
|
+
// Shared site filter: an inner join, not an id list that overflows past ~350 contacts (#3674).
|
|
2397
|
+
// Deliberately everyone on the site, so allowEmpty.
|
|
2398
|
+
const siteFiltered = await applySegmentRules(filtered, { site_id: params.site_id }, db(), projectId, {}, { allowEmpty: true });
|
|
2399
|
+
if (siteFiltered.siteContactIds && siteFiltered.siteContactIds.length === 0) {
|
|
2400
|
+
return { query: siteFiltered.query, empty: true };
|
|
2401
|
+
}
|
|
2402
|
+
filtered = siteFiltered.query;
|
|
2403
|
+
}
|
|
2404
|
+
return { query: filtered };
|
|
2405
|
+
};
|
|
2406
|
+
let sampleBase = db()
|
|
2407
|
+
.from('subscribers')
|
|
2408
|
+
.select('id, email, first_name, last_name, engagement_score, lifecycle_stage, source, created_at, last_opened_at, last_clicked_at')
|
|
2409
|
+
.eq('project_id', projectId)
|
|
2410
|
+
.eq('global_unsubscribed', false);
|
|
2411
|
+
const sampleFilterResult = await applyEstimateFilters(sampleBase);
|
|
2412
|
+
if (sampleFilterResult.error)
|
|
2413
|
+
return sampleFilterResult.error;
|
|
2414
|
+
if (sampleFilterResult.empty)
|
|
2415
|
+
return j({ total: 0, sample: [] });
|
|
2416
|
+
let q = sampleFilterResult.query;
|
|
2391
2417
|
const { data, error } = await q.order('engagement_score', { ascending: false }).limit(sampleSize);
|
|
2392
2418
|
if (error)
|
|
2393
2419
|
return dbErrorResult(error);
|
|
2394
|
-
// Get full count separately
|
|
2395
|
-
let
|
|
2420
|
+
// Get full count separately using the exact same filters
|
|
2421
|
+
let countBase = db()
|
|
2396
2422
|
.from('subscribers')
|
|
2397
2423
|
.select('id', { count: 'exact', head: true })
|
|
2398
2424
|
.eq('project_id', projectId)
|
|
2399
2425
|
.eq('global_unsubscribed', false);
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
|
|
2406
|
-
if (params.source)
|
|
2407
|
-
countQ = countQ.eq('source', params.source);
|
|
2408
|
-
if (params.subscribed_after)
|
|
2409
|
-
countQ = countQ.gte('created_at', `${params.subscribed_after}T00:00:00Z`);
|
|
2410
|
-
if (params.subscribed_before)
|
|
2411
|
-
countQ = countQ.lte('created_at', `${params.subscribed_before}T23:59:59Z`);
|
|
2412
|
-
if (params.has_opened === true)
|
|
2413
|
-
countQ = countQ.not('last_opened_at', 'is', null);
|
|
2414
|
-
if (params.has_opened === false)
|
|
2415
|
-
countQ = countQ.is('last_opened_at', null);
|
|
2416
|
-
if (params.has_clicked === true)
|
|
2417
|
-
countQ = countQ.not('last_clicked_at', 'is', null);
|
|
2418
|
-
if (params.has_clicked === false)
|
|
2419
|
-
countQ = countQ.is('last_clicked_at', null);
|
|
2420
|
-
if (params.tag)
|
|
2421
|
-
countQ = countQ.contains('tags', [params.tag]);
|
|
2422
|
-
if (params.custom_property) {
|
|
2423
|
-
({ query: countQ } = await applySegmentRules(countQ, { custom_property: params.custom_property }, db(), projectId, fieldTypes, { pushDownOnly: true }));
|
|
2424
|
-
}
|
|
2425
|
-
// The total must count the same site the sample came from (#3674).
|
|
2426
|
-
if (params.site_id)
|
|
2427
|
-
({ query: countQ } = await applySegmentRules(countQ, { site_id: params.site_id }, db(), projectId, {}, { allowEmpty: true }));
|
|
2426
|
+
const countFilterResult = await applyEstimateFilters(countBase);
|
|
2427
|
+
if (countFilterResult.error)
|
|
2428
|
+
return countFilterResult.error;
|
|
2429
|
+
if (countFilterResult.empty)
|
|
2430
|
+
return j({ total: 0, sample: [] });
|
|
2431
|
+
let countQ = countFilterResult.query;
|
|
2428
2432
|
const { count, error: countError } = await countQ;
|
|
2429
2433
|
if (countError)
|
|
2430
2434
|
return dbErrorResult(countError);
|
|
@@ -2837,7 +2841,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
2837
2841
|
: null;
|
|
2838
2842
|
const projectId = getProjectId();
|
|
2839
2843
|
const orgId = await getProjectOrgId(projectId);
|
|
2840
|
-
const { data, error } = await db()
|
|
2844
|
+
const { data, error } = await withCreatedItems('custom_field_definitions', db()
|
|
2841
2845
|
.from('custom_field_definitions')
|
|
2842
2846
|
.insert({
|
|
2843
2847
|
org_id: orgId,
|
|
@@ -2850,7 +2854,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
2850
2854
|
display_order: display_order ?? 0,
|
|
2851
2855
|
})
|
|
2852
2856
|
.select('id, key, label, type, required, options, display_order')
|
|
2853
|
-
.single();
|
|
2857
|
+
.single());
|
|
2854
2858
|
if (error) {
|
|
2855
2859
|
if (error.code === '23505')
|
|
2856
2860
|
return contactInputError('conflict', `Error: A custom field with key "${key}" already exists.`);
|
package/build/tools/content.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { withCreatedItems } from '../lib/mcp-created-items.js';
|
|
1
2
|
import { withApproval } from '../lib/action-approvals.js';
|
|
2
3
|
import { z } from 'zod';
|
|
3
4
|
import { adminDb, db } from '../db.js';
|
|
@@ -241,10 +242,10 @@ export function registerContentTools(server) {
|
|
|
241
242
|
if (category === 'footer' && !/href\s*=\s*["']\s*\{\{\s*unsubscribe_url\s*\}\}/i.test(html) && !validateUnsubscribeFooterTemplate(html).ok) {
|
|
242
243
|
return toolError({ error: 'Footer blocks require {{unsubscribe_url}} in a link or <unsubscribe>link text</unsubscribe>', code: 'validation_failed' });
|
|
243
244
|
}
|
|
244
|
-
const { data, error } = await db().from('content_blocks')
|
|
245
|
+
const { data, error } = await withCreatedItems('content_blocks', db().from('content_blocks')
|
|
245
246
|
.insert({ project_id: getProjectId(), name, category, html, description: description ?? null })
|
|
246
247
|
.select('id, name, category, html, description, created_at')
|
|
247
|
-
.single();
|
|
248
|
+
.single());
|
|
248
249
|
if (error)
|
|
249
250
|
return toolError(dbToolError(error));
|
|
250
251
|
await logAudit({ action: 'content_block.created', resourceType: 'content_block', resourceId: data.id });
|
package/build/tools/drafts.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { withCreatedItems } from '../lib/mcp-created-items.js';
|
|
1
2
|
import { z } from 'zod';
|
|
2
3
|
import { db } from '../db.js';
|
|
3
4
|
import { getProjectId } from '../project.js';
|
|
@@ -179,14 +180,14 @@ export function registerDraftTools(server) {
|
|
|
179
180
|
insert.trigger_event_name = trigger_event_name;
|
|
180
181
|
if (trigger_tag)
|
|
181
182
|
insert.trigger_tag = trigger_tag.trim().toLowerCase();
|
|
182
|
-
const { data: campaign, error: campaignError } = await db()
|
|
183
|
+
const { data: campaign, error: campaignError } = await withCreatedItems('campaigns', db()
|
|
183
184
|
.from('campaigns')
|
|
184
185
|
.insert(insert)
|
|
185
186
|
.select()
|
|
186
|
-
.single();
|
|
187
|
+
.single());
|
|
187
188
|
if (campaignError)
|
|
188
189
|
return { content: [{ type: 'text', text: `Failed to create campaign: ${dbToolError(campaignError).error}` }] };
|
|
189
|
-
const { data: step, error: stepError } = await db()
|
|
190
|
+
const { data: step, error: stepError } = await withCreatedItems('campaign_steps', db()
|
|
190
191
|
.from('campaign_steps')
|
|
191
192
|
.insert({
|
|
192
193
|
campaign_id: campaign.id,
|
|
@@ -198,7 +199,7 @@ export function registerDraftTools(server) {
|
|
|
198
199
|
delay_hours: 0,
|
|
199
200
|
})
|
|
200
201
|
.select()
|
|
201
|
-
.single();
|
|
202
|
+
.single());
|
|
202
203
|
if (stepError)
|
|
203
204
|
return { content: [{ type: 'text', text: `Campaign created (${campaign.id}) but failed to create step: ${dbToolError(stepError).error}` }] };
|
|
204
205
|
await recordBrandAssetUsage({
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { withCreatedItems } from '../lib/mcp-created-items.js';
|
|
1
2
|
import { withApproval } from '../lib/action-approvals.js';
|
|
2
3
|
import { z } from 'zod';
|
|
3
4
|
import { db } from '../db.js';
|
|
@@ -147,11 +148,11 @@ export function registerLandingPageTools(server) {
|
|
|
147
148
|
insert.accent_color = accent_color;
|
|
148
149
|
if (custom_css)
|
|
149
150
|
insert.custom_css = custom_css;
|
|
150
|
-
const { data, error } = await db()
|
|
151
|
+
const { data, error } = await withCreatedItems('landing_pages', db()
|
|
151
152
|
.from('landing_pages')
|
|
152
153
|
.insert(insert)
|
|
153
154
|
.select()
|
|
154
|
-
.single();
|
|
155
|
+
.single());
|
|
155
156
|
if (error) {
|
|
156
157
|
if (error.code === '23505')
|
|
157
158
|
return toolError({ error: 'A page with this slug already exists', code: 'conflict' });
|
package/build/tools/lists.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { withCreatedItems } from '../lib/mcp-created-items.js';
|
|
1
2
|
import { literalIlikePattern } from '@sendinel/domain';
|
|
2
3
|
import { z } from 'zod';
|
|
3
4
|
import { applySegmentRules, buildSegmentBaseQuery, evaluateSegmentRules, hydrateEmailSendsContacts, isSegmentRulesError, normalizeSegmentRulesToTree, readAllRows, } from '@sendinel/domain';
|
|
@@ -127,7 +128,7 @@ export function registerListTools(server) {
|
|
|
127
128
|
}, async ({ name, description }) => {
|
|
128
129
|
const projectId = getProjectId();
|
|
129
130
|
const normalizedName = name.trim();
|
|
130
|
-
const { data, error } = await db()
|
|
131
|
+
const { data, error } = await withCreatedItems('contact_lists', db()
|
|
131
132
|
.from('contact_lists')
|
|
132
133
|
.insert({
|
|
133
134
|
project_id: projectId,
|
|
@@ -135,7 +136,7 @@ export function registerListTools(server) {
|
|
|
135
136
|
description: description?.trim() ?? null,
|
|
136
137
|
})
|
|
137
138
|
.select('id, name, description, created_at')
|
|
138
|
-
.single();
|
|
139
|
+
.single());
|
|
139
140
|
if (error)
|
|
140
141
|
return txt(`Error: ${dbToolError(error).error}`);
|
|
141
142
|
await logAudit({
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { withCreatedItems } from '../lib/mcp-created-items.js';
|
|
1
2
|
import { withApproval } from '../lib/action-approvals.js';
|
|
2
3
|
import { z } from 'zod';
|
|
3
4
|
import { stripActiveEmailContent } from '@sendinel/domain';
|
|
@@ -88,7 +89,7 @@ async function ensureNewsletterCampaign(newsletter, issue, projectId, scheduledA
|
|
|
88
89
|
campaignId = null;
|
|
89
90
|
}
|
|
90
91
|
if (!campaignId) {
|
|
91
|
-
const { data: campaign, error } = await db().from('campaigns').insert({
|
|
92
|
+
const { data: campaign, error } = await withCreatedItems('campaigns', db().from('campaigns').insert({
|
|
92
93
|
project_id: projectId,
|
|
93
94
|
site_id: newsletter.site_id,
|
|
94
95
|
name: campaignName,
|
|
@@ -97,7 +98,7 @@ async function ensureNewsletterCampaign(newsletter, issue, projectId, scheduledA
|
|
|
97
98
|
requires_approval: false,
|
|
98
99
|
send_at: scheduledAt,
|
|
99
100
|
metadata,
|
|
100
|
-
}).select('id').single();
|
|
101
|
+
}).select('id').single());
|
|
101
102
|
if (error || !campaign)
|
|
102
103
|
return { error: (error ? dbToolError(error).error : undefined) ?? 'Campaign create failed' };
|
|
103
104
|
campaignId = campaign.id;
|
|
@@ -117,7 +118,7 @@ async function ensureNewsletterCampaign(newsletter, issue, projectId, scheduledA
|
|
|
117
118
|
return { ...dbToolError(error) };
|
|
118
119
|
}
|
|
119
120
|
else {
|
|
120
|
-
const { error } = await db().from('campaign_steps').insert({
|
|
121
|
+
const { error } = await withCreatedItems('campaign_steps', db().from('campaign_steps').insert({
|
|
121
122
|
campaign_id: campaignId,
|
|
122
123
|
step_order: 0,
|
|
123
124
|
subject: issue.subject,
|
|
@@ -125,7 +126,7 @@ async function ensureNewsletterCampaign(newsletter, issue, projectId, scheduledA
|
|
|
125
126
|
body_text: issue.preheader,
|
|
126
127
|
delay_days: 0,
|
|
127
128
|
delay_hours: 0,
|
|
128
|
-
});
|
|
129
|
+
}).select('id'));
|
|
129
130
|
if (error)
|
|
130
131
|
return { ...dbToolError(error) };
|
|
131
132
|
}
|
|
@@ -504,6 +505,12 @@ export function registerNewsletterTools(server) {
|
|
|
504
505
|
return j(dbToolError(newsletterError));
|
|
505
506
|
if (!newsletter)
|
|
506
507
|
return j({ error: 'Newsletter not found', code: 'NOT_FOUND' });
|
|
508
|
+
// Separate insert from update so reactivating a seed/other client's subscriber grants no ownership.
|
|
509
|
+
const { error: creationError } = await withCreatedItems('subscribers', db().from('subscribers').upsert({
|
|
510
|
+
project_id: projectId, email: normalizedEmail, first_name: first_name ?? null, last_name: last_name ?? null, source,
|
|
511
|
+
}, { onConflict: 'project_id,email', ignoreDuplicates: true }).select('id'));
|
|
512
|
+
if (creationError)
|
|
513
|
+
return j(dbToolError(creationError));
|
|
507
514
|
const { data: subscriber, error: subscriberError } = await db().from('subscribers').upsert({
|
|
508
515
|
project_id: projectId,
|
|
509
516
|
email: normalizedEmail,
|
package/build/tools/segments.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { withCreatedItems } from '../lib/mcp-created-items.js';
|
|
1
2
|
import { withApproval } from '../lib/action-approvals.js';
|
|
2
3
|
import { z } from 'zod';
|
|
3
4
|
import Anthropic from '@anthropic-ai/sdk';
|
|
@@ -94,11 +95,11 @@ export function registerSegmentTools(server) {
|
|
|
94
95
|
return invalid;
|
|
95
96
|
throw rulesError;
|
|
96
97
|
}
|
|
97
|
-
const { data, error } = await db()
|
|
98
|
+
const { data, error } = await withCreatedItems('segments', db()
|
|
98
99
|
.from('segments')
|
|
99
100
|
.insert({ project_id: getProjectId(), name, description, rules: storedRules })
|
|
100
101
|
.select('id, name, description, rules, created_at')
|
|
101
|
-
.single();
|
|
102
|
+
.single());
|
|
102
103
|
if (error?.code === '23505')
|
|
103
104
|
return toolError({ error: 'A segment with this name already exists', code: 'conflict' });
|
|
104
105
|
if (error)
|
|
@@ -247,14 +248,14 @@ export function registerSegmentTools(server) {
|
|
|
247
248
|
if (error)
|
|
248
249
|
return dbErrorResult(error);
|
|
249
250
|
if (!seg)
|
|
250
|
-
return
|
|
251
|
+
return toolError({ error: 'Segment not found', code: 'not_found' });
|
|
251
252
|
segRules = seg.rules;
|
|
252
253
|
}
|
|
253
254
|
else if (rules !== undefined) {
|
|
254
255
|
segRules = rules;
|
|
255
256
|
}
|
|
256
257
|
else {
|
|
257
|
-
return
|
|
258
|
+
return toolError({ error: 'Provide either segment_id or rules.', code: 'validation_failed' });
|
|
258
259
|
}
|
|
259
260
|
let ids;
|
|
260
261
|
try {
|
|
@@ -335,11 +336,11 @@ export function registerSegmentTools(server) {
|
|
|
335
336
|
message: `Parsed "${description}" into rules. ${ids.length} contacts match. Call again with confirmed: true to create.`,
|
|
336
337
|
});
|
|
337
338
|
}
|
|
338
|
-
const { data, error } = await db()
|
|
339
|
+
const { data, error } = await withCreatedItems('segments', db()
|
|
339
340
|
.from('segments')
|
|
340
341
|
.insert({ project_id: getProjectId(), name, description, rules: parsedRules })
|
|
341
342
|
.select('id, name, description, rules, created_at')
|
|
342
|
-
.single();
|
|
343
|
+
.single());
|
|
343
344
|
if (error)
|
|
344
345
|
return txt(`Error: ${dbToolError(error).error}`);
|
|
345
346
|
return j({ ...data, estimated_contacts: ids.length });
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { withCreatedItems } from '../lib/mcp-created-items.js';
|
|
1
2
|
import { withApproval } from '../lib/action-approvals.js';
|
|
2
3
|
import { z } from 'zod';
|
|
3
4
|
import { db } from '../db.js';
|
|
@@ -58,7 +59,7 @@ export function registerSignatureTools(server) {
|
|
|
58
59
|
if (clearError)
|
|
59
60
|
return j(dbToolError(clearError));
|
|
60
61
|
}
|
|
61
|
-
const { data, error } = await db()
|
|
62
|
+
const { data, error } = await withCreatedItems('signature_blocks', db()
|
|
62
63
|
.from('signature_blocks')
|
|
63
64
|
.insert({
|
|
64
65
|
project_id: projectId,
|
|
@@ -68,7 +69,7 @@ export function registerSignatureTools(server) {
|
|
|
68
69
|
is_default: is_default ?? false,
|
|
69
70
|
})
|
|
70
71
|
.select(COLUMNS)
|
|
71
|
-
.single();
|
|
72
|
+
.single());
|
|
72
73
|
if (error)
|
|
73
74
|
return j(dbToolError(error));
|
|
74
75
|
await logAudit({
|
package/build/tools/sms.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { withCreatedItems } from '../lib/mcp-created-items.js';
|
|
1
2
|
import { assertProjectAllowsSms } from "@sendinel/domain";
|
|
2
3
|
import { randomUUID } from 'crypto';
|
|
3
4
|
import { withApproval } from '../lib/action-approvals.js';
|
|
@@ -419,7 +420,7 @@ export function registerSmsTools(server) {
|
|
|
419
420
|
},
|
|
420
421
|
}, withApproval('send_sms_campaign', async ({ site_id, name, body, type, segment_id, scheduled_at, activate }) => {
|
|
421
422
|
const projectId = getProjectId();
|
|
422
|
-
const { data: campaign, error: campaignError } = await db()
|
|
423
|
+
const { data: campaign, error: campaignError } = await withCreatedItems('campaigns', db()
|
|
423
424
|
.from('campaigns')
|
|
424
425
|
.insert({
|
|
425
426
|
project_id: projectId,
|
|
@@ -431,10 +432,10 @@ export function registerSmsTools(server) {
|
|
|
431
432
|
send_at: scheduled_at ?? null,
|
|
432
433
|
})
|
|
433
434
|
.select('id, name, type, status, channel, site_id, send_at')
|
|
434
|
-
.single();
|
|
435
|
+
.single());
|
|
435
436
|
if (campaignError || !campaign)
|
|
436
437
|
return j({ error: (campaignError ? dbToolError(campaignError).error : undefined) ?? 'Failed to create campaign' });
|
|
437
|
-
const { data: step, error: stepError } = await db()
|
|
438
|
+
const { data: step, error: stepError } = await withCreatedItems('campaign_steps', db()
|
|
438
439
|
.from('campaign_steps')
|
|
439
440
|
.insert({
|
|
440
441
|
project_id: projectId,
|
|
@@ -448,7 +449,7 @@ export function registerSmsTools(server) {
|
|
|
448
449
|
status: 'active',
|
|
449
450
|
})
|
|
450
451
|
.select('id, step_order, channel')
|
|
451
|
-
.single();
|
|
452
|
+
.single());
|
|
452
453
|
if (stepError)
|
|
453
454
|
return j({ ...dbToolError(stepError), campaign });
|
|
454
455
|
return j({
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { withCreatedItems } from '../lib/mcp-created-items.js';
|
|
1
2
|
import Anthropic from '@anthropic-ai/sdk';
|
|
2
3
|
import { withApproval } from '../lib/action-approvals.js';
|
|
3
4
|
import { z } from 'zod';
|
|
@@ -535,7 +536,7 @@ export function registerSocialPostTools(server) {
|
|
|
535
536
|
socialCampaignId = socialCampaign.id;
|
|
536
537
|
}
|
|
537
538
|
const status = statusFromSchedule(scheduledAt, Boolean(queue_requested));
|
|
538
|
-
const { data, error } = await db()
|
|
539
|
+
const { data, error } = await withCreatedItems('social_posts', db()
|
|
539
540
|
.from('social_posts')
|
|
540
541
|
.insert({
|
|
541
542
|
org_id: orgId,
|
|
@@ -557,7 +558,7 @@ export function registerSocialPostTools(server) {
|
|
|
557
558
|
status,
|
|
558
559
|
})
|
|
559
560
|
.select()
|
|
560
|
-
.single();
|
|
561
|
+
.single());
|
|
561
562
|
if (error)
|
|
562
563
|
return txt(`Error: ${dbToolError(error).error}`);
|
|
563
564
|
fireWebhookEvent('social.post.created', { social_post_id: data.id, title: data.title, status: data.status, platforms: data.platforms }, projectId).catch(() => { });
|
package/build/tools/templates.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { withCreatedItems } from '../lib/mcp-created-items.js';
|
|
1
2
|
import { literalIlikePattern } from '@sendinel/domain';
|
|
2
3
|
import { withApproval } from '../lib/action-approvals.js';
|
|
3
4
|
import { snapshotTemplateVersion, versionHasBodySnapshot } from '@sendinel/domain';
|
|
@@ -164,7 +165,7 @@ export function registerTemplateTools(server) {
|
|
|
164
165
|
return j(dbToolError(error));
|
|
165
166
|
let savedTemplateId = null;
|
|
166
167
|
if (save) {
|
|
167
|
-
const { data: saved, error: saveError } = await db().from('campaign_templates').insert({
|
|
168
|
+
const { data: saved, error: saveError } = await withCreatedItems('campaign_templates', db().from('campaign_templates').insert({
|
|
168
169
|
project_id: projectId,
|
|
169
170
|
name: translated.name,
|
|
170
171
|
category: 'migration',
|
|
@@ -179,7 +180,7 @@ export function registerTemplateTools(server) {
|
|
|
179
180
|
tags: ['migration', provider],
|
|
180
181
|
imported_from: provider,
|
|
181
182
|
external_id: source_template_id ?? null,
|
|
182
|
-
}).select('id').single();
|
|
183
|
+
}).select('id').single());
|
|
183
184
|
if (saveError)
|
|
184
185
|
return j(dbToolError(saveError));
|
|
185
186
|
savedTemplateId = saved?.id ?? null;
|
|
@@ -242,7 +243,7 @@ export function registerTemplateTools(server) {
|
|
|
242
243
|
},
|
|
243
244
|
}, async ({ name, brief, subject_hint, body_html, category, description, tags, is_transactional, brand_asset_ids }) => {
|
|
244
245
|
const projectId = getProjectId();
|
|
245
|
-
const { data, error } = await db().from('campaign_templates')
|
|
246
|
+
const { data, error } = await withCreatedItems('campaign_templates', db().from('campaign_templates')
|
|
246
247
|
.insert({
|
|
247
248
|
project_id: projectId,
|
|
248
249
|
name,
|
|
@@ -256,7 +257,7 @@ export function registerTemplateTools(server) {
|
|
|
256
257
|
is_transactional: is_transactional ?? false,
|
|
257
258
|
})
|
|
258
259
|
.select('id, name, category, description, subject_hint, brief_template, body_html, body_source, body_text, format, variables, tags, created_at')
|
|
259
|
-
.single();
|
|
260
|
+
.single());
|
|
260
261
|
if (error)
|
|
261
262
|
return j(dbToolError(error));
|
|
262
263
|
await recordBrandAssetUsage({
|
package/build/tools/webhooks.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { withCreatedItems } from '../lib/mcp-created-items.js';
|
|
1
2
|
import { withApproval } from '../lib/action-approvals.js';
|
|
2
3
|
import { z } from 'zod';
|
|
3
4
|
import { listSandboxWebhookEvents } from '@sendinel/domain';
|
|
@@ -61,7 +62,7 @@ export function registerWebhookTools(server) {
|
|
|
61
62
|
if (!validation.valid)
|
|
62
63
|
return txt(`Invalid URL: ${validation.reason}`);
|
|
63
64
|
const secret = generateWebhookSecret();
|
|
64
|
-
const { data, error } = await db()
|
|
65
|
+
const { data, error } = await withCreatedItems('webhook_subscriptions', db()
|
|
65
66
|
.from('webhook_subscriptions')
|
|
66
67
|
.insert({
|
|
67
68
|
project_id: getProjectId(),
|
|
@@ -71,7 +72,7 @@ export function registerWebhookTools(server) {
|
|
|
71
72
|
description: description ?? null,
|
|
72
73
|
})
|
|
73
74
|
.select('id, url, events, active, created_at')
|
|
74
|
-
.single();
|
|
75
|
+
.single());
|
|
75
76
|
if (error)
|
|
76
77
|
return txt(`Error creating subscription: ${dbToolError(error).error}`);
|
|
77
78
|
return j({
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sendinel/mcp-server",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.175",
|
|
4
4
|
"description": "AI-controlled email operations control plane for campaigns, contacts, segments, analytics, and multi-provider sending via MCP.",
|
|
5
5
|
"mcpName": "ai.sendinel/mcp-server",
|
|
6
6
|
"type": "module",
|