Skip to content
Merged
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
17 changes: 17 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Environment Configuration
NODE_ENV=development
PORT=3000

# Contract Configuration
CONTRACT_ID=CB7PSJZALNWNX7NLOAM6LOEL4OJZMFPQZJMIYO522ZSACYWXTZIDEDSS

# Logging Configuration
LOG_LEVEL=info

# Security Configuration
# Maximum request body size (e.g., '1mb', '10mb', '100kb')
# Default: '1mb'
MAX_BODY_SIZE=1mb

# OpenTelemetry Configuration
OTEL_SERVICE_NAME=equipchain-api
29 changes: 29 additions & 0 deletions .eslintrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"env": {
"node": true,
"es2021": true
},
"extends": "eslint:recommended",
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module"
},
"plugins": [
"security"
],
"rules": {
"security/detect-object-injection": "warn",
"security/detect-non-literal-fs-filename": "warn",
"security/detect-non-literal-regexp": "warn",
"security/detect-non-literal-require": "warn",
"security/detect-unsafe-regex": "error",
"security/detect-buffer-noassert": "error",
"security/detect-child-process": "warn",
"security/detect-disable-mustache-escape": "error",
"security/detect-eval-with-expression": "error",
"security/detect-no-csrf-before-method-override": "warn",
"security/detect-possible-timing-attacks": "warn",
"security/detect-pseudoRandomBytes": "warn",
"no-console": "off"
}
}
56 changes: 54 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -229,8 +229,60 @@ Same parameters as daily-summary, returns monthly rollups.

| Method | Path | Description | Auth |
|--------|------|-------------|------|
| `GET` | `/exports/meters` | Export meter data (CSV/JSON) | Yes |
| `GET` | `/exports/readings` | Export readings report | Yes |
| `GET` | `/api/exports/readings` | Export meter readings (CSV/JSON/NDJSON) | Yes |
| `GET` | `/api/exports/analytics/:summaryType` | Export analytics summaries (daily/weekly/monthly) | Yes |
| `GET` | `/api/exports/system-report` | Export system-wide report (meters, readings, alerts) | Admin |
| `GET` | `/api/exports/meters` | Export meter registry | Yes |

#### Export Query Parameters

All export endpoints support the following query parameters:

- `format` - Output format: `csv`, `json`, or `ndjson` (default: `csv`)
- `fields` - Comma-separated list of fields to include (e.g., `id,timestamp,value`)
- `startDate` - Filter by start date (ISO format: `2026-01-01` or `2026-01-01T00:00:00Z`)
- `endDate` - Filter by end date (ISO format)
- `pretty` - Set to `true` for pretty-printed JSON (default: `false`)

Additional parameters specific to endpoints:

- `/api/exports/readings`: `meterIds` (comma-separated), `status`
- `/api/exports/analytics/:summaryType`: Date range filtering for daily summaries
- `/api/exports/system-report`: `sections` (comma-separated: `meters,readings,alerts,summary`)
- `/api/exports/meters`: `status`, `location`

#### Export Examples

```bash
# Export all readings as CSV
curl -H "Authorization: Bearer YOUR_TOKEN" \
"http://localhost:3000/api/exports/readings?format=csv"

# Export specific fields as JSON
curl -H "Authorization: Bearer YOUR_TOKEN" \
"http://localhost:3000/api/exports/readings?format=json&fields=id,timestamp,value"

# Export readings for specific meters and date range
curl -H "Authorization: Bearer YOUR_TOKEN" \
"http://localhost:3000/api/exports/readings?format=csv&meterIds=meter-001,meter-002&startDate=2026-01-01&endDate=2026-06-01"

# Export daily analytics as NDJSON (streaming)
curl -H "Authorization: Bearer YOUR_TOKEN" \
"http://localhost:3000/api/exports/analytics/daily?format=ndjson"

# Export system report (admin only)
curl -H "Authorization: Bearer YOUR_TOKEN" \
-H "x-role: admin" \
"http://localhost:3000/api/exports/system-report?format=json&sections=meters,summary"
```

#### Streaming Support

All export endpoints use streaming to handle large datasets efficiently:
- Responses use `Transfer-Encoding: chunked`
- Data is streamed row-by-row for CSV and line-by-line for NDJSON
- Memory usage remains constant regardless of dataset size
- Suitable for exporting 10,000+ records

### Webhooks (Planned)

Expand Down
8 changes: 5 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,15 @@
"@opentelemetry/exporter-trace-otlp-http": "^0.221.0",
"@opentelemetry/sdk-node": "^0.221.0",
"cors": "^2.8.6",
"csv-stringify": "^6.5.1",
"dotenv": "^17.3.1",
"escape-html": "^1.0.3",
"express": "^5.2.1",
"jsonwebtoken": "^9.0.3",
"pino": "^10.3.1",
"zod": "^4.4.3"
"helmet": "^8.0.0",
"pino": "^10.3.1"
},
"devDependencies": {
"eslint-plugin-security": "^3.0.1",
"pino-pretty": "^13.1.3"
}
}
101 changes: 101 additions & 0 deletions src/app.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
const crypto = require('crypto');
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const { trace } = require('@opentelemetry/api');
const { childLogger } = require('./config/logger');
const config = require('./config');
const routes = require('./routes');
const { sanitizeForLogging, sanitize } = require('./utils/sanitize');

const app = express();
const log = childLogger('http');

// Security middleware
app.use(helmet());
app.use(cors());

// Ensure Content-Type is application/json for all API responses
app.use((req, res, next) => {
const originalJson = res.json;
res.json = function (data) {
if (!res.headersSent) {
res.setHeader('Content-Type', 'application/json');
}
return originalJson.call(this, data);
};
next();
});

