diff --git a/src/__tests__/check-runner.test.ts b/src/__tests__/check-runner.test.ts index 550146b..72df601 100644 --- a/src/__tests__/check-runner.test.ts +++ b/src/__tests__/check-runner.test.ts @@ -6,6 +6,12 @@ vi.mock('@/lib/checker', () => ({ checkSite: vi.fn(), })) +// Mock the notifications module +const mockSendIncidentAlert = vi.fn() +vi.mock('@/lib/notifications', () => ({ + sendIncidentAlert: (...args: unknown[]) => mockSendIncidentAlert(...args), +})) + // Mock the admin client const mockFrom = vi.fn() const mockSupabase = { from: mockFrom } @@ -20,6 +26,7 @@ import { checkSite } from '@/lib/checker' describe('POST /api/checks/run', () => { beforeEach(() => { vi.clearAllMocks() + mockSendIncidentAlert.mockResolvedValue(undefined) process.env.CRON_SECRET = 'test-secret' }) @@ -60,11 +67,9 @@ describe('POST /api/checks/run', () => { expect(body).toEqual({ message: 'No sites to check', checks: 0, incidents: 0 }) }) - test('records checks and opens incidents for failures', async () => { + test('records checks and opens incidents for failures, sends email', async () => { const site = { id: 'site-1', name: 'Test', url: 'https://example.com' } - // Mock sites query - const mockSelect = vi.fn() mockFrom.mockImplementation((table: string) => { if (table === 'sites') { return { select: () => ({ data: [site], error: null }) } @@ -91,10 +96,21 @@ describe('POST /api/checks/run', () => { }), }), }), - insert: vi.fn().mockReturnValue({ data: null, error: null }), + insert: () => ({ + select: () => ({ + single: () => ({ data: { id: 'inc-1' } }), + }), + }), } } - return { select: mockSelect } + if (table === 'contacts') { + return { + select: () => ({ + data: [{ email: 'alice@example.com' }], + }), + } + } + return {} }) vi.mocked(checkSite).mockResolvedValue({ @@ -112,5 +128,63 @@ describe('POST /api/checks/run', () => { const body = await response.json() expect(body.checks).toBe(1) expect(body.incidents).toBe(1) + expect(mockSendIncidentAlert).toHaveBeenCalledWith({ + siteName: 'Test', + siteUrl: 'https://example.com', + error: 'HTTP 503', + incidentId: 'inc-1', + contactEmails: ['alice@example.com'], + }) + }) + + test('does not send email for existing open incident', async () => { + const site = { id: 'site-1', name: 'Test', url: 'https://example.com' } + + mockFrom.mockImplementation((table: string) => { + if (table === 'sites') { + return { select: () => ({ data: [site], error: null }) } + } + if (table === 'checks') { + return { + insert: () => ({ + select: () => ({ + single: () => ({ + data: { id: 'check-2', site_id: 'site-1', status: 'failure' }, + }), + }), + }), + } + } + if (table === 'incidents') { + return { + select: () => ({ + eq: () => ({ + eq: () => ({ + limit: () => ({ + single: () => ({ data: { id: 'inc-existing' } }), + }), + }), + }), + }), + } + } + return {} + }) + + vi.mocked(checkSite).mockResolvedValue({ + status: 'failure', + statusCode: 503, + error: 'HTTP 503', + }) + + const request = new NextRequest('http://localhost/api/checks/run', { + method: 'POST', + headers: { authorization: 'Bearer test-secret' }, + }) + + const response = await POST(request) + const body = await response.json() + expect(body.incidents).toBe(0) + expect(mockSendIncidentAlert).not.toHaveBeenCalled() }) }) diff --git a/src/__tests__/notifications.test.ts b/src/__tests__/notifications.test.ts new file mode 100644 index 0000000..81e36f7 --- /dev/null +++ b/src/__tests__/notifications.test.ts @@ -0,0 +1,82 @@ +import { describe, test, expect, vi, beforeEach } from "vitest" + +// Mock Resend +const mockSend = vi.fn() +vi.mock("resend", () => ({ + Resend: class { + emails = { send: mockSend } + }, +})) + +import { sendIncidentAlert } from "@/lib/notifications" + +describe("sendIncidentAlert", () => { + beforeEach(() => { + vi.clearAllMocks() + mockSend.mockResolvedValue({ id: "email-1" }) + }) + + test("sends email to all contacts with correct content", async () => { + await sendIncidentAlert({ + siteName: "Main Website", + siteUrl: "https://example.com", + error: "HTTP 503", + incidentId: "inc-1", + contactEmails: ["alice@example.com", "bob@example.com"], + }) + + expect(mockSend).toHaveBeenCalledTimes(1) + const call = mockSend.mock.calls[0][0] + expect(call.to).toEqual(["alice@example.com", "bob@example.com"]) + expect(call.subject).toContain("Main Website") + expect(call.subject).toContain("[Down]") + expect(call.html).toContain("Main Website") + expect(call.html).toContain("https://example.com") + expect(call.html).toContain("HTTP 503") + expect(call.html).toContain("/incidents/inc-1") + }) + + test("does nothing when contactEmails is empty", async () => { + await sendIncidentAlert({ + siteName: "Main Website", + siteUrl: "https://example.com", + error: "HTTP 503", + incidentId: "inc-1", + contactEmails: [], + }) + + expect(mockSend).not.toHaveBeenCalled() + }) + + test("handles email send failure gracefully", async () => { + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + mockSend.mockRejectedValue(new Error("API rate limit")) + + await sendIncidentAlert({ + siteName: "Main Website", + siteUrl: "https://example.com", + error: "HTTP 503", + incidentId: "inc-1", + contactEmails: ["alice@example.com"], + }) + + expect(consoleSpy).toHaveBeenCalledWith( + "Failed to send incident alert email:", + expect.any(Error) + ) + consoleSpy.mockRestore() + }) + + test("works without error field", async () => { + await sendIncidentAlert({ + siteName: "Main Website", + siteUrl: "https://example.com", + error: null, + incidentId: "inc-1", + contactEmails: ["alice@example.com"], + }) + + const call = mockSend.mock.calls[0][0] + expect(call.html).not.toContain("Error:") + }) +}) diff --git a/src/app/api/checks/run/route.ts b/src/app/api/checks/run/route.ts index 0fe636d..0e18aad 100644 --- a/src/app/api/checks/run/route.ts +++ b/src/app/api/checks/run/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from 'next/server' import { createAdminClient } from '@/lib/supabase/admin' import { checkSite } from '@/lib/checker' +import { sendIncidentAlert } from '@/lib/notifications' export const maxDuration = 60 @@ -59,12 +60,30 @@ export async function POST(request: NextRequest) { .single() if (!openIncident) { - await supabase.from('incidents').insert({ - site_id: site.id, - check_id: check.id, - status: 'open', - }) + const { data: incident } = await supabase + .from('incidents') + .insert({ + site_id: site.id, + check_id: check.id, + status: 'open', + }) + .select('id') + .single() newIncidents++ + + if (incident) { + const { data: contacts } = await supabase + .from('contacts') + .select('email') + + await sendIncidentAlert({ + siteName: site.name, + siteUrl: site.url, + error, + incidentId: incident.id, + contactEmails: (contacts ?? []).map((c: { email: string }) => c.email), + }) + } } } } diff --git a/src/lib/notifications.ts b/src/lib/notifications.ts new file mode 100644 index 0000000..f528caf --- /dev/null +++ b/src/lib/notifications.ts @@ -0,0 +1,49 @@ +import { Resend } from "resend" + +let _resend: Resend | null = null +function getResend() { + if (!_resend) _resend = new Resend(process.env.RESEND_API_KEY) + return _resend +} + +type IncidentAlertParams = { + siteName: string + siteUrl: string + error: string | null + incidentId: string + contactEmails: string[] +} + +export async function sendIncidentAlert({ + siteName, + siteUrl, + error, + incidentId, + contactEmails, +}: IncidentAlertParams) { + if (contactEmails.length === 0) return + + const appUrl = process.env.NEXT_PUBLIC_APP_URL || "https://status.rootsofprogress.org" + const incidentLink = `${appUrl}/incidents/${incidentId}` + + const subject = `[Down] ${siteName} is not responding` + const html = ` +
URL: ${siteUrl}
+ ${error ? `Error: ${error}
` : ""} + + `.trim() + + const fromAddress = process.env.RESEND_FROM_EMAIL || "alerts@status.rootsofprogress.org" + + try { + await getResend().emails.send({ + from: fromAddress, + to: contactEmails, + subject, + html, + }) + } catch (err) { + console.error("Failed to send incident alert email:", err) + } +}