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
+14
View File
@@ -0,0 +1,14 @@
# MongoDB Atlas connection string. Never leaves the server.
MONGODB_URI=mongodb+srv://user:password@cluster.mongodb.net/?retryWrites=true&w=majority
MONGODB_DATABASE=fatihsmg4
# Write-only token the car sends as `Authorization: Bearer ...` on the PUT endpoints.
# Generate with: openssl rand -hex 32
INGEST_TOKEN=
# Password for the web dashboard. Anyone who knows it can read the history.
DASHBOARD_PASSWORD=
# Signs the session cookie. Rotating it logs every browser out.
# Generate with: openssl rand -hex 32
SESSION_SECRET=
+6
View File
@@ -0,0 +1,6 @@
node_modules
.next
.env
.env.local
next-env.d.ts
*.tsbuildinfo
+68
View File
@@ -0,0 +1,68 @@
# FatihsMG4 web
Next.js app on Vercel: the ingest API the car writes to, and a phone-shaped dashboard for
reading the history back. Replaces the standalone Express service that used to live in
`server/`.
**The car never connects to MongoDB and never holds a MongoDB URI.** The URI is a Vercel
environment variable and is only ever read by a server-side route.
## Two credentials, on purpose
| | Who holds it | What it opens |
|---|---|---|
| `INGEST_TOKEN` | the car, in the APK | the `PUT` endpoints — write only |
| `DASHBOARD_PASSWORD` | you, in a browser | the dashboard and the `GET` history |
The APK is published, so its token must be assumed extractable: anyone can `unzip` an APK
and read its strings. That is survivable here because the write token cannot read anything
back. The password is never compiled into anything.
## Environment variables
Set all five in Vercel → Settings → Environment Variables, for Production and Preview.
| Name | Notes |
|---|---|
| `MONGODB_URI` | Atlas connection string |
| `MONGODB_DATABASE` | defaults to `fatihsmg4` |
| `INGEST_TOKEN` | `openssl rand -hex 32` |
| `DASHBOARD_PASSWORD` | at least 8 characters, or login is refused outright |
| `SESSION_SECRET` | `openssl rand -hex 32`; rotating it signs every browser out |
Atlas network access must allow Vercel's egress, which is not a fixed address on the Hobby
plan — either allow `0.0.0.0/0` and rely on the database user's credentials, or use Atlas
private networking on a paid plan.
## Endpoints
Write, `Authorization: Bearer <INGEST_TOKEN>`:
- `PUT /v1/consumption/daily`
- `PUT /v1/charging-sessions/:sessionId`
Read, session cookie or `Authorization: Bearer <DASHBOARD_PASSWORD>`:
- `GET /v1/history/:installationId/consumption`
- `GET /v1/history/:installationId/charging`
`GET /v1/health` is unauthenticated and returns `{"status":"ok"}`.
The car builds its URLs as `CONSUMPTION_API_URL + /v1/...`, so the Android build variable
is the bare origin — `https://your-app.vercel.app`, no trailing path.
## Indexes
There is no boot step on Vercel, so `ensureIndexes()` runs on the first write of each warm
lambda and is skipped afterwards. The unique indexes on `(installationId, date)` and
`(installationId, sessionId)` are what make the upserts idempotent when the car retries a
record it could not confirm.
## Local development
```sh
cp .env.example .env.local # Next loads .env.local automatically
npm install
npm run dev
npm test # pure validation and session-signing tests, no database
```
+6
View File
@@ -0,0 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
// Every page reads live telemetry; a cached shell would show yesterday's drive.
experimental: { serverActions: { bodySizeLimit: "1mb" } },
};
export default nextConfig;
+1145
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
{
"name": "fatihsmg4-web",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"test": "node --test --experimental-strip-types test/*.test.ts"
},
"engines": {
"node": ">=22"
},
"dependencies": {
"mongodb": "^6.20.0",
"next": "^15.5.4",
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
"devDependencies": {
"@types/node": "^22.15.3",
"@types/react": "^19.1.2",
"typescript": "^5.8.3"
},
"type": "module"
}
+23
View File
@@ -0,0 +1,23 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
import { SESSION_COOKIE, checkDashboardPassword, issueSessionValue, sessionCookieOptions } from "@/lib/auth";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* Deliberately slow to fail. The password is the only thing between the open internet and
* the history, and a quarter second is unnoticeable once but ruinous for anyone working
* through a list.
*/
export async function POST(request: Request) {
const form = await request.formData();
const password = String(form.get("password") ?? "");
if (!checkDashboardPassword(password)) {
await new Promise((resolve) => setTimeout(resolve, 250));
return NextResponse.redirect(new URL("/login?error=1", request.url), { status: 303 });
}
const store = await cookies();
store.set(SESSION_COOKIE, issueSessionValue(), sessionCookieOptions);
return NextResponse.redirect(new URL("/", request.url), { status: 303 });
}
+97
View File
@@ -0,0 +1,97 @@
import { requireSession } from "@/lib/auth";
import { loadOverview, sumCharging } from "@/lib/data";
import { dateTime, duration, money, num, precise } from "@/lib/format";
import { ChargeCurveChart } from "@/components/charts";
import { Row, Shell, Tile } from "@/components/shell";
export const dynamic = "force-dynamic";
export default async function ChargingPage() {
await requireSession();
const { installationId, sessions } = await loadOverview(1, 200);
if (!installationId || sessions.length === 0) {
return (
<Shell active="/charging" title="Şarj">
<div className="card">
<p className="empty">
Şarj kaydı yok. Yalnızca 10 kW üstü DC şarjlar ölçülür; AC şarj kaydedilmez.
</p>
</div>
</Shell>
);
}
const total = sumCharging(sessions);
return (
<Shell active="/charging" title="Şarj" subtitle={`${sessions.length} DC seans`}>
<div className="tiles">
<Tile label="Toplam alınan" value={precise(total.energyKwh)} unit="kWh" tone="gold" />
<Tile label="Toplam ödenen" value={money(total.cost)} tone="gold" />
<Tile
label="Ortalama birim"
value={total.energyKwh > 0 ? money(total.cost / total.energyKwh) : "—"}
note="kWh başına"
tone="teal"
/>
<Tile label="En yüksek güç" value={num(total.peakKw)} unit="kW" tone="teal" />
</div>
<section className="card">
<h2 className="card-title">Seanslar</h2>
<ul className="list">
{sessions.map((session) => {
const gained =
session.startSocPercent !== undefined && session.endSocPercent !== undefined
? session.endSocPercent - session.startSocPercent
: null;
const peak = session.curve.reduce((max, point) => Math.max(max, point.powerKw), 0);
const average =
session.durationSeconds > 0 ? (session.energyKwh * 3600) / session.durationSeconds : 0;
return (
<li key={session.sessionId}>
<details className="session">
<summary>
<div>
<div className="session-when">{dateTime(session.startedAt)}</div>
<div className="session-meta">
{duration(session.durationSeconds)}
{gained !== null && ` · %${num(session.startSocPercent as number)} → %${num(session.endSocPercent as number)}`}
</div>
</div>
<div className="session-energy">
{precise(session.energyKwh)}
<span className="session-cost"> kWh</span>
<div className="session-cost">{money(session.totalCost)}</div>
</div>
</summary>
<div className="session-body">
<ChargeCurveChart
curve={session.curve.map((point) => ({
timestamp: new Date(point.timestamp).toISOString(),
powerKw: point.powerKw,
socPercent: point.socPercent,
}))}
/>
<div className="rows" style={{ marginTop: 14 }}>
<Row label="Başlangıç" value={dateTime(session.startedAt)} />
<Row label="Bitiş" value={dateTime(session.endedAt)} />
<Row label="Ölçülen süre" value={duration(session.durationSeconds)} />
<Row label="Alınan enerji" value={`${precise(session.energyKwh)} kWh`} />
<Row label="Kazanılan doluluk" value={gained === null ? "—" : `%${num(gained)}`} />
<Row label="Tepe güç" value={peak > 0 ? `${num(peak)} kW` : "—"} />
<Row label="Ortalama güç" value={average > 0 ? `${num(average)} kW` : "—"} />
<Row label="Birim fiyat" value={`${money(session.pricePerKwh)}/kWh`} />
<Row label="Tutar" value={money(session.totalCost)} />
</div>
</div>
</details>
</li>
);
})}
</ul>
</section>
</Shell>
);
}
+98
View File
@@ -0,0 +1,98 @@
import { requireSession } from "@/lib/auth";
import { loadOverview, sumDays } from "@/lib/data";
import { duration, efficiency, isoDate, num, precise } from "@/lib/format";
import { DailyEnergyChart, EfficiencyChart } from "@/components/charts";
import { Row, Shell, Tile } from "@/components/shell";
export const dynamic = "force-dynamic";
export default async function ConsumptionPage() {
await requireSession();
const { installationId, days } = await loadOverview(90, 1);
if (!installationId || days.length === 0) {
return (
<Shell active="/consumption" title="Tüketim">
<div className="card">
<p className="empty">Günlük tüketim kaydı yok. Araç günü kapattığında yüklenir.</p>
</div>
</Shell>
);
}
const chartDays = days.map((day) => ({ date: day.date, kwh: day.day.kwh, km: day.day.km }));
const total = sumDays(days);
const lifetime = days[days.length - 1].lifetime;
// Newest first for reading; the charts above keep chronological order.
const table = [...days].reverse();
return (
<Shell active="/consumption" title="Tüketim" subtitle={`${days.length} günlük kayıt`}>
<div className="tiles">
<Tile label="Toplam mesafe" value={num(total.km)} unit="km" tone="teal" />
<Tile label="Toplam enerji" value={precise(total.kwh)} unit="kWh" tone="gold" />
<Tile label="Ortalama" value={efficiency(total.kwh, total.km)} tone="teal" />
<Tile
label="Ortalama hız"
value={total.hours > 0 ? num(total.km / total.hours) : "—"}
unit={total.hours > 0 ? "km/h" : undefined}
/>
</div>
<section className="card">
<h2 className="card-title">Günlük enerji</h2>
<DailyEnergyChart days={chartDays} />
</section>
<section className="card">
<h2 className="card-title">Verimlilik eğilimi</h2>
<EfficiencyChart days={chartDays} />
</section>
<section className="card">
<h2 className="card-title">Ömür boyu</h2>
<div className="rows">
<Row label="Mesafe" value={`${num(lifetime.km)} km`} />
<Row label="Enerji" value={`${precise(lifetime.kwh)} kWh`} />
<Row label="Ortalama" value={efficiency(lifetime.kwh, lifetime.km)} />
<Row label="Sürüş süresi" value={duration(lifetime.hours * 3600)} />
</div>
</section>
<section className="card">
<h2 className="card-title">Gün gün</h2>
<ul className="list">
{table.map((day) => (
<li key={day.date}>
<details className="session">
<summary>
<div>
<div className="session-when">{isoDate(day.date)}</div>
<div className="session-meta">
{num(day.day.km)} km · {efficiency(day.day.kwh, day.day.km)}
</div>
</div>
<div className="session-energy">
{precise(day.day.kwh)}
<span className="session-cost"> kWh</span>
</div>
</summary>
<div className="session-body">
<div className="rows">
<Row label="Sürüş süresi" value={duration(day.day.hours * 3600)} />
<Row
label="Ortalama hız"
value={day.day.hours > 0 ? `${num(day.day.km / day.day.hours)} km/h` : "—"}
/>
<Row label="Doluluk düşüşü" value={`%${num(day.day.socDrop)}`} />
<Row label="O gün sonu ömür boyu" value={`${num(day.lifetime.km)} km`} />
</div>
</div>
</details>
</li>
))}
</ul>
</section>
</Shell>
);
}
+363
View File
@@ -0,0 +1,363 @@
/*
* The car's palette, from app/src/main/res/values/colors.xml.
*
* Permanent OLED black with charcoal cards and muted gold/cyan accents — the same visual
* direction AGENTS.md pins for the head unit, so the phone and the dash read as one app.
* No light mode: this is looked at in a dark car as often as anywhere else.
*/
:root {
--bg: #000000;
--surface: #121212;
--raised: #1b1d1e;
--outline: #363b3d;
--text: #f4f3ef;
--muted: #bdc4c6;
--accent: #d8c39a;
--on-accent: #17140e;
--cyan: #a0e2e0;
--cyan-deep: #103436;
--gold-surface: #28241b;
--ok: #a4dcc1;
--warn: #e9ca96;
--error: #ffb4ab;
--radius: 18px;
--gap: 12px;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
background: var(--bg);
color: var(--text);
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
-webkit-font-smoothing: antialiased;
font-variant-numeric: tabular-nums;
}
a {
color: inherit;
text-decoration: none;
}
/* The phone frame. Wide windows get the same column rather than a stretched dashboard. */
.shell {
max-width: 480px;
margin: 0 auto;
min-height: 100dvh;
padding: 20px 16px calc(84px + env(safe-area-inset-bottom));
display: flex;
flex-direction: column;
gap: var(--gap);
}
.header {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 4px;
}
.title {
font-size: 26px;
font-weight: 650;
letter-spacing: -0.4px;
margin: 0;
}
.subtitle {
color: var(--muted);
font-size: 13px;
margin: 2px 0 0;
}
.card {
background: var(--surface);
border: 1px solid var(--outline);
border-radius: var(--radius);
padding: 16px;
}
.card-title {
font-size: 12px;
letter-spacing: 1.1px;
text-transform: uppercase;
color: var(--muted);
margin: 0 0 12px;
font-weight: 600;
}
/* Headline numbers: two per row, gold for energy/cost, cyan for distance/efficiency. */
.tiles {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--gap);
}
.tile {
background: var(--surface);
border: 1px solid var(--outline);
border-radius: var(--radius);
padding: 14px 16px;
}
.tile.gold {
background: linear-gradient(160deg, var(--gold-surface), var(--surface) 70%);
}
.tile.teal {
background: linear-gradient(160deg, var(--cyan-deep), var(--surface) 70%);
}
.tile-label {
font-size: 11px;
letter-spacing: 0.8px;
text-transform: uppercase;
color: var(--muted);
margin: 0;
}
.tile-value {
font-size: 24px;
font-weight: 650;
margin: 6px 0 0;
letter-spacing: -0.5px;
}
.tile-unit {
font-size: 13px;
font-weight: 500;
color: var(--muted);
margin-left: 3px;
}
.tile-note {
font-size: 11px;
color: var(--muted);
margin: 4px 0 0;
}
/* Key/value rows inside a card. */
.rows {
display: flex;
flex-direction: column;
gap: 10px;
}
.row {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 12px;
font-size: 14px;
}
.row + .row {
border-top: 1px solid var(--outline);
padding-top: 10px;
}
.row-key {
color: var(--muted);
}
.row-value {
font-weight: 600;
}
.list {
display: flex;
flex-direction: column;
gap: 10px;
list-style: none;
margin: 0;
padding: 0;
}
.session {
background: var(--surface);
border: 1px solid var(--outline);
border-radius: var(--radius);
padding: 14px 16px;
}
.session summary {
cursor: pointer;
list-style: none;
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
}
.session summary::-webkit-details-marker {
display: none;
}
.session-when {
font-size: 14px;
font-weight: 600;
}
.session-meta {
font-size: 12px;
color: var(--muted);
margin-top: 3px;
}
.session-energy {
text-align: right;
font-size: 18px;
font-weight: 650;
color: var(--accent);
white-space: nowrap;
}
.session-cost {
font-size: 12px;
color: var(--muted);
font-weight: 500;
}
.session-body {
margin-top: 14px;
padding-top: 14px;
border-top: 1px solid var(--outline);
}
.chart {
width: 100%;
height: auto;
display: block;
overflow: visible;
}
.legend {
display: flex;
gap: 14px;
font-size: 11px;
color: var(--muted);
margin-top: 8px;
}
.swatch {
display: inline-block;
width: 9px;
height: 9px;
border-radius: 2px;
margin-right: 5px;
vertical-align: baseline;
}
.empty {
color: var(--muted);
font-size: 14px;
text-align: center;
padding: 28px 12px;
}
/* Bottom navigation — the car app falls back to the same pattern below 1000dp. */
.nav {
position: fixed;
left: 0;
right: 0;
bottom: 0;
background: rgba(10, 10, 10, 0.92);
backdrop-filter: blur(14px);
border-top: 1px solid var(--outline);
padding: 8px 0 calc(8px + env(safe-area-inset-bottom));
}
.nav-inner {
max-width: 480px;
margin: 0 auto;
display: grid;
grid-template-columns: repeat(3, 1fr);
}
.nav a {
text-align: center;
padding: 8px 4px;
font-size: 11px;
color: var(--muted);
font-weight: 600;
letter-spacing: 0.3px;
}
.nav a[aria-current="page"] {
color: var(--accent);
}
.nav-icon {
display: block;
font-size: 17px;
margin-bottom: 2px;
}
/* Login */
.login {
min-height: 100dvh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.login-card {
width: 100%;
max-width: 360px;
background: var(--surface);
border: 1px solid var(--outline);
border-radius: var(--radius);
padding: 28px 24px;
}
.field {
width: 100%;
background: var(--raised);
border: 1px solid var(--outline);
border-radius: 12px;
color: var(--text);
padding: 13px 14px;
font-size: 16px;
margin-top: 16px;
}
.field:focus {
outline: none;
border-color: var(--accent);
}
.button {
width: 100%;
margin-top: 14px;
background: var(--accent);
color: var(--on-accent);
border: none;
border-radius: 12px;
padding: 13px;
font-size: 15px;
font-weight: 650;
cursor: pointer;
}
.error {
color: var(--error);
font-size: 13px;
margin: 14px 0 0;
}
.signout {
font-size: 12px;
color: var(--muted);
background: none;
border: 1px solid var(--outline);
border-radius: 999px;
padding: 5px 12px;
cursor: pointer;
}
+23
View File
@@ -0,0 +1,23 @@
import type { Metadata, Viewport } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "FatihsMG4",
description: "MG4 tüketim ve şarj geçmişi",
};
export const viewport: Viewport = {
themeColor: "#000000",
width: "device-width",
initialScale: 1,
// The dashboard is pinned to a phone-width column; zooming it is still the reader's call.
maximumScale: 5,
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="tr">
<body>{children}</body>
</html>
);
}
+34
View File
@@ -0,0 +1,34 @@
import { redirect } from "next/navigation";
import { hasSession } from "@/lib/auth";
export const dynamic = "force-dynamic";
export default async function LoginPage({
searchParams,
}: {
searchParams: Promise<{ error?: string }>;
}) {
if (await hasSession()) redirect("/");
const { error } = await searchParams;
return (
<div className="login">
<form className="login-card" action="/api/session" method="post">
<h1 className="title">FatihsMG4</h1>
<p className="subtitle">Tüketim ve şarj geçmişi</p>
<input
className="field"
type="password"
name="password"
placeholder="Parola"
autoComplete="current-password"
autoFocus
required
/>
<button className="button" type="submit">
Giriş
</button>
{error && <p className="error">Parola hatalı.</p>}
</form>
</div>
);
}
+12
View File
@@ -0,0 +1,12 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
import { SESSION_COOKIE } from "@/lib/auth";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET(request: Request) {
const store = await cookies();
store.delete(SESSION_COOKIE);
return NextResponse.redirect(new URL("/login", request.url));
}
+98
View File
@@ -0,0 +1,98 @@
import { requireSession } from "@/lib/auth";
import { loadOverview, sumCharging, sumDays } from "@/lib/data";
import { dateTime, duration, efficiency, money, num, precise } from "@/lib/format";
import { DailyEnergyChart } from "@/components/charts";
import { Row, Shell, Tile } from "@/components/shell";
export const dynamic = "force-dynamic";
export default async function OverviewPage() {
await requireSession();
const { installationId, installationCount, days, sessions } = await loadOverview(30, 200);
if (!installationId) {
return (
<Shell active="/" title="Özet">
<div className="card">
<p className="empty">
Henüz veri yok. Araç ilk günlük kaydı gönderdiğinde burada görünecek.
</p>
</div>
</Shell>
);
}
const charge = sumCharging(sessions);
const recent = sumDays(days);
const lifetime = days.length > 0 ? days[days.length - 1].lifetime : null;
const soh = [...days].reverse().find((day) => day.sohPercent !== undefined)?.sohPercent;
const lastSession = sessions[0];
return (
<Shell
active="/"
title="Özet"
subtitle={installationCount > 1 ? `${installationCount} araçtan en günceli` : "MG4"}
>
<div className="tiles">
<Tile
label="Toplam alınan"
value={precise(charge.energyKwh)}
unit="kWh"
note={`${charge.sessions} DC şarj`}
tone="gold"
/>
<Tile
label="Ödenen"
value={money(charge.cost)}
note={charge.energyKwh > 0 ? `${money(charge.cost / charge.energyKwh)}/kWh ort.` : undefined}
tone="gold"
/>
<Tile label="Son 30 gün" value={num(recent.km)} unit="km" note={efficiency(recent.kwh, recent.km)} tone="teal" />
<Tile
label="Ömür boyu"
value={lifetime ? num(lifetime.km) : "—"}
unit="km"
note={lifetime ? efficiency(lifetime.kwh, lifetime.km) : undefined}
tone="teal"
/>
</div>
<section className="card">
<h2 className="card-title">Son 30 gün</h2>
<DailyEnergyChart days={days.map((day) => ({ date: day.date, kwh: day.day.kwh, km: day.day.km }))} />
</section>
{lastSession && (
<section className="card">
<h2 className="card-title">Son şarj</h2>
<div className="rows">
<Row label="Tarih" value={dateTime(lastSession.startedAt)} />
<Row label="Alınan enerji" value={`${precise(lastSession.energyKwh)} kWh`} />
<Row
label="Doluluk"
value={
lastSession.startSocPercent !== undefined && lastSession.endSocPercent !== undefined
? `%${num(lastSession.startSocPercent)} → %${num(lastSession.endSocPercent)}`
: "—"
}
/>
<Row label="Süre" value={duration(lastSession.durationSeconds)} />
<Row label="Ücret" value={money(lastSession.totalCost)} />
</div>
</section>
)}
<section className="card">
<h2 className="card-title">Batarya ve toplamlar</h2>
<div className="rows">
<Row label="Sağlık (SOH)" value={soh === undefined ? "—" : `%${num(soh)}`} />
<Row label="Toplam şarj süresi" value={duration(charge.seconds)} />
<Row label="Görülen en yüksek güç" value={charge.peakKw > 0 ? `${num(charge.peakKw)} kW` : "—"} />
<Row label="Şarjla kazanılan doluluk" value={charge.socGained > 0 ? `%${num(charge.socGained)}` : "—"} />
<Row label="Kayıtlı gün" value={`${days.length}`} />
</div>
</section>
</Shell>
);
}
@@ -0,0 +1,25 @@
import { NextResponse } from "next/server";
import { handle, parseJson, requireIngest } from "@/lib/api";
import { chargingCollection, ensureIndexes } from "@/lib/mongo";
import { validateChargingSession } from "@/lib/validate";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function PUT(request: Request, context: { params: Promise<{ sessionId: string }> }) {
const denied = requireIngest(request);
if (denied) return denied;
return handle(async () => {
const { sessionId } = await context.params;
const record = validateChargingSession(await parseJson(request), sessionId);
await ensureIndexes();
const collection = await chargingCollection();
const updatedAt = new Date();
await collection.updateOne(
{ installationId: record.installationId, sessionId: record.sessionId },
{ $set: { ...record, updatedAt }, $setOnInsert: { createdAt: updatedAt } },
{ upsert: true },
);
return new NextResponse(null, { status: 204 });
});
}
+29
View File
@@ -0,0 +1,29 @@
import { NextResponse } from "next/server";
import { handle, parseJson, requireIngest } from "@/lib/api";
import { consumptionCollection, ensureIndexes } from "@/lib/mongo";
import { validateConsumption } from "@/lib/validate";
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.
*/
export async function PUT(request: Request) {
const denied = requireIngest(request);
if (denied) return denied;
return handle(async () => {
const record = validateConsumption(await parseJson(request));
await ensureIndexes();
const collection = await consumptionCollection();
const updatedAt = new Date();
await collection.updateOne(
{ installationId: record.installationId, date: record.date },
{ $set: { ...record, updatedAt }, $setOnInsert: { createdAt: updatedAt } },
{ upsert: true },
);
return new NextResponse(null, { status: 204 });
});
}
+9
View File
@@ -0,0 +1,9 @@
import { NextResponse } from "next/server";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/** Unauthenticated on purpose: it reveals nothing and the car has no other probe. */
export function GET() {
return NextResponse.json({ status: "ok" });
}
@@ -0,0 +1,22 @@
import { NextResponse } from "next/server";
import { handle, requireReader } from "@/lib/api";
import { chargingCollection } from "@/lib/mongo";
import { validateUuid } from "@/lib/validate";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET(request: Request, context: { params: Promise<{ installationId: string }> }) {
const denied = await requireReader(request);
if (denied) return denied;
return handle(async () => {
const { installationId } = await context.params;
const collection = await chargingCollection();
const records = await collection
.find({ installationId: validateUuid(installationId, "installationId") }, { projection: { _id: 0 } })
.sort({ startedAt: -1 })
.limit(200)
.toArray();
return NextResponse.json({ records });
});
}
@@ -0,0 +1,22 @@
import { NextResponse } from "next/server";
import { handle, requireReader } from "@/lib/api";
import { consumptionCollection } from "@/lib/mongo";
import { validateUuid } from "@/lib/validate";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET(request: Request, context: { params: Promise<{ installationId: string }> }) {
const denied = await requireReader(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 });
});
}
+267
View File
@@ -0,0 +1,267 @@
/*
* Hand-rolled SVG charts.
*
* A charting library would be the larger part of the bundle for three small plots, and
* these are server-rendered: no hydration, no client JavaScript, and they draw correctly
* before anything loads. Each one states its own units in the legend rather than relying
* on a tooltip, because a tooltip needs a pointer and this is read on a phone.
*/
const GOLD = "#d8c39a";
const CYAN = "#a0e2e0";
const GRID = "#363b3d";
const MUTED = "#bdc4c6";
interface Plot {
width: number;
height: number;
padTop: number;
padRight: number;
padBottom: number;
padLeft: number;
}
const PLOT: Plot = { width: 320, height: 150, padTop: 10, padRight: 8, padBottom: 20, padLeft: 34 };
function innerWidth(plot: Plot) {
return plot.width - plot.padLeft - plot.padRight;
}
function innerHeight(plot: Plot) {
return plot.height - plot.padTop - plot.padBottom;
}
/** A rounded axis maximum, so the gridline labels are readable numbers. */
function niceMax(value: number): number {
if (!(value > 0)) return 1;
const magnitude = 10 ** Math.floor(Math.log10(value));
const normalised = value / magnitude;
const step = normalised <= 1 ? 1 : normalised <= 2 ? 2 : normalised <= 5 ? 5 : 10;
return step * magnitude;
}
function Grid({ plot, max, format }: { plot: Plot; max: number; format: (value: number) => string }) {
const lines = [0, 0.5, 1];
return (
<g>
{lines.map((fraction) => {
const y = plot.padTop + innerHeight(plot) * (1 - fraction);
return (
<g key={fraction}>
<line
x1={plot.padLeft}
x2={plot.width - plot.padRight}
y1={y}
y2={y}
stroke={GRID}
strokeWidth={1}
strokeDasharray={fraction === 0 ? undefined : "2 4"}
/>
<text x={plot.padLeft - 6} y={y + 3.5} fill={MUTED} fontSize={9} textAnchor="end">
{format(max * fraction)}
</text>
</g>
);
})}
</g>
);
}
export interface DayDatum {
date: string;
kwh: number;
km: number;
}
/**
* Daily energy as bars. Days with no record are absent rather than drawn as zero — the
* car was not driven, which is not the same as a day of zero consumption, and the same
* distinction the telemetry side makes between a failed read and a zero.
*/
export function DailyEnergyChart({ days }: { days: DayDatum[] }) {
if (days.length === 0) return <p className="empty">Henüz günlük kayıt yok</p>;
const plot = PLOT;
const max = niceMax(Math.max(...days.map((day) => day.kwh)));
const slot = innerWidth(plot) / days.length;
const barWidth = Math.max(1.5, Math.min(14, slot * 0.62));
return (
<>
<svg className="chart" viewBox={`0 0 ${plot.width} ${plot.height}`} role="img"
aria-label="Gün gün tüketilen enerji">
<Grid plot={plot} max={max} format={(value) => value.toFixed(0)} />
{days.map((day, index) => {
const height = (day.kwh / max) * innerHeight(plot);
const x = plot.padLeft + slot * (index + 0.5) - barWidth / 2;
return (
<rect
key={day.date}
x={x}
y={plot.padTop + innerHeight(plot) - height}
width={barWidth}
height={Math.max(height, day.kwh > 0 ? 1 : 0)}
rx={2}
fill={GOLD}
opacity={0.92}
>
<title>{`${day.date} — ${day.kwh.toFixed(2)} kWh, ${day.km.toFixed(1)} km`}</title>
</rect>
);
})}
<text x={plot.padLeft} y={plot.height - 6} fill={MUTED} fontSize={9}>
{shortDate(days[0].date)}
</text>
<text x={plot.width - plot.padRight} y={plot.height - 6} fill={MUTED} fontSize={9} textAnchor="end">
{shortDate(days[days.length - 1].date)}
</text>
</svg>
<div className="legend">
<span>
<span className="swatch" style={{ background: GOLD }} />
Günlük enerji (kWh)
</span>
</div>
</>
);
}
/** Efficiency over time. Days under 1 km are dropped: the ratio is meaningless there. */
export function EfficiencyChart({ days }: { days: DayDatum[] }) {
const points = days
.filter((day) => day.km >= 1 && day.kwh > 0)
.map((day) => ({ date: day.date, value: (day.kwh * 100) / day.km }));
if (points.length < 2) return <p className="empty">Grafik için yeterli gün yok</p>;
const plot = PLOT;
const max = niceMax(Math.max(...points.map((point) => point.value)));
const step = innerWidth(plot) / Math.max(1, points.length - 1);
const path = points
.map((point, index) => {
const x = plot.padLeft + step * index;
const y = plot.padTop + innerHeight(plot) * (1 - point.value / max);
return `${index === 0 ? "M" : "L"}${x.toFixed(2)} ${y.toFixed(2)}`;
})
.join(" ");
const average = points.reduce((total, point) => total + point.value, 0) / points.length;
const averageY = plot.padTop + innerHeight(plot) * (1 - average / max);
return (
<>
<svg className="chart" viewBox={`0 0 ${plot.width} ${plot.height}`} role="img"
aria-label="Gün gün ortalama tüketim">
<Grid plot={plot} max={max} format={(value) => value.toFixed(0)} />
<line
x1={plot.padLeft}
x2={plot.width - plot.padRight}
y1={averageY}
y2={averageY}
stroke={GOLD}
strokeWidth={1}
strokeDasharray="4 4"
opacity={0.7}
/>
<path d={path} fill="none" stroke={CYAN} strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" />
{points.map((point, index) => (
<circle
key={point.date}
cx={plot.padLeft + step * index}
cy={plot.padTop + innerHeight(plot) * (1 - point.value / max)}
r={1.8}
fill={CYAN}
>
<title>{`${point.date} — ${point.value.toFixed(1)} kWh/100 km`}</title>
</circle>
))}
</svg>
<div className="legend">
<span>
<span className="swatch" style={{ background: CYAN }} />
kWh/100 km
</span>
<span>
<span className="swatch" style={{ background: GOLD }} />
Ortalama {average.toFixed(1)}
</span>
</div>
</>
);
}
export interface CurveDatum {
timestamp: string;
powerKw: number;
socPercent?: number;
}
/**
* Charge curve: power against time, with SOC on its own 0–100 scale.
*
* Two quantities on one plot because the shape of a DC charge is the relationship between
* them — the taper is only legible next to the percentage that caused it.
*/
export function ChargeCurveChart({ curve }: { curve: CurveDatum[] }) {
if (curve.length < 2) return <p className="empty">Bu seans için eğri kaydı yok</p>;
const plot = { ...PLOT, padRight: 26 };
const times = curve.map((point) => new Date(point.timestamp).getTime());
const start = times[0];
const span = Math.max(1, times[times.length - 1] - start);
const maxPower = niceMax(Math.max(...curve.map((point) => point.powerKw)));
const x = (time: number) => plot.padLeft + (innerWidth(plot) * (time - start)) / span;
const yPower = (kw: number) => plot.padTop + innerHeight(plot) * (1 - kw / maxPower);
const ySoc = (percent: number) => plot.padTop + innerHeight(plot) * (1 - percent / 100);
const powerPath = curve
.map((point, index) => `${index === 0 ? "M" : "L"}${x(times[index]).toFixed(2)} ${yPower(point.powerKw).toFixed(2)}`)
.join(" ");
const areaPath = `${powerPath} L${x(times[times.length - 1]).toFixed(2)} ${(plot.padTop + innerHeight(plot)).toFixed(2)} L${x(start).toFixed(2)} ${(plot.padTop + innerHeight(plot)).toFixed(2)} Z`;
const socPoints = curve
.map((point, index) => ({ point, index }))
.filter((entry) => typeof entry.point.socPercent === "number");
const socPath = socPoints
.map((entry, order) =>
`${order === 0 ? "M" : "L"}${x(times[entry.index]).toFixed(2)} ${ySoc(entry.point.socPercent as number).toFixed(2)}`)
.join(" ");
return (
<>
<svg className="chart" viewBox={`0 0 ${plot.width} ${plot.height}`} role="img"
aria-label="Şarj gücü ve doluluk eğrisi">
<Grid plot={plot} max={maxPower} format={(value) => value.toFixed(0)} />
<defs>
<linearGradient id="powerFill" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stopColor={GOLD} stopOpacity={0.32} />
<stop offset="100%" stopColor={GOLD} stopOpacity={0.02} />
</linearGradient>
</defs>
<path d={areaPath} fill="url(#powerFill)" stroke="none" />
<path d={powerPath} fill="none" stroke={GOLD} strokeWidth={2} strokeLinejoin="round" />
{socPath && <path d={socPath} fill="none" stroke={CYAN} strokeWidth={1.6} strokeDasharray="5 3" />}
<text x={plot.width - plot.padRight + 4} y={plot.padTop + 4} fill={MUTED} fontSize={9}>
100%
</text>
<text x={plot.width - plot.padRight + 4} y={plot.padTop + innerHeight(plot)} fill={MUTED} fontSize={9}>
0%
</text>
<text x={plot.padLeft} y={plot.height - 6} fill={MUTED} fontSize={9}>
0 dk
</text>
<text x={plot.width - plot.padRight} y={plot.height - 6} fill={MUTED} fontSize={9} textAnchor="end">
{Math.round(span / 60000)} dk
</text>
</svg>
<div className="legend">
<span>
<span className="swatch" style={{ background: GOLD }} />
Güç (kW)
</span>
<span>
<span className="swatch" style={{ background: CYAN }} />
Doluluk (%)
</span>
</div>
</>
);
}
function shortDate(iso: string): string {
const [, month, day] = iso.split("-");
return `${day}.${month}`;
}
+91
View File
@@ -0,0 +1,91 @@
import Link from "next/link";
import type { ReactNode } from "react";
const TABS = [
{ href: "/", label: "Özet", icon: "◈" },
{ href: "/consumption", label: "Tüketim", icon: "▤" },
{ href: "/charging", label: "Şarj", icon: "⚡" },
] as const;
export type Tab = (typeof TABS)[number]["href"];
/**
* The phone frame and the bottom tab bar.
*
* `active` is passed in rather than read from the pathname so every page stays a server
* component — the whole dashboard renders without shipping a line of client JavaScript.
*/
export function Shell({
active,
title,
subtitle,
children,
}: {
active: Tab;
title: string;
subtitle?: string;
children: ReactNode;
}) {
return (
<>
<main className="shell">
<header className="header">
<div>
<h1 className="title">{title}</h1>
{subtitle && <p className="subtitle">{subtitle}</p>}
</div>
<Link className="signout" href="/logout">
Çıkış
</Link>
</header>
{children}
</main>
<nav className="nav">
<div className="nav-inner">
{TABS.map((tab) => (
<Link key={tab.href} href={tab.href} aria-current={tab.href === active ? "page" : undefined}>
<span className="nav-icon" aria-hidden>
{tab.icon}
</span>
{tab.label}
</Link>
))}
</div>
</nav>
</>
);
}
export function Tile({
label,
value,
unit,
note,
tone,
}: {
label: string;
value: string;
unit?: string;
note?: string;
tone?: "gold" | "teal";
}) {
return (
<div className={`tile${tone ? ` ${tone}` : ""}`}>
<p className="tile-label">{label}</p>
<p className="tile-value">
{value}
{unit && <span className="tile-unit">{unit}</span>}
</p>
{note && <p className="tile-note">{note}</p>}
</div>
);
}
export function Row({ label, value }: { label: string; value: string }) {
return (
<div className="row">
<span className="row-key">{label}</span>
<span className="row-value">{value}</span>
</div>
);
}
+53
View File
@@ -0,0 +1,53 @@
import { NextResponse } from "next/server";
import { hasIngestToken, hasSession, checkDashboardPassword } from "./auth";
import { ValidationError } from "./validate";
/** Every endpoint touches MongoDB, which rules out the edge runtime. */
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export function unauthorized(): NextResponse {
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
}
/**
* Wraps a handler so a validation failure is a 400 and anything else is a 500 whose
* detail stays in the server log. An error message is allowed to name a bad field; it is
* never allowed to carry a connection string.
*/
export async function handle(work: () => Promise<NextResponse>): Promise<NextResponse> {
try {
return await work();
} catch (error) {
if (error instanceof ValidationError) {
return NextResponse.json({ error: error.message }, { status: 400 });
}
console.error("Request failed", error);
return NextResponse.json({ error: "internal server error" }, { status: 500 });
}
}
export function requireIngest(request: Request): NextResponse | null {
return hasIngestToken(request) ? null : unauthorized();
}
/**
* Read access: the dashboard's own session cookie, or the dashboard password as a bearer
* token so a native client can read the same history without a browser.
*/
export async function requireReader(request: Request): Promise<NextResponse | null> {
if (await hasSession()) return null;
const header = request.headers.get("authorization") ?? "";
if (header.startsWith("Bearer ") && checkDashboardPassword(header.slice(7))) return null;
return unauthorized();
}
export async function parseJson(request: Request): Promise<unknown> {
const raw = await request.text();
if (raw.length > 512 * 1024) throw new ValidationError("body is too large");
try {
return JSON.parse(raw);
} catch {
throw new ValidationError("body is not valid JSON");
}
}
+20
View File
@@ -0,0 +1,20 @@
/**
* The Next-aware half of authentication. Everything cryptographic lives in
* {@link ./session}, which imports nothing from Next and is therefore unit-testable
* without a request, a build, or a running server.
*/
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { SESSION_COOKIE, isValidSessionValue } from "./session";
export * from "./session";
export async function hasSession(): Promise<boolean> {
const store = await cookies();
return isValidSessionValue(store.get(SESSION_COOKIE)?.value);
}
/** For pages: send an unauthenticated visitor to the password screen. */
export async function requireSession(): Promise<void> {
if (!(await hasSession())) redirect("/login");
}
+84
View File
@@ -0,0 +1,84 @@
import { chargingCollection, consumptionCollection, type ChargingSession, type DailyConsumption } from "./mongo";
/**
* Reads for the dashboard.
*
* Every query is scoped to one installation id. There is one car today, but the id is in
* the schema and in the unique indexes, so scoping now costs nothing and means a second
* head unit does not silently merge its history into the first one's charts.
*/
export interface Overview {
installationId: string | null;
installationCount: number;
days: DailyConsumption[];
sessions: ChargingSession[];
}
/** The installation that reported most recently — the car in use, if there are ever two. */
export async function latestInstallationId(): Promise<{ id: string | null; count: number }> {
const collection = await consumptionCollection();
const ids = await collection.distinct("installationId");
if (ids.length === 0) {
const charging = await chargingCollection();
const chargingIds = await charging.distinct("installationId");
return { id: chargingIds[0] ?? null, count: chargingIds.length };
}
if (ids.length === 1) return { id: ids[0], count: 1 };
const newest = await collection.find({}, { projection: { installationId: 1 } })
.sort({ date: -1 }).limit(1).next();
return { id: newest?.installationId ?? ids[0], count: ids.length };
}
export async function loadOverview(dayLimit = 60, sessionLimit = 60): Promise<Overview> {
const { id, count } = await latestInstallationId();
if (!id) return { installationId: null, installationCount: 0, days: [], sessions: [] };
const [consumption, charging] = await Promise.all([consumptionCollection(), chargingCollection()]);
const [days, sessions] = await Promise.all([
consumption.find({ installationId: id }, { projection: { _id: 0 } })
.sort({ date: -1 }).limit(dayLimit).toArray(),
charging.find({ installationId: id }, { projection: { _id: 0 } })
.sort({ startedAt: -1 }).limit(sessionLimit).toArray(),
]);
// Charts read left-to-right in time; the queries sort newest-first so the limit keeps
// the recent end rather than the beginning of history.
return { installationId: id, installationCount: count, days: days.reverse(), sessions };
}
export interface ChargeTotals {
energyKwh: number;
cost: number;
sessions: number;
seconds: number;
socGained: number;
peakKw: number;
}
export function sumCharging(sessions: ChargingSession[]): ChargeTotals {
return sessions.reduce<ChargeTotals>(
(total, session) => ({
energyKwh: total.energyKwh + session.energyKwh,
cost: total.cost + session.totalCost,
sessions: total.sessions + 1,
seconds: total.seconds + session.durationSeconds,
socGained:
total.socGained +
(session.startSocPercent !== undefined && session.endSocPercent !== undefined
? Math.max(0, session.endSocPercent - session.startSocPercent)
: 0),
peakKw: Math.max(total.peakKw, ...session.curve.map((point) => point.powerKw), 0),
}),
{ energyKwh: 0, cost: 0, sessions: 0, seconds: 0, socGained: 0, peakKw: 0 },
);
}
export function sumDays(days: DailyConsumption[]) {
return days.reduce(
(total, record) => ({
km: total.km + record.day.km,
kwh: total.kwh + record.day.kwh,
hours: total.hours + record.day.hours,
}),
{ km: 0, kwh: 0, hours: 0 },
);
}
+48
View File
@@ -0,0 +1,48 @@
/** Turkish formatting for every user-facing number and date. */
const NUMBER = new Intl.NumberFormat("tr-TR", { maximumFractionDigits: 1 });
const PRECISE = new Intl.NumberFormat("tr-TR", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
const CURRENCY = new Intl.NumberFormat("tr-TR", { style: "currency", currency: "TRY", maximumFractionDigits: 2 });
const DATE = new Intl.DateTimeFormat("tr-TR", { day: "numeric", month: "long", year: "numeric" });
const DATE_TIME = new Intl.DateTimeFormat("tr-TR", {
day: "numeric",
month: "short",
hour: "2-digit",
minute: "2-digit",
});
export function num(value: number): string {
return NUMBER.format(value);
}
export function precise(value: number): string {
return PRECISE.format(value);
}
export function money(value: number): string {
return CURRENCY.format(value);
}
export function isoDate(iso: string): string {
const [year, month, day] = iso.split("-").map(Number);
return DATE.format(new Date(Date.UTC(year, month - 1, day)));
}
export function dateTime(value: Date | string): string {
return DATE_TIME.format(new Date(value));
}
/** `2 sa 05 dk` — the same shape the car's charging pane uses. */
export function duration(seconds: number): string {
const total = Math.max(0, Math.round(seconds));
const hours = Math.floor(total / 3600);
const minutes = Math.floor((total % 3600) / 60);
if (hours === 0) return `${minutes} dk`;
return `${hours} sa ${String(minutes).padStart(2, "0")} dk`;
}
/** Energy per 100 km, or a dash — never a ratio computed from a distance of nothing. */
export function efficiency(kwh: number, km: number): string {
if (km < 1) return "—";
return `${num((kwh * 100) / km)} kWh/100 km`;
}
+96
View File
@@ -0,0 +1,96 @@
import { MongoClient, type Collection, type Db } from "mongodb";
/**
* One MongoClient per warm lambda, cached on globalThis.
*
* A serverless function is re-entered far more often than it is cold-started, and a fresh
* MongoClient per request opens a fresh pool per request — Atlas starts refusing
* connections long before the traffic justifies it. The global survives module reloads in
* dev, where Next re-evaluates modules on every edit.
*/
const globalForMongo = globalThis as unknown as { mongoClient?: Promise<MongoClient> };
export interface Totals {
km: number;
kwh: number;
hours: number;
socDrop: number;
}
export interface DailyConsumption {
installationId: string;
date: string;
recordedAt: Date;
day: Totals;
lifetime: Totals;
sohPercent?: number;
createdAt: Date;
updatedAt: Date;
}
export interface CurvePoint {
timestamp: Date;
powerKw: number;
socPercent?: number;
}
export interface ChargingSession {
installationId: string;
sessionId: string;
startedAt: Date;
endedAt: Date;
durationSeconds: number;
energyKwh: number;
pricePerKwh: number;
totalCost: number;
startSocPercent?: number;
endSocPercent?: number;
curve: CurvePoint[];
createdAt: Date;
updatedAt: Date;
}
function clientPromise(): Promise<MongoClient> {
const uri = process.env.MONGODB_URI;
if (!uri) throw new Error("MONGODB_URI is not configured");
if (!globalForMongo.mongoClient) {
globalForMongo.mongoClient = new MongoClient(uri, {
serverSelectionTimeoutMS: 10_000,
maxPoolSize: 10,
}).connect();
}
return globalForMongo.mongoClient;
}
async function db(): Promise<Db> {
const client = await clientPromise();
return client.db(process.env.MONGODB_DATABASE || "fatihsmg4");
}
export async function consumptionCollection(): Promise<Collection<DailyConsumption>> {
return (await db()).collection<DailyConsumption>("daily_consumption");
}
export async function chargingCollection(): Promise<Collection<ChargingSession>> {
return (await db()).collection<ChargingSession>("charging_sessions");
}
/**
* Creates the indexes the Express service used to create at boot.
*
* There is no boot here — a lambda starts on a request — so this runs on the first write
* of each warm instance and is skipped afterwards. `createIndex` is idempotent, but it is
* still a round trip, and the uniqueness constraints are what make the upserts safe.
*/
let indexesEnsured = false;
export async function ensureIndexes(): Promise<void> {
if (indexesEnsured) return;
const [consumption, charging] = await Promise.all([consumptionCollection(), chargingCollection()]);
await Promise.all([
consumption.createIndex({ installationId: 1, date: 1 }, { unique: true }),
consumption.createIndex({ installationId: 1, date: -1 }),
charging.createIndex({ installationId: 1, sessionId: 1 }, { unique: true }),
charging.createIndex({ installationId: 1, startedAt: -1 }),
]);
indexesEnsured = true;
}
+80
View File
@@ -0,0 +1,80 @@
import crypto from "node:crypto";
/**
* Two separate credentials, deliberately.
*
* The car holds {@link INGEST_TOKEN} and can only write. It is baked into a build that
* lives on a head unit and is published as an APK, so it must be assumed extractable —
* and an extracted write token buys nothing but the ability to post junk under an
* installation id the attacker would have to guess.
*
* Reading the history needs the dashboard password, which never leaves the browser's
* cookie jar and is never compiled into anything.
*/
export const SESSION_COOKIE = "fatihsmg4_session";
const SESSION_TTL_SECONDS = 60 * 60 * 24 * 30;
/** Constant-time compare that does not leak length through an early return. */
function sameSecret(supplied: string, expected: string): boolean {
const a = crypto.createHash("sha256").update(supplied).digest();
const b = crypto.createHash("sha256").update(expected).digest();
return crypto.timingSafeEqual(a, b);
}
function requireEnv(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`${name} is not configured`);
return value;
}
/** Does this request carry the car's write token? */
export function hasIngestToken(request: Request): boolean {
const expected = process.env.INGEST_TOKEN;
if (!expected || expected.length < 24) return false;
const header = request.headers.get("authorization") ?? "";
if (!header.startsWith("Bearer ")) return false;
return sameSecret(header.slice(7), expected);
}
export function checkDashboardPassword(supplied: string): boolean {
const expected = process.env.DASHBOARD_PASSWORD;
if (!expected || expected.length < 8) return false;
return sameSecret(supplied, expected);
}
/**
* `<expiry>.<hmac>` — no server-side session store, which a serverless deployment has
* nowhere to keep anyway. The expiry is inside the signed payload, so a browser that
* keeps the cookie past its date cannot use it.
*/
export function issueSessionValue(): string {
const expiresAt = Math.floor(Date.now() / 1000) + SESSION_TTL_SECONDS;
return `${expiresAt}.${sign(String(expiresAt))}`;
}
function sign(payload: string): string {
return crypto.createHmac("sha256", requireEnv("SESSION_SECRET")).update(payload).digest("hex");
}
export function isValidSessionValue(value: string | undefined): boolean {
if (!value) return false;
const separator = value.lastIndexOf(".");
if (separator <= 0) return false;
const expiresAt = Number(value.slice(0, separator));
if (!Number.isSafeInteger(expiresAt) || expiresAt * 1000 < Date.now()) return false;
try {
const expected = Buffer.from(sign(String(expiresAt)), "hex");
const supplied = Buffer.from(value.slice(separator + 1), "hex");
return expected.length === supplied.length && crypto.timingSafeEqual(expected, supplied);
} catch {
return false;
}
}
export const sessionCookieOptions = {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: SESSION_TTL_SECONDS,
} as const;
+105
View File
@@ -0,0 +1,105 @@
/**
* Request validation for the ingest endpoints — a direct port of the Express service's
* rules, kept pure so it can be unit-tested without a database or a request.
*
* The car is the only writer and it is trusted, but a bad field here is written once and
* read forever. Every number is bounded; an out-of-range value is rejected rather than
* stored and quietly plotted.
*/
import type { ChargingSession, CurvePoint, DailyConsumption, Totals } from "./mongo";
export class ValidationError extends Error {}
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
export type ConsumptionInput = Omit<DailyConsumption, "createdAt" | "updatedAt">;
export type ChargingInput = Omit<ChargingSession, "createdAt" | "updatedAt">;
export function validateConsumption(body: unknown): ConsumptionInput {
const record = asObject(body);
const date = record.date;
if (typeof date !== "string" || !ISO_DATE.test(date)) throw new ValidationError("date is invalid");
const sohPercent = record.sohPercent;
return {
installationId: validateUuid(record.installationId, "installationId"),
date,
recordedAt: validateDate(record.recordedAt, "recordedAt"),
day: validateTotals(record.day, "day"),
lifetime: validateTotals(record.lifetime, "lifetime"),
...(sohPercent === undefined ? {} : { sohPercent: bounded(sohPercent, 0, 100, "sohPercent") }),
};
}
export function validateChargingSession(body: unknown, pathSessionId: string): ChargingInput {
const record = asObject(body);
const sessionId = validateUuid(record.sessionId, "sessionId");
if (sessionId !== pathSessionId) throw new ValidationError("sessionId does not match path");
if (!Array.isArray(record.curve) || record.curve.length > 720) {
throw new ValidationError("curve is invalid");
}
const curve: CurvePoint[] = record.curve.map((entry) => {
const point = asObject(entry);
const socPercent = point.socPercent;
return {
timestamp: validateDate(point.timestampMs, "curve.timestampMs"),
powerKw: bounded(point.powerKw, 0, 1000, "curve.powerKw"),
...(socPercent === undefined ? {} : { socPercent: bounded(socPercent, 0, 100, "curve.socPercent") }),
};
});
const startSocPercent = record.startSocPercent;
const endSocPercent = record.endSocPercent;
return {
installationId: validateUuid(record.installationId, "installationId"),
sessionId,
startedAt: validateDate(record.startedAt, "startedAt"),
endedAt: validateDate(record.endedAt, "endedAt"),
durationSeconds: bounded(record.durationSeconds, 0, 604800, "durationSeconds"),
energyKwh: bounded(record.energyKwh, 0, 1000, "energyKwh"),
pricePerKwh: bounded(record.pricePerKwh, 0, 1_000_000, "pricePerKwh"),
totalCost: bounded(record.totalCost, 0, 1_000_000_000, "totalCost"),
...(startSocPercent === undefined ? {} : { startSocPercent: bounded(startSocPercent, 0, 100, "startSocPercent") }),
...(endSocPercent === undefined ? {} : { endSocPercent: bounded(endSocPercent, 0, 100, "endSocPercent") }),
curve,
};
}
export function validateUuid(value: unknown, name: string): string {
if (typeof value !== "string" || !UUID.test(value)) throw new ValidationError(`${name} is invalid`);
return value;
}
function asObject(value: unknown): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new ValidationError("body is required");
}
return value as Record<string, unknown>;
}
/** Epoch milliseconds on the wire; a Date in the database. */
function validateDate(value: unknown, name: string): Date {
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
throw new ValidationError(`${name} is invalid`);
}
return new Date(value);
}
function bounded(value: unknown, min: number, max: number, name: string): number {
if (typeof value !== "number" || !Number.isFinite(value) || value < min || value > max) {
throw new ValidationError(`${name} is invalid`);
}
return value;
}
function validateTotals(value: unknown, name: string): Totals {
const record = asObject(value);
const result = {} as Totals;
for (const field of ["km", "kwh", "hours", "socDrop"] as const) {
const number = record[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;
}
+44
View File
@@ -0,0 +1,44 @@
import assert from "node:assert/strict";
import { test } from "node:test";
process.env.SESSION_SECRET = "0123456789abcdef0123456789abcdef";
process.env.DASHBOARD_PASSWORD = "correct-horse-battery";
process.env.INGEST_TOKEN = "a".repeat(64);
const { checkDashboardPassword, hasIngestToken, isValidSessionValue, issueSessionValue } =
await import("../src/lib/session.ts");
test("a freshly issued session validates and a tampered one does not", () => {
const value = issueSessionValue();
assert.equal(isValidSessionValue(value), true);
const [expiry, signature] = value.split(".");
// Moving the expiry out invalidates the signature, so a cookie cannot extend itself.
assert.equal(isValidSessionValue(`${Number(expiry) + 86_400}.${signature}`), false);
assert.equal(isValidSessionValue(`${expiry}.${"0".repeat(signature.length)}`), false);
assert.equal(isValidSessionValue(undefined), false);
assert.equal(isValidSessionValue("garbage"), false);
});
test("an expired session is refused even with a valid signature", async () => {
const past = Math.floor(Date.now() / 1000) - 10;
// Signed by the same secret, so only the embedded expiry rejects it.
const crypto = await import("node:crypto");
const signature = crypto.createHmac("sha256", process.env.SESSION_SECRET as string)
.update(String(past)).digest("hex");
assert.equal(isValidSessionValue(`${past}.${signature}`), false);
});
test("the dashboard password is exact", () => {
assert.equal(checkDashboardPassword("correct-horse-battery"), true);
assert.equal(checkDashboardPassword("correct-horse-batter"), false);
assert.equal(checkDashboardPassword(""), false);
});
test("the ingest token is only accepted as a bearer header", () => {
const token = "a".repeat(64);
const bearer = (value: string) => new Request("https://x/v1", { headers: { authorization: value } });
assert.equal(hasIngestToken(bearer(`Bearer ${token}`)), true);
assert.equal(hasIngestToken(bearer(token)), false);
assert.equal(hasIngestToken(bearer(`Bearer ${"b".repeat(64)}`)), false);
assert.equal(hasIngestToken(new Request("https://x/v1")), false);
});
+90
View File
@@ -0,0 +1,90 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { ValidationError, validateChargingSession, validateConsumption } from "../src/lib/validate.ts";
const INSTALLATION = "6f1a2b3c-4d5e-4f60-8a9b-0c1d2e3f4a5b";
const SESSION = "11111111-2222-4333-8444-555555555555";
const day = { km: 42.5, kwh: 7.25, hours: 0.9, socDrop: 11.5 };
test("accepts a well-formed daily record", () => {
const record = validateConsumption({
installationId: INSTALLATION,
date: "2026-10-07",
recordedAt: 1_760_000_000_000,
day,
lifetime: { km: 12000, kwh: 2100, hours: 240, socDrop: 3400 },
sohPercent: 97.5,
});
assert.equal(record.date, "2026-10-07");
assert.equal(record.day.kwh, 7.25);
assert.ok(record.recordedAt instanceof Date);
});
test("a missing SOH is omitted rather than defaulted", () => {
// The car omits a field it could not read. Writing 0 here would be a battery at
// nothing, which is the same class of lie the telemetry side refuses to send.
const record = validateConsumption({
installationId: INSTALLATION,
date: "2026-10-07",
recordedAt: 1,
day,
lifetime: day,
});
assert.equal("sohPercent" in record, false);
});
test("rejects malformed identifiers, dates and out-of-range numbers", () => {
const base = { installationId: INSTALLATION, date: "2026-10-07", recordedAt: 1, day, lifetime: day };
assert.throws(() => validateConsumption({ ...base, installationId: "nope" }), ValidationError);
assert.throws(() => validateConsumption({ ...base, date: "07.10.2026" }), ValidationError);
assert.throws(() => validateConsumption({ ...base, sohPercent: 140 }), ValidationError);
assert.throws(() => validateConsumption({ ...base, day: { ...day, kwh: "7" } }), ValidationError);
assert.throws(() => validateConsumption({ ...base, recordedAt: -1 }), ValidationError);
assert.throws(() => validateConsumption(null), ValidationError);
});
test("a charging session must match the id in its path", () => {
const body = {
installationId: INSTALLATION,
sessionId: SESSION,
startedAt: 1_760_000_000_000,
endedAt: 1_760_002_000_000,
durationSeconds: 1800,
energyKwh: 31.4,
pricePerKwh: 7.5,
totalCost: 235.5,
startSocPercent: 18,
endSocPercent: 72,
curve: [{ timestampMs: 1_760_000_060_000, powerKw: 84.2, socPercent: 20 }],
};
const record = validateChargingSession(body, SESSION);
assert.equal(record.curve.length, 1);
assert.equal(record.curve[0].powerKw, 84.2);
assert.throws(() => validateChargingSession(body, "11111111-2222-4333-8444-555555555556"), ValidationError);
});
test("refuses a curve longer than the car can produce", () => {
const curve = Array.from({ length: 721 }, (_unused, index) => ({
timestampMs: 1_760_000_000_000 + index * 60_000,
powerKw: 50,
}));
assert.throws(
() =>
validateChargingSession(
{
installationId: INSTALLATION,
sessionId: SESSION,
startedAt: 1,
endedAt: 2,
durationSeconds: 1,
energyKwh: 1,
pricePerKwh: 1,
totalCost: 1,
curve,
},
SESSION,
),
ValidationError,
);
});
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "ES2022"],
"allowJs": false,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./src/*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}