diff --git a/.gitignore b/.gitignore index 6167f86..bfb2ed2 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,4 @@ videos/remotion-demo/out videos/remotion-demo/public/*.mov product/*.mov .gstack/ +.playwright-mcp/ diff --git a/src/index.ts b/src/index.ts index 62ee8b8..b573b9d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -34,6 +34,7 @@ import { recordUsage, getMonthlyUsage, getUsageBreakdown, checkLimit } from './b import { getCustomerInvoices, getInvoiceByNumber, markInvoiceSent, markInvoicePaid, generateMonthlyInvoice } from './billing/invoices.js'; import { getAllPlatformHealth } from './multitenancy/platform-health.js'; import { deliverWebhook, isValidWebhookUrl } from './webhooks/delivery.js'; +import { notifyNewPilotRequest } from './notifications/index.js'; import redis from './redis.js'; const app = express(); @@ -2041,6 +2042,10 @@ app.post('/api/pilot-request', async (req, res) => { try { 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 }); } catch (error) { console.error(`[squaremcp] pilot_request ERROR requestId=${requestId}:`, error); diff --git a/src/notifications/index.ts b/src/notifications/index.ts new file mode 100644 index 0000000..97a7ec6 --- /dev/null +++ b/src/notifications/index.ts @@ -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 { + 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) + ); +} diff --git a/src/notifications/slack.ts b/src/notifications/slack.ts new file mode 100644 index 0000000..b8b0bef --- /dev/null +++ b/src/notifications/slack.ts @@ -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 { + 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; + } +}