feat(notifications): Slack alerts for new pilot requests
- Add src/notifications/slack.ts — Slack webhook integration with rich blocks - Add src/notifications/index.ts — dispatcher with test-submission filtering - Wire notifyNewPilotRequest() into POST /api/pilot-request (fire-and-forget) - Filter out test submissions (@example.com, E2E, Smoke Test, QA Test, Browser Test) - Skip alert gracefully when SLACK_PILOT_WEBHOOK_URL is not set - Update .gitignore to exclude .playwright-mcp/ artifacts
This commit is contained in:
59
src/notifications/index.ts
Normal file
59
src/notifications/index.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { sendSlackAlert, type PilotAlertPayload } from './slack.js';
|
||||
|
||||
const TEST_PATTERNS = {
|
||||
email: [/@example\.com$/i],
|
||||
name: [/e2e/i, /smoke test/i, /qa test/i, /browser test/i],
|
||||
company: [/qa check/i, /qa corp/i, /^squaremcp\s*(e2e|test|browser)/i],
|
||||
};
|
||||
|
||||
function isTestSubmission(body: {
|
||||
name: string;
|
||||
email: string;
|
||||
company: string;
|
||||
}): boolean {
|
||||
if (TEST_PATTERNS.email.some((p) => p.test(body.email))) return true;
|
||||
if (TEST_PATTERNS.name.some((p) => p.test(body.name))) return true;
|
||||
if (TEST_PATTERNS.company.some((p) => p.test(body.company))) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
interface PilotRequestBody {
|
||||
name: string;
|
||||
email: string;
|
||||
company: string;
|
||||
role: string;
|
||||
use_case: string;
|
||||
timeline: string;
|
||||
systems: string;
|
||||
requirements: string;
|
||||
}
|
||||
|
||||
export async function notifyNewPilotRequest(
|
||||
body: PilotRequestBody,
|
||||
requestId: string,
|
||||
req: { get: (header: string) => string | undefined; ip?: string; socket?: { remoteAddress?: string } }
|
||||
): Promise<void> {
|
||||
if (isTestSubmission(body)) {
|
||||
console.log(`[notification] skipped test submission: ${body.email}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: PilotAlertPayload = {
|
||||
requestId,
|
||||
name: body.name,
|
||||
email: body.email,
|
||||
company: body.company,
|
||||
role: body.role,
|
||||
use_case: body.use_case,
|
||||
timeline: body.timeline,
|
||||
systems: body.systems,
|
||||
requirements: body.requirements,
|
||||
source: req.get('origin') || req.get('host') || 'unknown',
|
||||
ipAddress: req.ip || req.socket?.remoteAddress || 'unknown',
|
||||
};
|
||||
|
||||
// Fire-and-forget — don't block the HTTP response
|
||||
sendSlackAlert(payload).catch((err) =>
|
||||
console.error('[notification] slack error:', err)
|
||||
);
|
||||
}
|
||||
95
src/notifications/slack.ts
Normal file
95
src/notifications/slack.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
const SLACK_WEBHOOK_URL = process.env['SLACK_PILOT_WEBHOOK_URL'] ?? '';
|
||||
|
||||
export interface PilotAlertPayload {
|
||||
requestId: string;
|
||||
name: string;
|
||||
email: string;
|
||||
company: string;
|
||||
role: string;
|
||||
use_case: string;
|
||||
timeline: string;
|
||||
systems: string;
|
||||
requirements: string;
|
||||
source: string;
|
||||
ipAddress: string;
|
||||
}
|
||||
|
||||
export async function sendSlackAlert(payload: PilotAlertPayload): Promise<boolean> {
|
||||
if (!SLACK_WEBHOOK_URL) {
|
||||
console.warn('[notification:slack] SLACK_PILOT_WEBHOOK_URL not set, skipping');
|
||||
return false;
|
||||
}
|
||||
|
||||
const blocks = [
|
||||
{
|
||||
type: 'header',
|
||||
text: {
|
||||
type: 'plain_text',
|
||||
text: '🚨 New SquareMCP Pilot Request',
|
||||
emoji: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'section',
|
||||
fields: [
|
||||
{ type: 'mrkdwn', text: `*Name:*\n${payload.name}` },
|
||||
{ type: 'mrkdwn', text: `*Company:*\n${payload.company}` },
|
||||
{ type: 'mrkdwn', text: `*Email:*\n${payload.email}` },
|
||||
{ type: 'mrkdwn', text: `*Role:*\n${payload.role}` },
|
||||
{ type: 'mrkdwn', text: `*Timeline:*\n${payload.timeline}` },
|
||||
{ type: 'mrkdwn', text: `*Source:*\n${payload.source}` },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'section',
|
||||
text: {
|
||||
type: 'mrkdwn',
|
||||
text: `*Use Case:*\n${payload.use_case}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'section',
|
||||
text: {
|
||||
type: 'mrkdwn',
|
||||
text: `*Systems:*\n${payload.systems || '_None specified_'}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'section',
|
||||
text: {
|
||||
type: 'mrkdwn',
|
||||
text: `*Requirements:*\n${payload.requirements || '_None specified_'}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'context',
|
||||
elements: [
|
||||
{
|
||||
type: 'mrkdwn',
|
||||
text: `Request ID: \`${payload.requestId}\` | IP: ${payload.ipAddress}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
try {
|
||||
const res = await fetch(SLACK_WEBHOOK_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ blocks }),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
console.error(`[notification:slack] HTTP ${res.status}: ${text}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log(`[notification:slack] alert sent for request ${payload.requestId}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('[notification:slack] fetch error:', (err as Error).message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user