@sendinel/mcp-server 1.0.175 → 1.0.176

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.
@@ -37,6 +37,36 @@ import { callSendinelV1 } from '../lib/v1-client.js';
37
37
  import { countUniqueEngagementByCampaign } from '../lib/campaign-engagement.js';
38
38
  const txt = (text) => ({ content: [{ type: 'text', text }] });
39
39
  const j = (data) => txt(JSON.stringify(data, null, 2));
40
+ /** Diagnose a rejected DELETE, without exposing Postgres diagnostics or guessing missing counts. */
41
+ async function campaignDeleteConflict(campaignId) {
42
+ const sources = [
43
+ { table: 'ai_drafts', column: 'campaign_id', singular: 'AI draft', plural: 'AI drafts' },
44
+ { table: 'email_log', column: 'campaign_id', singular: 'email history record', plural: 'email history records' },
45
+ { table: 'revenue_attribution', column: 'campaign_id', singular: 'revenue attribution record', plural: 'revenue attribution records' },
46
+ { table: 'campaign_goals', column: 'target_campaign_id', singular: 'campaign goal target', plural: 'campaign goal targets' },
47
+ ];
48
+ // The caller has verified ownership of this campaign. Count all references to its UUID;
49
+ // campaign_goals has no project_id, and filtering by the owning campaign misses targets.
50
+ const results = await Promise.allSettled(sources.map(async (source) => {
51
+ const { count, error } = await db().schema('email').from(source.table)
52
+ .select('id', { count: 'exact', head: true }).eq(source.column, campaignId);
53
+ return !error && typeof count === 'number' && Number.isInteger(count) && count >= 0 ? count : null;
54
+ }));
55
+ const linked = [];
56
+ const unknown = [];
57
+ results.forEach((result, index) => {
58
+ const source = sources[index];
59
+ const count = result.status === 'fulfilled' ? result.value : null;
60
+ if (count === null)
61
+ unknown.push(source.table);
62
+ else if (count > 0)
63
+ linked.push(`${count} ${count === 1 ? source.singular : source.plural} (${source.table})`);
64
+ });
65
+ const reason = linked.length
66
+ ? `This campaign still has ${linked.join(', ')} linked to it, so it cannot be deleted.`
67
+ : 'This campaign cannot be deleted because linked records still reference it.';
68
+ return unknown.length ? `${reason} Counts for ${unknown.join(', ')} are unavailable.` : reason;
69
+ }
40
70
  const ACTIVATION_APPROVAL_DESCRIPTION = 'Activation-only: requires_approval gates campaign activation. It does not stop sends of an already-active campaign or sends triggered by other paths.';
41
71
  const REENTRY_DESCRIPTION = 'Re-entry defaults to once ever. per_event_period uses a flat event-data period_key on event-triggered campaigns only. Changing a live campaign returns an approval request. Existing enrollments without a period cannot re-enter; sends wait for campaign-cycle approval.';
42
72
  const reentryFields = {
@@ -3613,6 +3643,10 @@ Return ONLY valid JSON with this exact shape:
3613
3643
  .delete()
3614
3644
  .eq('id', campaign_id)
3615
3645
  .eq('project_id', projectId);
3646
+ if (error?.code === '23503') {
3647
+ // A rejected statement made no changes, so the same approval is safe to retry.
3648
+ return markNotExecuted(toolError({ code: 'conflict', error: await campaignDeleteConflict(campaign_id) }));
3649
+ }
3616
3650
  if (error)
3617
3651
  return j(dbToolError(error));
3618
3652
  return j({ success: true, deleted: campaign.name });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendinel/mcp-server",
3
- "version": "1.0.175",
3
+ "version": "1.0.176",
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",