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(); /** 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 = {}; for (const conn of connectors) { if (!connectorsByCP[conn.chargePointId]) connectorsByCP[conn.chargePointId] = []; connectorsByCP[conn.chargePointId].push(conn); } const cpStatusByCP: Record = {}; 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;