219 lines
7.5 KiB
TypeScript
219 lines
7.5 KiB
TypeScript
import { Hono } from "hono";
|
|
import { desc, eq, sql } from "drizzle-orm";
|
|
import dayjs from "dayjs";
|
|
import { useDrizzle } from "@/lib/db.js";
|
|
import { chargePoint, connector } from "@/db/schema.js";
|
|
import { ocppConnections } from "@/ocpp/handler.js";
|
|
import type { HonoEnv } from "@/types/hono.ts";
|
|
|
|
const app = new Hono<HonoEnv>();
|
|
|
|
/** GET /api/charge-points — list all charge points with connectors */
|
|
app.get("/", async (c) => {
|
|
const db = useDrizzle();
|
|
const isAdmin = c.get("user")?.role === "admin";
|
|
|
|
const cps = await db
|
|
.select()
|
|
.from(chargePoint)
|
|
.where(isAdmin ? undefined : eq(chargePoint.registrationStatus, "Accepted"))
|
|
.orderBy(desc(chargePoint.createdAt));
|
|
|
|
if (!cps.length) return c.json([]);
|
|
|
|
const cpIdList = sql.raw(`array[${cps.map((cp) => `'${cp.id}'`).join(",")}]`);
|
|
|
|
// connectorId > 0: real connectors to display
|
|
const connectors = await db
|
|
.select()
|
|
.from(connector)
|
|
.where(sql`${connector.chargePointId} = any(${cpIdList}) and ${connector.connectorId} > 0`);
|
|
|
|
// connectorId = 0: whole-station status row
|
|
const cpStatusRows = await db
|
|
.select({
|
|
chargePointId: connector.chargePointId,
|
|
status: connector.status,
|
|
errorCode: connector.errorCode,
|
|
})
|
|
.from(connector)
|
|
.where(sql`${connector.chargePointId} = any(${cpIdList}) and ${connector.connectorId} = 0`);
|
|
|
|
const connectorsByCP: Record<string, typeof connectors> = {};
|
|
for (const conn of connectors) {
|
|
if (!connectorsByCP[conn.chargePointId]) connectorsByCP[conn.chargePointId] = [];
|
|
connectorsByCP[conn.chargePointId].push(conn);
|
|
}
|
|
|
|
const cpStatusByCP: Record<string, { status: string; errorCode: string }> = {};
|
|
for (const row of cpStatusRows) {
|
|
cpStatusByCP[row.chargePointId] = { status: row.status, errorCode: row.errorCode };
|
|
}
|
|
|
|
return c.json(
|
|
cps.map((cp) => ({
|
|
...cp,
|
|
connectors: connectorsByCP[cp.id] ?? [],
|
|
chargePointStatus: cpStatusByCP[cp.id]?.status ?? null,
|
|
chargePointErrorCode: cpStatusByCP[cp.id]?.errorCode ?? null,
|
|
})),
|
|
);
|
|
});
|
|
|
|
/** POST /api/charge-points — manually pre-register a charge point */
|
|
app.post("/", async (c) => {
|
|
if (c.get("user")?.role !== "admin") return c.json({ error: "Forbidden" }, 403);
|
|
const db = useDrizzle();
|
|
const body = await c.req.json<{
|
|
chargePointIdentifier: string;
|
|
chargePointVendor?: string;
|
|
chargePointModel?: string;
|
|
registrationStatus?: "Accepted" | "Pending" | "Rejected";
|
|
feePerKwh?: number;
|
|
pricingMode?: "fixed" | "tou";
|
|
deviceName?: string;
|
|
}>();
|
|
|
|
if (!body.chargePointIdentifier?.trim()) {
|
|
return c.json({ error: "chargePointIdentifier is required" }, 400);
|
|
}
|
|
if (body.feePerKwh !== undefined && (!Number.isInteger(body.feePerKwh) || body.feePerKwh < 0)) {
|
|
return c.json({ error: "feePerKwh must be a non-negative integer" }, 400);
|
|
}
|
|
if (body.pricingMode !== undefined && !['fixed', 'tou'].includes(body.pricingMode)) {
|
|
return c.json({ error: "pricingMode must be 'fixed' or 'tou'" }, 400);
|
|
}
|
|
|
|
const [created] = await db
|
|
.insert(chargePoint)
|
|
.values({
|
|
id: crypto.randomUUID(),
|
|
chargePointIdentifier: body.chargePointIdentifier.trim(),
|
|
chargePointVendor: body.chargePointVendor?.trim() || "Unknown",
|
|
chargePointModel: body.chargePointModel?.trim() || "Unknown",
|
|
registrationStatus: body.registrationStatus ?? "Pending",
|
|
feePerKwh: body.feePerKwh ?? 0,
|
|
pricingMode: body.pricingMode ?? "fixed",
|
|
deviceName: body.deviceName?.trim() || null,
|
|
})
|
|
.returning()
|
|
.catch((err: Error) => {
|
|
if (err.message.includes("unique")) throw Object.assign(err, { status: 409 });
|
|
throw err;
|
|
});
|
|
|
|
return c.json({ ...created, connectors: [] }, 201);
|
|
});
|
|
|
|
/** GET /api/charge-points/connections — list currently active OCPP WebSocket connections */
|
|
app.get("/connections", (c) => {
|
|
return c.json({
|
|
connectedIdentifiers: Array.from(ocppConnections.keys()),
|
|
});
|
|
});
|
|
|
|
/** GET /api/charge-points/:id — single charge point */
|
|
app.get("/:id", async (c) => {
|
|
const db = useDrizzle();
|
|
const id = c.req.param("id");
|
|
|
|
const [cp] = await db.select().from(chargePoint).where(eq(chargePoint.id, id)).limit(1);
|
|
|
|
if (!cp) return c.json({ error: "Not found" }, 404);
|
|
|
|
const allConnectors = await db.select().from(connector).where(eq(connector.chargePointId, id));
|
|
const cpStatus = allConnectors.find((conn) => conn.connectorId === 0);
|
|
const displayConnectors = allConnectors.filter((conn) => conn.connectorId > 0);
|
|
|
|
return c.json({
|
|
...cp,
|
|
connectors: displayConnectors,
|
|
chargePointStatus: cpStatus?.status ?? null,
|
|
chargePointErrorCode: cpStatus?.errorCode ?? null,
|
|
});
|
|
});
|
|
|
|
/** PATCH /api/charge-points/:id — update charge point fields */
|
|
app.patch("/:id", async (c) => {
|
|
if (c.get("user")?.role !== "admin") return c.json({ error: "Forbidden" }, 403);
|
|
const db = useDrizzle();
|
|
const id = c.req.param("id");
|
|
const body = await c.req.json<{
|
|
feePerKwh?: number;
|
|
pricingMode?: "fixed" | "tou";
|
|
registrationStatus?: string;
|
|
chargePointVendor?: string;
|
|
chargePointModel?: string;
|
|
deviceName?: string | null;
|
|
}>();
|
|
|
|
const set: {
|
|
feePerKwh?: number;
|
|
pricingMode?: "fixed" | "tou";
|
|
registrationStatus?: "Accepted" | "Pending" | "Rejected";
|
|
chargePointVendor?: string;
|
|
chargePointModel?: string;
|
|
deviceName?: string | null;
|
|
updatedAt: Date;
|
|
} = { updatedAt: dayjs().toDate() };
|
|
|
|
if (body.feePerKwh !== undefined) {
|
|
if (!Number.isInteger(body.feePerKwh) || body.feePerKwh < 0) {
|
|
return c.json({ error: "feePerKwh must be a non-negative integer (unit: fen/kWh)" }, 400);
|
|
}
|
|
set.feePerKwh = body.feePerKwh;
|
|
}
|
|
if (body.registrationStatus !== undefined) {
|
|
if (!["Accepted", "Pending", "Rejected"].includes(body.registrationStatus)) {
|
|
return c.json({ error: "invalid registrationStatus" }, 400);
|
|
}
|
|
set.registrationStatus = body.registrationStatus as "Accepted" | "Pending" | "Rejected";
|
|
}
|
|
if (body.chargePointVendor !== undefined) set.chargePointVendor = body.chargePointVendor.trim() || "Unknown";
|
|
if (body.chargePointModel !== undefined) set.chargePointModel = body.chargePointModel.trim() || "Unknown";
|
|
if ("deviceName" in body) set.deviceName = body.deviceName?.trim() || null;
|
|
if (body.pricingMode !== undefined) {
|
|
if (!['fixed', 'tou'].includes(body.pricingMode)) {
|
|
return c.json({ error: "pricingMode must be 'fixed' or 'tou'" }, 400);
|
|
}
|
|
set.pricingMode = body.pricingMode;
|
|
}
|
|
|
|
const [updated] = await db
|
|
.update(chargePoint)
|
|
.set(set)
|
|
.where(eq(chargePoint.id, id))
|
|
.returning();
|
|
|
|
if (!updated) return c.json({ error: "Not found" }, 404);
|
|
|
|
const allConnectors = await db.select().from(connector).where(eq(connector.chargePointId, id));
|
|
const cpStatus = allConnectors.find((conn) => conn.connectorId === 0);
|
|
const displayConnectors = allConnectors.filter((conn) => conn.connectorId > 0);
|
|
|
|
return c.json({
|
|
...updated,
|
|
connectors: displayConnectors,
|
|
chargePointStatus: cpStatus?.status ?? null,
|
|
chargePointErrorCode: cpStatus?.errorCode ?? null,
|
|
});
|
|
});
|
|
|
|
/** DELETE /api/charge-points/:id — delete a charge point (cascades to connectors, transactions, meter values) */
|
|
app.delete("/:id", async (c) => {
|
|
if (c.get("user")?.role !== "admin") return c.json({ error: "Forbidden" }, 403);
|
|
const db = useDrizzle();
|
|
const id = c.req.param("id");
|
|
|
|
const [deleted] = await db
|
|
.delete(chargePoint)
|
|
.where(eq(chargePoint.id, id))
|
|
.returning({ id: chargePoint.id });
|
|
|
|
if (!deleted) return c.json({ error: "Not found" }, 404);
|
|
|
|
return c.json({ success: true });
|
|
});
|
|
|
|
export default app;
|