Replace the Express service with a Next.js app on Vercel
The standalone service in server/ was never deployed anywhere, and keeping it alongside a Vercel app would mean maintaining two implementations of the same validation rules. It is deleted; web/ is the only ingest path now. Its routes live at /v1/... rather than under /api, so the Android client's URL building is unchanged and CONSUMPTION_API_URL stays a bare origin. Credentials are split in two. INGEST_TOKEN is baked into the published APK and must be assumed extractable — anyone can unzip an APK and read its strings — so it opens the PUT endpoints and nothing else. Reading the history needs the dashboard password, which is never compiled into anything and reaches the browser as an HMAC-signed httpOnly cookie. The MongoDB URI stays a server-side environment variable; the car has never held one and still does not. The dashboard is a phone-shaped reader for the same data: totals for energy taken and paid, a daily energy chart, an efficiency trend, and every DC session with its power/SOC curve. It reuses the head unit's OLED palette so the two read as one app. Every page is a server component and the charts are hand-rolled SVG, so the whole thing renders without shipping client JavaScript. MongoClient is cached on globalThis: a fresh client per request means a fresh pool per request, and Atlas starts refusing connections long before the traffic would justify it. Index creation moved to the first write of each warm lambda, since a serverless deployment has no boot step to hang it on. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
e9bfa1217b
commit
8242797ece
@@ -1,4 +0,0 @@
|
||||
MONGODB_URI=mongodb+srv://username:password@cluster.example.mongodb.net/
|
||||
MONGODB_DATABASE=fatihsmg4
|
||||
API_TOKEN=replace-with-a-long-random-secret
|
||||
PORT=8080
|
||||
@@ -1,3 +0,0 @@
|
||||
node_modules/
|
||||
.env
|
||||
npm-debug.log*
|
||||
@@ -1,9 +0,0 @@
|
||||
FROM node:22-alpine
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci --omit=dev
|
||||
COPY src ./src
|
||||
ENV NODE_ENV=production
|
||||
USER node
|
||||
EXPOSE 8080
|
||||
CMD ["node", "src/server.js"]
|
||||
@@ -1,27 +0,0 @@
|
||||
# FatihsMG4 consumption API
|
||||
|
||||
The Android app never connects to MongoDB directly and never contains a MongoDB URI. This
|
||||
service stores completed daily totals in `daily_consumption` and each completed charge,
|
||||
including its SOC/power curve and price, in `charging_sessions`.
|
||||
|
||||
## Run
|
||||
|
||||
1. Copy `.env.example` to `.env` and fill in the MongoDB Atlas URI and a random API token.
|
||||
2. Export the variables (Node does not load `.env` automatically), then run `npm install` and
|
||||
`npm start`.
|
||||
3. Build the Android app with the public HTTPS service address and the same token:
|
||||
|
||||
```sh
|
||||
CONSUMPTION_API_URL=https://your-api.example.com \
|
||||
CONSUMPTION_API_TOKEN=your-random-token \
|
||||
./gradlew assembleUnstableRelease
|
||||
```
|
||||
|
||||
If either Android build variable is missing, cloud sync is disabled and local consumption
|
||||
tracking continues unchanged. The car retries locally queued charging sessions every five minutes.
|
||||
Daily consumption is uploaded once after the day closes; missed days are retried in order.
|
||||
|
||||
The future mobile client can read authenticated history from:
|
||||
|
||||
- `GET /v1/consumption/:installationId`
|
||||
- `GET /v1/charging-sessions/:installationId`
|
||||
Generated
-1040
File diff suppressed because it is too large
Load Diff
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"name": "fatihsmg4-consumption-api",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node src/server.js",
|
||||
"test": "node --test"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^5.1.0",
|
||||
"mongodb": "^6.20.0"
|
||||
}
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
import crypto from "node:crypto";
|
||||
import express from "express";
|
||||
|
||||
export function createApp({ consumptionCollection, chargingCollection, apiToken }) {
|
||||
if (!consumptionCollection || !chargingCollection) throw new Error("collections are required");
|
||||
if (!apiToken || apiToken.length < 24) throw new Error("API_TOKEN must contain at least 24 characters");
|
||||
const app = express();
|
||||
app.disable("x-powered-by");
|
||||
app.use(express.json({ limit: "256kb", strict: true }));
|
||||
const auth = authenticate(apiToken);
|
||||
app.get("/health", (_request, response) => response.json({ status: "ok" }));
|
||||
|
||||
app.put("/v1/consumption/daily", auth, route(async (request, response) => {
|
||||
const record = validateConsumption(request.body);
|
||||
const updatedAt = new Date();
|
||||
await consumptionCollection.updateOne(
|
||||
{ installationId: record.installationId, date: record.date },
|
||||
{ $set: { ...record, updatedAt }, $setOnInsert: { createdAt: updatedAt } }, { upsert: true });
|
||||
response.status(204).end();
|
||||
}));
|
||||
|
||||
app.put("/v1/charging-sessions/:sessionId", auth, route(async (request, response) => {
|
||||
const record = validateChargingSession(request.body, request.params.sessionId);
|
||||
const updatedAt = new Date();
|
||||
await chargingCollection.updateOne(
|
||||
{ installationId: record.installationId, sessionId: record.sessionId },
|
||||
{ $set: { ...record, updatedAt }, $setOnInsert: { createdAt: updatedAt } }, { upsert: true });
|
||||
response.status(204).end();
|
||||
}));
|
||||
|
||||
app.get("/v1/consumption/:installationId", auth, route(async (request, response) => {
|
||||
const installationId = validateUuid(request.params.installationId, "installationId");
|
||||
const records = await consumptionCollection.find({ installationId }).sort({ date: -1 }).limit(400).toArray();
|
||||
response.json({ records });
|
||||
}));
|
||||
app.get("/v1/charging-sessions/:installationId", auth, route(async (request, response) => {
|
||||
const installationId = validateUuid(request.params.installationId, "installationId");
|
||||
const records = await chargingCollection.find({ installationId }).sort({ startedAt: -1 }).limit(200).toArray();
|
||||
response.json({ records });
|
||||
}));
|
||||
|
||||
app.use((error, _request, response, _next) => {
|
||||
if (error instanceof ValidationError || error?.type === "entity.parse.failed" || error?.type === "entity.too.large") {
|
||||
response.status(400).json({ error: error.message || "invalid request" });
|
||||
return;
|
||||
}
|
||||
console.error("Request failed", error);
|
||||
response.status(500).json({ error: "internal server error" });
|
||||
});
|
||||
return app;
|
||||
}
|
||||
|
||||
function route(handler) { return (request, response, next) => Promise.resolve(handler(request, response)).catch(next); }
|
||||
|
||||
function authenticate(expected) {
|
||||
const expectedBytes = Buffer.from(expected);
|
||||
return (request, response, next) => {
|
||||
const header = request.get("authorization") ?? "";
|
||||
const supplied = header.startsWith("Bearer ") ? header.slice(7) : "";
|
||||
const suppliedBytes = Buffer.from(supplied);
|
||||
if (suppliedBytes.length !== expectedBytes.length || !crypto.timingSafeEqual(suppliedBytes, expectedBytes)) {
|
||||
response.status(401).json({ error: "unauthorized" }); return;
|
||||
}
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
class ValidationError extends Error {}
|
||||
|
||||
function validateConsumption(body) {
|
||||
validateBody(body);
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(body.date ?? "")) throw new ValidationError("date is invalid");
|
||||
return {
|
||||
installationId: validateUuid(body.installationId, "installationId"), date: body.date,
|
||||
recordedAt: validateDate(body.recordedAt, "recordedAt"), day: validateTotals(body.day, "day"),
|
||||
lifetime: validateTotals(body.lifetime, "lifetime"),
|
||||
...(body.sohPercent === undefined ? {} : { sohPercent: bounded(body.sohPercent, 0, 100, "sohPercent") })
|
||||
};
|
||||
}
|
||||
|
||||
function validateChargingSession(body, pathSessionId) {
|
||||
validateBody(body);
|
||||
const sessionId = validateUuid(body.sessionId, "sessionId");
|
||||
if (sessionId !== pathSessionId) throw new ValidationError("sessionId does not match path");
|
||||
if (!Array.isArray(body.curve) || body.curve.length > 720) throw new ValidationError("curve is invalid");
|
||||
const curve = body.curve.map((point) => ({
|
||||
timestamp: validateDate(point.timestampMs, "curve.timestampMs"),
|
||||
powerKw: bounded(point.powerKw, 0, 1000, "curve.powerKw"),
|
||||
...(point.socPercent === undefined ? {} : { socPercent: bounded(point.socPercent, 0, 100, "curve.socPercent") })
|
||||
}));
|
||||
return {
|
||||
installationId: validateUuid(body.installationId, "installationId"), sessionId,
|
||||
startedAt: validateDate(body.startedAt, "startedAt"), endedAt: validateDate(body.endedAt, "endedAt"),
|
||||
durationSeconds: bounded(body.durationSeconds, 0, 604800, "durationSeconds"),
|
||||
energyKwh: bounded(body.energyKwh, 0, 1000, "energyKwh"),
|
||||
pricePerKwh: bounded(body.pricePerKwh, 0, 1_000_000, "pricePerKwh"),
|
||||
totalCost: bounded(body.totalCost, 0, 1_000_000_000, "totalCost"),
|
||||
...(body.startSocPercent === undefined ? {} : { startSocPercent: bounded(body.startSocPercent, 0, 100, "startSocPercent") }),
|
||||
...(body.endSocPercent === undefined ? {} : { endSocPercent: bounded(body.endSocPercent, 0, 100, "endSocPercent") }), curve
|
||||
};
|
||||
}
|
||||
|
||||
function validateBody(body) {
|
||||
if (!body || typeof body !== "object" || Array.isArray(body)) throw new ValidationError("body is required");
|
||||
}
|
||||
function validateUuid(value, name) {
|
||||
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value ?? ""))
|
||||
throw new ValidationError(`${name} is invalid`);
|
||||
return value;
|
||||
}
|
||||
function validateDate(value, name) {
|
||||
if (!Number.isSafeInteger(value) || value < 0) throw new ValidationError(`${name} is invalid`);
|
||||
return new Date(value);
|
||||
}
|
||||
function bounded(value, min, max, name) {
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value < min || value > max)
|
||||
throw new ValidationError(`${name} is invalid`);
|
||||
return value;
|
||||
}
|
||||
function validateTotals(value, name) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) throw new ValidationError(`${name} is invalid`);
|
||||
const result = {};
|
||||
for (const field of ["km", "kwh", "hours", "socDrop"]) {
|
||||
const number = value[field];
|
||||
if (typeof number !== "number" || !Number.isFinite(number) || Math.abs(number) > 10_000_000)
|
||||
throw new ValidationError(`${name}.${field} is invalid`);
|
||||
result[field] = number;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { MongoClient } from "mongodb";
|
||||
import { createApp } from "./app.js";
|
||||
|
||||
const { MONGODB_URI, API_TOKEN } = process.env;
|
||||
const databaseName = process.env.MONGODB_DATABASE || "fatihsmg4";
|
||||
const port = Number(process.env.PORT || 8080);
|
||||
|
||||
if (!MONGODB_URI) throw new Error("MONGODB_URI is required");
|
||||
if (!API_TOKEN) throw new Error("API_TOKEN is required");
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("PORT is invalid");
|
||||
|
||||
const client = new MongoClient(MONGODB_URI, { serverSelectionTimeoutMS: 10_000 });
|
||||
await client.connect();
|
||||
const database = client.db(databaseName);
|
||||
const consumptionCollection = database.collection("daily_consumption");
|
||||
const chargingCollection = database.collection("charging_sessions");
|
||||
await consumptionCollection.createIndex({ installationId: 1, date: 1 }, { unique: true });
|
||||
await consumptionCollection.createIndex({ installationId: 1, date: -1 });
|
||||
await chargingCollection.createIndex({ installationId: 1, sessionId: 1 }, { unique: true });
|
||||
await chargingCollection.createIndex({ installationId: 1, startedAt: -1 });
|
||||
|
||||
const app = createApp({ consumptionCollection, chargingCollection, apiToken: API_TOKEN });
|
||||
const server = app.listen(port, () => console.log(`FatihsMG4 API listening on ${port}`));
|
||||
|
||||
async function shutdown() {
|
||||
server.close(async () => {
|
||||
await client.close();
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
process.on("SIGINT", shutdown);
|
||||
process.on("SIGTERM", shutdown);
|
||||
@@ -1,57 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { after, before, test } from "node:test";
|
||||
import { createApp } from "../src/app.js";
|
||||
|
||||
const token = "a-secure-test-token-with-24-chars";
|
||||
const consumptionWrites = [], chargingWrites = [];
|
||||
const readable = (writes) => ({
|
||||
async updateOne(filter, update, options) { writes.push({ filter, update, options }); },
|
||||
find() { return { sort() { return this; }, limit() { return this; }, async toArray() { return []; } }; }
|
||||
});
|
||||
let server, baseUrl;
|
||||
|
||||
before(async () => {
|
||||
const app = createApp({ consumptionCollection: readable(consumptionWrites),
|
||||
chargingCollection: readable(chargingWrites), apiToken: token });
|
||||
await new Promise((resolve) => { server = app.listen(0, "127.0.0.1", resolve); });
|
||||
baseUrl = `http://127.0.0.1:${server.address().port}`;
|
||||
});
|
||||
after(() => new Promise((resolve) => server.close(resolve)));
|
||||
|
||||
const auth = { "content-type": "application/json", authorization: `Bearer ${token}` };
|
||||
const installationId = "123e4567-e89b-42d3-a456-426614174000";
|
||||
const consumption = { installationId, recordedAt: 1791417600000, date: "2026-10-08",
|
||||
day: { km: 12, kwh: 2.1, hours: .4, socDrop: 4 },
|
||||
lifetime: { km: 1000, kwh: 170, hours: 30, socDrop: 400 }, sohPercent: 91.5 };
|
||||
|
||||
test("rejects requests without the API token", async () => {
|
||||
const response = await fetch(`${baseUrl}/v1/consumption/daily`, { method: "PUT", body: JSON.stringify(consumption), headers: { "content-type": "application/json" } });
|
||||
assert.equal(response.status, 401);
|
||||
});
|
||||
|
||||
test("upserts one daily consumption record", async () => {
|
||||
const response = await fetch(`${baseUrl}/v1/consumption/daily`, { method: "PUT", headers: auth, body: JSON.stringify(consumption) });
|
||||
assert.equal(response.status, 204);
|
||||
assert.deepEqual(consumptionWrites.at(-1).filter, { installationId, date: "2026-10-08" });
|
||||
assert.equal(consumptionWrites.at(-1).update.$set.day.kwh, 2.1);
|
||||
});
|
||||
|
||||
test("upserts a complete charging session with its curve and price", async () => {
|
||||
const sessionId = "223e4567-e89b-42d3-a456-426614174001";
|
||||
const session = { installationId, sessionId, startedAt: 1791417600000, endedAt: 1791421200000,
|
||||
durationSeconds: 3600, energyKwh: 50, pricePerKwh: 8.5, totalCost: 425,
|
||||
startSocPercent: 10, endSocPercent: 90,
|
||||
curve: [{ timestampMs: 1791417600000, socPercent: 10, powerKw: 72 }] };
|
||||
const response = await fetch(`${baseUrl}/v1/charging-sessions/${sessionId}`,
|
||||
{ method: "PUT", headers: auth, body: JSON.stringify(session) });
|
||||
assert.equal(response.status, 204);
|
||||
assert.deepEqual(chargingWrites.at(-1).filter, { installationId, sessionId });
|
||||
assert.equal(chargingWrites.at(-1).update.$set.totalCost, 425);
|
||||
assert.equal(chargingWrites.at(-1).update.$set.curve[0].powerKw, 72);
|
||||
});
|
||||
|
||||
test("read APIs are authenticated and mobile-client ready", async () => {
|
||||
const response = await fetch(`${baseUrl}/v1/charging-sessions/${installationId}`, { headers: { authorization: `Bearer ${token}` } });
|
||||
assert.equal(response.status, 200);
|
||||
assert.deepEqual(await response.json(), { records: [] });
|
||||
});
|
||||
Reference in New Issue
Block a user