// Body parsing middleware with size limits
app.use(express.json({ limit: config.maxBodySize }));
app.use(express.urlencoded({ extended: true, limit: config.maxBodySize }));

// Correlation ID and request logging middleware
app.use((req, res, next) => {
const correlationId = req.headers['x-correlation-id'] || crypto.randomUUID();
req.correlationId = correlationId;
res.setHeader('x-correlation-id', correlationId);

const activeSpan = trace.getActiveSpan();
if (activeSpan) {
activeSpan.setAttribute('correlation.id', correlationId);
}

const start = process.hrtime.bigint();

res.on('finish', () => {
const durationMs = Number(process.hrtime.bigint() - start) / 1e6;
log.info(
{
correlationId,
method: req.method,
url: sanitizeForLogging(req.originalUrl),
status: res.statusCode,
durationMs,
},
'request completed'
);
});

next();
});

// Mount routes
app.use('/', routes);

// Root route with project info
app.get('/', (req, res) => {
res.json({
project: 'Equipchain',
status: 'Monitoring Meters',
contract: config.contractId,
});
});

// 404 handler
app.use((req, res) => {
res.status(404).json({
error: 'Not Found',
message: sanitize(`Cannot ${req.method} ${req.originalUrl}`),
});
});

// Error handling middleware
app.use((err, req, res, next) => {
log.error(
{
correlationId: req.correlationId,
error: sanitizeForLogging(err.message),
stack: err.stack,
},
'request error'
);

res.status(err.status || 500).json({
error: err.name || 'Internal Server Error',
message: config.isProduction ? 'An error occurred' : sanitize(err.message),
});
});

module.exports = app;
35 changes: 35 additions & 0 deletions src/config/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
require('dotenv').config();

const {
NODE_ENV = 'development',
PORT = '3000',
CONTRACT_ID = 'CB7PSJZALNWNX7NLOAM6LOEL4OJZMFPQZJMIYO522ZSACYWXTZIDEDSS',
LOG_LEVEL = 'info',
OTEL_SERVICE_NAME = 'equipchain-api',
MAX_BODY_SIZE = '1mb',
} = process.env;

// Validate required environment variables for production
const isProduction = NODE_ENV === 'production';
const isTest = NODE_ENV === 'test';

if (isProduction) {
// Add any production-specific required variables here
// For example: JWT_SECRET, DATABASE_URL, etc.
// Currently no additional required variables for this project
}

const config = Object.freeze({
env: NODE_ENV,
port: parseInt(PORT, 10),
contractId: CONTRACT_ID,
logLevel: LOG_LEVEL,
maxBodySize: MAX_BODY_SIZE,
otel: {
serviceName: OTEL_SERVICE_NAME,
},
isProduction,
isTest,
});

module.exports = config;
14 changes: 14 additions & 0 deletions src/config/logger.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,20 @@ const isTest = process.env.NODE_ENV === 'test';

const logger = pino({
level: process.env.LOG_LEVEL || 'info',
// Redact sensitive fields to prevent log injection and protect sensitive data
redact: {
paths: [
'password',
'token',
'apiKey',
'secret',
'authorization',
'cookie',
'req.headers.authorization',
'req.headers.cookie',
],
remove: true,
},
transport:
isProduction || isTest
? undefined
Expand Down
42 changes: 42 additions & 0 deletions src/jobs/billing.job.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
const { childLogger } = require('../config/logger');

const log = childLogger('job:billing');

/**
* Billing job handler
* Aggregates meter readings and computes charges
*
* @param {Object} data - Job data
* @param {string} data.period - Billing period (e.g., "2024-01")
* @param {string} data.accountId - Account ID to bill (optional, null for all accounts)
* @returns {Object} Billing results
*/
async function billingHandler(data) {
const { period, accountId } = data;

log.info({ period, accountId }, 'Starting billing job');

// TODO: Implement actual billing logic
// 1. Fetch meter readings for the period
// 2. Aggregate readings by account
// 3. Compute charges based on rates
// 4. Generate invoices
// 5. Store results in database

// Placeholder implementation
await new Promise(resolve => setTimeout(resolve, 1000));

const result = {
period,
accountId: accountId || 'all',
invoicesGenerated: Math.floor(Math.random() * 100),
totalAmount: (Math.random() * 10000).toFixed(2),
processedAt: new Date().toISOString(),
};

log.info({ result }, 'Billing job completed');

return result;
}

module.exports = billingHandler;
41 changes: 41 additions & 0 deletions src/jobs/cacheWarm.job.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
const { childLogger } = require('../config/logger');

const log = childLogger('job:cacheWarm');

/**
* Cache warm job handler
* Warms cache for frequently accessed data
*
* @param {Object} data - Job data
* @param {string} data.cacheType - Type of cache to warm ('meter_data', 'user_profiles', 'contract_state')
* @param {Array<string>} data.keys - Specific cache keys to warm (optional)
* @returns {Object} Cache warming results
*/
async function cacheWarmHandler(data) {
const { cacheType, keys } = data;

log.info({ cacheType, keys }, 'Starting cache warm job');

// TODO: Implement actual cache warming logic
// 1. Identify frequently accessed data patterns
// 2. Fetch data from database or blockchain
// 3. Populate cache with pre-fetched data
// 4. Set appropriate TTL values
// 5. Monitor cache hit rates

// Placeholder implementation
await new Promise(resolve => setTimeout(resolve, 300));

const result = {
cacheType,
keysWarmed: keys?.length || Math.floor(Math.random() * 50),
cacheSize: (Math.random() * 10).toFixed(2) + 'MB',
warmedAt: new Date().toISOString(),
};

log.info({ result }, 'Cache warm job completed');

return result;
}

module.exports = cacheWarmHandler;
Loading
Loading