From 64811a1508726c0c5cba0feeca9b858f52b8bca3 Mon Sep 17 00:00:00 2001 From: TCFDONMEZ Date: Fri, 9 Oct 2026 17:28:37 +0300 Subject: [PATCH] feat: chunk-based consumption sync + ingest-token history read endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- web/src/app/v1/consumption/daily/route.ts | 31 +++++++++++++++--- .../history/[installationId]/route.ts | 32 +++++++++++++++++++ 2 files changed, 59 insertions(+), 4 deletions(-) create mode 100644 web/src/app/v1/consumption/history/[installationId]/route.ts diff --git a/web/src/app/v1/consumption/daily/route.ts b/web/src/app/v1/consumption/daily/route.ts index 6c753ab..a42f94a 100644 --- a/web/src/app/v1/consumption/daily/route.ts +++ b/web/src/app/v1/consumption/daily/route.ts @@ -7,9 +7,13 @@ export const runtime = "nodejs"; export const dynamic = "force-dynamic"; /** - * Upsert on (installationId, date). The car retries a day it could not confirm, so the - * same day arrives more than once and must land on the same document rather than a - * duplicate — the unique index is what guarantees that under a concurrent retry. + * Upsert on (installationId, date), incrementally. The car sends a 30-minute delta chunk + * (not a cumulative total), so each PUT adds to the day's running totals rather than + * 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) { const denied = requireIngest(request); @@ -19,11 +23,30 @@ export async function PUT(request: Request) { await ensureIndexes(); const collection = await consumptionCollection(); const updatedAt = new Date(); + const setOnInsert: Record = { + createdAt: updatedAt, + installationId: record.installationId, + date: record.date, + "day.km": 0, "day.kwh": 0, "day.hours": 0, "day.socDrop": 0, + }; + const update: Record> = { + $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( { installationId: record.installationId, date: record.date }, - { $set: { ...record, updatedAt }, $setOnInsert: { createdAt: updatedAt } }, + update, { upsert: true }, ); return new NextResponse(null, { status: 204 }); }); } + diff --git a/web/src/app/v1/consumption/history/[installationId]/route.ts b/web/src/app/v1/consumption/history/[installationId]/route.ts new file mode 100644 index 0000000..631ba01 --- /dev/null +++ b/web/src/app/v1/consumption/history/[installationId]/route.ts @@ -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 }); + }); +}