diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bcd0b80 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +.env +.DS_Store +*log \ No newline at end of file diff --git a/README.md b/README.md index 0c40ea1..10c8be3 100644 --- a/README.md +++ b/README.md @@ -1,135 +1,168 @@ -# Pulse-Check-API ("Watchdog" Sentinel) -This challenge is designed to test your ability to bridge Computer Science fundamentals with Modern Backend Engineering. +# Pulse-Check-API (Watchdog Sentinel) -## 1. Business Context -> **Client:** *CritMon Servers Inc.* (A Critical Infrastructure Monitoring Company). +A Dead Man's Switch REST API built for CritMon Servers Inc. — monitors remote devices (solar farms, weather stations) that send periodic "I'm alive" heartbeats. If a device stops checking in before its timeout expires, the system automatically fires an alert. -### The Problem -CritMon provides monitoring for remote solar farms and unmanned weather stations in areas with poor connectivity. These devices are supposed to send "I'm alive" signals every hour. +Built with **pure Node.js** — no frameworks, no external dependencies. Every piece of routing, timer management, and request handling is hand-rolled to demonstrate an understanding of what frameworks like Express normally abstract away. -Currently, CritMon has no way of knowing if a device has gone offline (due to power failure or theft) until a human manually checks the logs. They need a system that alerts *them* when a device *stops* talking. +## Architecture Diagram -### The Solution -You need to build a **Dead Man’s Switch API**. Devices will register a "monitor" with a countdown timer (e.g., 60 seconds). If the device fails to "ping" (send a heartbeat) to the API before the timer runs out, the system automatically triggers an alert. +The core flow: a device registers a monitor, then must "heartbeat" before its timeout expires or an alert fires automatically. +```mermaid --- - -## 2. Technical Objective -Build a backend service that manages stateful timers. - -* **Registration:** Allow a client to create a monitor with a specific timeout duration. -* **Heartbeat:** Reset the countdown when a ping is received. -* **Trigger:** Fire a webhook (or log a critical error) if the countdown reaches zero. - - +config: + layout: elk --- - -## 3. Getting Started - -1. **Fork this Repository:** Do not clone it directly. Create a fork to your own GitHub account. -2. **Environment:** You may use **Node.js, Python, Java or Go, etc.**. -3. **Submission:** Your final submission will be a link to your forked repository containing: - * The source code. - * The **Architecture Diagram** - * The `README.md` with documentation. +sequenceDiagram + participant Client as Client / Device + participant API as Pulse-Check-API + participant Store as Monitor Store + participant Timer as Timer Engine + + Note over Client,Timer: Initial Setup - Create Monitor + Client->>API: POST /monitors
{id, timeout, alert_email} + API->>Store: createMonitor(id, timeout, alert_email) + Store-->>API: monitor created + API->>Timer: startTimer(id, timeout) + Note over Timer: setTimeout(timeout)
starts countdown + Timer-->>API: timer started + API-->>Client: 201 Created + + Note over Timer: Countdown running... + + Note over Client,Timer: Heartbeat - Reset Timer + Client->>API: POST /monitors/:id/heartbeat + API->>Store: getMonitor(id) + Store-->>API: monitor data + API->>Timer: resetTimer(id) + Note over Timer: clearTimeout()
startTimer() again + Timer-->>API: timer reset + API-->>Client: 200 OK - timer reset + + Note over Timer: If no heartbeat arrives
before timer expires... + + Note over Client,Timer: Timer Expiration - Alert Triggered + Timer->>API: onExpire(id) + API->>Store: updateMonitor(id, status='down') + Store-->>API: monitor updated + Note over API: log alert + API->>Store: recordEvent(id, 'expired') + Store-->>API: event recorded + + Note over Client,Timer: Pause / Resume Flow + + Note over Client,Timer: Pause Monitor + Client->>API: POST /monitors/:id/pause + API->>Timer: pauseTimer(id) + Note over Timer: clearTimeout()
no restart + Timer-->>API: timer paused + API-->>Client: 200 OK - paused + + Note over Client,Timer: Heartbeat on Paused Monitor + Client->>API: POST /monitors/:id/heartbeat + Note over API: Heartbeat automatically
resumes a paused monitor + API->>Timer: resetTimer(id) + Note over Timer: startTimer() restarts
the countdown + Timer-->>API: timer started + API-->>Client: 200 OK - active again +``` + +## Setup Instructions + +**Requirements:** Node.js 18+ (no `npm install` needed — zero external dependencies) + +```bash +git clone https://github.com/kkanim/Pulse-Check-API.git +cd Pulse-Check-API +git checkout feat/deadmans-switch +npm start +``` + +Server starts at `http://localhost:3000`. Use `npm run dev` instead for auto-restart on file changes during development. + +## API Documentation + +### `POST /monitors` +Registers a new monitor and starts its countdown immediately. + +**Request:** +```json +{ "id": "device-123", "timeout": 60, "alert_email": "admin@critmon.com" } +``` + +**Response — `201 Created`:** +```json +{ + "message": "Monitor \"device-123\" created successfully", + "monitor": { "id": "device-123", "timeout": 60, "status": "active", "...": "..." } +} +``` + +**Errors:** `400` (validation failure), `409` (id already exists) --- -## 4. The Architecture Diagram -**Task:** Before you write any code, you must design the logic flow. -**Deliverable:** A **Sequence Diagram** or **State Flowchart** embedded in your `README.md`. +### `POST /monitors/:id/heartbeat` +Resets the countdown. If the monitor was paused, this also resumes it. ---- +**Response — `200 OK`:** +```json +{ "message": "Heartbeat received for \"device-123\". Timer reset.", "monitor": { "...": "..." } } +``` -## 5. User Stories & Acceptance Criteria - -### User Story 1: Registering a Monitor -**As a** device administrator, -**I want to** create a new monitor for my device, -**So that** the system knows to track its status. - -**Acceptance Criteria:** -- [ ] The API accepts a `POST /monitors` request. -- [ ] Input: `{"id": "device-123", "timeout": 60, "alert_email": "admin@critmon.com"}`. -- [ ] The system starts a countdown timer for 60 seconds associated with `device-123`. -- [ ] Response: `201 Created` with a confirmation message. - -### User Story 2: The Heartbeat (Reset) -**As a** remote device, -**I want to** send a signal to the server, -**So that** my timer is reset and no alert is sent. - -**Acceptance Criteria:** -- [ ] The API accepts a `POST /monitors/{id}/heartbeat` request. -- [ ] If the ID exists and the timer has NOT expired: - - [ ] Restart the countdown from the beginning (e.g., reset to 60 seconds). - - [ ] Return `200 OK`. -- [ ] If the ID does not exist: - - [ ] Return `404 Not Found`. - -### User Story 3: The Alert (Failure State) -**As a** support engineer, -**I want to** be notified immediately if a device stops sending heartbeats, -**So that** I can deploy a repair team. - -**Acceptance Criteria:** -- [ ] If the timer for `device-123` reaches 0 seconds (no heartbeat received): - - [ ] The system must internally "fire" an alert. - - [ ] **Implementation:** For this project, simply `console.log` a JSON object: `{"ALERT": "Device device-123 is down!", "time": }`. (Or simulate sending an email). - - [ ] The monitor status changes to `down`. +**Errors:** `404` (unknown id) --- -## 6. Bonus User Story (The "Snooze" Button) -**As a** maintenance technician, -**I want to** pause monitoring while I am repairing a device, -**So that** I don't trigger false alarms. +### `POST /monitors/:id/pause` +Freezes the countdown entirely. No alerts fire while paused. + +**Response — `200 OK`:** +```json +{ "message": "Monitor \"device-123\" paused. No alerts will fire until the next heartbeat.", "monitor": { "...": "..." } } +``` -**Acceptance Criteria:** -- [ ] Create a `POST /monitors/{id}/pause` endpoint. -- [ ] When called, the timer stops completely. No alerts will fire. -- [ ] Calling the heartbeat endpoint again automatically "un-pauses" the monitor and restarts the timer. +**Errors:** `404` (unknown id) --- -## 7. The "Developer's Choice" Challenge -We value engineers who look for "what's missing." +### `GET /alerts` +Returns all fired alerts, newest first. -**Task:** Identify **one** additional feature that makes this system more robust or user-friendly. -1. **Implement it.** -2. **Document it:** Explain *why* you added it in your README. +**Response — `200 OK`:** +```json +{ "count": 1, "alerts": [{ "monitorId": "device-123", "message": "Device device-123 is down!", "firedAt": "..." }] } +``` --- -## 8. Documentation Requirements -Your final `README.md` must replace these instructions. It must cover: +### `GET /monitors/:id/history` +Returns the full event timeline for a monitor (creation, heartbeats, pauses, expiry), oldest first, alongside its current state. -1. **Architecture Diagram** -2. **Setup Instructions** -3. **API Documentation** -4. **The Developer's Choice:** Explanation of your added feature. +**Response — `200 OK`:** +```json +{ + "monitor": { "id": "device-123", "status": "active", "...": "..." }, + "eventCount": 3, + "history": [ + { "monitorId": "device-123", "type": "created", "detail": { "timeout": 60 }, "timestamp": "..." }, + { "monitorId": "device-123", "type": "heartbeat", "detail": {}, "timestamp": "..." }, + { "monitorId": "device-123", "type": "paused", "detail": {}, "timestamp": "..." } + ] +} +``` ---- -Submit your repo link via the [online](https://forms.office.com/e/rGKtfeZCsH) form. +**Errors:** `404` (unknown id) -## 🛑 Pre-Submission Checklist -**WARNING:** Before you submit your solution, you **MUST** pass every item on this list. -If you miss any of these critical steps, your submission will be **automatically rejected** and you will **NOT** be invited to an interview. +## Developer's Choice: Event History Tracking -### 1. 📂 Repository & Code -- [ ] **Public Access:** Is your GitHub repository set to **Public**? (We cannot review private repos). -- [ ] **Clean Code:** Did you remove unnecessary files (like `node_modules`, `.env` with real keys, or `.DS_Store`)? -- [ ] **Run Check:** if we clone your repo and run `npm start` (or equivalent), does the server start immediately without crashing? +**What I added:** a `GET /monitors/:id/history` endpoint that records and returns a full event timeline for every monitor — creation, every heartbeat, every pause, and every expiry — each with its own timestamp. -### 2. 📄 Documentation (Crucial) -- [ ] **Architecture Diagram:** Did you include a visual Diagram (Flowchart or Sequence Diagram) in the README? -- [ ] **README Swap:** Did you **DELETE** the original instructions (the problem brief) from this file and replace it with your own documentation? -- [ ] **API Docs:** Is there a clear list of Endpoints and Example Requests in the README? +**Why I added it:** the base API only exposes a monitor's *current* state. In a real incident — say a solar farm monitor goes down at 3am — a support engineer's first question isn't "what's its status now," it's "what happened, and when." Without a history, debugging why an alert fired (or didn't) means guessing. This feature turns the API from a simple status-check tool into something that supports actual incident investigation, which is the difference between a toy monitoring system and one that's operationally useful. +**Design decision:** I kept event history as a separate store (`eventStore.js`) from the alerts store (`alertStore.js`), since alerts are a specific type of event (a down-trigger) while history is the complete lifecycle audit trail — conflating the two would have made alerts noisier and history less complete. -### 3. 🧹 Git Hygiene -- [ ] **Commit History:** Does your repo have multiple commits with meaningful messages? (A single "Initial Commit" is a red flag). +## Known Limitations ---- -**Ready?** -If you checked all the boxes above, submit your repository link in the application form. Good luck! 🚀 +- **In-memory only:** all monitor and event data is lost on server restart. This was a deliberate choice — the brief doesn't call for persistence, and the core challenge (stateful timers) can't be solved by a database anyway. In production, monitor *definitions* would be persisted, with live countdowns re-hydrated on boot. +- **Single-process:** timers live in this one Node process's memory; horizontally scaling would need a shared timer/state coordination layer (e.g. Redis-backed schedule). \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..84ecd44 --- /dev/null +++ b/package.json @@ -0,0 +1,25 @@ +{ + "name": "pulse-check-api", + "version": "1.0.0", + "description": "Dead Man's Switch API for CritMon device monitoring", + "main": "src/server.js", + "type": "module", + "scripts": { + "start": "node src/server.js", + "dev": "node --watch src/server.js" + }, + "keywords": [], + "author": "", + "license": "ISC", + "repository": { + "type": "git", + "url": "git+https://github.com/kkanim/Pulse-Check-API.git" + }, + "bugs": { + "url": "https://github.com/kkanim/Pulse-Check-API/issues" + }, + "homepage": "https://github.com/kkanim/Pulse-Check-API#readme", + "engines": { + "node": ">=18.0.0" + } +} \ No newline at end of file diff --git a/src/models/monitor.js b/src/models/monitor.js new file mode 100644 index 0000000..128de63 --- /dev/null +++ b/src/models/monitor.js @@ -0,0 +1,40 @@ +/** + * Possible lifecycle states for a monitor. + * - active: timer is running, counting down normally + * - paused: timer is frozen (bonus "snooze" feature, Phase 7) + * - down: timer expired without a heartbeat, alert has fired +*/ +export const MonitorStatus = { + ACTIVE: 'active', + PAUSED: 'paused', + DOWN: 'down', +}; + +/** + * Creates a new Monitor object with a consistent shape. + * Keeping this in one factoryfunction means every monitor + * in the system - no matter which route created it - has the + * exact same fields, defaults, and types. + */ +export function createMonitorEntity({id, timeout, alertEmail}) { + const now = new Date().toISOString(); + + return{ + id, // string, unique device identifier + timeout, // number, seconds until expiry + alertEmail, // string, where alerts would be "sent" + status: MonitorStatus.ACTIVE, + createdAt: now, + lastHeartbeatAt: now, + timerHandle: null, // will hold the setTimeout reference (Phase 5) + }; +} + +/** + * Strips internal-only fields (like timerHandle) before a monitor + * is sent back in an API response. +*/ +export function toPublicMonitor(monitor) { + const { timerHandle, ...publicFields } = monitor; + return publicFields; +} \ No newline at end of file diff --git a/src/router.js b/src/router.js new file mode 100644 index 0000000..9222ccf --- /dev/null +++ b/src/router.js @@ -0,0 +1,63 @@ +import { sendJSON } from './utils/respond.js'; + +//Minimal manual router: matches method + path patterns, supports : params. +export class Router { + constructor() { + this.routes = []; + } + + _register(method, path, handler) { + const paramNames = []; + + //Compiles a path pattern into a regex and stores it with its handler. + const pattern = path + .split('/') + .map((segment) => { + if (segment.startsWith(':')) { + paramNames.push(segment.slice(1)); + return '([^/]+)'; + } + return segment; + }) + .join('/'); + + const regex = new RegExp(`^${pattern}$`); + this.routes.push({ method, regex, paramNames, handler }); + } + + //Registers a GET route. + get(path, handler) { + this._register('GET', path, handler); + } + + //Registers a POST route. + post(path, handler) { + this._register('POST', path, handler); + } + + // Matches a request against registered rotes and invokes the handler. + handle(req, res, pathname) { + for (const route of this.routes) { + if (route.method !== req.method) continue; + + const match = pathname.match(route.regex); + if (!match) continue; + + //match[0] is the full match, match[1..] are captured params in order + const params = {}; + route.paramNames.forEach((name, index) => { + params[name] = match[index + 1]; + }); + + req.params = params; + route.handler(req, res); + return true; + } + return false; + } +} + +//Fallback handler for unmatched routes. +export function notFoundHandler(req, res) { + sendJSON(res, 404, {error: 'Not FOund', path: req.url}); +} \ No newline at end of file diff --git a/src/routes/alerts.js b/src/routes/alerts.js new file mode 100644 index 0000000..e595b19 --- /dev/null +++ b/src/routes/alerts.js @@ -0,0 +1,11 @@ +import { sendJSON } from "../utils/respond.js"; +import { getAllAlerts } from "../store/alertStore.js"; + +//Hanldes GET /alerts - returns all fired alerts, newest first. +export function handleGetAlerts(req, res) { + const alerts = getAllAlerts(); + return sendJSON(res, 200, { + count: alerts.length, + alerts, + }); +} \ No newline at end of file diff --git a/src/routes/history.js b/src/routes/history.js new file mode 100644 index 0000000..d367708 --- /dev/null +++ b/src/routes/history.js @@ -0,0 +1,22 @@ +import { sendJSON } from "../utils/respond.js"; +import { getMonitor } from '../store/monitorStore.js' +import { getHistory } from "../store/eventStore.js"; +import { toPublicMonitor } from "../models/monitor.js"; + +//Handles GET /monitors/:id/history - returns a monitor's full event timeline. +export function handleGetHistory(req, res) { + const { id } = req.params; + const monitor = getMonitor(id); + + if(!monitor) { + return sendJSON(res, 404, { error: `Monitor with id "${id}" not found` }); + } + + const history = getHistory(id); + + return sendJSON(res, 200, { + monitor: toPublicMonitor(monitor), + eventCount: history.length, + history, + }); +} \ No newline at end of file diff --git a/src/routes/monitors.js b/src/routes/monitors.js new file mode 100644 index 0000000..aec2d88 --- /dev/null +++ b/src/routes/monitors.js @@ -0,0 +1,92 @@ +import { sendJSON } from '../utils/respond.js'; +import { parseBody } from '../utils/parseBody.js'; +import { validateCreateMonitor } from '../utils/validate.js'; +import { createMonitor, monitorExists, getMonitor, updateMonitor } from '../store/monitorStore.js'; +import { toPublicMonitor, MonitorStatus } from '../models/monitor.js'; +import { startTimer, resetTimer, pauseTimer } from '../services/timerEngine.js'; +import { recordAlert } from '../store/alertStore.js'; +import { recordEvent, EventType } from '../store/eventStore.js'; + + +//Handles POST /monitors - registers a device and starts its countdown. +export async function handleCreateMonitor(req, res) { + let body; + try { + body = await parseBody(req); + } catch (err) { + return sendJSON(res, 400, { error: 'Invalid JSON in request body'}); + } + + const errors = validateCreateMonitor(body); + if (errors.length > 0) { + return sendJSON(res, 400, { error: 'Validation failed', details: errors}); + } + + if (monitorExists(body.id)) { + return sendJSON(res, 409, { error: `Monitor with id "${body.id}" already exists`}); + } + + const monitor = createMonitor({ + id: body.id, + timeout: body.timeout, + alertEmail: body.alert_email, + }); + startTimer(monitor.id, onMonitorExpire); + recordEvent(monitor.id, EventType.CREATED, { timeout: monitor.timeout }); + + return sendJSON(res, 201, { + message: `Monitor "${monitor.id}" created successfully`, + monitor: toPublicMonitor(monitor), + }); +} + +// Handles POST /monitors/:id/heartbeat - resets (and un-pauses) the countdown. +export async function handleHeartbeat(req, res) { + const { id } = req.params; + const monitor = getMonitor(id); + + if (!monitor) { + return sendJSON(res, 404, { error: `Monitor with id "${id}" not found` }); + } + + resetTimer(id, onMonitorExpire); + recordEvent(id, EventType.HEARTBEAT); + + return sendJSON(res, 200, { + message: `Heartbeat received for "${id}". Timer reset.`, + monitor: toPublicMonitor(getMonitor(id)), + }); +} + +// Fires when a monitor's timer expires: marks it down and logs the alert. +function onMonitorExpire(id) { + updateMonitor(id, { status: MonitorStatus.DOWN }); + + const alert = recordAlert({ monitorId: id }); + recordEvent(id, EventType.EXPIRED, { alertMessage: alert.message }); + + //Exact JSON shape required the brief's acceptance criteria: + //{"ALERT": "Device device-123 is down!", "time": } + console.log(JSON.stringify({ + ALERT: alert.message, + time: alert.firedAt, + })); +} + +//Hadles POST /monitors/:id/pause - freezes the countdown (bonus "snooze" feature.) +export async function handlePause(req, res) { + const { id } = req.params; + const monitor = getMonitor(id); + + if (!monitor) { + return sendJSON(res, 404, { error: `Monitor with id "${id}" not found` }); + } + + const paused = pauseTimer(id); + recordEvent(id, EventType.PAUSED); + + return sendJSON(res, 200, { + message: `Monitor "${id}" paused. No alerts will fire until the next heartbeat.`, + monitor: toPublicMonitor(paused), + }); +} \ No newline at end of file diff --git a/src/server.js b/src/server.js new file mode 100644 index 0000000..c435a9b --- /dev/null +++ b/src/server.js @@ -0,0 +1,34 @@ +import http from 'node:http'; +import { handleCreateMonitor, handleHeartbeat, handlePause } from './routes/monitors.js'; +import { sendJSON } from './utils/respond.js'; +import { Router, notFoundHandler } from './router.js' +import { handleGetAlerts } from './routes/alerts.js'; +import { handleGetHistory } from './routes/history.js'; + +const PORT = process.env.PORT || 3000; +const router = new Router(); + +// Health check +router.get('/', (req, res) => { + sendJSON(res, 200, { message: 'Pulse-Check-API is alive'}); +}); + +//Dead Man's Switch endpoints. +router.post('/monitors', handleCreateMonitor); +router.post('/monitors/:id/heartbeat', handleHeartbeat); +router.get('/alerts', handleGetAlerts); +router.post('/monitors/:id/pause', handlePause); +router.get('/monitors/:id/history', handleGetHistory); + +const server = http.createServer((req, res) => { + const { pathname } = new URL(req.url, `http://${req.headers.host}`) + + const matched = router.handle(req, res, pathname) + if (!matched) { + notFoundHandler(req, res); + } +}); + +server.listen(PORT, () => { + console.log(` 🟢 Pulse-Check-API listening on http://localhost:${PORT}`); +}); \ No newline at end of file diff --git a/src/services/timerEngine.js b/src/services/timerEngine.js new file mode 100644 index 0000000..526a1ba --- /dev/null +++ b/src/services/timerEngine.js @@ -0,0 +1,55 @@ +import { getMonitor, updateMonitor } from '../store/monitorStore.js'; +import { MonitorStatus } from '../models/monitor.js'; + +// Starts a countdown for a monitor; calls onExpire(id) if it runs out. +export function startTimer(id, onExpire) { + const monitor = getMonitor(id); + if (!monitor) return; + + const handle = setTimeout(() => { + onExpire(id); + }, monitor.timeout * 1000); + + updateMonitor(id, {timerHandle: handle}); +} + +// Resets the countdown on heartbeat; also resumes a paused monitor. +export function resetTimer(id, onExpire) { + const monitor = getMonitor(id); + if (!monitor) return; + + if(monitor.timerHandle) { + clearTimeout(monitor.timerHandle); + } + + updateMonitor(id, { + lastHeartbeatAt: new Date().toISOString(), + status: MonitorStatus.ACTIVE, + }); + + startTimer(id, onExpire); +} + +//Pauses a monitor: clears its timer and marks it PAUSED. +export function pauseTimer(id) { + const monitor = getMonitor(id); + if (!monitor) return null; + + if (monitor.timerHandle) { + clearTimeout(monitor.timerHandle); + } + + return updateMonitor(id, { + status: MonitorStatus.PAUSED, + timerHandle: null, + }); +} + +//Stops a monitor's timer without changing its status. +export function clearTimer(id) { + const monitor = getMonitor(id); + if (monitor?.timerHandle) { + clearTimeout(monitor.timerHandle); + updateMonitor(id, { timerHandle: null }); + } +} \ No newline at end of file diff --git a/src/store/alertStore.js b/src/store/alertStore.js new file mode 100644 index 0000000..d9aa370 --- /dev/null +++ b/src/store/alertStore.js @@ -0,0 +1,18 @@ +// In-memory, append-only log of fired alerts. +const alerts = []; + +//Records a new alert for a monitor that went down. +export function recordAlert({ monitorId }) { + const alert = { + monitorId, + message: `Device ${monitorId} is down!`, + firedAt: new Date().toISOString(), + }; + alerts.push(alert); + return alert; +} + +//Returns all fired alerts, newest first. +export function getAllAlerts() { + return [...alerts].reverse(); +} \ No newline at end of file diff --git a/src/store/eventStore.js b/src/store/eventStore.js new file mode 100644 index 0000000..a931f4f --- /dev/null +++ b/src/store/eventStore.js @@ -0,0 +1,27 @@ +// In-memory, append-only log of every monitor lifecycle event. +const events = []; + +//Event types tracked in a monitor's history. +export const EventType = { + CREATED: 'created', + HEARTBEAT: 'heartbeat', + PAUSED: 'paused', + EXPIRED: 'expired', +}; + +//Appends a new event to a monitor's history. +export function recordEvent(monitorId, type, detail = {}) { + const event = { + monitorId, + type, + detail, + timestamp: new Date().toISOString(), + }; + events.push(event); + return event; +} + +// Returns all events for one monitor, oldest first. +export function getHistory(monitorId) { + return events.filter((e) => e.monitorId === monitorId); +} \ No newline at end of file diff --git a/src/store/monitorStore.js b/src/store/monitorStore.js new file mode 100644 index 0000000..2fcbefe --- /dev/null +++ b/src/store/monitorStore.js @@ -0,0 +1,44 @@ +import { createMonitorEntity} from '../models/monitor.js'; + +// In-memory store of all monitors, keyed by id. +const monitors = new Map(); + +// Registers a new monitor; throws if the id already exists. +export function createMonitor({ id, timeout, alertEmail }) { + if (monitors.has(id)) { + throw new Error(`Monitor with id "${id}" already exists`); + } + const monitor = createMonitorEntity({ id, timeout, alertEmail }); + monitors.set(id, monitor); + return monitor; +} + +//Looks up a single monitor by id. +export function getMonitor(id) { + return monitors.get(id) || null; +} + +//Returns all monitors, in insertion order. +export function getAllMonitors() { + return Array.from(monitors.values()); +} + +//Merges partial updates into an existing monitor. +export function updateMonitor(id, updates) { + const existing = monitors.get(id); + if (!existing) return null; + + const updated = { ...existing, ...updates }; + monitors.set(id, updated); + return updated; +} + +//Remove a monitor entirely. +export function deleteMonitor(id) { + return monitors.delete(id); //returns true/false +} + +//Checks whether a monitor id is already registered. +export function monitorExists(id) { + return monitors.has(id); +} \ No newline at end of file diff --git a/src/utils/parseBody.js b/src/utils/parseBody.js new file mode 100644 index 0000000..1a13548 --- /dev/null +++ b/src/utils/parseBody.js @@ -0,0 +1,23 @@ +// Reads and parse a JSON request body from a raw Node http request. +export function parseBody(req) { + return new Promise((resolve, reject) => { + let rawData = ''; + + req.on('data', (chunk) => { + rawData += chunk; + }); + + req.on('end', () => { + if (!rawData) { + resolve({}); + return; + } + try { + resolve(JSON.parse(rawData)); + } catch (err) { + reject(new Error('Invalid JSON body')); + } + }); + req.on('error', reject); + }); +} \ No newline at end of file diff --git a/src/utils/respond.js b/src/utils/respond.js new file mode 100644 index 0000000..2696c70 --- /dev/null +++ b/src/utils/respond.js @@ -0,0 +1,5 @@ +// Sends a JSON reponse with the given status code. +export function sendJSON(res, statusCode, data) { + res.writeHead(statusCode, {'Content-Type': 'application/json'}); + res.end(JSON.stringify(data)); +} \ No newline at end of file diff --git a/src/utils/validate.js b/src/utils/validate.js new file mode 100644 index 0000000..c1b3fc5 --- /dev/null +++ b/src/utils/validate.js @@ -0,0 +1,19 @@ +// Validate a POST /monitors payload; returns an array of error messages (empty = valid). +export function validateCreateMonitor(body) { + const errors = []; + + if(!body.id || typeof body.id !== 'string') { + errors.push('"id" is required and must be a string'); + } + + if (body.timeout === undefined || typeof body.timeout !== 'number' || body.timeout <= 0 ) { + errors.push('"timeout" is required and must be a positive number (seconds)'); + } + + if (!body.alert_email || typeof body.alert_email !== 'string') { + errors.push('"alert_email" is required and must be a string'); + } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(body.alert_email)) { + errors.push('"alert_email" must be a valid email address'); + } + return errors; +} \ No newline at end of file