@sendinel/mcp-server 1.0.70 → 1.0.72
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/tools/advisor.js +11 -10
- package/build/tools/analytics.d.ts +1 -0
- package/build/tools/analytics.js +10 -9
- package/build/tools/campaigns.d.ts +5 -1
- package/build/tools/campaigns.js +105 -9
- package/package.json +1 -1
package/build/tools/advisor.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isBouncedRow } from "@sendinel/domain";
|
|
1
2
|
import { z } from 'zod';
|
|
2
3
|
import Anthropic from '@anthropic-ai/sdk';
|
|
3
4
|
import { db } from '../db.js';
|
|
@@ -14,7 +15,7 @@ export function computeMetrics(rows) {
|
|
|
14
15
|
const delivered = rows.filter((r) => ['delivered', 'opened', 'clicked'].includes(r.status)).length;
|
|
15
16
|
const opened = rows.filter((r) => ['opened', 'clicked'].includes(r.status)).length;
|
|
16
17
|
const clicked = rows.filter((r) => r.status === 'clicked').length;
|
|
17
|
-
const bounced = rows.filter(
|
|
18
|
+
const bounced = rows.filter(isBouncedRow).length;
|
|
18
19
|
const complained = rows.filter((r) => r.status === 'complained').length;
|
|
19
20
|
return {
|
|
20
21
|
sent,
|
|
@@ -78,14 +79,14 @@ export function registerAdvisorTools(server) {
|
|
|
78
79
|
const recommendations = [];
|
|
79
80
|
let skipped = 0;
|
|
80
81
|
for (const campaign of campaigns ?? []) {
|
|
81
|
-
const rows = await readAllRows(db().from('email_log').select(
|
|
82
|
+
const rows = await readAllRows(db().from('email_log').select("status, bounced_at")
|
|
82
83
|
.eq('project_id', projectId).eq('campaign_id', campaign.id).gte('created_at', since));
|
|
83
84
|
const sends = rows.filter((row) => ['sent', 'delivered', 'opened', 'clicked', 'bounced', 'complained'].includes(row.status)).length;
|
|
84
85
|
if (sends < 100) {
|
|
85
86
|
skipped++;
|
|
86
87
|
continue;
|
|
87
88
|
}
|
|
88
|
-
const bounces = rows.filter(
|
|
89
|
+
const bounces = rows.filter(isBouncedRow).length;
|
|
89
90
|
const bounceRate = sends ? Number(((bounces / sends) * 100).toFixed(1)) : 0;
|
|
90
91
|
recommendations.push({
|
|
91
92
|
campaign_id: campaign.id,
|
|
@@ -128,7 +129,7 @@ export function registerAdvisorTools(server) {
|
|
|
128
129
|
// 1. Current period email_log
|
|
129
130
|
let currentQuery = db()
|
|
130
131
|
.from('email_log')
|
|
131
|
-
.select(
|
|
132
|
+
.select("status, created_at, bounced_at")
|
|
132
133
|
.eq('project_id', projectId)
|
|
133
134
|
.gte('created_at', periodStart.toISOString());
|
|
134
135
|
if (site_id)
|
|
@@ -137,7 +138,7 @@ export function registerAdvisorTools(server) {
|
|
|
137
138
|
// 2. Prior period
|
|
138
139
|
let priorQuery = db()
|
|
139
140
|
.from('email_log')
|
|
140
|
-
.select(
|
|
141
|
+
.select("status, created_at, bounced_at")
|
|
141
142
|
.eq('project_id', projectId)
|
|
142
143
|
.gte('created_at', priorStart.toISOString())
|
|
143
144
|
.lt('created_at', periodStart.toISOString());
|
|
@@ -325,7 +326,7 @@ export function registerAdvisorTools(server) {
|
|
|
325
326
|
// 2. Top-performing historical subjects
|
|
326
327
|
let logQuery = db()
|
|
327
328
|
.from('email_log')
|
|
328
|
-
.select(
|
|
329
|
+
.select("subject, status, bounced_at")
|
|
329
330
|
.eq('project_id', projectId)
|
|
330
331
|
.in('status', ['delivered', 'opened', 'clicked']);
|
|
331
332
|
if (site_id)
|
|
@@ -447,7 +448,7 @@ Each subject should be distinct in approach (urgency, curiosity, benefit, social
|
|
|
447
448
|
// 1. Query email_log with campaign step subjects
|
|
448
449
|
const logRows = await readAllRows(db()
|
|
449
450
|
.from('email_log')
|
|
450
|
-
.select(
|
|
451
|
+
.select("campaign_id, subject, status, bounced_at")
|
|
451
452
|
.eq('project_id', projectId)
|
|
452
453
|
.eq('site_id', site_id)
|
|
453
454
|
.gte('created_at', since)
|
|
@@ -583,7 +584,7 @@ Return ONLY valid JSON (no markdown fences): { "topics": [{ "title": "...", "rat
|
|
|
583
584
|
// 2. Query email_log
|
|
584
585
|
let query = db()
|
|
585
586
|
.from('email_log')
|
|
586
|
-
.select(
|
|
587
|
+
.select("status, created_at, bounced_at")
|
|
587
588
|
.eq('project_id', projectId)
|
|
588
589
|
.eq('site_id', site_id)
|
|
589
590
|
.gte('created_at', since)
|
|
@@ -667,13 +668,13 @@ Return ONLY valid JSON (no markdown fences): { "topics": [{ "title": "...", "rat
|
|
|
667
668
|
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
|
|
668
669
|
const { data: recentLogs } = await db()
|
|
669
670
|
.from('email_log')
|
|
670
|
-
.select(
|
|
671
|
+
.select("status, opened_at, clicked_at, bounced_at")
|
|
671
672
|
.eq('project_id', projectId)
|
|
672
673
|
.eq('site_id', site_id)
|
|
673
674
|
.gte('sent_at', sevenDaysAgo);
|
|
674
675
|
const logs = recentLogs ?? [];
|
|
675
676
|
const totalSent = logs.length;
|
|
676
|
-
const bounced = logs.filter(
|
|
677
|
+
const bounced = logs.filter(isBouncedRow).length;
|
|
677
678
|
const complained = logs.filter((l) => l.status === 'complained').length;
|
|
678
679
|
const delivered = logs.filter((l) => ['delivered', 'opened', 'clicked'].includes(l.status)).length;
|
|
679
680
|
const opened = logs.filter((l) => l.opened_at).length;
|
package/build/tools/analytics.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isBouncedRow } from "@sendinel/domain";
|
|
1
2
|
import { withApproval } from '../lib/action-approvals.js';
|
|
2
3
|
import { fetchAllRows, readAllRows } from '@sendinel/domain';
|
|
3
4
|
import { z } from 'zod';
|
|
@@ -78,7 +79,7 @@ export function computeAnalyticsMetrics(rows, eventCounts) {
|
|
|
78
79
|
const delivered = rows.filter(r => ['delivered', 'opened', 'clicked'].includes(r.status)).length;
|
|
79
80
|
const opened = eventCounts?.opened ?? rows.filter(r => r.status === 'opened' || r.status === 'clicked').length;
|
|
80
81
|
const clicked = eventCounts?.clicked ?? rows.filter(r => r.status === 'clicked').length;
|
|
81
|
-
const bounced = rows.filter(
|
|
82
|
+
const bounced = rows.filter(isBouncedRow).length;
|
|
82
83
|
const complained = rows.filter(r => r.status === 'complained').length;
|
|
83
84
|
const unsubscribed = rows.filter(r => r.status === 'unsubscribed').length;
|
|
84
85
|
return {
|
|
@@ -326,7 +327,7 @@ export function registerAnalyticsTools(server) {
|
|
|
326
327
|
const since = new Date(Date.now() - period_days * 86400000).toISOString();
|
|
327
328
|
let rows;
|
|
328
329
|
try {
|
|
329
|
-
rows = await readAllRows(db().from('email_log').select(
|
|
330
|
+
rows = await readAllRows(db().from('email_log').select("site_id, status, sent_at, created_at, bounced_at")
|
|
330
331
|
.eq('project_id', getProjectId()).gte('created_at', since));
|
|
331
332
|
}
|
|
332
333
|
catch (readError) {
|
|
@@ -547,7 +548,7 @@ export function registerAnalyticsTools(server) {
|
|
|
547
548
|
const trackingEnabled = site?.tracking_enabled ?? false;
|
|
548
549
|
const { data, error, pageCount: logPageCount } = await fetchAllRows(async (offset, limit) => db()
|
|
549
550
|
.from('email_log')
|
|
550
|
-
.select(
|
|
551
|
+
.select("id, provider_message_id, status, sent_at, bounced_at")
|
|
551
552
|
.eq('project_id', projectId)
|
|
552
553
|
.eq('site_id', site_id)
|
|
553
554
|
.gte('sent_at', `${start_date}T00:00:00Z`)
|
|
@@ -700,7 +701,7 @@ export function registerAnalyticsTools(server) {
|
|
|
700
701
|
const since7d = new Date(Date.now() - 7 * 86_400_000).toISOString();
|
|
701
702
|
let q = db()
|
|
702
703
|
.from('email_log')
|
|
703
|
-
.select(
|
|
704
|
+
.select("status, opened_at, clicked_at, sent_at, bounced_at")
|
|
704
705
|
.eq('project_id', projectId)
|
|
705
706
|
.gte('sent_at', since30d);
|
|
706
707
|
if (site_id)
|
|
@@ -873,13 +874,13 @@ export function registerAnalyticsTools(server) {
|
|
|
873
874
|
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
|
|
874
875
|
const { data: recentLogs } = await db()
|
|
875
876
|
.from('email_log')
|
|
876
|
-
.select(
|
|
877
|
+
.select("status, bounced_at")
|
|
877
878
|
.eq('project_id', projectId)
|
|
878
879
|
.eq('site_id', site_id)
|
|
879
880
|
.gte('sent_at', sevenDaysAgo);
|
|
880
881
|
const logs = recentLogs ?? [];
|
|
881
882
|
const totalSent = logs.length;
|
|
882
|
-
const bounced = logs.filter((l) => l
|
|
883
|
+
const bounced = logs.filter((l) => isBouncedRow(l)).length;
|
|
883
884
|
const complained = logs.filter((l) => l.status === 'complained').length;
|
|
884
885
|
const bounceRate = totalSent > 0 ? (bounced / totalSent * 100) : 0;
|
|
885
886
|
const complaintRate = totalSent > 0 ? (complained / totalSent * 100) : 0;
|
|
@@ -969,7 +970,7 @@ export function registerAnalyticsTools(server) {
|
|
|
969
970
|
const delivered = set.filter(r => ['delivered', 'opened', 'clicked'].includes(r.status)).length;
|
|
970
971
|
const opened = set.filter(r => r.opened_at).length;
|
|
971
972
|
const clicked = set.filter(r => r.clicked_at).length;
|
|
972
|
-
const bounced = set.filter(
|
|
973
|
+
const bounced = set.filter(isBouncedRow).length;
|
|
973
974
|
const complained = set.filter(r => r.status === 'complained').length;
|
|
974
975
|
return {
|
|
975
976
|
total_sent: total, delivered, opened, clicked, bounced, complained,
|
|
@@ -1143,7 +1144,7 @@ export function registerAnalyticsTools(server) {
|
|
|
1143
1144
|
const projDb = client ?? adminDb();
|
|
1144
1145
|
const { data: logs } = await projDb
|
|
1145
1146
|
.from('email_log')
|
|
1146
|
-
.select(
|
|
1147
|
+
.select("status, opened_at, clicked_at, bounced_at")
|
|
1147
1148
|
.eq('project_id', proj.id)
|
|
1148
1149
|
.gte('sent_at', since);
|
|
1149
1150
|
const rows = logs ?? [];
|
|
@@ -1151,7 +1152,7 @@ export function registerAnalyticsTools(server) {
|
|
|
1151
1152
|
const delivered = rows.filter((r) => ['delivered', 'opened', 'clicked'].includes(r.status)).length;
|
|
1152
1153
|
const opened = rows.filter((r) => r.opened_at).length;
|
|
1153
1154
|
const clicked = rows.filter((r) => r.clicked_at).length;
|
|
1154
|
-
const bounced = rows.filter(
|
|
1155
|
+
const bounced = rows.filter(isBouncedRow).length;
|
|
1155
1156
|
const complained = rows.filter((r) => r.status === 'complained').length;
|
|
1156
1157
|
results.push({
|
|
1157
1158
|
project_id: proj.id,
|
|
@@ -4,11 +4,15 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
|
4
4
|
* row. Includes a warning when the campaign cannot fire yet (#3855).
|
|
5
5
|
*/
|
|
6
6
|
export declare function buildCampaignFiresOn(campaign: Record<string, unknown>): string;
|
|
7
|
+
/**
|
|
8
|
+
* Diagnostic warnings indicating why a campaign's trigger configuration cannot fire (#3852).
|
|
9
|
+
*/
|
|
10
|
+
export declare function buildCampaignTriggerDiagnostics(campaign: Record<string, unknown>): string[];
|
|
7
11
|
export declare function applyCampaignTargetingFields(updates: Record<string, unknown>, input: {
|
|
8
12
|
trigger_event_name?: string | null;
|
|
9
13
|
trigger_tag?: string | null;
|
|
10
14
|
trigger_type?: string | null;
|
|
11
15
|
trigger_config?: Record<string, unknown> | null;
|
|
12
16
|
segment_id?: string | null;
|
|
13
|
-
}, projectId: string): Promise<string | null>;
|
|
17
|
+
}, projectId: string, existingCampaign?: Record<string, unknown> | null): Promise<string | null>;
|
|
14
18
|
export declare function registerCampaignTools(server: McpServer): void;
|
package/build/tools/campaigns.js
CHANGED
|
@@ -93,6 +93,37 @@ export function buildCampaignFiresOn(campaign) {
|
|
|
93
93
|
summary += ` Warning: ${warnings.join('; ')}.`;
|
|
94
94
|
return summary;
|
|
95
95
|
}
|
|
96
|
+
/**
|
|
97
|
+
* Diagnostic warnings indicating why a campaign's trigger configuration cannot fire (#3852).
|
|
98
|
+
*/
|
|
99
|
+
export function buildCampaignTriggerDiagnostics(campaign) {
|
|
100
|
+
const config = (campaign.trigger_config && typeof campaign.trigger_config === 'object' ? campaign.trigger_config : {});
|
|
101
|
+
const events = normalizeTriggerList([...normalizeTriggerList(config.events), campaign.trigger_event_name]);
|
|
102
|
+
const tags = normalizeTriggerList([...normalizeTriggerList(config.tags), campaign.trigger_tag]);
|
|
103
|
+
const diagnostics = [];
|
|
104
|
+
if ((events.length || tags.length) && campaign.type !== 'triggered') {
|
|
105
|
+
diagnostics.push(`campaign type is "${String(campaign.type)}"; event and tag triggers only start campaigns of type "triggered" (it will never fire)`);
|
|
106
|
+
}
|
|
107
|
+
if (campaign.trigger_type === 'date_field') {
|
|
108
|
+
const field = typeof config.field === 'string' && config.field ? config.field : null;
|
|
109
|
+
if (!field) {
|
|
110
|
+
diagnostics.push('trigger_type is "date_field", but no date field is set (it will never fire)');
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
else if (campaign.trigger_type === 'contact_event') {
|
|
114
|
+
const eventType = typeof config.event_type === 'string' && config.event_type ? config.event_type : null;
|
|
115
|
+
if (!eventType) {
|
|
116
|
+
diagnostics.push('trigger_type is "contact_event", but no event_type is set (it will never fire)');
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if ((campaign.trigger_type === 'date_field' || campaign.trigger_type === 'contact_event') && (events.length || tags.length) && campaign.type === 'triggered') {
|
|
120
|
+
diagnostics.push(`trigger_type is "${String(campaign.trigger_type)}" but event/tag triggers are also set; event intake will start it immediately, outside the ${String(campaign.trigger_type)} schedule`);
|
|
121
|
+
}
|
|
122
|
+
if (campaign.status !== 'active') {
|
|
123
|
+
diagnostics.push(`it is ${String(campaign.status)}, so nothing starts until it is active`);
|
|
124
|
+
}
|
|
125
|
+
return diagnostics;
|
|
126
|
+
}
|
|
96
127
|
function applyCampaignSendAtField(updates, sendAt, campaignType) {
|
|
97
128
|
if (sendAt === undefined)
|
|
98
129
|
return null;
|
|
@@ -108,19 +139,22 @@ function applyCampaignSendAtField(updates, sendAt, campaignType) {
|
|
|
108
139
|
updates.send_at = parsed.scheduledAt;
|
|
109
140
|
return null;
|
|
110
141
|
}
|
|
111
|
-
export async function applyCampaignTargetingFields(updates, input, projectId) {
|
|
142
|
+
export async function applyCampaignTargetingFields(updates, input, projectId, existingCampaign) {
|
|
143
|
+
const hasTriggerEvent = input.trigger_event_name !== undefined && input.trigger_event_name !== null && input.trigger_event_name.trim() !== '';
|
|
112
144
|
if (input.trigger_event_name !== undefined)
|
|
113
145
|
updates.trigger_event_name = input.trigger_event_name?.trim() || null;
|
|
114
146
|
if (input.trigger_tag !== undefined)
|
|
115
147
|
updates.trigger_tag = input.trigger_tag?.trim().toLowerCase() || null;
|
|
116
148
|
if (input.trigger_type === null || input.trigger_type === 'manual') {
|
|
117
149
|
// campaigns.trigger_type is NOT NULL DEFAULT 'manual', so "clear" means back to 'manual'.
|
|
118
|
-
// Also drops a stale date_field config unless the caller sent a new trigger_config (#3855).
|
|
150
|
+
// Also drops a stale date_field or contact_event config unless the caller sent a new trigger_config (#3855, #3852).
|
|
119
151
|
updates.trigger_type = 'manual';
|
|
120
152
|
if (input.trigger_config === undefined)
|
|
121
153
|
updates.trigger_config = null;
|
|
122
154
|
}
|
|
123
155
|
if (input.trigger_type === 'date_field') {
|
|
156
|
+
if (hasTriggerEvent)
|
|
157
|
+
return 'cannot set trigger_event_name on a date_field campaign';
|
|
124
158
|
const config = input.trigger_config ?? {};
|
|
125
159
|
const field = typeof config.field === 'string' ? config.field.trim() : '';
|
|
126
160
|
if (!/^[A-Za-z0-9_.-]{1,80}$/.test(field))
|
|
@@ -132,6 +166,23 @@ export async function applyCampaignTargetingFields(updates, input, projectId) {
|
|
|
132
166
|
recurring: config.recurring !== false,
|
|
133
167
|
};
|
|
134
168
|
}
|
|
169
|
+
else if (input.trigger_type === 'contact_event') {
|
|
170
|
+
// trigger_event_name would also make event intake enroll at once, skipping days_after.
|
|
171
|
+
if (hasTriggerEvent)
|
|
172
|
+
return 'cannot set trigger_event_name on a contact_event campaign; use trigger_config.event_type';
|
|
173
|
+
const config = input.trigger_config ?? {};
|
|
174
|
+
const eventType = typeof config.event_type === 'string' ? config.event_type.trim() : '';
|
|
175
|
+
const daysAfter = Number(config.days_after ?? 0);
|
|
176
|
+
if (!eventType)
|
|
177
|
+
return 'event_type is required';
|
|
178
|
+
if (!Number.isInteger(daysAfter))
|
|
179
|
+
return 'days_after must be an integer';
|
|
180
|
+
updates.trigger_type = 'contact_event';
|
|
181
|
+
updates.trigger_config = {
|
|
182
|
+
event_type: eventType,
|
|
183
|
+
days_after: Math.max(0, Math.min(365, daysAfter)),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
135
186
|
else if (input.trigger_config !== undefined) {
|
|
136
187
|
if (input.trigger_config === null) {
|
|
137
188
|
updates.trigger_config = null;
|
|
@@ -143,6 +194,14 @@ export async function applyCampaignTargetingFields(updates, input, projectId) {
|
|
|
143
194
|
updates.trigger_config = config;
|
|
144
195
|
}
|
|
145
196
|
}
|
|
197
|
+
// If trigger_type was not specified, but caller is setting an event trigger on a campaign
|
|
198
|
+
// whose current trigger_type is date_field or contact_event, normalize trigger_type to 'manual' (#3852).
|
|
199
|
+
const setsEventTrigger = hasTriggerEvent || (updates.trigger_config && typeof updates.trigger_config === 'object' && Array.isArray(updates.trigger_config.events) && updates.trigger_config.events.length > 0);
|
|
200
|
+
if (input.trigger_type === undefined && setsEventTrigger) {
|
|
201
|
+
if (existingCampaign?.trigger_type === 'date_field' || existingCampaign?.trigger_type === 'contact_event') {
|
|
202
|
+
updates.trigger_type = 'manual';
|
|
203
|
+
}
|
|
204
|
+
}
|
|
146
205
|
if (input.segment_id !== undefined) {
|
|
147
206
|
if (!input.segment_id) {
|
|
148
207
|
updates.segment_id = null;
|
|
@@ -3287,7 +3346,7 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3287
3346
|
idempotentHint: true,
|
|
3288
3347
|
openWorldHint: false,
|
|
3289
3348
|
},
|
|
3290
|
-
description: 'Update a campaign name, site, segment, send time, topic, trigger settings, or send class (broadcast vs product). trigger_type null (or manual) clears a date_field trigger; type switches a draft between sequence and triggered. The response includes fires_on, a plain summary of what the saved campaign fires on. send_at schedules a broadcast send (type=broadcast only) and must be a future ISO 8601 instant; set null to clear a pending schedule. Only provided fields are updated.',
|
|
3349
|
+
description: 'Update a campaign name, site, segment, send time, topic, trigger settings, or send class (broadcast vs product). trigger_type null (or manual) clears a date_field or contact_event trigger; type switches a draft between sequence and triggered. The response includes fires_on, a plain summary of what the saved campaign fires on, and trigger_diagnostics naming any trigger problems. send_at schedules a broadcast send (type=broadcast only) and must be a future ISO 8601 instant; set null to clear a pending schedule. Only provided fields are updated.',
|
|
3291
3350
|
inputSchema: {
|
|
3292
3351
|
campaign_id: z.string().uuid(),
|
|
3293
3352
|
name: z.string().min(1).max(200).optional(),
|
|
@@ -3297,7 +3356,7 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3297
3356
|
topic: z.string().nullable().optional(),
|
|
3298
3357
|
trigger_event_name: z.string().nullable().optional(),
|
|
3299
3358
|
trigger_tag: z.string().nullable().optional(),
|
|
3300
|
-
trigger_type: z.enum(['date_field', 'manual']).nullable().optional().describe("'date_field' fires relative to a contact date field (needs trigger_config.field). null or 'manual' clears a date_field trigger."),
|
|
3359
|
+
trigger_type: z.enum(['date_field', 'manual', 'contact_event']).nullable().optional().describe("'date_field' fires relative to a contact date field (needs trigger_config.field). 'contact_event' fires relative to a contact event (needs trigger_config.event_type and days_after). null or 'manual' clears a date_field or contact_event trigger."),
|
|
3301
3360
|
trigger_config: z.record(z.string(), z.unknown()).nullable().optional(),
|
|
3302
3361
|
type: z.enum(['sequence', 'triggered']).optional().describe("Switch a DRAFT campaign between 'sequence' and 'triggered'. Event and tag triggers only start campaigns of type 'triggered'."),
|
|
3303
3362
|
sends_per_hour: z.number().int().min(1).nullable().optional().describe('Optional hourly send cap for wave sending. Set null to clear.'),
|
|
@@ -3309,12 +3368,21 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3309
3368
|
let campaignType;
|
|
3310
3369
|
const loadExisting = () => db()
|
|
3311
3370
|
.from('campaigns')
|
|
3312
|
-
.select('id, type, status')
|
|
3371
|
+
.select('id, type, status, trigger_type, trigger_config, trigger_event_name, trigger_tag')
|
|
3313
3372
|
.eq('id', campaign_id)
|
|
3314
3373
|
.eq('project_id', projectId)
|
|
3315
3374
|
.maybeSingle();
|
|
3375
|
+
let cachedExisting = null;
|
|
3376
|
+
const getExisting = async () => {
|
|
3377
|
+
if (cachedExisting)
|
|
3378
|
+
return { data: cachedExisting, error: null };
|
|
3379
|
+
const { data, error } = await loadExisting();
|
|
3380
|
+
if (!error && data)
|
|
3381
|
+
cachedExisting = data;
|
|
3382
|
+
return { data, error };
|
|
3383
|
+
};
|
|
3316
3384
|
if (send_at !== undefined && send_at !== null) {
|
|
3317
|
-
const { data: existing, error: fetchError } = await
|
|
3385
|
+
const { data: existing, error: fetchError } = await getExisting();
|
|
3318
3386
|
if (fetchError)
|
|
3319
3387
|
return j({ error: fetchError.message });
|
|
3320
3388
|
if (!existing)
|
|
@@ -3322,7 +3390,7 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3322
3390
|
campaignType = existing.type;
|
|
3323
3391
|
}
|
|
3324
3392
|
if (type !== undefined) {
|
|
3325
|
-
const { data: existing, error: fetchError } = await
|
|
3393
|
+
const { data: existing, error: fetchError } = await getExisting();
|
|
3326
3394
|
if (fetchError)
|
|
3327
3395
|
return j({ error: fetchError.message });
|
|
3328
3396
|
if (!existing)
|
|
@@ -3334,6 +3402,16 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3334
3402
|
return j({ error: `a ${existing.type} campaign cannot become ${type}`, code: 'INVALID_FIELD' });
|
|
3335
3403
|
}
|
|
3336
3404
|
}
|
|
3405
|
+
const hasTriggerUpdate = trigger_event_name !== undefined || trigger_tag !== undefined || trigger_type !== undefined || trigger_config !== undefined;
|
|
3406
|
+
let existingCampaignForTargeting = null;
|
|
3407
|
+
if (hasTriggerUpdate) {
|
|
3408
|
+
const { data: existing, error: fetchError } = await getExisting();
|
|
3409
|
+
if (fetchError)
|
|
3410
|
+
return j({ error: fetchError.message });
|
|
3411
|
+
if (!existing)
|
|
3412
|
+
return j({ error: 'Campaign not found', code: 'NOT_FOUND' });
|
|
3413
|
+
existingCampaignForTargeting = existing;
|
|
3414
|
+
}
|
|
3337
3415
|
const updates = { updated_at: new Date().toISOString() };
|
|
3338
3416
|
if (name !== undefined)
|
|
3339
3417
|
updates.name = name;
|
|
@@ -3352,9 +3430,19 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3352
3430
|
updates.send_in_contact_timezone = send_in_contact_timezone;
|
|
3353
3431
|
if (send_class !== undefined)
|
|
3354
3432
|
updates.send_class = send_class;
|
|
3355
|
-
const targetingError = await applyCampaignTargetingFields(updates, { trigger_event_name, trigger_tag, trigger_type, trigger_config, segment_id }, projectId);
|
|
3433
|
+
const targetingError = await applyCampaignTargetingFields(updates, { trigger_event_name, trigger_tag, trigger_type, trigger_config, segment_id }, projectId, existingCampaignForTargeting);
|
|
3356
3434
|
if (targetingError)
|
|
3357
3435
|
return j({ error: targetingError, code: targetingError === 'Segment not found' ? 'NOT_FOUND' : 'INVALID_FIELD' });
|
|
3436
|
+
// If type was not explicitly changed, but this update sets an event trigger on a draft sequence,
|
|
3437
|
+
// normalize type to 'triggered' so that event intake matches and enrolls it (#3852).
|
|
3438
|
+
if (type === undefined && existingCampaignForTargeting?.type === 'sequence' && existingCampaignForTargeting?.status === 'draft') {
|
|
3439
|
+
const isSettingEvent = (updates.trigger_event_name !== undefined && updates.trigger_event_name !== null)
|
|
3440
|
+
|| (updates.trigger_tag !== undefined && updates.trigger_tag !== null)
|
|
3441
|
+
|| (updates.trigger_config && typeof updates.trigger_config === 'object' && Array.isArray(updates.trigger_config.events) && updates.trigger_config.events.length > 0);
|
|
3442
|
+
if (isSettingEvent) {
|
|
3443
|
+
updates.type = 'triggered';
|
|
3444
|
+
}
|
|
3445
|
+
}
|
|
3358
3446
|
const { data, error } = await db()
|
|
3359
3447
|
.from('campaigns')
|
|
3360
3448
|
.update(updates)
|
|
@@ -3364,7 +3452,15 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3364
3452
|
.single();
|
|
3365
3453
|
if (error)
|
|
3366
3454
|
return j({ error: error.message });
|
|
3367
|
-
|
|
3455
|
+
const diagnostics = buildCampaignTriggerDiagnostics(data);
|
|
3456
|
+
const warning = diagnostics.find((d) => d.includes('never fire')) || (diagnostics.length ? diagnostics[0] : null);
|
|
3457
|
+
return j({
|
|
3458
|
+
...data,
|
|
3459
|
+
fires_on: buildCampaignFiresOn(data),
|
|
3460
|
+
trigger_diagnostics: diagnostics,
|
|
3461
|
+
warnings: diagnostics,
|
|
3462
|
+
...(warning ? { warning } : {}),
|
|
3463
|
+
});
|
|
3368
3464
|
});
|
|
3369
3465
|
server.registerTool('get_campaign', {
|
|
3370
3466
|
title: 'Get Campaign',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sendinel/mcp-server",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.72",
|
|
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",
|