@sendinel/mcp-server 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +113 -0
- package/build/anthropic-model.d.ts +5 -0
- package/build/anthropic-model.js +6 -0
- package/build/audit.d.ts +21 -0
- package/build/audit.js +38 -0
- package/build/auth.d.ts +10 -0
- package/build/auth.js +57 -0
- package/build/byod-client.d.ts +30 -0
- package/build/byod-client.js +95 -0
- package/build/crypto.d.ts +2 -0
- package/build/crypto.js +27 -0
- package/build/db.d.ts +38 -0
- package/build/db.js +70 -0
- package/build/index.d.ts +1 -0
- package/build/index.js +97 -0
- package/build/lib/action-approvals.d.ts +30 -0
- package/build/lib/action-approvals.js +154 -0
- package/build/lib/schedule.d.ts +36 -0
- package/build/lib/schedule.js +63 -0
- package/build/lib/social-engine/archetypes.d.ts +27 -0
- package/build/lib/social-engine/archetypes.js +160 -0
- package/build/lib/social-engine/index.d.ts +5 -0
- package/build/lib/social-engine/index.js +6 -0
- package/build/lib/social-engine/parse.d.ts +7 -0
- package/build/lib/social-engine/parse.js +83 -0
- package/build/lib/social-engine/platform-constraints.d.ts +27 -0
- package/build/lib/social-engine/platform-constraints.js +70 -0
- package/build/lib/social-engine/prompt-builder.d.ts +13 -0
- package/build/lib/social-engine/prompt-builder.js +80 -0
- package/build/lib/social-engine/types.d.ts +60 -0
- package/build/lib/social-engine/types.js +19 -0
- package/build/lib/url-validation.d.ts +3 -0
- package/build/lib/url-validation.js +51 -0
- package/build/lib/voice.d.ts +32 -0
- package/build/lib/voice.js +140 -0
- package/build/lib/webhook-events.d.ts +15 -0
- package/build/lib/webhook-events.js +120 -0
- package/build/plan-limits.d.ts +8 -0
- package/build/plan-limits.js +9 -0
- package/build/project.d.ts +1 -0
- package/build/project.js +9 -0
- package/build/server.d.ts +18 -0
- package/build/server.js +235 -0
- package/build/tools/ab-testing.d.ts +2 -0
- package/build/tools/ab-testing.js +204 -0
- package/build/tools/advisor.d.ts +23 -0
- package/build/tools/advisor.js +762 -0
- package/build/tools/analytics.d.ts +33 -0
- package/build/tools/analytics.js +1105 -0
- package/build/tools/approvals.d.ts +2 -0
- package/build/tools/approvals.js +32 -0
- package/build/tools/automations.d.ts +2 -0
- package/build/tools/automations.js +344 -0
- package/build/tools/campaigns.d.ts +2 -0
- package/build/tools/campaigns.js +1335 -0
- package/build/tools/compound.d.ts +2 -0
- package/build/tools/compound.js +312 -0
- package/build/tools/contacts.d.ts +2 -0
- package/build/tools/contacts.js +1483 -0
- package/build/tools/content.d.ts +2 -0
- package/build/tools/content.js +68 -0
- package/build/tools/data-proposals.d.ts +2 -0
- package/build/tools/data-proposals.js +155 -0
- package/build/tools/data.d.ts +2 -0
- package/build/tools/data.js +707 -0
- package/build/tools/delivery-ops.d.ts +2 -0
- package/build/tools/delivery-ops.js +387 -0
- package/build/tools/drafts.d.ts +2 -0
- package/build/tools/drafts.js +204 -0
- package/build/tools/forms.d.ts +2 -0
- package/build/tools/forms.js +46 -0
- package/build/tools/gdpr.d.ts +2 -0
- package/build/tools/gdpr.js +61 -0
- package/build/tools/org.d.ts +2 -0
- package/build/tools/org.js +71 -0
- package/build/tools/segments.d.ts +2 -0
- package/build/tools/segments.js +384 -0
- package/build/tools/sites.d.ts +2 -0
- package/build/tools/sites.js +182 -0
- package/build/tools/sms.d.ts +2 -0
- package/build/tools/sms.js +489 -0
- package/build/tools/social-posts.d.ts +2 -0
- package/build/tools/social-posts.js +380 -0
- package/build/tools/templates.d.ts +2 -0
- package/build/tools/templates.js +282 -0
- package/build/tools/warmup.d.ts +2 -0
- package/build/tools/warmup.js +57 -0
- package/build/tools/webhooks.d.ts +2 -0
- package/build/tools/webhooks.js +127 -0
- package/package.json +63 -0
|
@@ -0,0 +1,1105 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { db, adminDb } from '../db.js';
|
|
3
|
+
import { getProjectId } from '../project.js';
|
|
4
|
+
import { resolveProjectClient } from '../byod-client.js';
|
|
5
|
+
import { decrypt } from '../crypto.js';
|
|
6
|
+
const txt = (text) => ({ content: [{ type: 'text', text }] });
|
|
7
|
+
const j = (data) => txt(JSON.stringify(data, null, 2));
|
|
8
|
+
const percent = (rate) => `${(rate * 100).toFixed(1)}%`;
|
|
9
|
+
const pctNumber = (rate) => Math.round(rate * 1000) / 10;
|
|
10
|
+
const roundRate = (rate) => Math.round(rate * 1000) / 1000;
|
|
11
|
+
const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
|
|
12
|
+
const socialPlatforms = ['instagram', 'linkedin', 'twitter', 'facebook', 'tiktok', 'youtube', 'pinterest', 'uploadpost', 'other'];
|
|
13
|
+
const socialPublishers = ['uploadpost', 'buffer'];
|
|
14
|
+
const UPLOADPOST_API_BASE = process.env.UPLOADPOST_API_BASE || 'https://api.upload-post.com/api';
|
|
15
|
+
function slugifyCampaign(value) {
|
|
16
|
+
return value
|
|
17
|
+
.trim()
|
|
18
|
+
.toLowerCase()
|
|
19
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
20
|
+
.replace(/^-+|-+$/g, '')
|
|
21
|
+
.slice(0, 100) || 'campaign';
|
|
22
|
+
}
|
|
23
|
+
function buildSocialTrackedUrl(params) {
|
|
24
|
+
const url = new URL(params.destination_url);
|
|
25
|
+
const utmCampaign = params.utm_campaign?.trim() || slugifyCampaign(params.name);
|
|
26
|
+
url.searchParams.set('utm_source', params.platform);
|
|
27
|
+
url.searchParams.set('utm_medium', 'social');
|
|
28
|
+
url.searchParams.set('utm_campaign', utmCampaign);
|
|
29
|
+
if (params.utm_content?.trim())
|
|
30
|
+
url.searchParams.set('utm_content', params.utm_content.trim());
|
|
31
|
+
return {
|
|
32
|
+
tracked_url: url.toString(),
|
|
33
|
+
utm_source: params.platform,
|
|
34
|
+
utm_medium: 'social',
|
|
35
|
+
utm_campaign: utmCampaign,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
async function scheduleUploadPost(apiKey, params) {
|
|
39
|
+
const res = await fetch(`${UPLOADPOST_API_BASE}/uploadposts/schedule`, {
|
|
40
|
+
method: 'POST',
|
|
41
|
+
headers: {
|
|
42
|
+
Authorization: `Apikey ${apiKey}`,
|
|
43
|
+
'Content-Type': 'application/json',
|
|
44
|
+
},
|
|
45
|
+
body: JSON.stringify({
|
|
46
|
+
platform: params.platform,
|
|
47
|
+
content: params.link ? `${params.content}\n\n${params.link}` : params.content,
|
|
48
|
+
scheduled_at: params.scheduledAt?.toISOString(),
|
|
49
|
+
media_urls: params.mediaUrls,
|
|
50
|
+
link: params.link ?? undefined,
|
|
51
|
+
}),
|
|
52
|
+
});
|
|
53
|
+
if (!res.ok) {
|
|
54
|
+
const text = await res.text().catch(() => '');
|
|
55
|
+
throw new Error(`UploadPost schedule failed: ${res.status}${text ? ` ${text.slice(0, 200)}` : ''}`);
|
|
56
|
+
}
|
|
57
|
+
const json = await res.json();
|
|
58
|
+
const publisherPostId = json.id ?? json.request_id ?? json.post_id;
|
|
59
|
+
if (!publisherPostId)
|
|
60
|
+
throw new Error('UploadPost schedule response did not include a post id.');
|
|
61
|
+
return {
|
|
62
|
+
publisherPostId: String(publisherPostId),
|
|
63
|
+
scheduledAt: json.scheduled_at ? new Date(json.scheduled_at) : params.scheduledAt,
|
|
64
|
+
postUrl: json.post_url ?? json.post?.post_url,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
async function publishViaByoPublisher(publisher, credentials, params) {
|
|
68
|
+
if (publisher === 'uploadpost')
|
|
69
|
+
return scheduleUploadPost(credentials, params);
|
|
70
|
+
throw new Error('Buffer publisher is not yet implemented. Connect UploadPost instead.');
|
|
71
|
+
}
|
|
72
|
+
export function computeAnalyticsMetrics(rows, eventCounts) {
|
|
73
|
+
const sent = rows.length;
|
|
74
|
+
const delivered = rows.filter(r => ['delivered', 'opened', 'clicked'].includes(r.status)).length;
|
|
75
|
+
const opened = eventCounts?.opened ?? rows.filter(r => r.status === 'opened' || r.status === 'clicked').length;
|
|
76
|
+
const clicked = eventCounts?.clicked ?? rows.filter(r => r.status === 'clicked').length;
|
|
77
|
+
const bounced = rows.filter(r => r.status === 'bounced').length;
|
|
78
|
+
const complained = rows.filter(r => r.status === 'complained').length;
|
|
79
|
+
const unsubscribed = rows.filter(r => r.status === 'unsubscribed').length;
|
|
80
|
+
return {
|
|
81
|
+
sent,
|
|
82
|
+
delivered,
|
|
83
|
+
opened,
|
|
84
|
+
clicked,
|
|
85
|
+
bounced,
|
|
86
|
+
complained,
|
|
87
|
+
unsubscribed,
|
|
88
|
+
delivery_rate: sent > 0 ? delivered / sent : 0,
|
|
89
|
+
open_rate: delivered > 0 ? opened / delivered : 0,
|
|
90
|
+
click_rate: delivered > 0 ? clicked / delivered : 0,
|
|
91
|
+
bounce_rate: sent > 0 ? bounced / sent : 0,
|
|
92
|
+
complaint_rate: sent > 0 ? complained / sent : 0,
|
|
93
|
+
unsubscribe_rate: sent > 0 ? unsubscribed / sent : 0,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
export function computeSenderHealthScore(metrics, trackingEnabled) {
|
|
97
|
+
let score = 100;
|
|
98
|
+
if (metrics.bounce_rate > 0.05)
|
|
99
|
+
score -= 40;
|
|
100
|
+
else if (metrics.bounce_rate > 0.02)
|
|
101
|
+
score -= 20;
|
|
102
|
+
if (metrics.complaint_rate > 0.005)
|
|
103
|
+
score -= 40;
|
|
104
|
+
else if (metrics.complaint_rate > 0.001)
|
|
105
|
+
score -= 20;
|
|
106
|
+
if (metrics.unsubscribe_rate > 0.02)
|
|
107
|
+
score -= 10;
|
|
108
|
+
if (trackingEnabled && metrics.open_rate < 0.10)
|
|
109
|
+
score -= 10;
|
|
110
|
+
return clamp(score, 0, 100);
|
|
111
|
+
}
|
|
112
|
+
function sendVolumeByDay(rows) {
|
|
113
|
+
const byDay = new Map();
|
|
114
|
+
for (const row of rows) {
|
|
115
|
+
if (!row.sent_at)
|
|
116
|
+
continue;
|
|
117
|
+
const date = new Date(row.sent_at).toISOString().slice(0, 10);
|
|
118
|
+
byDay.set(date, (byDay.get(date) ?? 0) + 1);
|
|
119
|
+
}
|
|
120
|
+
return Array.from(byDay.entries())
|
|
121
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
122
|
+
.map(([date, sent]) => ({ date, sent }));
|
|
123
|
+
}
|
|
124
|
+
function daysBetween(startDate, endDate) {
|
|
125
|
+
const start = new Date(`${startDate}T00:00:00Z`).getTime();
|
|
126
|
+
const end = new Date(`${endDate}T23:59:59Z`).getTime();
|
|
127
|
+
return Math.max(1, Math.ceil((end - start) / 86_400_000));
|
|
128
|
+
}
|
|
129
|
+
function addDays(date, days) {
|
|
130
|
+
const next = new Date(date);
|
|
131
|
+
next.setUTCDate(next.getUTCDate() + days);
|
|
132
|
+
return next;
|
|
133
|
+
}
|
|
134
|
+
function dateOnly(date) {
|
|
135
|
+
return date.toISOString().slice(0, 10);
|
|
136
|
+
}
|
|
137
|
+
function addDeviationFlag(flags, metric, recent, baseline) {
|
|
138
|
+
if (baseline === 0)
|
|
139
|
+
return;
|
|
140
|
+
const deviation = (recent - baseline) / baseline;
|
|
141
|
+
if (Math.abs(deviation) > 0.20) {
|
|
142
|
+
flags.push(`${metric} ${deviation > 0 ? 'up' : 'down'} ${(Math.abs(deviation) * 100).toFixed(1)}% vs 30d baseline`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
function distinctEventCount(events, eventType, logIds) {
|
|
146
|
+
return new Set(events
|
|
147
|
+
.filter(event => event.event_type === eventType && event.email_log_id && logIds.has(event.email_log_id))
|
|
148
|
+
.map(event => event.email_log_id)).size;
|
|
149
|
+
}
|
|
150
|
+
export function registerAnalyticsTools(server) {
|
|
151
|
+
server.registerTool('list_event_log', {
|
|
152
|
+
description: 'List received API event tracking calls with optional filters.',
|
|
153
|
+
inputSchema: {
|
|
154
|
+
event_name: z.string().optional(),
|
|
155
|
+
email: z.string().optional(),
|
|
156
|
+
source: z.string().optional(),
|
|
157
|
+
site_id: z.string().uuid().optional(),
|
|
158
|
+
limit: z.number().int().min(1).max(100).optional().default(50),
|
|
159
|
+
},
|
|
160
|
+
}, async ({ event_name, email, source, site_id, limit = 50 }) => {
|
|
161
|
+
let query = db().from('event_log')
|
|
162
|
+
.select('id, site_id, contact_id, email, event_name, source, campaigns_enrolled, campaigns_skipped, skip_reason, properties, created_at')
|
|
163
|
+
.eq('project_id', getProjectId())
|
|
164
|
+
.order('created_at', { ascending: false })
|
|
165
|
+
.limit(limit);
|
|
166
|
+
if (event_name)
|
|
167
|
+
query = query.ilike('event_name', `%${event_name}%`);
|
|
168
|
+
if (email)
|
|
169
|
+
query = query.ilike('email', `%${email}%`);
|
|
170
|
+
if (source)
|
|
171
|
+
query = query.eq('source', source);
|
|
172
|
+
if (site_id)
|
|
173
|
+
query = query.eq('site_id', site_id);
|
|
174
|
+
const { data, error } = await query;
|
|
175
|
+
if (error)
|
|
176
|
+
return j({ error: error.message });
|
|
177
|
+
return j({ events: data ?? [] });
|
|
178
|
+
});
|
|
179
|
+
server.registerTool('get_portfolio_analytics', {
|
|
180
|
+
description: 'Get cross-site analytics totals and per-site breakdown.',
|
|
181
|
+
inputSchema: { period_days: z.number().int().min(1).max(365).optional().default(30) },
|
|
182
|
+
}, async ({ period_days = 30 }) => {
|
|
183
|
+
const since = new Date(Date.now() - period_days * 86400000).toISOString();
|
|
184
|
+
const { data, error } = await db().from('email_log').select('site_id, status, sent_at, created_at').eq('project_id', getProjectId()).gte('created_at', since).limit(20000);
|
|
185
|
+
if (error)
|
|
186
|
+
return j({ error: error.message });
|
|
187
|
+
const rows = data ?? [];
|
|
188
|
+
const totals = computeAnalyticsMetrics(rows);
|
|
189
|
+
const bySite = new Map();
|
|
190
|
+
for (const row of rows) {
|
|
191
|
+
const key = row.site_id ?? 'unknown';
|
|
192
|
+
bySite.set(key, [...(bySite.get(key) ?? []), row]);
|
|
193
|
+
}
|
|
194
|
+
return j({
|
|
195
|
+
totals,
|
|
196
|
+
by_site: Array.from(bySite.entries()).map(([site_id, siteRows]) => ({ site_id, ...computeAnalyticsMetrics(siteRows) })),
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
server.registerTool('list_projects', {
|
|
200
|
+
description: 'List all projects accessible to this API key. Returns project ID, name, plan, and associated organization. Useful for orchestrators managing multiple products through a single Sendinel account.',
|
|
201
|
+
inputSchema: {},
|
|
202
|
+
}, async () => {
|
|
203
|
+
// API keys are scoped to a single project, but an organization can have multiple projects.
|
|
204
|
+
// Look up the org for this project, then list all projects in that org.
|
|
205
|
+
const projectId = getProjectId();
|
|
206
|
+
const { data: project, error: projErr } = await adminDb()
|
|
207
|
+
.from('projects')
|
|
208
|
+
.select('id, name, org_id')
|
|
209
|
+
.eq('id', projectId)
|
|
210
|
+
.maybeSingle();
|
|
211
|
+
if (projErr || !project)
|
|
212
|
+
return txt(`Error: could not find project ${projectId}`);
|
|
213
|
+
if (!project.org_id) {
|
|
214
|
+
// No org — return just this project
|
|
215
|
+
return j({ projects: [{ id: project.id, name: project.name, current: true }] });
|
|
216
|
+
}
|
|
217
|
+
// List all projects in the same organization
|
|
218
|
+
const { data: projects, error: listErr } = await adminDb()
|
|
219
|
+
.from('projects')
|
|
220
|
+
.select('id, name')
|
|
221
|
+
.eq('org_id', project.org_id)
|
|
222
|
+
.order('name');
|
|
223
|
+
if (listErr)
|
|
224
|
+
return txt(`Error: ${listErr.message}`);
|
|
225
|
+
// Get org info
|
|
226
|
+
const { data: org } = await adminDb()
|
|
227
|
+
.from('organizations')
|
|
228
|
+
.select('id, name, plan')
|
|
229
|
+
.eq('id', project.org_id)
|
|
230
|
+
.maybeSingle();
|
|
231
|
+
return j({
|
|
232
|
+
organization: org ? { id: org.id, name: org.name, plan: org.plan } : null,
|
|
233
|
+
projects: (projects ?? []).map((p) => ({
|
|
234
|
+
id: p.id,
|
|
235
|
+
name: p.name,
|
|
236
|
+
current: p.id === projectId,
|
|
237
|
+
})),
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
server.registerTool('get_sites', {
|
|
241
|
+
description: 'List all active Sendinel sites',
|
|
242
|
+
inputSchema: {},
|
|
243
|
+
}, async () => {
|
|
244
|
+
const { data, error } = await db()
|
|
245
|
+
.from('sites')
|
|
246
|
+
.select('id, slug, name, sending_domain, from_email, active')
|
|
247
|
+
.eq('active', true)
|
|
248
|
+
.eq('project_id', getProjectId())
|
|
249
|
+
.order('name');
|
|
250
|
+
if (error)
|
|
251
|
+
return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
|
|
252
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
253
|
+
});
|
|
254
|
+
server.registerTool('get_stats', {
|
|
255
|
+
description: 'Aggregate email metrics for a site over a date range. Returns sent, delivered, bounce/complaint rates, Sender Health Score (0–100), send volume by day, list growth rate, inactive subscriber percentage, and open/click rates when tracking is enabled.',
|
|
256
|
+
inputSchema: {
|
|
257
|
+
site_id: z.string().uuid().describe('Site UUID'),
|
|
258
|
+
start_date: z.string().describe('ISO date e.g. 2026-01-01'),
|
|
259
|
+
end_date: z.string().optional().describe('ISO date, defaults to today'),
|
|
260
|
+
},
|
|
261
|
+
}, async ({ site_id, start_date, end_date }) => {
|
|
262
|
+
const projectId = getProjectId();
|
|
263
|
+
const end = end_date ?? new Date().toISOString().slice(0, 10);
|
|
264
|
+
const { data: site, error: siteError } = await db()
|
|
265
|
+
.from('sites')
|
|
266
|
+
.select('tracking_enabled')
|
|
267
|
+
.eq('project_id', projectId)
|
|
268
|
+
.eq('id', site_id)
|
|
269
|
+
.maybeSingle();
|
|
270
|
+
if (siteError)
|
|
271
|
+
return { content: [{ type: 'text', text: `Error: ${siteError.message}` }] };
|
|
272
|
+
const trackingEnabled = site?.tracking_enabled ?? false;
|
|
273
|
+
const { data, error } = await db()
|
|
274
|
+
.from('email_log')
|
|
275
|
+
.select('id, status, sent_at')
|
|
276
|
+
.eq('project_id', projectId)
|
|
277
|
+
.eq('site_id', site_id)
|
|
278
|
+
.gte('sent_at', `${start_date}T00:00:00Z`)
|
|
279
|
+
.lte('sent_at', `${end}T23:59:59Z`);
|
|
280
|
+
if (error)
|
|
281
|
+
return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
|
|
282
|
+
const rows = (data ?? []);
|
|
283
|
+
let eventCounts = { opened: 0, clicked: 0 };
|
|
284
|
+
if (trackingEnabled) {
|
|
285
|
+
const { data: events, error: eventsError } = await db()
|
|
286
|
+
.from('email_events')
|
|
287
|
+
.select('email_log_id, event_type')
|
|
288
|
+
.eq('project_id', projectId)
|
|
289
|
+
.eq('site_id', site_id)
|
|
290
|
+
.in('event_type', ['open', 'click'])
|
|
291
|
+
.gte('occurred_at', `${start_date}T00:00:00Z`)
|
|
292
|
+
.lte('occurred_at', `${end}T23:59:59Z`);
|
|
293
|
+
if (eventsError)
|
|
294
|
+
return { content: [{ type: 'text', text: `Error: ${eventsError.message}` }] };
|
|
295
|
+
const logIds = new Set(rows.map(row => row.id).filter((id) => Boolean(id)));
|
|
296
|
+
eventCounts = {
|
|
297
|
+
opened: distinctEventCount(events ?? [], 'open', logIds),
|
|
298
|
+
clicked: distinctEventCount(events ?? [], 'click', logIds),
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
const metrics = computeAnalyticsMetrics(rows, trackingEnabled ? eventCounts : undefined);
|
|
302
|
+
const periodDays = daysBetween(start_date, end);
|
|
303
|
+
const priorStart = dateOnly(addDays(new Date(`${start_date}T00:00:00Z`), -periodDays));
|
|
304
|
+
const priorEnd = dateOnly(addDays(new Date(`${start_date}T00:00:00Z`), -1));
|
|
305
|
+
const { data: subscribers } = await db()
|
|
306
|
+
.from('subscribers')
|
|
307
|
+
.select('created_at, last_engaged_at, last_opened_at, last_clicked_at')
|
|
308
|
+
.eq('project_id', projectId);
|
|
309
|
+
const subscriberRows = (subscribers ?? []);
|
|
310
|
+
const currentNew = subscriberRows.filter(s => s.created_at && s.created_at >= `${start_date}T00:00:00Z` && s.created_at <= `${end}T23:59:59Z`).length;
|
|
311
|
+
const priorNew = subscriberRows.filter(s => s.created_at && s.created_at >= `${priorStart}T00:00:00Z` && s.created_at <= `${priorEnd}T23:59:59Z`).length;
|
|
312
|
+
const inactiveCutoff = dateOnly(addDays(new Date(`${end}T00:00:00Z`), -90));
|
|
313
|
+
const inactive = subscriberRows.filter(s => {
|
|
314
|
+
const lastEngaged = s.last_engaged_at ?? s.last_clicked_at ?? s.last_opened_at ?? null;
|
|
315
|
+
return !lastEngaged || lastEngaged < `${inactiveCutoff}T00:00:00Z`;
|
|
316
|
+
}).length;
|
|
317
|
+
const response = {
|
|
318
|
+
site_id, period: { start: start_date, end },
|
|
319
|
+
tracking_enabled: trackingEnabled,
|
|
320
|
+
total_sent: metrics.sent,
|
|
321
|
+
total_delivered: metrics.delivered,
|
|
322
|
+
total_bounced: metrics.bounced,
|
|
323
|
+
total_complained: metrics.complained,
|
|
324
|
+
total_unsubscribed: metrics.unsubscribed,
|
|
325
|
+
delivery_rate: metrics.sent > 0 ? percent(metrics.delivery_rate) : 'N/A',
|
|
326
|
+
send_volume_by_day: sendVolumeByDay(rows),
|
|
327
|
+
list_growth_rate: subscriberRows.length > 0 ? pctNumber(currentNew / subscriberRows.length) : 0,
|
|
328
|
+
inactive_pct: subscriberRows.length > 0 ? pctNumber(inactive / subscriberRows.length) : 0,
|
|
329
|
+
sender_health_score: computeSenderHealthScore(metrics, trackingEnabled),
|
|
330
|
+
};
|
|
331
|
+
if (trackingEnabled) {
|
|
332
|
+
response.open_rate = metrics.sent > 0 ? percent(metrics.open_rate) : 'N/A';
|
|
333
|
+
response.click_rate = metrics.sent > 0 ? percent(metrics.click_rate) : 'N/A';
|
|
334
|
+
}
|
|
335
|
+
else {
|
|
336
|
+
response.tracking_note = 'Enable tracking in Site Settings to see open and click rates';
|
|
337
|
+
}
|
|
338
|
+
return { content: [{ type: 'text', text: JSON.stringify(response, null, 2) }] };
|
|
339
|
+
});
|
|
340
|
+
server.registerTool('get_domain_health', {
|
|
341
|
+
description: 'SPF/DKIM/DMARC status and warmup progress for a site',
|
|
342
|
+
inputSchema: {
|
|
343
|
+
site_id: z.string().uuid().describe('Site UUID'),
|
|
344
|
+
days: z.number().int().min(1).max(90).optional().describe('Recent days to return, default 7'),
|
|
345
|
+
},
|
|
346
|
+
}, async ({ site_id, days = 7 }) => {
|
|
347
|
+
const { data, error } = await db()
|
|
348
|
+
.from('domain_health')
|
|
349
|
+
.select('*')
|
|
350
|
+
.eq('site_id', site_id)
|
|
351
|
+
.order('check_date', { ascending: false })
|
|
352
|
+
.limit(days);
|
|
353
|
+
if (error)
|
|
354
|
+
return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
|
|
355
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
356
|
+
});
|
|
357
|
+
server.registerTool('get_engagement_insights', {
|
|
358
|
+
description: 'Get engagement scoring summary: lifecycle distribution, score buckets, top engaged contacts, at-risk contacts, and never-engaged counts. Use this data to recommend re-engagement campaigns, identify high-value contacts, or suggest list hygiene actions.',
|
|
359
|
+
inputSchema: {
|
|
360
|
+
site_id: z.string().uuid().optional().describe('Optional site UUID for site-specific tracking settings and engagement anomaly analysis'),
|
|
361
|
+
},
|
|
362
|
+
}, async ({ site_id }) => {
|
|
363
|
+
const projectId = getProjectId();
|
|
364
|
+
const { data, error } = await db()
|
|
365
|
+
.rpc('get_engagement_summary', site_id ? { p_project_id: projectId, p_site_id: site_id } : { p_project_id: projectId });
|
|
366
|
+
if (error)
|
|
367
|
+
return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
|
|
368
|
+
let trackingEnabled = true;
|
|
369
|
+
if (site_id) {
|
|
370
|
+
const { data: site, error: siteError } = await db()
|
|
371
|
+
.from('sites')
|
|
372
|
+
.select('tracking_enabled')
|
|
373
|
+
.eq('project_id', projectId)
|
|
374
|
+
.eq('id', site_id)
|
|
375
|
+
.maybeSingle();
|
|
376
|
+
if (siteError)
|
|
377
|
+
return { content: [{ type: 'text', text: `Error: ${siteError.message}` }] };
|
|
378
|
+
trackingEnabled = site?.tracking_enabled ?? false;
|
|
379
|
+
}
|
|
380
|
+
else {
|
|
381
|
+
const { data: sites, error: sitesError } = await db()
|
|
382
|
+
.from('sites')
|
|
383
|
+
.select('tracking_enabled')
|
|
384
|
+
.eq('project_id', projectId);
|
|
385
|
+
if (sitesError)
|
|
386
|
+
return { content: [{ type: 'text', text: `Error: ${sitesError.message}` }] };
|
|
387
|
+
trackingEnabled = (sites ?? []).some((site) => site.tracking_enabled);
|
|
388
|
+
}
|
|
389
|
+
const since30d = new Date(Date.now() - 30 * 86_400_000).toISOString();
|
|
390
|
+
const since7d = new Date(Date.now() - 7 * 86_400_000).toISOString();
|
|
391
|
+
let q = db()
|
|
392
|
+
.from('email_log')
|
|
393
|
+
.select('status, opened_at, clicked_at, sent_at')
|
|
394
|
+
.eq('project_id', projectId)
|
|
395
|
+
.gte('sent_at', since30d);
|
|
396
|
+
if (site_id)
|
|
397
|
+
q = q.eq('site_id', site_id);
|
|
398
|
+
const { data: logs, error: logsError } = await q;
|
|
399
|
+
if (logsError)
|
|
400
|
+
return { content: [{ type: 'text', text: `Error: ${logsError.message}` }] };
|
|
401
|
+
const rows = (logs ?? []);
|
|
402
|
+
const metrics30d = computeAnalyticsMetrics(rows);
|
|
403
|
+
const metrics7d = computeAnalyticsMetrics(rows.filter(row => row.sent_at && row.sent_at >= since7d));
|
|
404
|
+
const anomalyFlags = [];
|
|
405
|
+
addDeviationFlag(anomalyFlags, 'delivery_rate', metrics7d.delivery_rate, metrics30d.delivery_rate);
|
|
406
|
+
addDeviationFlag(anomalyFlags, 'bounce_rate', metrics7d.bounce_rate, metrics30d.bounce_rate);
|
|
407
|
+
addDeviationFlag(anomalyFlags, 'complaint_rate', metrics7d.complaint_rate, metrics30d.complaint_rate);
|
|
408
|
+
addDeviationFlag(anomalyFlags, 'unsubscribe_rate', metrics7d.unsubscribe_rate, metrics30d.unsubscribe_rate);
|
|
409
|
+
if (trackingEnabled) {
|
|
410
|
+
addDeviationFlag(anomalyFlags, 'open_rate', metrics7d.open_rate, metrics30d.open_rate);
|
|
411
|
+
addDeviationFlag(anomalyFlags, 'click_rate', metrics7d.click_rate, metrics30d.click_rate);
|
|
412
|
+
}
|
|
413
|
+
else {
|
|
414
|
+
anomalyFlags.push('Tracking is disabled; open and click anomalies are not evaluated. Enable tracking in Site Settings to see open and click rates');
|
|
415
|
+
}
|
|
416
|
+
if (metrics7d.bounce_rate > 0.05)
|
|
417
|
+
anomalyFlags.push(`HIGH bounce rate: ${pctNumber(metrics7d.bounce_rate)}%`);
|
|
418
|
+
else if (metrics7d.bounce_rate > 0.02)
|
|
419
|
+
anomalyFlags.push(`Elevated bounce rate: ${pctNumber(metrics7d.bounce_rate)}%`);
|
|
420
|
+
if (metrics7d.complaint_rate > 0.005)
|
|
421
|
+
anomalyFlags.push(`HIGH complaint rate: ${(metrics7d.complaint_rate * 100).toFixed(3)}%`);
|
|
422
|
+
else if (metrics7d.complaint_rate > 0.001)
|
|
423
|
+
anomalyFlags.push(`Elevated complaint rate: ${(metrics7d.complaint_rate * 100).toFixed(3)}%`);
|
|
424
|
+
const rolling = {
|
|
425
|
+
sent_7d: metrics7d.sent,
|
|
426
|
+
sent_30d: metrics30d.sent,
|
|
427
|
+
delivery_rate_7d: roundRate(metrics7d.delivery_rate),
|
|
428
|
+
delivery_rate_30d: roundRate(metrics30d.delivery_rate),
|
|
429
|
+
bounce_rate_7d: roundRate(metrics7d.bounce_rate),
|
|
430
|
+
bounce_rate_30d: roundRate(metrics30d.bounce_rate),
|
|
431
|
+
complaint_rate_7d: roundRate(metrics7d.complaint_rate),
|
|
432
|
+
complaint_rate_30d: roundRate(metrics30d.complaint_rate),
|
|
433
|
+
unsubscribe_rate_7d: roundRate(metrics7d.unsubscribe_rate),
|
|
434
|
+
unsubscribe_rate_30d: roundRate(metrics30d.unsubscribe_rate),
|
|
435
|
+
};
|
|
436
|
+
if (trackingEnabled) {
|
|
437
|
+
rolling.open_rate_7d = roundRate(metrics7d.open_rate);
|
|
438
|
+
rolling.open_rate_30d = roundRate(metrics30d.open_rate);
|
|
439
|
+
rolling.click_rate_7d = roundRate(metrics7d.click_rate);
|
|
440
|
+
rolling.click_rate_30d = roundRate(metrics30d.click_rate);
|
|
441
|
+
}
|
|
442
|
+
return { content: [{ type: 'text', text: JSON.stringify({
|
|
443
|
+
...(typeof data === 'object' && data !== null ? data : { summary: data }),
|
|
444
|
+
tracking_enabled: trackingEnabled,
|
|
445
|
+
rolling_7d_vs_30d: rolling,
|
|
446
|
+
anomaly_flags: anomalyFlags,
|
|
447
|
+
}, null, 2) }] };
|
|
448
|
+
});
|
|
449
|
+
server.registerTool('deliverability_check', {
|
|
450
|
+
description: 'Run a deliverability check for a site: DNS records (SPF/DKIM/DMARC), warmup status, bounce/complaint rates, and risk assessment',
|
|
451
|
+
inputSchema: {
|
|
452
|
+
site_id: z.string().uuid(),
|
|
453
|
+
},
|
|
454
|
+
}, async ({ site_id }) => {
|
|
455
|
+
const projectId = getProjectId();
|
|
456
|
+
const { data: site, error: siteErr } = await db()
|
|
457
|
+
.from('sites')
|
|
458
|
+
.select('id, slug, name, sending_domain, from_email')
|
|
459
|
+
.eq('id', site_id)
|
|
460
|
+
.eq('project_id', projectId)
|
|
461
|
+
.maybeSingle();
|
|
462
|
+
if (siteErr || !site)
|
|
463
|
+
return txt(`Error: Site not found — ${siteErr?.message ?? 'not found'}`);
|
|
464
|
+
const domain = site.sending_domain;
|
|
465
|
+
const dnsResults = { spf: null, dkim: null, dmarc: null };
|
|
466
|
+
let verificationSource = 'dns_lookup';
|
|
467
|
+
let resendDomainStatus = null;
|
|
468
|
+
// Try Resend domain verification API first — avoids false negatives from
|
|
469
|
+
// raw DNS lookups (Resend uses varying DKIM selectors per domain vintage).
|
|
470
|
+
try {
|
|
471
|
+
// Resolve Resend API key: site key → project key → env
|
|
472
|
+
let apiKey = null;
|
|
473
|
+
const { data: siteRow } = await adminDb()
|
|
474
|
+
.from('sites')
|
|
475
|
+
.select('provider_api_key_enc')
|
|
476
|
+
.eq('id', site_id)
|
|
477
|
+
.eq('project_id', projectId)
|
|
478
|
+
.maybeSingle();
|
|
479
|
+
if (siteRow?.provider_api_key_enc) {
|
|
480
|
+
apiKey = decrypt(siteRow.provider_api_key_enc);
|
|
481
|
+
}
|
|
482
|
+
else {
|
|
483
|
+
const { data: projRow } = await adminDb()
|
|
484
|
+
.from('projects')
|
|
485
|
+
.select('provider_api_key_enc')
|
|
486
|
+
.eq('id', projectId)
|
|
487
|
+
.maybeSingle();
|
|
488
|
+
if (projRow?.provider_api_key_enc) {
|
|
489
|
+
apiKey = decrypt(projRow.provider_api_key_enc);
|
|
490
|
+
}
|
|
491
|
+
else if (process.env.RESEND_API_KEY) {
|
|
492
|
+
apiKey = process.env.RESEND_API_KEY;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
if (apiKey) {
|
|
496
|
+
const { Resend } = await import('resend');
|
|
497
|
+
const resend = new Resend(apiKey);
|
|
498
|
+
const { data: domainList } = await resend.domains.list();
|
|
499
|
+
const domains = domainList?.data ?? [];
|
|
500
|
+
const match = domains.find((d) => d.name === domain);
|
|
501
|
+
if (match) {
|
|
502
|
+
resendDomainStatus = match.status;
|
|
503
|
+
if (match.status === 'verified') {
|
|
504
|
+
verificationSource = 'resend_api';
|
|
505
|
+
dnsResults.spf = { found: true, verified_by: 'resend_api', note: 'Resend handles SPF authentication at infrastructure level' };
|
|
506
|
+
dnsResults.dkim = { found: true, verified_by: 'resend_api', note: 'DKIM signing managed by Resend (selector may vary)' };
|
|
507
|
+
dnsResults.dmarc = { found: true, verified_by: 'resend_api', note: 'Domain verified by Resend — DMARC alignment confirmed' };
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
catch {
|
|
513
|
+
// Resend API unavailable or key missing — fall back to DNS lookup
|
|
514
|
+
}
|
|
515
|
+
// Fall back to raw DNS checks if Resend API didn't confirm verification
|
|
516
|
+
if (verificationSource === 'dns_lookup') {
|
|
517
|
+
try {
|
|
518
|
+
const dns = await import('dns/promises');
|
|
519
|
+
try {
|
|
520
|
+
const txtRecords = await dns.resolveTxt(domain);
|
|
521
|
+
const spfRecord = txtRecords.flat().find((r) => r.startsWith('v=spf1'));
|
|
522
|
+
dnsResults.spf = { found: !!spfRecord, record: spfRecord ?? null, includes_resend: spfRecord?.includes('include:amazonses.com') || spfRecord?.includes('include:_spf.resend.com') || false };
|
|
523
|
+
}
|
|
524
|
+
catch {
|
|
525
|
+
dnsResults.spf = { found: false, error: 'No SPF record found' };
|
|
526
|
+
}
|
|
527
|
+
try {
|
|
528
|
+
const dmarcRecords = await dns.resolveTxt(`_dmarc.${domain}`);
|
|
529
|
+
const dmarcRecord = dmarcRecords.flat().find((r) => r.startsWith('v=DMARC1'));
|
|
530
|
+
const policy = dmarcRecord?.match(/p=(\w+)/)?.[1] ?? 'unknown';
|
|
531
|
+
dnsResults.dmarc = { found: !!dmarcRecord, record: dmarcRecord ?? null, policy, strict: policy === 'quarantine' || policy === 'reject' };
|
|
532
|
+
}
|
|
533
|
+
catch {
|
|
534
|
+
dnsResults.dmarc = { found: false, error: 'No DMARC record found' };
|
|
535
|
+
}
|
|
536
|
+
try {
|
|
537
|
+
const cnames = await dns.resolveCname(`resend._domainkey.${domain}`);
|
|
538
|
+
dnsResults.dkim = { found: cnames.length > 0, cname: cnames[0] ?? null };
|
|
539
|
+
}
|
|
540
|
+
catch {
|
|
541
|
+
dnsResults.dkim = { found: false, error: 'No DKIM CNAME found for resend._domainkey' };
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
catch { /* dns module unavailable */ }
|
|
545
|
+
}
|
|
546
|
+
const { data: healthRows } = await db()
|
|
547
|
+
.from('domain_health')
|
|
548
|
+
.select('*')
|
|
549
|
+
.eq('site_id', site_id)
|
|
550
|
+
.order('check_date', { ascending: false })
|
|
551
|
+
.limit(14);
|
|
552
|
+
const health = healthRows ?? [];
|
|
553
|
+
const latest = health[0];
|
|
554
|
+
const warmupDay = latest?.warmup_day ?? null;
|
|
555
|
+
const warmupLimit = latest?.warmup_daily_limit ?? null;
|
|
556
|
+
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
|
|
557
|
+
const { data: recentLogs } = await db()
|
|
558
|
+
.from('email_log')
|
|
559
|
+
.select('status')
|
|
560
|
+
.eq('project_id', projectId)
|
|
561
|
+
.eq('site_id', site_id)
|
|
562
|
+
.gte('sent_at', sevenDaysAgo);
|
|
563
|
+
const logs = recentLogs ?? [];
|
|
564
|
+
const totalSent = logs.length;
|
|
565
|
+
const bounced = logs.filter((l) => l.status === 'bounced').length;
|
|
566
|
+
const complained = logs.filter((l) => l.status === 'complained').length;
|
|
567
|
+
const bounceRate = totalSent > 0 ? (bounced / totalSent * 100) : 0;
|
|
568
|
+
const complaintRate = totalSent > 0 ? (complained / totalSent * 100) : 0;
|
|
569
|
+
const risks = [];
|
|
570
|
+
if (verificationSource === 'dns_lookup') {
|
|
571
|
+
if (!dnsResults.spf?.found)
|
|
572
|
+
risks.push('SPF record missing');
|
|
573
|
+
if (!dnsResults.dkim?.found)
|
|
574
|
+
risks.push('DKIM not configured');
|
|
575
|
+
if (!dnsResults.dmarc?.found)
|
|
576
|
+
risks.push('DMARC record missing');
|
|
577
|
+
}
|
|
578
|
+
if (resendDomainStatus && resendDomainStatus !== 'verified') {
|
|
579
|
+
risks.push(`Resend domain status is '${resendDomainStatus}' — domain verification incomplete`);
|
|
580
|
+
}
|
|
581
|
+
if (bounceRate > 5)
|
|
582
|
+
risks.push(`HIGH bounce rate: ${bounceRate.toFixed(1)}%`);
|
|
583
|
+
else if (bounceRate > 2)
|
|
584
|
+
risks.push(`Elevated bounce rate: ${bounceRate.toFixed(1)}%`);
|
|
585
|
+
if (complaintRate > 0.3)
|
|
586
|
+
risks.push(`HIGH complaint rate: ${complaintRate.toFixed(2)}%`);
|
|
587
|
+
else if (complaintRate > 0.1)
|
|
588
|
+
risks.push(`Elevated complaint rate: ${complaintRate.toFixed(2)}%`);
|
|
589
|
+
if (warmupDay && warmupDay < 43)
|
|
590
|
+
risks.push(`Domain still in warmup (day ${warmupDay})`);
|
|
591
|
+
return j({
|
|
592
|
+
site: { slug: site.slug, domain, from_email: site.from_email },
|
|
593
|
+
verification_source: verificationSource,
|
|
594
|
+
resend_domain_status: resendDomainStatus,
|
|
595
|
+
dns: dnsResults,
|
|
596
|
+
warmup: { day: warmupDay, daily_limit: warmupLimit, completed: warmupDay ? warmupDay >= 43 : null },
|
|
597
|
+
recent_7d: { total_sent: totalSent, bounced, complained, bounce_rate: `${bounceRate.toFixed(2)}%`, complaint_rate: `${complaintRate.toFixed(3)}%` },
|
|
598
|
+
health_trend: health.slice(0, 7).map((h) => ({ date: h.check_date, sent: h.total_sent, delivery_rate: h.delivery_rate, bounce_rate: h.bounce_rate })),
|
|
599
|
+
risks,
|
|
600
|
+
overall: risks.length === 0 ? 'HEALTHY' : risks.some(r => r.startsWith('HIGH')) ? 'CRITICAL' : 'WARNING',
|
|
601
|
+
});
|
|
602
|
+
});
|
|
603
|
+
server.registerTool('performance_report', {
|
|
604
|
+
description: 'Generate a performance report: delivery/open/click rates, best send times, trends. Optionally group by campaign, site, company, day, or week.',
|
|
605
|
+
inputSchema: {
|
|
606
|
+
days: z.number().int().min(1).max(365).optional().describe('Lookback period in days, default 30'),
|
|
607
|
+
site_id: z.string().uuid().optional(),
|
|
608
|
+
campaign_id: z.string().uuid().optional(),
|
|
609
|
+
group_by: z.enum(['campaign', 'site', 'company', 'day', 'week']).optional(),
|
|
610
|
+
},
|
|
611
|
+
}, async ({ days = 30, site_id, campaign_id, group_by }) => {
|
|
612
|
+
const projectId = getProjectId();
|
|
613
|
+
const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
|
|
614
|
+
let q = db()
|
|
615
|
+
.from('email_log')
|
|
616
|
+
.select('id, site_id, campaign_id, contact_id, status, category, sent_at, opened_at, clicked_at, bounced_at, complained_at')
|
|
617
|
+
.eq('project_id', projectId)
|
|
618
|
+
.gte('sent_at', since);
|
|
619
|
+
if (site_id)
|
|
620
|
+
q = q.eq('site_id', site_id);
|
|
621
|
+
if (campaign_id)
|
|
622
|
+
q = q.eq('campaign_id', campaign_id);
|
|
623
|
+
const { data: logs, error } = await q;
|
|
624
|
+
if (error)
|
|
625
|
+
return txt(`Error: ${error.message}`);
|
|
626
|
+
const rows = logs ?? [];
|
|
627
|
+
if (!rows.length)
|
|
628
|
+
return j({ message: `No email activity in the last ${days} days.`, total: 0 });
|
|
629
|
+
const computeMetrics = (set) => {
|
|
630
|
+
const total = set.length;
|
|
631
|
+
const delivered = set.filter(r => ['delivered', 'opened', 'clicked'].includes(r.status)).length;
|
|
632
|
+
const opened = set.filter(r => r.opened_at).length;
|
|
633
|
+
const clicked = set.filter(r => r.clicked_at).length;
|
|
634
|
+
const bounced = set.filter(r => r.status === 'bounced').length;
|
|
635
|
+
const complained = set.filter(r => r.status === 'complained').length;
|
|
636
|
+
return {
|
|
637
|
+
total_sent: total, delivered, opened, clicked, bounced, complained,
|
|
638
|
+
delivery_rate: total > 0 ? `${((delivered / total) * 100).toFixed(1)}%` : 'N/A',
|
|
639
|
+
open_rate: total > 0 ? `${((opened / total) * 100).toFixed(1)}%` : 'N/A',
|
|
640
|
+
click_rate: total > 0 ? `${((clicked / total) * 100).toFixed(1)}%` : 'N/A',
|
|
641
|
+
bounce_rate: total > 0 ? `${((bounced / total) * 100).toFixed(1)}%` : 'N/A',
|
|
642
|
+
complaint_rate: total > 0 ? `${((complained / total) * 100).toFixed(3)}%` : 'N/A',
|
|
643
|
+
click_to_open: opened > 0 ? `${((clicked / opened) * 100).toFixed(1)}%` : 'N/A',
|
|
644
|
+
};
|
|
645
|
+
};
|
|
646
|
+
const overall = computeMetrics(rows);
|
|
647
|
+
const hourBuckets = {};
|
|
648
|
+
rows.forEach((r) => {
|
|
649
|
+
if (!r.sent_at)
|
|
650
|
+
return;
|
|
651
|
+
const hour = new Date(r.sent_at).getUTCHours();
|
|
652
|
+
if (!hourBuckets[hour])
|
|
653
|
+
hourBuckets[hour] = { sent: 0, opened: 0 };
|
|
654
|
+
hourBuckets[hour].sent++;
|
|
655
|
+
if (r.opened_at)
|
|
656
|
+
hourBuckets[hour].opened++;
|
|
657
|
+
});
|
|
658
|
+
const bestHours = Object.entries(hourBuckets)
|
|
659
|
+
.map(([hour, b]) => ({ hour: Number(hour), sent: b.sent, open_rate: b.sent > 0 ? (b.opened / b.sent * 100) : 0 }))
|
|
660
|
+
.filter(h => h.sent >= 5)
|
|
661
|
+
.sort((a, b) => b.open_rate - a.open_rate)
|
|
662
|
+
.slice(0, 3)
|
|
663
|
+
.map(h => ({ hour_utc: h.hour, sent: h.sent, open_rate: `${h.open_rate.toFixed(1)}%` }));
|
|
664
|
+
let grouped = null;
|
|
665
|
+
if (group_by === 'campaign' || group_by === 'site') {
|
|
666
|
+
const byKey = {};
|
|
667
|
+
rows.forEach((r) => {
|
|
668
|
+
const key = (group_by === 'campaign' ? r.campaign_id : r.site_id) ?? '(none)';
|
|
669
|
+
if (!byKey[key])
|
|
670
|
+
byKey[key] = [];
|
|
671
|
+
byKey[key].push(r);
|
|
672
|
+
});
|
|
673
|
+
grouped = Object.entries(byKey).map(([id, set]) => ({
|
|
674
|
+
[`${group_by}_id`]: id,
|
|
675
|
+
...computeMetrics(set),
|
|
676
|
+
})).sort((a, b) => b.total_sent - a.total_sent);
|
|
677
|
+
}
|
|
678
|
+
else if (group_by === 'company') {
|
|
679
|
+
const contactIds = [...new Set(rows.map((row) => typeof row.contact_id === 'string' ? row.contact_id : null).filter(Boolean))];
|
|
680
|
+
const companyByContact = new Map();
|
|
681
|
+
if (contactIds.length > 0) {
|
|
682
|
+
const { data: subscribers } = await db()
|
|
683
|
+
.from('subscribers')
|
|
684
|
+
.select('id, company_id, company:companies(name, domain)')
|
|
685
|
+
.eq('project_id', projectId)
|
|
686
|
+
.in('id', contactIds);
|
|
687
|
+
for (const subscriber of subscribers ?? []) {
|
|
688
|
+
const company = Array.isArray(subscriber.company) ? subscriber.company[0] : subscriber.company;
|
|
689
|
+
companyByContact.set(subscriber.id, {
|
|
690
|
+
company_id: typeof subscriber.company_id === 'string' ? subscriber.company_id : null,
|
|
691
|
+
company_name: typeof company?.name === 'string' ? company.name : null,
|
|
692
|
+
company_domain: typeof company?.domain === 'string' ? company.domain : null,
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
const byCompany = new Map();
|
|
697
|
+
rows.forEach((row) => {
|
|
698
|
+
const company = typeof row.contact_id === 'string' ? companyByContact.get(row.contact_id) : null;
|
|
699
|
+
const key = company?.company_id ?? '(none)';
|
|
700
|
+
const current = byCompany.get(key) ?? { id: company?.company_id ?? null, name: company?.company_name ?? null, domain: company?.company_domain ?? null, rows: [] };
|
|
701
|
+
current.rows.push(row);
|
|
702
|
+
byCompany.set(key, current);
|
|
703
|
+
});
|
|
704
|
+
grouped = Array.from(byCompany.values()).map((entry) => ({
|
|
705
|
+
company_id: entry.id,
|
|
706
|
+
company_name: entry.name,
|
|
707
|
+
company_domain: entry.domain,
|
|
708
|
+
...computeMetrics(entry.rows),
|
|
709
|
+
})).sort((a, b) => b.total_sent - a.total_sent);
|
|
710
|
+
}
|
|
711
|
+
else if (group_by === 'day' || group_by === 'week') {
|
|
712
|
+
const byPeriod = {};
|
|
713
|
+
rows.forEach((r) => {
|
|
714
|
+
if (!r.sent_at)
|
|
715
|
+
return;
|
|
716
|
+
const d = new Date(r.sent_at);
|
|
717
|
+
let key;
|
|
718
|
+
if (group_by === 'week') {
|
|
719
|
+
const weekStart = new Date(d);
|
|
720
|
+
weekStart.setUTCDate(weekStart.getUTCDate() - weekStart.getUTCDay());
|
|
721
|
+
key = weekStart.toISOString().slice(0, 10);
|
|
722
|
+
}
|
|
723
|
+
else {
|
|
724
|
+
key = d.toISOString().slice(0, 10);
|
|
725
|
+
}
|
|
726
|
+
if (!byPeriod[key])
|
|
727
|
+
byPeriod[key] = [];
|
|
728
|
+
byPeriod[key].push(r);
|
|
729
|
+
});
|
|
730
|
+
grouped = Object.entries(byPeriod).sort(([a], [b]) => a.localeCompare(b)).map(([period, set]) => ({ period, ...computeMetrics(set) }));
|
|
731
|
+
}
|
|
732
|
+
const midpoint = new Date(Date.now() - (days / 2) * 24 * 60 * 60 * 1000).toISOString();
|
|
733
|
+
const firstHalf = rows.filter((r) => r.sent_at < midpoint);
|
|
734
|
+
const secondHalf = rows.filter((r) => r.sent_at >= midpoint);
|
|
735
|
+
const firstMetrics = computeMetrics(firstHalf);
|
|
736
|
+
const secondMetrics = computeMetrics(secondHalf);
|
|
737
|
+
const trend = {};
|
|
738
|
+
const parseRate = (s) => parseFloat(s) || 0;
|
|
739
|
+
if (firstHalf.length > 0 && secondHalf.length > 0) {
|
|
740
|
+
trend.open_rate = `${(parseRate(secondMetrics.open_rate) - parseRate(firstMetrics.open_rate) >= 0 ? '+' : '')}${(parseRate(secondMetrics.open_rate) - parseRate(firstMetrics.open_rate)).toFixed(1)}pp`;
|
|
741
|
+
trend.click_rate = `${(parseRate(secondMetrics.click_rate) - parseRate(firstMetrics.click_rate) >= 0 ? '+' : '')}${(parseRate(secondMetrics.click_rate) - parseRate(firstMetrics.click_rate)).toFixed(1)}pp`;
|
|
742
|
+
trend.volume = `${firstHalf.length} → ${secondHalf.length} emails`;
|
|
743
|
+
}
|
|
744
|
+
return j({
|
|
745
|
+
period: `Last ${days} days`,
|
|
746
|
+
overall,
|
|
747
|
+
best_send_times_utc: bestHours,
|
|
748
|
+
trend_vs_prior_period: trend,
|
|
749
|
+
...(grouped ? { [`by_${group_by}`]: grouped } : {}),
|
|
750
|
+
});
|
|
751
|
+
});
|
|
752
|
+
// ── Cross-project analytics ─────────────────────────────────────────────
|
|
753
|
+
server.registerTool('get_portfolio_stats', {
|
|
754
|
+
description: 'Aggregate email metrics across all projects in your organization. Returns per-project and combined totals for the specified period. Useful for orchestrators managing multiple products.',
|
|
755
|
+
inputSchema: {
|
|
756
|
+
days: z.number().int().min(1).max(90).optional().default(30).describe('Lookback period in days (default: 30)'),
|
|
757
|
+
},
|
|
758
|
+
}, async ({ days }) => {
|
|
759
|
+
const projectId = getProjectId();
|
|
760
|
+
// Look up org
|
|
761
|
+
const { data: project } = await adminDb()
|
|
762
|
+
.from('projects')
|
|
763
|
+
.select('id, name, org_id')
|
|
764
|
+
.eq('id', projectId)
|
|
765
|
+
.maybeSingle();
|
|
766
|
+
if (!project)
|
|
767
|
+
return txt('Project not found');
|
|
768
|
+
// Get all projects in org
|
|
769
|
+
let projects;
|
|
770
|
+
if (project.org_id) {
|
|
771
|
+
const { data } = await adminDb()
|
|
772
|
+
.from('projects')
|
|
773
|
+
.select('id, name')
|
|
774
|
+
.eq('org_id', project.org_id)
|
|
775
|
+
.order('name');
|
|
776
|
+
projects = data ?? [{ id: project.id, name: project.name }];
|
|
777
|
+
}
|
|
778
|
+
else {
|
|
779
|
+
projects = [{ id: project.id, name: project.name }];
|
|
780
|
+
}
|
|
781
|
+
// Get org info
|
|
782
|
+
const org = project.org_id
|
|
783
|
+
? (await adminDb().from('organizations').select('name, plan').eq('id', project.org_id).maybeSingle()).data
|
|
784
|
+
: null;
|
|
785
|
+
const since = new Date(Date.now() - days * 86_400_000).toISOString();
|
|
786
|
+
const results = [];
|
|
787
|
+
for (const proj of projects) {
|
|
788
|
+
try {
|
|
789
|
+
// Resolve per-project client (BYOD-aware)
|
|
790
|
+
const client = await resolveProjectClient(proj.id);
|
|
791
|
+
const projDb = client ?? adminDb();
|
|
792
|
+
const { data: logs } = await projDb
|
|
793
|
+
.from('email_log')
|
|
794
|
+
.select('status, opened_at, clicked_at')
|
|
795
|
+
.eq('project_id', proj.id)
|
|
796
|
+
.gte('sent_at', since);
|
|
797
|
+
const rows = logs ?? [];
|
|
798
|
+
const sent = rows.length;
|
|
799
|
+
const delivered = rows.filter((r) => ['delivered', 'opened', 'clicked'].includes(r.status)).length;
|
|
800
|
+
const opened = rows.filter((r) => r.opened_at).length;
|
|
801
|
+
const clicked = rows.filter((r) => r.clicked_at).length;
|
|
802
|
+
const bounced = rows.filter((r) => r.status === 'bounced').length;
|
|
803
|
+
const complained = rows.filter((r) => r.status === 'complained').length;
|
|
804
|
+
results.push({
|
|
805
|
+
project_id: proj.id,
|
|
806
|
+
name: proj.name,
|
|
807
|
+
sent,
|
|
808
|
+
delivered,
|
|
809
|
+
opened,
|
|
810
|
+
clicked,
|
|
811
|
+
bounced,
|
|
812
|
+
complained,
|
|
813
|
+
open_rate: sent > 0 ? Math.round((opened / sent) * 1000) / 10 : 0,
|
|
814
|
+
click_rate: sent > 0 ? Math.round((clicked / sent) * 1000) / 10 : 0,
|
|
815
|
+
});
|
|
816
|
+
}
|
|
817
|
+
catch {
|
|
818
|
+
results.push({
|
|
819
|
+
project_id: proj.id,
|
|
820
|
+
name: proj.name,
|
|
821
|
+
sent: 0, delivered: 0, opened: 0, clicked: 0, bounced: 0, complained: 0,
|
|
822
|
+
open_rate: 0, click_rate: 0,
|
|
823
|
+
});
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
const totals = {
|
|
827
|
+
sent: results.reduce((s, r) => s + r.sent, 0),
|
|
828
|
+
delivered: results.reduce((s, r) => s + r.delivered, 0),
|
|
829
|
+
opened: results.reduce((s, r) => s + r.opened, 0),
|
|
830
|
+
clicked: results.reduce((s, r) => s + r.clicked, 0),
|
|
831
|
+
bounced: results.reduce((s, r) => s + r.bounced, 0),
|
|
832
|
+
complained: results.reduce((s, r) => s + r.complained, 0),
|
|
833
|
+
};
|
|
834
|
+
return j({
|
|
835
|
+
organization: org ? { name: org.name, plan: org.plan } : null,
|
|
836
|
+
period: `last_${days}_days`,
|
|
837
|
+
projects: results,
|
|
838
|
+
totals: {
|
|
839
|
+
...totals,
|
|
840
|
+
open_rate: totals.sent > 0 ? Math.round((totals.opened / totals.sent) * 1000) / 10 : 0,
|
|
841
|
+
click_rate: totals.sent > 0 ? Math.round((totals.clicked / totals.sent) * 1000) / 10 : 0,
|
|
842
|
+
},
|
|
843
|
+
});
|
|
844
|
+
});
|
|
845
|
+
server.registerTool('abuse_monitor_status', {
|
|
846
|
+
description: 'Return per-tenant abuse monitor metrics for a site, including complaint rate and pause level.',
|
|
847
|
+
inputSchema: {
|
|
848
|
+
siteId: z.string().uuid(),
|
|
849
|
+
},
|
|
850
|
+
}, async ({ siteId }) => {
|
|
851
|
+
const { data: site, error: siteErr } = await db()
|
|
852
|
+
.from('sites')
|
|
853
|
+
.select('id')
|
|
854
|
+
.eq('id', siteId)
|
|
855
|
+
.eq('project_id', getProjectId())
|
|
856
|
+
.maybeSingle();
|
|
857
|
+
if (siteErr || !site)
|
|
858
|
+
return j({ error: siteErr?.message ?? 'Site not found', code: 'NOT_FOUND' });
|
|
859
|
+
const { data, error } = await db()
|
|
860
|
+
.from('tenant_email_metrics')
|
|
861
|
+
.select('site_id, window_30d_sent, window_30d_complaints, window_30d_opens, complaint_rate, pause_level, paused_at, pause_reason, updated_at')
|
|
862
|
+
.eq('site_id', siteId)
|
|
863
|
+
.maybeSingle();
|
|
864
|
+
if (error)
|
|
865
|
+
return j({ error: error.message });
|
|
866
|
+
const since = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();
|
|
867
|
+
const { count: reputationSignals30d, error: reputationErr } = await db()
|
|
868
|
+
.from('email_events')
|
|
869
|
+
.select('id', { count: 'exact', head: true })
|
|
870
|
+
.eq('site_id', siteId)
|
|
871
|
+
.eq('event_type', 'bounce')
|
|
872
|
+
.eq('bounce_category', 'reputation_signal')
|
|
873
|
+
.gte('occurred_at', since);
|
|
874
|
+
if (reputationErr)
|
|
875
|
+
return j({ error: reputationErr.message });
|
|
876
|
+
return j({
|
|
877
|
+
site_id: siteId,
|
|
878
|
+
metrics: data ?? null,
|
|
879
|
+
reputation_signals_30d: reputationSignals30d ?? 0,
|
|
880
|
+
});
|
|
881
|
+
});
|
|
882
|
+
server.registerTool('abuse_monitor_override', {
|
|
883
|
+
description: 'Create a temporary abuse monitor override for a paused site. Overrides expire automatically and are capped at 72 hours.',
|
|
884
|
+
inputSchema: {
|
|
885
|
+
siteId: z.string().uuid(),
|
|
886
|
+
reason: z.string().min(3).max(500),
|
|
887
|
+
ttlHours: z.number().int().min(1).max(72),
|
|
888
|
+
},
|
|
889
|
+
}, async ({ siteId, reason, ttlHours }) => {
|
|
890
|
+
const projectId = getProjectId();
|
|
891
|
+
const { data: site, error: siteErr } = await db()
|
|
892
|
+
.from('sites')
|
|
893
|
+
.select('id')
|
|
894
|
+
.eq('id', siteId)
|
|
895
|
+
.eq('project_id', projectId)
|
|
896
|
+
.maybeSingle();
|
|
897
|
+
if (siteErr || !site)
|
|
898
|
+
return j({ error: siteErr?.message ?? 'Site not found', code: 'NOT_FOUND' });
|
|
899
|
+
const expiresAt = new Date(Date.now() + ttlHours * 3600 * 1000).toISOString();
|
|
900
|
+
const { data: metrics } = await db()
|
|
901
|
+
.from('tenant_email_metrics')
|
|
902
|
+
.select('complaint_rate, pause_level')
|
|
903
|
+
.eq('site_id', siteId)
|
|
904
|
+
.maybeSingle();
|
|
905
|
+
const { error } = await db().from('audit_log').insert({
|
|
906
|
+
project_id: projectId,
|
|
907
|
+
actor_type: 'mcp_tool',
|
|
908
|
+
actor_id: 'abuse_monitor_override',
|
|
909
|
+
action: 'abuse_override',
|
|
910
|
+
resource_type: 'site',
|
|
911
|
+
resource_id: siteId,
|
|
912
|
+
entity_id: siteId,
|
|
913
|
+
metadata: { reason, expires_at: expiresAt },
|
|
914
|
+
details: { reason, expires_at: expiresAt },
|
|
915
|
+
});
|
|
916
|
+
if (error)
|
|
917
|
+
return j({ error: error.message });
|
|
918
|
+
return j({
|
|
919
|
+
ok: true,
|
|
920
|
+
site_id: siteId,
|
|
921
|
+
expires_at: expiresAt,
|
|
922
|
+
complaint_rate: metrics?.complaint_rate ?? null,
|
|
923
|
+
pause_level: metrics?.pause_level ?? null,
|
|
924
|
+
warning: Number(metrics?.complaint_rate ?? 0) >= 0.001
|
|
925
|
+
? 'Complaint rate is still at or above 0.10%; use this override only for verified false positives.'
|
|
926
|
+
: null,
|
|
927
|
+
});
|
|
928
|
+
});
|
|
929
|
+
server.registerTool('get_site_insights', {
|
|
930
|
+
description: 'Get pre-computed AI insights for a site — anomalies, deliverability alerts, send-time recommendations, and attribution summaries. Call this before making campaign decisions to check for active warnings.',
|
|
931
|
+
inputSchema: {
|
|
932
|
+
site_id: z.string().uuid().describe('Site UUID'),
|
|
933
|
+
include_dismissed: z.boolean().optional().default(false),
|
|
934
|
+
},
|
|
935
|
+
}, async ({ site_id, include_dismissed }) => {
|
|
936
|
+
const projectId = getProjectId();
|
|
937
|
+
const { data: site, error: siteError } = await db()
|
|
938
|
+
.from('sites')
|
|
939
|
+
.select('id')
|
|
940
|
+
.eq('project_id', projectId)
|
|
941
|
+
.eq('id', site_id)
|
|
942
|
+
.maybeSingle();
|
|
943
|
+
if (siteError)
|
|
944
|
+
return txt(`Error: ${siteError.message}`);
|
|
945
|
+
if (!site)
|
|
946
|
+
return j({ error: 'Site not found', code: 'NOT_FOUND' });
|
|
947
|
+
let q = db()
|
|
948
|
+
.from('ai_insights')
|
|
949
|
+
.select('id, site_id, type, severity, headline, detail, action, evidence, dismissed_at, acted_at, generated_at')
|
|
950
|
+
.eq('project_id', projectId)
|
|
951
|
+
.or(`site_id.is.null,site_id.eq.${site_id}`)
|
|
952
|
+
.order('generated_at', { ascending: false })
|
|
953
|
+
.limit(10);
|
|
954
|
+
if (!include_dismissed)
|
|
955
|
+
q = q.is('dismissed_at', null);
|
|
956
|
+
const { data, error } = await q;
|
|
957
|
+
if (error)
|
|
958
|
+
return txt(`Error: ${error.message}`);
|
|
959
|
+
const rank = { critical: 0, warning: 1, info: 2 };
|
|
960
|
+
return j((data ?? []).sort((a, b) => (rank[a.severity] ?? 9) - (rank[b.severity] ?? 9)));
|
|
961
|
+
});
|
|
962
|
+
server.registerTool('create_social_campaign', {
|
|
963
|
+
description: 'Create a social media campaign with a UTM-tracked link. Returns the tracked URL to paste into the social post.',
|
|
964
|
+
inputSchema: {
|
|
965
|
+
name: z.string(),
|
|
966
|
+
platform: z.enum(['instagram', 'linkedin', 'twitter', 'facebook', 'tiktok', 'youtube', 'pinterest', 'uploadpost', 'other']),
|
|
967
|
+
destination_url: z.string().url(),
|
|
968
|
+
site_id: z.string().uuid().optional(),
|
|
969
|
+
utm_campaign: z.string().optional(),
|
|
970
|
+
utm_content: z.string().optional(),
|
|
971
|
+
notes: z.string().optional(),
|
|
972
|
+
},
|
|
973
|
+
}, async (p) => {
|
|
974
|
+
const projectId = getProjectId();
|
|
975
|
+
const utm = buildSocialTrackedUrl(p);
|
|
976
|
+
const insert = {
|
|
977
|
+
project_id: projectId,
|
|
978
|
+
site_id: p.site_id ?? null,
|
|
979
|
+
name: p.name,
|
|
980
|
+
platform: p.platform,
|
|
981
|
+
destination_url: p.destination_url,
|
|
982
|
+
utm_source: utm.utm_source,
|
|
983
|
+
utm_medium: utm.utm_medium,
|
|
984
|
+
utm_campaign: utm.utm_campaign,
|
|
985
|
+
utm_content: p.utm_content ?? null,
|
|
986
|
+
tracked_url: utm.tracked_url,
|
|
987
|
+
notes: p.notes ?? null,
|
|
988
|
+
status: 'draft',
|
|
989
|
+
};
|
|
990
|
+
const { data, error } = await db().from('social_campaigns').insert(insert).select().single();
|
|
991
|
+
if (error)
|
|
992
|
+
return txt(`Error: ${error.message}`);
|
|
993
|
+
return j({ id: data.id, tracked_url: data.tracked_url, campaign: data });
|
|
994
|
+
});
|
|
995
|
+
server.registerTool('list_social_campaigns', {
|
|
996
|
+
description: 'List social campaigns with their signup attribution and manual metrics. Use to compare channel performance.',
|
|
997
|
+
inputSchema: {
|
|
998
|
+
site_id: z.string().uuid().optional(),
|
|
999
|
+
status: z.enum(['active', 'paused', 'completed']).optional(),
|
|
1000
|
+
},
|
|
1001
|
+
}, async (p) => {
|
|
1002
|
+
const projectId = getProjectId();
|
|
1003
|
+
await db().rpc('refresh_social_signups', { p_project_id: projectId });
|
|
1004
|
+
let q = db()
|
|
1005
|
+
.from('social_campaigns')
|
|
1006
|
+
.select('id, site_id, name, platform, status, tracked_url, utm_source, utm_campaign, signups_attributed, impressions, engagements, link_clicks, publisher, publisher_post_id, publish_status, publish_error, scheduled_at, published_at, created_at')
|
|
1007
|
+
.eq('project_id', projectId)
|
|
1008
|
+
.order('created_at', { ascending: false });
|
|
1009
|
+
if (p.site_id)
|
|
1010
|
+
q = q.eq('site_id', p.site_id);
|
|
1011
|
+
if (p.status)
|
|
1012
|
+
q = q.eq('status', p.status);
|
|
1013
|
+
const { data, error } = await q;
|
|
1014
|
+
if (error)
|
|
1015
|
+
return txt(`Error: ${error.message}`);
|
|
1016
|
+
return j({ campaigns: data ?? [] });
|
|
1017
|
+
});
|
|
1018
|
+
server.registerTool('schedule_social_post', {
|
|
1019
|
+
description: 'Schedule a social campaign post via a connected BYO publisher (UploadPost, Buffer). Returns publisherPostId on success.',
|
|
1020
|
+
inputSchema: {
|
|
1021
|
+
campaign_id: z.string().uuid().describe('UUID of the social campaign in Sendinel'),
|
|
1022
|
+
content: z.string().min(1).describe('Post body text'),
|
|
1023
|
+
publisher: z.enum(socialPublishers).describe('Which BYO publisher to route through'),
|
|
1024
|
+
scheduled_at: z.string().optional().describe('ISO 8601 datetime. Omit for immediate publish.'),
|
|
1025
|
+
media_urls: z.array(z.string().url()).optional().describe('Optional image/video URLs'),
|
|
1026
|
+
},
|
|
1027
|
+
}, async (p) => {
|
|
1028
|
+
const projectId = getProjectId();
|
|
1029
|
+
const scheduledAt = p.scheduled_at ? new Date(p.scheduled_at) : undefined;
|
|
1030
|
+
if (scheduledAt && Number.isNaN(scheduledAt.getTime()))
|
|
1031
|
+
return txt('Error: scheduled_at must be a valid ISO 8601 datetime.');
|
|
1032
|
+
const admin = adminDb();
|
|
1033
|
+
const { data: campaign, error: campaignError } = await admin
|
|
1034
|
+
.from('social_campaigns')
|
|
1035
|
+
.select('id, project_id, platform, tracked_url')
|
|
1036
|
+
.eq('project_id', projectId)
|
|
1037
|
+
.eq('id', p.campaign_id)
|
|
1038
|
+
.maybeSingle();
|
|
1039
|
+
if (campaignError)
|
|
1040
|
+
return txt(`Error: ${campaignError.message}`);
|
|
1041
|
+
if (!campaign)
|
|
1042
|
+
return j({ error: 'Campaign not found', code: 'NOT_FOUND' });
|
|
1043
|
+
const { data: project, error: projectError } = await admin
|
|
1044
|
+
.from('projects')
|
|
1045
|
+
.select('org_id')
|
|
1046
|
+
.eq('id', campaign.project_id)
|
|
1047
|
+
.maybeSingle();
|
|
1048
|
+
if (projectError)
|
|
1049
|
+
return txt(`Error: ${projectError.message}`);
|
|
1050
|
+
if (!project?.org_id)
|
|
1051
|
+
return j({ error: 'Project not found', code: 'NOT_FOUND' });
|
|
1052
|
+
const { data: connection, error: connectionError } = await admin
|
|
1053
|
+
.from('social_platform_connections')
|
|
1054
|
+
.select('encrypted_access_token')
|
|
1055
|
+
.eq('org_id', project.org_id)
|
|
1056
|
+
.eq('platform', p.publisher)
|
|
1057
|
+
.maybeSingle();
|
|
1058
|
+
if (connectionError)
|
|
1059
|
+
return txt(`Error: ${connectionError.message}`);
|
|
1060
|
+
if (!connection?.encrypted_access_token) {
|
|
1061
|
+
return j({ error: `No ${p.publisher} connection found. Connect it in Settings -> Integrations.`, code: 'NO_CONNECTION' });
|
|
1062
|
+
}
|
|
1063
|
+
try {
|
|
1064
|
+
const result = await publishViaByoPublisher(p.publisher, decrypt(connection.encrypted_access_token), {
|
|
1065
|
+
content: p.content,
|
|
1066
|
+
platform: campaign.platform,
|
|
1067
|
+
scheduledAt,
|
|
1068
|
+
mediaUrls: p.media_urls,
|
|
1069
|
+
link: campaign.tracked_url,
|
|
1070
|
+
});
|
|
1071
|
+
const publishStatus = result.scheduledAt || scheduledAt ? 'scheduled' : 'published';
|
|
1072
|
+
const { error: updateError } = await admin
|
|
1073
|
+
.from('social_campaigns')
|
|
1074
|
+
.update({
|
|
1075
|
+
publisher: p.publisher,
|
|
1076
|
+
publisher_post_id: result.publisherPostId,
|
|
1077
|
+
scheduled_at: result.scheduledAt?.toISOString() ?? scheduledAt?.toISOString() ?? null,
|
|
1078
|
+
publish_status: publishStatus,
|
|
1079
|
+
publish_error: null,
|
|
1080
|
+
platform_post_url: result.postUrl ?? null,
|
|
1081
|
+
status: 'active',
|
|
1082
|
+
updated_at: new Date().toISOString(),
|
|
1083
|
+
})
|
|
1084
|
+
.eq('project_id', projectId)
|
|
1085
|
+
.eq('id', p.campaign_id);
|
|
1086
|
+
if (updateError)
|
|
1087
|
+
return txt(`Error: ${updateError.message}`);
|
|
1088
|
+
return j({ success: true, publisherPostId: result.publisherPostId, publish_status: publishStatus, post_url: result.postUrl ?? null });
|
|
1089
|
+
}
|
|
1090
|
+
catch (err) {
|
|
1091
|
+
const message = err instanceof Error ? err.message : 'Publish failed';
|
|
1092
|
+
await admin
|
|
1093
|
+
.from('social_campaigns')
|
|
1094
|
+
.update({
|
|
1095
|
+
publisher: p.publisher,
|
|
1096
|
+
publish_status: 'failed',
|
|
1097
|
+
publish_error: message.slice(0, 500),
|
|
1098
|
+
updated_at: new Date().toISOString(),
|
|
1099
|
+
})
|
|
1100
|
+
.eq('project_id', projectId)
|
|
1101
|
+
.eq('id', p.campaign_id);
|
|
1102
|
+
return txt(`Error: ${message}`);
|
|
1103
|
+
}
|
|
1104
|
+
});
|
|
1105
|
+
}
|