Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@
"escape-html": "^1.0.3",
"express": "^5.2.1",
"helmet": "^8.0.0",
"pino": "^10.3.1"
"pino": "^10.3.1",
"ioredis": "^5.4.0"
},
"devDependencies": {
"eslint-plugin-security": "^3.0.1",
Expand Down
84 changes: 84 additions & 0 deletions src/middleware/cache.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
// src/middleware/cache.js
//
// Express middleware for automatic GET response caching (issue #12).
// Only caches GET requests. Varies cache key by URL + query + auth status.

const { cache, TTL_PRESETS } = require('../services/cache');
const { childLogger } = require('../config/logger');

const log = childLogger('middleware:cache');

/**
* Determine TTL from the request path.
* /api/meters/* → meter TTL (60s)
* /api/admin/config/* → config TTL (300s)
* /api/analytics/* → analytics TTL (120s)
* default → 60s
*/
function getTTL(path) {
if (path.includes('/config')) return TTL_PRESETS.config;
if (path.includes('/analytics')) return TTL_PRESETS.analytics;
if (path.includes('/meter')) return TTL_PRESETS.meter;
return TTL_PRESETS.default;
}

/**
* Build a cache key from request.
*/
function buildCacheKey(req) {
const path = req.originalUrl || req.url;
const authStatus = req.headers.authorization ? 'authed' : 'anon';
return `equipchain:http:${authStatus}:${Buffer.from(path).toString('base64url')}`;
}

/**
* Cache middleware factory.
* @param {number} ttl - Cache TTL in seconds (overrides auto-detection)
*/
function cacheMiddleware(ttl) {
return async (req, res, next) => {
// Only cache GET requests
if (req.method !== 'GET') {
return next();
}

const key = buildCacheKey(req);
const effectiveTTL = ttl || getTTL(req.path);

// Try cache first
const cached = await cache.get(key);
if (cached) {
log.debug({ key }, 'Cache hit');
res.set('X-Cache', 'HIT');
res.set('X-Cache-TTL', effectiveTTL.toString());
return res.json(cached);
}

// Intercept res.json to cache the response
const originalJson = res.json.bind(res);
res.json = function (body) {
// Only cache successful responses (2xx)
if (res.statusCode >= 200 && res.statusCode < 300 && body) {
cache.set(key, body, effectiveTTL).catch((err) => {
log.error({ err: err.message, key }, 'Failed to cache response');
});
}

res.set('X-Cache', 'MISS');
return originalJson(body);
};

next();
};
}

/**
* Cache invalidation helper — call after write operations.
* @param {string} entity - Entity type (meter, config, etc.)
* @param {string} id - Entity ID
*/
async function invalidateCache(entity, id) {
return cache.invalidate(entity, id);
}

module.exports = { cacheMiddleware, invalidateCache, getTTL, buildCacheKey };
2 changes: 2 additions & 0 deletions src/routes/exports.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ const express = require('express');
const router = express.Router();
const { handleExport } = require('../services/exporter');
const { childLogger } = require('../config/logger');
const { validate } = require('../middleware/validate');
const { readingsQuerySchema, analyticsExportSchema, createReadingSchema, bulkReadingsSchema } = require('../schemas/metering.schema');

const log = childLogger('routes:exports');

Expand Down
14 changes: 10 additions & 4 deletions src/routes/index.js
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
const express = require('express');
const router = express.Router();
const { services } = require('../services');
const { cache } = require('../services/cache');

// Import route modules here as they are created
// const authRoutes = require('./auth');
// const adminRoutes = require('./admin');
// const analyticsRoutes = require('./analytics');
const adminRoutes = require('./admin');
const analyticsRoutes = require('./analytics');
const exportRoutes = require('./exports');

// Mount routes under their respective prefixes
// router.use('/api/auth', authRoutes);
// router.use('/api/admin', adminRoutes);
// router.use('/api/analytics', analyticsRoutes);
router.use('/api/admin', adminRoutes);
router.use('/api/analytics', analyticsRoutes);
router.use('/api/exports', exportRoutes);

// Health check route
Expand All @@ -34,6 +35,11 @@ router.get('/health', (req, res) => {
};
}

// Add cache stats if cache service is available
if (cache) {
healthData.cache = cache.health();
}

res.json(healthData);
});

Expand Down
53 changes: 53 additions & 0 deletions src/schemas/metering.schema.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// src/schemas/metering.schema.js
//
// Zod validation schemas for metering/export endpoints (issue #8).
// Covers the export routes that were missing validation in src/routes/exports.js.

const { z } = require('zod');
const { isoDateString, paginationQuerySchema } = require('./common.schema');

/**
* Query params for listing meter readings via export endpoints.
*/
const readingsQuerySchema = paginationQuerySchema.extend({
meterId: z.string().min(1).optional(),
status: z.enum(['verified', 'pending', 'rejected']).optional(),
unit: z.enum(['kWh', 'kW', 'V', 'A', 'VAR', 'VA']).optional(),
startDate: isoDateString.optional(),
endDate: isoDateString.optional(),
});

/**
* Query params for analytics export endpoints.
*/
const analyticsExportSchema = z.object({
startDate: isoDateString.optional(),
endDate: isoDateString.optional(),
granularity: z.enum(['hour', 'day', 'week', 'month']).default('day'),
format: z.enum(['json', 'csv']).default('json'),
});

/**
* Body schema for creating a meter reading submission.
*/
const createReadingSchema = z.object({
meterId: z.string().min(1),
timestamp: isoDateString,
value: z.number().finite(),
unit: z.enum(['kWh', 'kW', 'V', 'A', 'VAR', 'VA']),
status: z.enum(['verified', 'pending', 'rejected']).default('pending'),
}).strict();

/**
* Body schema for bulk reading import.
*/
const bulkReadingsSchema = z.object({
readings: z.array(createReadingSchema).min(1).max(1000),
}).strict();

module.exports = {
readingsQuerySchema,
analyticsExportSchema,
createReadingSchema,
bulkReadingsSchema,
};
Loading