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