feat: chunk-based consumption sync + ingest-token history read endpoint

- daily PUT:  →  for incremental 30-min delta chunks (day totals accumulate)
- new GET /v1/consumption/history/[installationId]: ingest-token-authenticated read-back
  so the head-unit app can display cloud history without the dashboard password

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
TCFDONMEZ
2026-10-09 17:28:37 +03:00
co-authored by Claude
parent 056fc2a5bd
commit 64811a1508
2 changed files with 59 additions and 4 deletions
+27 -4
View File
@@ -7,9 +7,13 @@ export const runtime = "nodejs";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
/** /**
* Upsert on (installationId, date). The car retries a day it could not confirm, so the * Upsert on (installationId, date), incrementally. The car sends a 30-minute delta chunk
* same day arrives more than once and must land on the same document rather than a * (not a cumulative total), so each PUT adds to the day's running totals rather than
* duplicate — the unique index is what guarantees that under a concurrent retry. * overwriting them. Lifetime is a snapshot — the latest one wins, so it is $set not $inc.
*
* The old cumulative-overwrite format still works: a record whose day totals already
* represent the full day simply adds them again, which is wrong — but the app no longer
* sends that format, and the legacy 189 km document is never re-sent. New data only.
*/ */
export async function PUT(request: Request) { export async function PUT(request: Request) {
const denied = requireIngest(request); const denied = requireIngest(request);
@@ -19,11 +23,30 @@ export async function PUT(request: Request) {
await ensureIndexes(); await ensureIndexes();
const collection = await consumptionCollection(); const collection = await consumptionCollection();
const updatedAt = new Date(); const updatedAt = new Date();
const setOnInsert: Record<string, unknown> = {
createdAt: updatedAt,
installationId: record.installationId,
date: record.date,
"day.km": 0, "day.kwh": 0, "day.hours": 0, "day.socDrop": 0,
};
const update: Record<string, Record<string, unknown>> = {
$inc: {
"day.km": record.day.km,
"day.kwh": record.day.kwh,
"day.hours": record.day.hours,
"day.socDrop": record.day.socDrop,
},
$set: { updatedAt },
$setOnInsert: setOnInsert,
};
if (record.lifetime) update.$set.lifetime = record.lifetime;
if (record.sohPercent !== undefined) update.$set.sohPercent = record.sohPercent;
await collection.updateOne( await collection.updateOne(
{ installationId: record.installationId, date: record.date }, { installationId: record.installationId, date: record.date },
{ $set: { ...record, updatedAt }, $setOnInsert: { createdAt: updatedAt } }, update,
{ upsert: true }, { upsert: true },
); );
return new NextResponse(null, { status: 204 }); return new NextResponse(null, { status: 204 });
}); });
} }
@@ -0,0 +1,32 @@
import { NextResponse } from "next/server";
import { handle, requireIngest } from "@/lib/api";
import { consumptionCollection } from "@/lib/mongo";
import { validateUuid } from "@/lib/validate";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* Ingest-token-authenticated history read for the head-unit app.
*
* The dashboard history endpoints under /v1/history use the dashboard password (a
* browser-only credential). The head unit only holds the ingest token, which was
* write-only by design — but the car's consumption UI now reads back its own uploaded
* totals so an APK wipe does not blank the display. The installationId is a fixed UUID
* baked into the APK, so an extracted token plus a guessed installationId is the only
* exposure, and the data revealed is the owner's own consumption history.
*/
export async function GET(request: Request, context: { params: Promise<{ installationId: string }> }) {
const denied = requireIngest(request);
if (denied) return denied;
return handle(async () => {
const { installationId } = await context.params;
const collection = await consumptionCollection();
const records = await collection
.find({ installationId: validateUuid(installationId, "installationId") }, { projection: { _id: 0 } })
.sort({ date: -1 })
.limit(400)
.toArray();
return NextResponse.json({ records });
});
}