diff --git a/display/middleware/src/api/routes/datapoints.ts b/display/middleware/src/api/routes/datapoints.ts new file mode 100644 index 0000000..c4eb61f --- /dev/null +++ b/display/middleware/src/api/routes/datapoints.ts @@ -0,0 +1,41 @@ +import { Router } from 'express'; +import { + getAllDatapoints, + getDatapointById, + updateDatapoint, +} from '../../modules/datapoints/datapoint-manager'; + +const router = Router(); + +// GET /api/datapoints +router.get('/', (_req, res): void => { + try { + res.json(getAllDatapoints()); + } catch (err) { + res.status(500).json({ error: (err as Error).message }); + } +}); + +// GET /api/datapoints/:id +router.get('/:id', (req, res): void => { + try { + const dp = getDatapointById(parseInt(req.params.id)); + if (!dp) { res.status(404).json({ error: 'Datapoint not found' }); return; } + res.json(dp); + } catch (err) { + res.status(500).json({ error: (err as Error).message }); + } +}); + +// PUT /api/datapoints/:id +router.put('/:id', (req, res): void => { + try { + const dp = updateDatapoint(parseInt(req.params.id), req.body); + if (!dp) { res.status(404).json({ error: 'Datapoint not found' }); return; } + res.json(dp); + } catch (err) { + res.status(500).json({ error: (err as Error).message }); + } +}); + +export default router; diff --git a/display/middleware/src/api/routes/display-api.ts b/display/middleware/src/api/routes/display-api.ts new file mode 100644 index 0000000..83487cd --- /dev/null +++ b/display/middleware/src/api/routes/display-api.ts @@ -0,0 +1,64 @@ +import { Router } from 'express'; +import type { Request, Response } from 'express'; +import { getDb } from '../../db/index'; +import { buildDisplayJson } from '../../modules/display/display-builder'; + +const router = Router(); + +/** + * GET /api/display/:id + * ESP32 endpoint — returns display JSON. + * Optional token auth via ?token=... or Authorization: Bearer ... + * If the display has an api_token set, the token MUST match. + */ +router.get('/:id', (req: Request, res: Response): void => { + try { + const db = getDb(); + const displayId = parseInt(req.params.id); + + const display = db + .prepare('SELECT id, api_token FROM displays WHERE id = ?') + .get(displayId) as { id: number; api_token: string | null } | undefined; + + if (!display) { + res.status(404).json({ error: 'Display not found' }); + return; + } + + // Check token if display has one configured + if (display.api_token) { + const authHeader = req.headers.authorization; + const queryToken = req.query.token as string | undefined; + + let provided: string | null = null; + if (authHeader && authHeader.startsWith('Bearer ')) { + provided = authHeader.slice(7); + } else if (queryToken) { + provided = queryToken; + } + + if (!provided || provided !== display.api_token) { + res.status(401).json({ error: 'Invalid or missing token' }); + return; + } + } + + // Update last_seen + db.run( + `UPDATE displays SET last_seen = datetime('now') WHERE id = ?`, + [displayId] + ); + + const json = buildDisplayJson(displayId); + if (!json) { + res.status(404).json({ error: 'Display configuration not found' }); + return; + } + + res.json(json); + } catch (err) { + res.status(500).json({ error: (err as Error).message }); + } +}); + +export default router; diff --git a/display/middleware/src/api/routes/displays.ts b/display/middleware/src/api/routes/displays.ts new file mode 100644 index 0000000..d74cebc --- /dev/null +++ b/display/middleware/src/api/routes/displays.ts @@ -0,0 +1,102 @@ +import { Router } from 'express'; +import { getDb } from '../../db/index'; + +export interface Display { + id: number; + name: string; + description: string | null; + layout_id: number | null; + api_token: string | null; + last_seen: string | null; + refresh_sec: number; + created_at: string; +} + +const router = Router(); + +// GET /api/displays +router.get('/', (_req, res): void => { + try { + const db = getDb(); + res.json(db.prepare('SELECT * FROM displays ORDER BY id ASC').all()); + } catch (err) { + res.status(500).json({ error: (err as Error).message }); + } +}); + +// GET /api/displays/:id +router.get('/:id', (req, res): void => { + try { + const db = getDb(); + const display = db.prepare('SELECT * FROM displays WHERE id = ?').get(parseInt(req.params.id)); + if (!display) { res.status(404).json({ error: 'Display not found' }); return; } + res.json(display); + } catch (err) { + res.status(500).json({ error: (err as Error).message }); + } +}); + +// POST /api/displays +router.post('/', (req, res): void => { + try { + const db = getDb(); + const { name, description, layout_id, api_token, refresh_sec } = req.body; + if (!name) { res.status(400).json({ error: 'name is required' }); return; } + + const result = db.run( + `INSERT INTO displays (name, description, layout_id, api_token, refresh_sec) + VALUES (?, ?, ?, ?, ?)`, + [name, description ?? null, layout_id ?? null, api_token ?? null, refresh_sec ?? 300] + ); + const id = result.lastInsertRowid as number; + res.status(201).json(db.prepare('SELECT * FROM displays WHERE id = ?').get(id)); + } catch (err) { + res.status(500).json({ error: (err as Error).message }); + } +}); + +// PUT /api/displays/:id +router.put('/:id', (req, res): void => { + try { + const db = getDb(); + const id = parseInt(req.params.id); + const fields: string[] = []; + const values: unknown[] = []; + + const { name, description, layout_id, api_token, refresh_sec } = req.body; + if (name !== undefined) { fields.push('name = ?'); values.push(name); } + if (description !== undefined) { fields.push('description = ?'); values.push(description); } + if (layout_id !== undefined) { fields.push('layout_id = ?'); values.push(layout_id); } + if (api_token !== undefined) { fields.push('api_token = ?'); values.push(api_token); } + if (refresh_sec !== undefined) { fields.push('refresh_sec = ?'); values.push(refresh_sec); } + + if (fields.length === 0) { + const display = db.prepare('SELECT * FROM displays WHERE id = ?').get(id); + if (!display) { res.status(404).json({ error: 'Display not found' }); return; } + res.json(display); + return; + } + + values.push(id); + db.run(`UPDATE displays SET ${fields.join(', ')} WHERE id = ?`, values); + const display = db.prepare('SELECT * FROM displays WHERE id = ?').get(id); + if (!display) { res.status(404).json({ error: 'Display not found' }); return; } + res.json(display); + } catch (err) { + res.status(500).json({ error: (err as Error).message }); + } +}); + +// DELETE /api/displays/:id +router.delete('/:id', (req, res): void => { + try { + const db = getDb(); + const result = db.run('DELETE FROM displays WHERE id = ?', [parseInt(req.params.id)]); + if ((result.changes as number) === 0) { res.status(404).json({ error: 'Display not found' }); return; } + res.status(204).send(); + } catch (err) { + res.status(500).json({ error: (err as Error).message }); + } +}); + +export default router; diff --git a/display/middleware/src/api/routes/layouts.ts b/display/middleware/src/api/routes/layouts.ts new file mode 100644 index 0000000..6a08db4 --- /dev/null +++ b/display/middleware/src/api/routes/layouts.ts @@ -0,0 +1,102 @@ +import { Router } from 'express'; +import { + getAllLayouts, + getLayoutById, + createLayout, + updateLayout, + deleteLayout, + addElement, + updateElement, + deleteElement, +} from '../../modules/display/layout-manager'; + +const router = Router(); + +// GET /api/layouts +router.get('/', (_req, res): void => { + try { + res.json(getAllLayouts()); + } catch (err) { + res.status(500).json({ error: (err as Error).message }); + } +}); + +// GET /api/layouts/:id +router.get('/:id', (req, res): void => { + try { + const layout = getLayoutById(parseInt(req.params.id)); + if (!layout) { res.status(404).json({ error: 'Layout not found' }); return; } + res.json(layout); + } catch (err) { + res.status(500).json({ error: (err as Error).message }); + } +}); + +// POST /api/layouts +router.post('/', (req, res): void => { + try { + if (!req.body.name) { res.status(400).json({ error: 'name is required' }); return; } + res.status(201).json(createLayout(req.body)); + } catch (err) { + res.status(500).json({ error: (err as Error).message }); + } +}); + +// PUT /api/layouts/:id +router.put('/:id', (req, res): void => { + try { + const layout = updateLayout(parseInt(req.params.id), req.body); + if (!layout) { res.status(404).json({ error: 'Layout not found' }); return; } + res.json(layout); + } catch (err) { + res.status(500).json({ error: (err as Error).message }); + } +}); + +// DELETE /api/layouts/:id +router.delete('/:id', (req, res): void => { + try { + const deleted = deleteLayout(parseInt(req.params.id)); + if (!deleted) { res.status(404).json({ error: 'Layout not found' }); return; } + res.status(204).send(); + } catch (err) { + res.status(500).json({ error: (err as Error).message }); + } +}); + +// ---- Elements ---- + +// POST /api/layouts/:layoutId/elements +router.post('/:layoutId/elements', (req, res): void => { + try { + const layoutId = parseInt(req.params.layoutId); + if (!req.body.type) { res.status(400).json({ error: 'type is required' }); return; } + res.status(201).json(addElement(layoutId, req.body)); + } catch (err) { + res.status(500).json({ error: (err as Error).message }); + } +}); + +// PUT /api/layouts/:layoutId/elements/:id +router.put('/:layoutId/elements/:id', (req, res): void => { + try { + const el = updateElement(parseInt(req.params.id), req.body); + if (!el) { res.status(404).json({ error: 'Element not found' }); return; } + res.json(el); + } catch (err) { + res.status(500).json({ error: (err as Error).message }); + } +}); + +// DELETE /api/layouts/:layoutId/elements/:id +router.delete('/:layoutId/elements/:id', (req, res): void => { + try { + const deleted = deleteElement(parseInt(req.params.id)); + if (!deleted) { res.status(404).json({ error: 'Element not found' }); return; } + res.status(204).send(); + } catch (err) { + res.status(500).json({ error: (err as Error).message }); + } +}); + +export default router; diff --git a/display/middleware/src/api/routes/mqtt-brokers.ts b/display/middleware/src/api/routes/mqtt-brokers.ts new file mode 100644 index 0000000..f18046a --- /dev/null +++ b/display/middleware/src/api/routes/mqtt-brokers.ts @@ -0,0 +1,92 @@ +import { Router } from 'express'; +import { + getAllBrokers, + getBrokerById, + createBroker, + updateBroker, + deleteBroker, + connectBroker, + disconnectBroker, + testBroker, +} from '../../modules/mqtt/broker-manager'; + +const router = Router(); + +// GET /api/mqtt/brokers +router.get('/', (_req, res): void => { + try { + res.json(getAllBrokers()); + } catch (err) { + res.status(500).json({ error: (err as Error).message }); + } +}); + +// GET /api/mqtt/brokers/:id +router.get('/:id', (req, res): void => { + try { + const broker = getBrokerById(parseInt(req.params.id)); + if (!broker) { res.status(404).json({ error: 'Broker not found' }); return; } + res.json(broker); + } catch (err) { + res.status(500).json({ error: (err as Error).message }); + } +}); + +// POST /api/mqtt/brokers +router.post('/', (req, res): void => { + try { + const { name, host } = req.body; + if (!name || !host) { res.status(400).json({ error: 'name and host are required' }); return; } + res.status(201).json(createBroker(req.body)); + } catch (err) { + res.status(500).json({ error: (err as Error).message }); + } +}); + +// PUT /api/mqtt/brokers/:id +router.put('/:id', (req, res): void => { + try { + const broker = updateBroker(parseInt(req.params.id), req.body); + if (!broker) { res.status(404).json({ error: 'Broker not found' }); return; } + res.json(broker); + } catch (err) { + res.status(500).json({ error: (err as Error).message }); + } +}); + +// DELETE /api/mqtt/brokers/:id +router.delete('/:id', (req, res): void => { + try { + const deleted = deleteBroker(parseInt(req.params.id)); + if (!deleted) { res.status(404).json({ error: 'Broker not found' }); return; } + res.status(204).send(); + } catch (err) { + res.status(500).json({ error: (err as Error).message }); + } +}); + +// POST /api/mqtt/brokers/:id/connect +router.post('/:id/connect', (req, res): void => { + connectBroker(parseInt(req.params.id)) + .then(() => res.json({ status: 'connected' })) + .catch((err) => res.status(500).json({ error: (err as Error).message })); +}); + +// POST /api/mqtt/brokers/:id/disconnect +router.post('/:id/disconnect', (req, res): void => { + try { + disconnectBroker(parseInt(req.params.id)); + res.json({ status: 'disconnected' }); + } catch (err) { + res.status(500).json({ error: (err as Error).message }); + } +}); + +// POST /api/mqtt/brokers/test +router.post('/test', (req, res): void => { + testBroker(req.body) + .then((ok) => res.json({ success: ok })) + .catch((err) => res.status(500).json({ error: (err as Error).message })); +}); + +export default router; diff --git a/display/middleware/src/api/routes/mqtt-topics.ts b/display/middleware/src/api/routes/mqtt-topics.ts new file mode 100644 index 0000000..a374e3f --- /dev/null +++ b/display/middleware/src/api/routes/mqtt-topics.ts @@ -0,0 +1,57 @@ +import { Router } from 'express'; +import { + getTopicsForBroker, + updateTopic, + startDiscovery, + addManualTopic, +} from '../../modules/mqtt/topic-discovery'; + +const router = Router({ mergeParams: true }); + +// GET /api/mqtt/brokers/:brokerId/topics +router.get('/', (req, res): void => { + try { + const brokerId = parseInt(req.params.brokerId); + res.json(getTopicsForBroker(brokerId)); + } catch (err) { + res.status(500).json({ error: (err as Error).message }); + } +}); + +// PUT /api/mqtt/brokers/:brokerId/topics/:id +router.put('/:id', (req, res): void => { + try { + const topic = updateTopic(parseInt(req.params.id), req.body); + if (!topic) { res.status(404).json({ error: 'Topic not found' }); return; } + res.json(topic); + } catch (err) { + res.status(500).json({ error: (err as Error).message }); + } +}); + +// POST /api/mqtt/brokers/:brokerId/topics/discover +router.post('/discover', (req, res): void => { + const brokerId = parseInt(req.params.brokerId); + const { prefix } = req.body; + + res.json({ message: 'Discovery started', brokerId, prefix: prefix ?? null }); + + // Run in background + startDiscovery(brokerId, prefix).catch((err) => { + console.error('Discovery error:', (err as Error).message); + }); +}); + +// POST /api/mqtt/brokers/:brokerId/topics/manual +router.post('/manual', (req, res): void => { + try { + const brokerId = parseInt(req.params.brokerId); + const { topic } = req.body; + if (!topic) { res.status(400).json({ error: 'topic is required' }); return; } + res.status(201).json(addManualTopic(brokerId, topic)); + } catch (err) { + res.status(500).json({ error: (err as Error).message }); + } +}); + +export default router; diff --git a/display/middleware/src/api/routes/pg-sources.ts b/display/middleware/src/api/routes/pg-sources.ts new file mode 100644 index 0000000..e96de77 --- /dev/null +++ b/display/middleware/src/api/routes/pg-sources.ts @@ -0,0 +1,185 @@ +import { Router } from 'express'; +import { + getAllSources, + getSourceById, + createSource, + updateSource, + deleteSource, + connectSource, + disconnectSource, + testSource, +} from '../../modules/postgres/source-manager'; +import { + getAllQueries, + getQueryById, + createQuery, + updateQuery, + deleteQuery, + startQueryTimer, + stopQueryTimer, +} from '../../modules/postgres/query-scheduler'; + +const router = Router(); + +// ---- Sources ---- + +// GET /api/pg/sources +router.get('/', (_req, res): void => { + try { + res.json(getAllSources()); + } catch (err) { + res.status(500).json({ error: (err as Error).message }); + } +}); + +// GET /api/pg/sources/:id +router.get('/:id', (req, res): void => { + try { + const source = getSourceById(parseInt(req.params.id)); + if (!source) { res.status(404).json({ error: 'Source not found' }); return; } + res.json(source); + } catch (err) { + res.status(500).json({ error: (err as Error).message }); + } +}); + +// POST /api/pg/sources +router.post('/', (req, res): void => { + try { + const { name, host, database_name, username, password } = req.body; + if (!name || !host || !database_name || !username || !password) { + res.status(400).json({ error: 'name, host, database_name, username, password are required' }); + return; + } + res.status(201).json(createSource(req.body)); + } catch (err) { + res.status(500).json({ error: (err as Error).message }); + } +}); + +// PUT /api/pg/sources/:id +router.put('/:id', (req, res): void => { + try { + const source = updateSource(parseInt(req.params.id), req.body); + if (!source) { res.status(404).json({ error: 'Source not found' }); return; } + res.json(source); + } catch (err) { + res.status(500).json({ error: (err as Error).message }); + } +}); + +// DELETE /api/pg/sources/:id +router.delete('/:id', (req, res): void => { + try { + const deleted = deleteSource(parseInt(req.params.id)); + if (!deleted) { res.status(404).json({ error: 'Source not found' }); return; } + res.status(204).send(); + } catch (err) { + res.status(500).json({ error: (err as Error).message }); + } +}); + +// POST /api/pg/sources/:id/connect +router.post('/:id/connect', (req, res): void => { + connectSource(parseInt(req.params.id)) + .then(() => res.json({ status: 'connected' })) + .catch((err) => res.status(500).json({ error: (err as Error).message })); +}); + +// POST /api/pg/sources/:id/disconnect +router.post('/:id/disconnect', (req, res): void => { + try { + disconnectSource(parseInt(req.params.id)); + res.json({ status: 'disconnected' }); + } catch (err) { + res.status(500).json({ error: (err as Error).message }); + } +}); + +// POST /api/pg/sources/test +router.post('/test', (req, res): void => { + testSource(req.body) + .then((ok) => res.json({ success: ok })) + .catch((err) => res.status(500).json({ error: (err as Error).message })); +}); + +// ---- Queries (sub-routes) ---- + +// GET /api/pg/sources/:sourceId/queries +router.get('/:sourceId/queries', (req, res): void => { + try { + res.json(getAllQueries(parseInt(req.params.sourceId))); + } catch (err) { + res.status(500).json({ error: (err as Error).message }); + } +}); + +// GET /api/pg/sources/:sourceId/queries/:id +router.get('/:sourceId/queries/:id', (req, res): void => { + try { + const q = getQueryById(parseInt(req.params.id)); + if (!q) { res.status(404).json({ error: 'Query not found' }); return; } + res.json(q); + } catch (err) { + res.status(500).json({ error: (err as Error).message }); + } +}); + +// POST /api/pg/sources/:sourceId/queries +router.post('/:sourceId/queries', (req, res): void => { + try { + const sourceId = parseInt(req.params.sourceId); + const { name, query, result_column } = req.body; + if (!name || !query || !result_column) { + res.status(400).json({ error: 'name, query, result_column are required' }); + return; + } + res.status(201).json(createQuery({ ...req.body, source_id: sourceId })); + } catch (err) { + res.status(500).json({ error: (err as Error).message }); + } +}); + +// PUT /api/pg/sources/:sourceId/queries/:id +router.put('/:sourceId/queries/:id', (req, res): void => { + try { + const q = updateQuery(parseInt(req.params.id), req.body); + if (!q) { res.status(404).json({ error: 'Query not found' }); return; } + res.json(q); + } catch (err) { + res.status(500).json({ error: (err as Error).message }); + } +}); + +// DELETE /api/pg/sources/:sourceId/queries/:id +router.delete('/:sourceId/queries/:id', (req, res): void => { + try { + const deleted = deleteQuery(parseInt(req.params.id)); + if (!deleted) { res.status(404).json({ error: 'Query not found' }); return; } + res.status(204).send(); + } catch (err) { + res.status(500).json({ error: (err as Error).message }); + } +}); + +// POST /api/pg/sources/:sourceId/queries/:id/start +router.post('/:sourceId/queries/:id/start', (req, res): void => { + try { + startQueryTimer(parseInt(req.params.id)); + res.json({ status: 'started' }); + } catch (err) { + res.status(500).json({ error: (err as Error).message }); + } +}); + +// POST /api/pg/sources/:sourceId/queries/:id/stop +router.post('/:sourceId/queries/:id/stop', (req, res): void => { + try { + stopQueryTimer(parseInt(req.params.id)); + res.json({ status: 'stopped' }); + } catch (err) { + res.status(500).json({ error: (err as Error).message }); + } +}); + +export default router; diff --git a/display/middleware/tests/api/display-api.test.ts b/display/middleware/tests/api/display-api.test.ts new file mode 100644 index 0000000..6c85f8e --- /dev/null +++ b/display/middleware/tests/api/display-api.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import request from 'supertest'; +import { initDb, closeDb } from '../../src/db/index'; +import { createApp } from '../../src/api/index'; + +const app = createApp(); + +beforeAll(() => { + const db = initDb(':memory:'); + + // Create a layout + db.run( + `INSERT INTO layouts (id, name, type, width, height) + VALUES (1, 'Test Layout', 'free', 800, 480)` + ); + + // Display without token + db.run( + `INSERT INTO displays (id, name, layout_id, refresh_sec) + VALUES (1, 'Open Display', 1, 60)` + ); + + // Display with token auth + db.run( + `INSERT INTO displays (id, name, layout_id, api_token, refresh_sec) + VALUES (2, 'Secure Display', 1, 'secret-token-123', 300)` + ); +}); + +afterAll(() => { + closeDb(); +}); + +describe('GET /api/display/:id', () => { + it('returns display JSON for open display', async () => { + const res = await request(app).get('/api/display/1'); + expect(res.status).toBe(200); + expect(res.body).toHaveProperty('display'); + expect(res.body.display.id).toBe(1); + expect(res.body).toHaveProperty('layout'); + expect(res.body).toHaveProperty('elements'); + }); + + it('returns 404 for non-existent display', async () => { + const res = await request(app).get('/api/display/999'); + expect(res.status).toBe(404); + }); + + it('returns 401 for token-protected display without token', async () => { + const res = await request(app).get('/api/display/2'); + expect(res.status).toBe(401); + }); + + it('returns 401 for token-protected display with wrong token', async () => { + const res = await request(app) + .get('/api/display/2') + .set('Authorization', 'Bearer wrong-token'); + expect(res.status).toBe(401); + }); + + it('returns display JSON with valid Bearer token', async () => { + const res = await request(app) + .get('/api/display/2') + .set('Authorization', 'Bearer secret-token-123'); + expect(res.status).toBe(200); + expect(res.body.display.id).toBe(2); + }); + + it('returns display JSON with valid query token', async () => { + const res = await request(app).get('/api/display/2?token=secret-token-123'); + expect(res.status).toBe(200); + expect(res.body.display.id).toBe(2); + }); + + it('updates last_seen on successful access', async () => { + await request(app).get('/api/display/1'); + // last_seen is updated — verify by checking display 1 via another endpoint + const res = await request(app).get('/api/display/1'); + expect(res.status).toBe(200); + // Just verify the request succeeds — last_seen updated in DB + }); +});