From bfb26f95b35e44196acdedc858abf684e2129e0f Mon Sep 17 00:00:00 2001 From: kkanim Date: Thu, 2 Jul 2026 13:43:13 +0000 Subject: [PATCH 01/10] chore: initialize project scaffold and base HTTP server --- .gitignore | 4 ++++ package.json | 25 +++++++++++++++++++++++++ src/router.js | 0 src/routes/monitors.js | 0 src/server.js | 13 +++++++++++++ src/store/monitorStore.js | 0 src/utils/respond.js | 4 ++++ 7 files changed, 46 insertions(+) create mode 100644 .gitignore create mode 100644 package.json create mode 100644 src/router.js create mode 100644 src/routes/monitors.js create mode 100644 src/server.js create mode 100644 src/store/monitorStore.js create mode 100644 src/utils/respond.js 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/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/router.js b/src/router.js new file mode 100644 index 0000000..e69de29 diff --git a/src/routes/monitors.js b/src/routes/monitors.js new file mode 100644 index 0000000..e69de29 diff --git a/src/server.js b/src/server.js new file mode 100644 index 0000000..1498a78 --- /dev/null +++ b/src/server.js @@ -0,0 +1,13 @@ +import http from 'node:http'; +import {sendJSON} from './utils/respond.js'; + +const PORT = process.env.PORT || 3000; + +const server = http.createServer((req, res) => { + //Placeholder - real routing logic arrives in Phase 3 + sendJSON(res, 200, {message: 'Pusle-Check-API is alive', path: req.url}); +}); + +server.listen(PORT, () => { + console.log('Pulse-CHeck-API listening on http://localhost:${PORT'); +}); \ No newline at end of file diff --git a/src/store/monitorStore.js b/src/store/monitorStore.js new file mode 100644 index 0000000..e69de29 diff --git a/src/utils/respond.js b/src/utils/respond.js new file mode 100644 index 0000000..45a4401 --- /dev/null +++ b/src/utils/respond.js @@ -0,0 +1,4 @@ +export function sendJSON(res, statusCode, data) { + res.writeHead(statusCode, {'Content-Type': 'application/json'}); + res.end(JSON.stringify(data)); +} \ No newline at end of file From fa2d7b456f99e9cc0467967e6332ec99c30b0bb7 Mon Sep 17 00:00:00 2001 From: kkanim Date: Thu, 2 Jul 2026 14:30:23 +0000 Subject: [PATCH 02/10] feat: add monitor model and in-memory store --- src/models/monitor.js | 41 ++++++++++++++++++++++++++++++++++ src/store/monitorStore.js | 46 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 src/models/monitor.js diff --git a/src/models/monitor.js b/src/models/monitor.js new file mode 100644 index 0000000..ee0a02a --- /dev/null +++ b/src/models/monitor.js @@ -0,0 +1,41 @@ +/** + * 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. We never want to leak a Node + * Timeout object into JSON +*/ +export function toPublicMonitor(monitor) { + const {timerHandle, ...publicFields} = monitor; + return publicFields; +} \ No newline at end of file diff --git a/src/store/monitorStore.js b/src/store/monitorStore.js index e69de29..89c371d 100644 --- a/src/store/monitorStore.js +++ b/src/store/monitorStore.js @@ -0,0 +1,46 @@ +import { createMonitorEntity} from '../models/monitor.js'; + +/** + * Single source of truth for all monitors, in-memorry + * A Map is used instead of a plain object because: + * - key lookups (has/get/delete) are 0(1) and more explicit than + * hasOwnProperty checks on a plain object + * - Map preserves insertion order, which is handy if we ever need + * to list monitors in the order they registered + * - no risk of prototype pollution or collisions with Object.prototype keys + */ +const monitors = new Map(); + +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; +} + +export function getMonitor(id) { + return monitors.get(id) || null; +} + +export function getAllMonitors() { + return Array.from(monitors.values()); +} + +export function updateMonitor(id, updates) { + const existing = monitors.get(id); + if (!existing) return null; + + const updated = { ...existing, ...updates }; + monitors.set(id, updated); + return updated; +} + +export function deleteMonitor(id) { + return monitors.delete(id); //returns true/false +} + +export function monitorExists(id) { + return monitors.has(id); +} \ No newline at end of file From 483613ba4ee5662aca9717bb06bfc579bfa3a88c Mon Sep 17 00:00:00 2001 From: kkanim Date: Thu, 2 Jul 2026 15:33:23 +0000 Subject: [PATCH 03/10] feat: add minimal manual router with dynamic param matching --- src/router.js | 77 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/server.js | 20 +++++++++++-- 2 files changed, 94 insertions(+), 3 deletions(-) diff --git a/src/router.js b/src/router.js index e69de29..85a196d 100644 --- a/src/router.js +++ b/src/router.js @@ -0,0 +1,77 @@ +import { sendJSON } from './utils/respond.js'; + +/** + * A minimal, dependency-free router for plain Node.js http servers. + * + * Design notes: + * - Routes are stored as an array (not a Map) because match order matters - + * more specific patterns could otherwise be shadowed by looser ones. + * - Each registered path is compiled into a RegExp once, at registration + * time, not every request - matching a live request should be cheap. + * - Dynamic segments (":id") are captured by name so handlers get a clean + * req.params.id instead of having to parse the URL themselves. + */ +export class Router { + constructor() { + this.routes = []; + } + + _register(method, path, handler) { + const paramNames = []; + + // Convert "/monitors/:id/heartbeat" into a regex like + // /^\/monitors\/([^/]+)\/heartbeat$/ and remember that + // capture group #1 corresponds to "id". + 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 }); + } + + get(path, handler) { + this._register('GET', path, handler); + } + + post(path, handler) { + this._register('POST', path, handler); + } + + /** + * Finds a matching route for the given method + pathname, + * extracts params from the URL, and invikes the handler. + * Returns true if a route matched, false otherwise - so the + * caller (server.js) knows whether to fall back to a 404. + */ + 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; + } +} + +export function notFoundHandler(req, res) { + sendJSON(res, 404, {error: 'Not FOund', path: req.url}); +} \ No newline at end of file diff --git a/src/server.js b/src/server.js index 1498a78..9618f5e 100644 --- a/src/server.js +++ b/src/server.js @@ -1,11 +1,25 @@ import http from 'node:http'; -import {sendJSON} from './utils/respond.js'; +import { sendJSON } from './utils/respond.js'; +import { Router, notFoundHandler } from './router.js' const PORT = process.env.PORT || 3000; +const router = new Router(); + +//Temporary health-check route - proves the router works end-to-end. +// Real /monitors routes get registered here in Phase 4. +router.get('/', (req, res) => { + sendJSON(res, 200, { message: 'Pulse-Check-API is alive'}); +}) const server = http.createServer((req, res) => { - //Placeholder - real routing logic arrives in Phase 3 - sendJSON(res, 200, {message: 'Pusle-Check-API is alive', path: req.url}); + // req.url can include a query string (e.g. "/monitors?staus=down"), + // so we parse it properly rather than treating the raw string as the path + 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, () => { From 05d23a2ecbdf32a0edc3e5cc50d701d297f26011 Mon Sep 17 00:00:00 2001 From: kkanim Date: Thu, 2 Jul 2026 23:10:27 +0000 Subject: [PATCH 04/10] feat: add POST /monitors registration endpoint with validation --- src/routes/monitors.js | 34 ++++++++++++++++++++++++++++++++++ src/server.js | 3 +++ src/utils/parseBody.js | 29 +++++++++++++++++++++++++++++ src/utils/validate.js | 24 ++++++++++++++++++++++++ 4 files changed, 90 insertions(+) create mode 100644 src/utils/parseBody.js create mode 100644 src/utils/validate.js diff --git a/src/routes/monitors.js b/src/routes/monitors.js index e69de29..64470aa 100644 --- a/src/routes/monitors.js +++ b/src/routes/monitors.js @@ -0,0 +1,34 @@ +import { sendJSON } from '../utils/respond.js'; +import { parseBody } from '../utils/parseBody.js'; +import { validateCreateMonitor } from '../utils/validate.js'; +import { createMonitor, monitorExists } from '../store/monitorStore.js'; +import { toPublicMonitor } from '../models/monitor.js'; + +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, + }); + + return sendJSON(res, 201, { + message: `Monitor "${monitor.id}" created successfully`, + monitor: toPublicMonitor(monitor), + }); +} \ No newline at end of file diff --git a/src/server.js b/src/server.js index 9618f5e..72162cf 100644 --- a/src/server.js +++ b/src/server.js @@ -1,4 +1,5 @@ import http from 'node:http'; +import { handleCreateMonitor } from './routes/monitors.js'; import { sendJSON } from './utils/respond.js'; import { Router, notFoundHandler } from './router.js' @@ -11,6 +12,8 @@ router.get('/', (req, res) => { sendJSON(res, 200, { message: 'Pulse-Check-API is alive'}); }) +router.post('/monitors', handleCreateMonitor); + const server = http.createServer((req, res) => { // req.url can include a query string (e.g. "/monitors?staus=down"), // so we parse it properly rather than treating the raw string as the path diff --git a/src/utils/parseBody.js b/src/utils/parseBody.js new file mode 100644 index 0000000..b1dc987 --- /dev/null +++ b/src/utils/parseBody.js @@ -0,0 +1,29 @@ +/** + * Node's http.IncomingMessage delivers the body as a streamof chunks, + * not a ready-made object. We have to manually collect the chunks, + * concatenate them, and parse as JSON ourselves. + * + * Returns a Promise so route handlers can simply `await parseBody(req)`. + */ +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/validate.js b/src/utils/validate.js new file mode 100644 index 0000000..065de44 --- /dev/null +++ b/src/utils/validate.js @@ -0,0 +1,24 @@ +/** + * Validates the payload for POST /monitors. + * Returns an array of error strings - empty array means valid. + * Kept as pure functions (no side effect) so they're easy to unit test + * in isolation from HTTP concerns. + */ +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 From e340f2ace164491568b2e7753f7c7af66abff355 Mon Sep 17 00:00:00 2001 From: kkanim Date: Sat, 4 Jul 2026 17:34:12 +0000 Subject: [PATCH 05/10] feat: implement timer engine and heartbeat reset logic --- src/models/monitor.js | 6 ++--- src/routes/monitors.js | 40 +++++++++++++++++++++++++-- src/server.js | 12 ++++----- src/services/timerEngine.js | 54 +++++++++++++++++++++++++++++++++++++ 4 files changed, 100 insertions(+), 12 deletions(-) create mode 100644 src/services/timerEngine.js diff --git a/src/models/monitor.js b/src/models/monitor.js index ee0a02a..661c382 100644 --- a/src/models/monitor.js +++ b/src/models/monitor.js @@ -24,8 +24,8 @@ export function createMonitorEntity({id, timeout, alertEmail}) { timeout, // number, seconds until expiry alertEmail, // string, where alerts would be "sent" status: MonitorStatus.ACTIVE, - createdAT: now, - lastHeartbeatAT: now, + createdAt: now, + lastHeartbeatAt: now, timerHandle: null, // will hold the setTimeout reference (Phase 5) }; } @@ -36,6 +36,6 @@ export function createMonitorEntity({id, timeout, alertEmail}) { * Timeout object into JSON */ export function toPublicMonitor(monitor) { - const {timerHandle, ...publicFields} = monitor; + const { timerHandle, ...publicFields } = monitor; return publicFields; } \ No newline at end of file diff --git a/src/routes/monitors.js b/src/routes/monitors.js index 64470aa..f0cd57a 100644 --- a/src/routes/monitors.js +++ b/src/routes/monitors.js @@ -1,8 +1,21 @@ import { sendJSON } from '../utils/respond.js'; import { parseBody } from '../utils/parseBody.js'; import { validateCreateMonitor } from '../utils/validate.js'; -import { createMonitor, monitorExists } from '../store/monitorStore.js'; -import { toPublicMonitor } from '../models/monitor.js'; +import { createMonitor, monitorExists, getMonitor, updateMonitor } from '../store/monitorStore.js'; +import { toPublicMonitor, MonitorStatus } from '../models/monitor.js'; +import { startTimer, resetTimer } from '../services/timerEngine.js'; + +/** + * Temporary expiry handler for Phase 5. Phase 6 will replace the + * body of this function with the real alert-firing logic (console.log + * JSON + alerts array + GET /alerts). Kept here, not in the timer + * engine, because "what happens on expiry" is business logic, not + * timer mechanics. + */ +function onMonitorExpire(id) { + updateMonitor(id, { status: MonitorStatus.DOWN }); + console.log(` ⚠️ [Phase 6 TODO] Monitor "${id}" expired β€” alert logic goes here.`) +} export async function handleCreateMonitor(req, res) { let body; @@ -27,8 +40,31 @@ export async function handleCreateMonitor(req, res) { alertEmail: body.alert_email, }); + // The countdown starts the instant the monitor is registered - + // not on the first heartbeat. This matches the brief: "the system + // starts a countdown timer for 60 seconds associated with device-123." + startTimer(monitor.id, onMonitorExpire); + return sendJSON(res, 201, { message: `Monitor "${monitor.id}" created successfully`, monitor: toPublicMonitor(monitor), }); +} + +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); + + + + return sendJSON(res, 200, { + message: `Heartbeat received for "${id}". Timer reset.`, + monitor: toPublicMonitor(getMonitor(id)), + }); } \ No newline at end of file diff --git a/src/server.js b/src/server.js index 72162cf..0cee8ba 100644 --- a/src/server.js +++ b/src/server.js @@ -1,22 +1,20 @@ import http from 'node:http'; -import { handleCreateMonitor } from './routes/monitors.js'; +import { handleCreateMonitor, handleHeartbeat } from './routes/monitors.js'; import { sendJSON } from './utils/respond.js'; import { Router, notFoundHandler } from './router.js' const PORT = process.env.PORT || 3000; const router = new Router(); -//Temporary health-check route - proves the router works end-to-end. -// Real /monitors routes get registered here in Phase 4. + router.get('/', (req, res) => { sendJSON(res, 200, { message: 'Pulse-Check-API is alive'}); -}) +}); router.post('/monitors', handleCreateMonitor); +router.post('/monitors/:id/heartbeat', handleHeartbeat); const server = http.createServer((req, res) => { - // req.url can include a query string (e.g. "/monitors?staus=down"), - // so we parse it properly rather than treating the raw string as the path const { pathname } = new URL(req.url, `http://${req.headers.host}`) const matched = router.handle(req, res, pathname) @@ -26,5 +24,5 @@ const server = http.createServer((req, res) => { }); server.listen(PORT, () => { - console.log('Pulse-CHeck-API listening on http://localhost:${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..a00a46e --- /dev/null +++ b/src/services/timerEngine.js @@ -0,0 +1,54 @@ +import { getMonitor, updateMonitor } from '../store/monitorStore.js'; +import { MonitorStatus } from '../models/monitor.js'; + +/** + * Starts a fresh countdown for a monitor. When the timeout elapses + * without a reset, `onExpire(id)` is invoked. + * the caller's responsibility (Phase 6 will pass the real alert + * logic). This keeps the timer engine reusable and easy to reason + * about in isolation. + */ +export function startTimer(id, onExpire) { + const monitor = getMonitor(id); + if (!monitor) return; + + const handle = setTimeout(() => { + onExpire(id); + }, monitor.timeout * 1000); + + updateMonitor(id, {timerHandle: handle}); +} + +/** + * Called on every heartbeat. Clears the existing timer (if any) + * and starts a brand new one - this IS the "reset the countdown" + * requirement from the brief. + */ +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); +} + +/** + * Stops the countdown entirely without starting a new one. + * Not used yet - this is what Phase 7 (pause/snooze) will call. + * Included now so the engine's public API is complete in one place. + */ +export function clearTimer(id) { + const monitor = getMonitor(id); + if (monitor?.timerHandle) { + clearTimeout(monitor.timerHandle); + updateMonitor(id, { timerHandle: null }); + } +} \ No newline at end of file From 6015b741296833e7df72990ea0f3af6b15d4fd61 Mon Sep 17 00:00:00 2001 From: kkanim Date: Sun, 5 Jul 2026 09:58:08 +0000 Subject: [PATCH 06/10] feat: implement alert trigger with GET /alerts endpoint --- src/routes/alerts.js | 10 ++++++++++ src/routes/monitors.js | 20 ++++++++++++++------ src/server.js | 2 ++ src/store/alertStore.js | 25 +++++++++++++++++++++++++ 4 files changed, 51 insertions(+), 6 deletions(-) create mode 100644 src/routes/alerts.js create mode 100644 src/store/alertStore.js diff --git a/src/routes/alerts.js b/src/routes/alerts.js new file mode 100644 index 0000000..01d4428 --- /dev/null +++ b/src/routes/alerts.js @@ -0,0 +1,10 @@ +import { sendJSON } from "../utils/respond.js"; +import { getAllAlerts } from "../store/alertStore.js"; + +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/monitors.js b/src/routes/monitors.js index f0cd57a..8311e0c 100644 --- a/src/routes/monitors.js +++ b/src/routes/monitors.js @@ -4,17 +4,25 @@ 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 } from '../services/timerEngine.js'; +import { recordAlert } from '../store/alertStore.js'; /** - * Temporary expiry handler for Phase 5. Phase 6 will replace the - * body of this function with the real alert-firing logic (console.log - * JSON + alerts array + GET /alerts). Kept here, not in the timer - * engine, because "what happens on expiry" is business logic, not - * timer mechanics. + * Fires when a monitor's countdown reaches zero without a heartbeat. + * Satisfies User Story 3: logs the required JSON shape from the brief, + * marks the monitor "down", and records the alert for the later retrieval + * via GET /alerts. */ function onMonitorExpire(id) { updateMonitor(id, { status: MonitorStatus.DOWN }); - console.log(` ⚠️ [Phase 6 TODO] Monitor "${id}" expired β€” alert logic goes here.`) + + const alert = recordAlert({ monitorId: id }); + + //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, + })); } export async function handleCreateMonitor(req, res) { diff --git a/src/server.js b/src/server.js index 0cee8ba..f731739 100644 --- a/src/server.js +++ b/src/server.js @@ -2,6 +2,7 @@ import http from 'node:http'; import { handleCreateMonitor, handleHeartbeat } from './routes/monitors.js'; import { sendJSON } from './utils/respond.js'; import { Router, notFoundHandler } from './router.js' +import { handleGetAlerts } from './routes/alerts.js'; const PORT = process.env.PORT || 3000; const router = new Router(); @@ -13,6 +14,7 @@ router.get('/', (req, res) => { router.post('/monitors', handleCreateMonitor); router.post('/monitors/:id/heartbeat', handleHeartbeat); +router.get('/alerts', handleGetAlerts); const server = http.createServer((req, res) => { const { pathname } = new URL(req.url, `http://${req.headers.host}`) diff --git a/src/store/alertStore.js b/src/store/alertStore.js new file mode 100644 index 0000000..dbd86d0 --- /dev/null +++ b/src/store/alertStore.js @@ -0,0 +1,25 @@ +/** + * A separate in-memory store just for fired alerts. + * kept independent from monitorStore.js because alerts are an + * append-only log (you never "update" a past alert), which is a + * different access pattern from monitors (which get read/updated + * constantly). Separating them keeps each store's responsibility + * single-purpose. + */ +const alerts = []; + +export function recordAlert({ monitorId }) { + const alert = { + monitorId, + message: `Device ${monitorId} is down!`, + firedAt: new Date().toISOString(), + }; + alerts.push(alert); + return alert; +} + +export function getAllAlerts() { + //Return newest-first - when demoing, the most recent alert + //is almost always the one you want to see first. + return [...alerts].reverse(); +} \ No newline at end of file From 55078c737e010d7d519c7ff18ee2fde8108691f0 Mon Sep 17 00:00:00 2001 From: kkanim Date: Sun, 5 Jul 2026 10:38:56 +0000 Subject: [PATCH 07/10] feat: add pause/resume (snooze) endpont for monitors --- src/routes/monitors.js | 54 ++++++++++++++++++++++++------------- src/server.js | 5 ++-- src/services/timerEngine.js | 21 +++++++++++++++ 3 files changed, 60 insertions(+), 20 deletions(-) diff --git a/src/routes/monitors.js b/src/routes/monitors.js index 8311e0c..d15aab1 100644 --- a/src/routes/monitors.js +++ b/src/routes/monitors.js @@ -3,27 +3,10 @@ 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 } from '../services/timerEngine.js'; +import { startTimer, resetTimer, pauseTimer } from '../services/timerEngine.js'; import { recordAlert } from '../store/alertStore.js'; -/** - * Fires when a monitor's countdown reaches zero without a heartbeat. - * Satisfies User Story 3: logs the required JSON shape from the brief, - * marks the monitor "down", and records the alert for the later retrieval - * via GET /alerts. - */ -function onMonitorExpire(id) { - updateMonitor(id, { status: MonitorStatus.DOWN }); - - const alert = recordAlert({ monitorId: id }); - //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, - })); -} export async function handleCreateMonitor(req, res) { let body; @@ -75,4 +58,39 @@ export async function handleHeartbeat(req, res) { message: `Heartbeat received for "${id}". Timer reset.`, monitor: toPublicMonitor(getMonitor(id)), }); +} + +/** + * Fires when a monitor's countdown reaches zero without a heartbeat. + * Satisfies User Story 3: logs the required JSON shape from the brief, + * marks the monitor "down", and records the alert for the later retrieval + * via GET /alerts. + */ +function onMonitorExpire(id) { + updateMonitor(id, { status: MonitorStatus.DOWN }); + + const alert = recordAlert({ monitorId: id }); + + //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, + })); +} + +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); + + 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 index f731739..0ff7b1d 100644 --- a/src/server.js +++ b/src/server.js @@ -1,5 +1,5 @@ import http from 'node:http'; -import { handleCreateMonitor, handleHeartbeat } from './routes/monitors.js'; +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'; @@ -15,6 +15,7 @@ router.get('/', (req, res) => { router.post('/monitors', handleCreateMonitor); router.post('/monitors/:id/heartbeat', handleHeartbeat); router.get('/alerts', handleGetAlerts); +router.post('/monitors/:id/pause', handlePause); const server = http.createServer((req, res) => { const { pathname } = new URL(req.url, `http://${req.headers.host}`) @@ -26,5 +27,5 @@ const server = http.createServer((req, res) => { }); server.listen(PORT, () => { - console.log(' 🟒 Pulse-CHeck-API listening on http://localhost:${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 index a00a46e..4d32b4d 100644 --- a/src/services/timerEngine.js +++ b/src/services/timerEngine.js @@ -40,6 +40,27 @@ export function resetTimer(id, onExpire) { startTimer(id, onExpire); } +/** + * Pauses a monitor: clears its active timer and marks it PAUSED. + * Distinct from clearTimer() below - clearTimer is a low-level + * "stop the setTimeout" primitive, while pauseTimer is the + * business-level action tied to the /pause endpoint (it also + * update status, which clearTimer deliberately does not). + */ +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 the countdown entirely without starting a new one. * Not used yet - this is what Phase 7 (pause/snooze) will call. From e44b79c241084bf745cb235c2cd5079079cc3a1f Mon Sep 17 00:00:00 2001 From: kkanim Date: Sun, 5 Jul 2026 11:10:14 +0000 Subject: [PATCH 08/10] feat: add monitor event history tracking (Developer's Choice) --- src/routes/history.js | 21 +++++++++++++++++++++ src/routes/monitors.js | 8 ++++++-- src/server.js | 2 ++ src/store/eventStore.js | 36 ++++++++++++++++++++++++++++++++++++ 4 files changed, 65 insertions(+), 2 deletions(-) create mode 100644 src/routes/history.js create mode 100644 src/store/eventStore.js diff --git a/src/routes/history.js b/src/routes/history.js new file mode 100644 index 0000000..19fb19a --- /dev/null +++ b/src/routes/history.js @@ -0,0 +1,21 @@ +import { sendJSON } from "../utils/respond.js"; +import { getMonitor } from '../store/monitorStore.js' +import { getHistory } from "../store/eventStore.js"; +import { toPublicMonitor } from "../models/monitor.js"; + +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 index d15aab1..994ff88 100644 --- a/src/routes/monitors.js +++ b/src/routes/monitors.js @@ -5,6 +5,7 @@ import { createMonitor, monitorExists, getMonitor, updateMonitor } from '../stor 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'; @@ -36,6 +37,8 @@ export async function handleCreateMonitor(req, res) { // starts a countdown timer for 60 seconds associated with device-123." 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), @@ -51,8 +54,7 @@ export async function handleHeartbeat(req, res) { } resetTimer(id, onMonitorExpire); - - + recordEvent(id, EventType.HEARTBEAT); return sendJSON(res, 200, { message: `Heartbeat received for "${id}". Timer reset.`, @@ -70,6 +72,7 @@ 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": } @@ -88,6 +91,7 @@ export async function handlePause(req, res) { } const paused = pauseTimer(id); + recordEvent(id, EventType.PAUSED); return sendJSON(res, 200, { message: `Monitor "${id}" paused. No alerts will fire until the next heartbeat.`, diff --git a/src/server.js b/src/server.js index 0ff7b1d..4e79589 100644 --- a/src/server.js +++ b/src/server.js @@ -3,6 +3,7 @@ import { handleCreateMonitor, handleHeartbeat, handlePause } from './routes/moni 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(); @@ -16,6 +17,7 @@ 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}`) diff --git a/src/store/eventStore.js b/src/store/eventStore.js new file mode 100644 index 0000000..08aeb9f --- /dev/null +++ b/src/store/eventStore.js @@ -0,0 +1,36 @@ +/** + * An append-only log of every meaningful state change for every + * monitor. Separate from alertStore.js because alerts are a + * specifi *type* of event (a down-trigger) - this store captures + * the full lifecycle: creation, every heartbeat, every pause, and + * every expiry, giving a complete audit trail per device. + */ +const events = []; + +export const EventType = { + CREATED: 'created', + HEARTBEAT: 'heartbeat', + PAUSED: 'paused', + EXPIRED: 'expired', +}; + +export function recordEvent(monitorId, type, detail = {}) { + const event = { + monitorId, + type, + detail, + timestamp: new Date().toISOString(), + }; + events.push(event); + return event; +} + +/** + * Return all events for one monitor, oldest first - a history + * makes sense read chronologically, unlike alerts (Phase 6) which + * we deliberately showed newest-first for quick "what's wrong right + * now" scanning. Different resource, different natural order. + */ +export function getHistory(monitorId) { + return events.filter((e) => e.monitorId === monitorId); +} \ No newline at end of file From 03ef98321999b2b6ade9b97ec309b4ac0d7df1a7 Mon Sep 17 00:00:00 2001 From: kkanim Date: Sun, 5 Jul 2026 12:58:40 +0000 Subject: [PATCH 09/10] docs: replace brief with full README, add architecturre and API docs --- README.md | 241 +++++++++++++++++++++++++++++++----------------------- 1 file changed, 137 insertions(+), 104 deletions(-) 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 From 2883962f72f3d471e6bedbbd56bc2726ef805ab7 Mon Sep 17 00:00:00 2001 From: kkanim Date: Sun, 5 Jul 2026 13:39:01 +0000 Subject: [PATCH 10/10] docs: add concise function-level comments across all modules --- src/models/monitor.js | 3 +-- src/router.js | 26 ++++++-------------------- src/routes/alerts.js | 1 + src/routes/history.js | 1 + src/routes/monitors.js | 16 ++++------------ src/server.js | 3 ++- src/services/timerEngine.js | 28 ++++------------------------ src/store/alertStore.js | 13 +++---------- src/store/eventStore.js | 17 ++++------------- src/store/monitorStore.js | 16 +++++++--------- src/utils/parseBody.js | 8 +------- src/utils/respond.js | 1 + src/utils/validate.js | 7 +------ 13 files changed, 36 insertions(+), 104 deletions(-) diff --git a/src/models/monitor.js b/src/models/monitor.js index 661c382..128de63 100644 --- a/src/models/monitor.js +++ b/src/models/monitor.js @@ -32,8 +32,7 @@ export function createMonitorEntity({id, timeout, alertEmail}) { /** * Strips internal-only fields (like timerHandle) before a monitor - * is sent back in an API response. We never want to leak a Node - * Timeout object into JSON + * is sent back in an API response. */ export function toPublicMonitor(monitor) { const { timerHandle, ...publicFields } = monitor; diff --git a/src/router.js b/src/router.js index 85a196d..9222ccf 100644 --- a/src/router.js +++ b/src/router.js @@ -1,16 +1,6 @@ import { sendJSON } from './utils/respond.js'; -/** - * A minimal, dependency-free router for plain Node.js http servers. - * - * Design notes: - * - Routes are stored as an array (not a Map) because match order matters - - * more specific patterns could otherwise be shadowed by looser ones. - * - Each registered path is compiled into a RegExp once, at registration - * time, not every request - matching a live request should be cheap. - * - Dynamic segments (":id") are captured by name so handlers get a clean - * req.params.id instead of having to parse the URL themselves. - */ +//Minimal manual router: matches method + path patterns, supports : params. export class Router { constructor() { this.routes = []; @@ -19,9 +9,7 @@ export class Router { _register(method, path, handler) { const paramNames = []; - // Convert "/monitors/:id/heartbeat" into a regex like - // /^\/monitors\/([^/]+)\/heartbeat$/ and remember that - // capture group #1 corresponds to "id". + //Compiles a path pattern into a regex and stores it with its handler. const pattern = path .split('/') .map((segment) => { @@ -37,20 +25,17 @@ export class Router { 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); } - /** - * Finds a matching route for the given method + pathname, - * extracts params from the URL, and invikes the handler. - * Returns true if a route matched, false otherwise - so the - * caller (server.js) knows whether to fall back to a 404. - */ + // 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; @@ -72,6 +57,7 @@ export class Router { } } +//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 index 01d4428..e595b19 100644 --- a/src/routes/alerts.js +++ b/src/routes/alerts.js @@ -1,6 +1,7 @@ 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, { diff --git a/src/routes/history.js b/src/routes/history.js index 19fb19a..d367708 100644 --- a/src/routes/history.js +++ b/src/routes/history.js @@ -3,6 +3,7 @@ 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); diff --git a/src/routes/monitors.js b/src/routes/monitors.js index 994ff88..aec2d88 100644 --- a/src/routes/monitors.js +++ b/src/routes/monitors.js @@ -8,7 +8,7 @@ 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 { @@ -31,12 +31,7 @@ export async function handleCreateMonitor(req, res) { timeout: body.timeout, alertEmail: body.alert_email, }); - - // The countdown starts the instant the monitor is registered - - // not on the first heartbeat. This matches the brief: "the system - // starts a countdown timer for 60 seconds associated with device-123." startTimer(monitor.id, onMonitorExpire); - recordEvent(monitor.id, EventType.CREATED, { timeout: monitor.timeout }); return sendJSON(res, 201, { @@ -45,6 +40,7 @@ export async function handleCreateMonitor(req, res) { }); } +// 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); @@ -62,12 +58,7 @@ export async function handleHeartbeat(req, res) { }); } -/** - * Fires when a monitor's countdown reaches zero without a heartbeat. - * Satisfies User Story 3: logs the required JSON shape from the brief, - * marks the monitor "down", and records the alert for the later retrieval - * via GET /alerts. - */ +// Fires when a monitor's timer expires: marks it down and logs the alert. function onMonitorExpire(id) { updateMonitor(id, { status: MonitorStatus.DOWN }); @@ -82,6 +73,7 @@ function onMonitorExpire(id) { })); } +//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); diff --git a/src/server.js b/src/server.js index 4e79589..c435a9b 100644 --- a/src/server.js +++ b/src/server.js @@ -8,11 +8,12 @@ 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); diff --git a/src/services/timerEngine.js b/src/services/timerEngine.js index 4d32b4d..526a1ba 100644 --- a/src/services/timerEngine.js +++ b/src/services/timerEngine.js @@ -1,13 +1,7 @@ import { getMonitor, updateMonitor } from '../store/monitorStore.js'; import { MonitorStatus } from '../models/monitor.js'; -/** - * Starts a fresh countdown for a monitor. When the timeout elapses - * without a reset, `onExpire(id)` is invoked. - * the caller's responsibility (Phase 6 will pass the real alert - * logic). This keeps the timer engine reusable and easy to reason - * about in isolation. - */ +// 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; @@ -19,11 +13,7 @@ export function startTimer(id, onExpire) { updateMonitor(id, {timerHandle: handle}); } -/** - * Called on every heartbeat. Clears the existing timer (if any) - * and starts a brand new one - this IS the "reset the countdown" - * requirement from the brief. - */ +// Resets the countdown on heartbeat; also resumes a paused monitor. export function resetTimer(id, onExpire) { const monitor = getMonitor(id); if (!monitor) return; @@ -40,13 +30,7 @@ export function resetTimer(id, onExpire) { startTimer(id, onExpire); } -/** - * Pauses a monitor: clears its active timer and marks it PAUSED. - * Distinct from clearTimer() below - clearTimer is a low-level - * "stop the setTimeout" primitive, while pauseTimer is the - * business-level action tied to the /pause endpoint (it also - * update status, which clearTimer deliberately does not). - */ +//Pauses a monitor: clears its timer and marks it PAUSED. export function pauseTimer(id) { const monitor = getMonitor(id); if (!monitor) return null; @@ -61,11 +45,7 @@ export function pauseTimer(id) { }); } -/** - * Stops the countdown entirely without starting a new one. - * Not used yet - this is what Phase 7 (pause/snooze) will call. - * Included now so the engine's public API is complete in one place. - */ +//Stops a monitor's timer without changing its status. export function clearTimer(id) { const monitor = getMonitor(id); if (monitor?.timerHandle) { diff --git a/src/store/alertStore.js b/src/store/alertStore.js index dbd86d0..d9aa370 100644 --- a/src/store/alertStore.js +++ b/src/store/alertStore.js @@ -1,13 +1,7 @@ -/** - * A separate in-memory store just for fired alerts. - * kept independent from monitorStore.js because alerts are an - * append-only log (you never "update" a past alert), which is a - * different access pattern from monitors (which get read/updated - * constantly). Separating them keeps each store's responsibility - * single-purpose. - */ +// 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, @@ -18,8 +12,7 @@ export function recordAlert({ monitorId }) { return alert; } +//Returns all fired alerts, newest first. export function getAllAlerts() { - //Return newest-first - when demoing, the most recent alert - //is almost always the one you want to see first. return [...alerts].reverse(); } \ No newline at end of file diff --git a/src/store/eventStore.js b/src/store/eventStore.js index 08aeb9f..a931f4f 100644 --- a/src/store/eventStore.js +++ b/src/store/eventStore.js @@ -1,12 +1,7 @@ -/** - * An append-only log of every meaningful state change for every - * monitor. Separate from alertStore.js because alerts are a - * specifi *type* of event (a down-trigger) - this store captures - * the full lifecycle: creation, every heartbeat, every pause, and - * every expiry, giving a complete audit trail per device. - */ +// 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', @@ -14,6 +9,7 @@ export const EventType = { EXPIRED: 'expired', }; +//Appends a new event to a monitor's history. export function recordEvent(monitorId, type, detail = {}) { const event = { monitorId, @@ -25,12 +21,7 @@ export function recordEvent(monitorId, type, detail = {}) { return event; } -/** - * Return all events for one monitor, oldest first - a history - * makes sense read chronologically, unlike alerts (Phase 6) which - * we deliberately showed newest-first for quick "what's wrong right - * now" scanning. Different resource, different natural order. - */ +// 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 index 89c371d..2fcbefe 100644 --- a/src/store/monitorStore.js +++ b/src/store/monitorStore.js @@ -1,16 +1,9 @@ import { createMonitorEntity} from '../models/monitor.js'; -/** - * Single source of truth for all monitors, in-memorry - * A Map is used instead of a plain object because: - * - key lookups (has/get/delete) are 0(1) and more explicit than - * hasOwnProperty checks on a plain object - * - Map preserves insertion order, which is handy if we ever need - * to list monitors in the order they registered - * - no risk of prototype pollution or collisions with Object.prototype keys - */ +// 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`); @@ -20,14 +13,17 @@ export function createMonitor({ id, timeout, alertEmail }) { 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; @@ -37,10 +33,12 @@ export function updateMonitor(id, updates) { 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 index b1dc987..1a13548 100644 --- a/src/utils/parseBody.js +++ b/src/utils/parseBody.js @@ -1,10 +1,4 @@ -/** - * Node's http.IncomingMessage delivers the body as a streamof chunks, - * not a ready-made object. We have to manually collect the chunks, - * concatenate them, and parse as JSON ourselves. - * - * Returns a Promise so route handlers can simply `await parseBody(req)`. - */ +// Reads and parse a JSON request body from a raw Node http request. export function parseBody(req) { return new Promise((resolve, reject) => { let rawData = ''; diff --git a/src/utils/respond.js b/src/utils/respond.js index 45a4401..2696c70 100644 --- a/src/utils/respond.js +++ b/src/utils/respond.js @@ -1,3 +1,4 @@ +// 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)); diff --git a/src/utils/validate.js b/src/utils/validate.js index 065de44..c1b3fc5 100644 --- a/src/utils/validate.js +++ b/src/utils/validate.js @@ -1,9 +1,4 @@ -/** - * Validates the payload for POST /monitors. - * Returns an array of error strings - empty array means valid. - * Kept as pure functions (no side effect) so they're easy to unit test - * in isolation from HTTP concerns. - */ +// Validate a POST /monitors payload; returns an array of error messages (empty = valid). export function validateCreateMonitor(body) { const errors = [];