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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -22,3 +22,4 @@ videos/remotion-demo/out
|
|||||||
videos/remotion-demo/public/*.mov
|
videos/remotion-demo/public/*.mov
|
||||||
product/*.mov
|
product/*.mov
|
||||||
.gstack/
|
.gstack/
|
||||||
|
.playwright-mcp/
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import { recordUsage, getMonthlyUsage, getUsageBreakdown, checkLimit } from './b
|
|||||||
import { getCustomerInvoices, getInvoiceByNumber, markInvoiceSent, markInvoicePaid, generateMonthlyInvoice } from './billing/invoices.js';
|
import { getCustomerInvoices, getInvoiceByNumber, markInvoiceSent, markInvoicePaid, generateMonthlyInvoice } from './billing/invoices.js';
|
||||||
import { getAllPlatformHealth } from './multitenancy/platform-health.js';
|
import { getAllPlatformHealth } from './multitenancy/platform-health.js';
|
||||||
import { deliverWebhook, isValidWebhookUrl } from './webhooks/delivery.js';
|
import { deliverWebhook, isValidWebhookUrl } from './webhooks/delivery.js';
|
||||||
|
import { notifyNewPilotRequest } from './notifications/index.js';
|
||||||
import redis from './redis.js';
|
import redis from './redis.js';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
@@ -2041,6 +2042,10 @@ app.post('/api/pilot-request', async (req, res) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await appendPilotRequestToVault(requestId, body, req);
|
await appendPilotRequestToVault(requestId, body, req);
|
||||||
|
// Fire-and-forget notification — don't block response
|
||||||
|
notifyNewPilotRequest(body, requestId, req).catch((err) =>
|
||||||
|
console.error(`[notification] requestId=${requestId} error:`, err)
|
||||||
|
);
|
||||||
res.status(201).json({ ok: true, request_id: requestId });
|
res.status(201).json({ ok: true, request_id: requestId });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[squaremcp] pilot_request ERROR requestId=${requestId}:`, error);
|
console.error(`[squaremcp] pilot_request ERROR requestId=${requestId}:`, error);
|
||||||
|
|||||||
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