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
+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 });
});
}