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:
TCFDONMEZ
2026-10-08 14:21:37 +03:00
co-authored by Claude Opus 5
parent e9bfa1217b
commit 8242797ece
40 changed files with 3120 additions and 1319 deletions
-4
View File
@@ -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
-3
View File
@@ -1,3 +0,0 @@
node_modules/
.env
npm-debug.log*
-9
View File
@@ -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"]
-27
View File
@@ -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`
-1040
View File
File diff suppressed because it is too large Load Diff
-17
View File
@@ -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"
}
}
-130
View File
@@ -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;
}
-32
View File
@@ -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);
-57
View File
@@ -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: [] });
});