---
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