--- phase: 02-backend-services plan: 03 type: execute wave: 2 depends_on: [02-02] files_modified: - backend/src/services/alertService.ts - backend/src/__tests__/unit/alertService.test.ts autonomous: true requirements: [ALRT-01, ALRT-02, ALRT-04] must_haves: truths: - "An alert email is sent when a probe returns 'degraded' or 'down'" - "A second probe failure within the cooldown period does NOT send a duplicate email" - "Alert recipient is read from process.env.EMAIL_WEEKLY_RECIPIENT, never hardcoded" - "Email failure does not throw or break the probe pipeline" - "Nodemailer transporter is created inside the function call, not at module level (Firebase Secret timing)" - "An alert_events row is created before sending the email" artifacts: - path: "backend/src/services/alertService.ts" provides: "Alert deduplication, email sending, and alert event creation" exports: ["alertService"] - path: "backend/src/__tests__/unit/alertService.test.ts" provides: "Unit tests for alert deduplication, email sending, recipient config" min_lines: 80 key_links: - from: "backend/src/services/alertService.ts" to: "backend/src/models/AlertEventModel.ts" via: "findRecentByService() for deduplication, create() for alert row" pattern: "AlertEventModel\\.(findRecentByService|create)" - from: "backend/src/services/alertService.ts" to: "nodemailer" via: "createTransport + sendMail for email delivery" pattern: "nodemailer\\.createTransport" - from: "backend/src/services/alertService.ts" to: "process.env.EMAIL_WEEKLY_RECIPIENT" via: "Config-based recipient (ALRT-04)" pattern: "process\\.env\\.EMAIL_WEEKLY_RECIPIENT" --- Create the alert service with deduplication logic, SMTP email sending via nodemailer, and config-based recipient. Purpose: ALRT-01 requires email alerts on service degradation/failure. ALRT-02 requires deduplication with cooldown. ALRT-04 requires the recipient to come from configuration, not hardcoded source code. Output: alertService.ts with evaluateAndAlert() and sendAlertEmail(), and unit tests. @/home/jonathan/.claude/get-shit-done/workflows/execute-plan.md @/home/jonathan/.claude/get-shit-done/templates/summary.md @.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/02-backend-services/02-RESEARCH.md @.planning/phases/01-data-foundation/01-01-SUMMARY.md @.planning/phases/02-backend-services/02-02-PLAN.md @backend/src/models/AlertEventModel.ts @backend/src/index.ts Task 1: Create alertService with deduplication and email backend/src/services/alertService.ts Create `backend/src/services/alertService.ts` with the following structure: **Import the ProbeResult type** from `'./healthProbeService'` (created in Plan 02). **Constants:** - `ALERT_COOLDOWN_MINUTES = parseInt(process.env.ALERT_COOLDOWN_MINUTES ?? '60', 10)` — configurable cooldown window **Private function `createTransporter()`:** Create nodemailer transporter INSIDE function scope (not module level — PITFALL A: Firebase Secrets not available at module load). Read SMTP config from `process.env`: - `host`: `process.env.EMAIL_HOST ?? 'smtp.gmail.com'` - `port`: `parseInt(process.env.EMAIL_PORT ?? '587', 10)` - `secure`: `process.env.EMAIL_SECURE === 'true'` - `auth.user`: `process.env.EMAIL_USER` - `auth.pass`: `process.env.EMAIL_PASS` **Private function `sendAlertEmail(serviceName, alertType, message)`:** - Read recipient from `process.env.EMAIL_WEEKLY_RECIPIENT` (ALRT-04: NEVER hardcode the email address) - If no recipient configured, log warning and return (do not throw) - Call `createTransporter()` then `transporter.sendMail({ from, to, subject, text, html })` - Subject format: `[CIM Summary] Alert: ${serviceName} — ${alertType}` - Wrap in try/catch — email failure logs error but does NOT throw (email failure must not break probe pipeline) **Exported function `evaluateAndAlert(probeResults: ProbeResult[])`:** For each ProbeResult where status is 'degraded' or 'down': 1. Map status to alert_type: 'down' -> 'service_down', 'degraded' -> 'service_degraded' 2. Call `AlertEventModel.findRecentByService(service_name, alert_type, ALERT_COOLDOWN_MINUTES)` 3. If recent alert exists within cooldown, log suppression and skip BOTH row creation AND email (PITFALL 3: prevent alert storms) 4. If no recent alert, create alert_events row via `AlertEventModel.create({ service_name, alert_type, message: error_message or status description })` 5. Then send email via `sendAlertEmail()` Export as: `export const alertService = { evaluateAndAlert }`. Use Winston logger for all logging. Use `import { AlertEventModel } from '../models/AlertEventModel'`. cd /home/jonathan/Coding/cim_summary/backend && npx tsc --noEmit --pretty 2>&1 | head -30 Verify alertService.ts exports alertService.evaluateAndAlert. Verify no hardcoded email addresses in source. alertService.ts exports evaluateAndAlert(). Deduplication checks AlertEventModel.findRecentByService() before creating rows or sending email. Recipient read from process.env.EMAIL_WEEKLY_RECIPIENT. Transporter created lazily. Email failures caught and logged. TypeScript compiles. Task 2: Create alertService unit tests backend/src/__tests__/unit/alertService.test.ts Create `backend/src/__tests__/unit/alertService.test.ts` using the Vitest mock pattern. Mock dependencies: - `vi.mock('../../models/AlertEventModel')` — mock `findRecentByService` and `create` - `vi.mock('nodemailer')` — mock `createTransport` returning `{ sendMail: vi.fn().mockResolvedValue({}) }` - `vi.mock('../../utils/logger')` — mock logger Create test ProbeResult fixtures: - `healthyProbe: { service_name: 'supabase', status: 'healthy', latency_ms: 50 }` - `downProbe: { service_name: 'document_ai', status: 'down', latency_ms: 0, error_message: 'Connection refused' }` - `degradedProbe: { service_name: 'llm_api', status: 'degraded', latency_ms: 6000 }` Test cases: 1. **Healthy probes — no alerts sent** — pass array of healthy ProbeResults, verify AlertEventModel.findRecentByService NOT called, sendMail NOT called 2. **Down probe — creates alert_events row and sends email** — pass downProbe, mock findRecentByService returning null (no recent alert), verify AlertEventModel.create called with service_name='document_ai' and alert_type='service_down', verify sendMail called 3. **Degraded probe — creates alert with type 'service_degraded'** — pass degradedProbe, mock findRecentByService returning null, verify AlertEventModel.create called with alert_type='service_degraded' 4. **Deduplication — suppresses within cooldown** — pass downProbe, mock findRecentByService returning an existing alert object (non-null), verify AlertEventModel.create NOT called, sendMail NOT called, logger.info called with 'suppress' in message 5. **Recipient from env — reads process.env.EMAIL_WEEKLY_RECIPIENT** — set `process.env.EMAIL_WEEKLY_RECIPIENT = 'test@example.com'`, pass downProbe with no recent alert, verify sendMail called with `to: 'test@example.com'` 6. **No recipient configured — skips email but still creates alert row** — delete process.env.EMAIL_WEEKLY_RECIPIENT, pass downProbe with no recent alert, verify AlertEventModel.create IS called, sendMail NOT called, logger.warn called 7. **Email failure — does not throw** — mock sendMail to reject, verify evaluateAndAlert does not throw, verify logger.error called 8. **Multiple probes — processes each independently** — pass [downProbe, degradedProbe, healthyProbe], verify findRecentByService called twice (for down and degraded, not for healthy) Use `beforeEach(() => { vi.clearAllMocks(); process.env.EMAIL_WEEKLY_RECIPIENT = 'admin@test.com'; })`. cd /home/jonathan/Coding/cim_summary/backend && npx vitest run src/__tests__/unit/alertService.test.ts --reporter=verbose 2>&1 All alertService tests pass. Deduplication verified (suppresses within cooldown). Email sending verified with config-based recipient. Email failure verified as non-throwing. Multiple probe evaluation verified. 1. `npx tsc --noEmit` passes 2. `npx vitest run src/__tests__/unit/alertService.test.ts` — all tests pass 3. `grep -r 'jpressnell\|bluepoint' backend/src/services/alertService.ts` returns nothing (no hardcoded emails) 4. alertService reads recipient from `process.env.EMAIL_WEEKLY_RECIPIENT` - alertService exports evaluateAndAlert(probeResults) - Deduplication uses AlertEventModel.findRecentByService with configurable cooldown - Alert rows created via AlertEventModel.create before email send - Suppressed alerts skip BOTH row creation AND email - Recipient from process.env, never hardcoded - Transporter created inside function, not at module level - Email failures caught and logged, never thrown - All unit tests pass After completion, create `.planning/phases/02-backend-services/02-03-SUMMARY.md`