feat: RBAC controlling

This commit is contained in:
2026-03-10 17:59:44 +08:00
parent f803a447b5
commit b9c0f3025c
11 changed files with 716 additions and 380 deletions

View File

@@ -2,14 +2,20 @@ import { Hono } from "hono";
import { desc, eq, sql } from "drizzle-orm";
import { useDrizzle } from "@/lib/db.js";
import { chargePoint, connector } from "@/db/schema.js";
import type { HonoEnv } from "@/types/hono.ts";
const app = new Hono();
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).orderBy(desc(chargePoint.createdAt));
const cps = await db
.select()
.from(chargePoint)
.where(isAdmin ? undefined : eq(chargePoint.registrationStatus, "Accepted"))
.orderBy(desc(chargePoint.createdAt));
// Attach connectors (connectorId > 0 only, excludes the main-controller row)
const connectors = cps.length
@@ -37,6 +43,7 @@ app.get("/", async (c) => {
/** 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;
@@ -88,6 +95,7 @@ app.get("/:id", async (c) => {
/** 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<{
@@ -134,6 +142,7 @@ app.patch("/:id", async (c) => {
/** 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");

View File

@@ -4,8 +4,9 @@ import { useDrizzle } from "@/lib/db.js";
import { idTag } from "@/db/schema.js";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";
import type { HonoEnv } from "@/types/hono.ts";
const app = new Hono();
const app = new Hono<HonoEnv>();
const idTagSchema = z.object({
idTag: z.string().min(1).max(20),
@@ -18,9 +19,32 @@ const idTagSchema = z.object({
const idTagUpdateSchema = idTagSchema.partial().omit({ idTag: true });
/** Generate an 8-character OCPP-compatible idTag (0-9 + A-F uppercase) */
function generateIdTag(): string {
const chars = "0123456789ABCDEF";
let result = "";
for (let i = 0; i < 8; i++) {
result += chars[Math.floor(Math.random() * chars.length)];
}
return result;
}
/** GET /api/id-tags */
app.get("/", async (c) => {
const db = useDrizzle();
const currentUser = c.get("user");
if (!currentUser) return c.json({ error: "Unauthorized" }, 401);
// Non-admin users only see their own cards
if (currentUser.role !== "admin") {
const tags = await db
.select()
.from(idTag)
.where(eq(idTag.userId, currentUser.id))
.orderBy(desc(idTag.createdAt));
return c.json(tags);
}
const tags = await db.select().from(idTag).orderBy(desc(idTag.createdAt));
return c.json(tags);
});
@@ -34,31 +58,69 @@ app.get("/:id", async (c) => {
return c.json(tag);
});
/** POST /api/id-tags */
app.post("/", zValidator("json", idTagSchema), async (c) => {
/** POST /api/id-tags — admin only, create arbitrary card */
app.post("/", async (c) => {
if (c.get("user")?.role !== "admin") return c.json({ error: "Forbidden" }, 403);
const db = useDrizzle();
const body = c.req.valid("json");
const body = await c.req.json().catch(() => {});
const parsed = idTagSchema.safeParse(body);
if (!parsed.success) return c.json({ error: parsed.error.issues }, 400);
const [created] = await db
.insert(idTag)
.values({
...body,
expiryDate: body.expiryDate ? new Date(body.expiryDate) : null,
...parsed.data,
expiryDate: parsed.data.expiryDate ? new Date(parsed.data.expiryDate) : null,
})
.returning();
return c.json(created, 201);
});
/** PATCH /api/id-tags/:id */
app.patch("/:id", zValidator("json", idTagUpdateSchema), async (c) => {
/** POST /api/id-tags/claim — user claims a new card assigned to themselves */
app.post("/claim", async (c) => {
const currentUser = c.get("user");
if (!currentUser) return c.json({ error: "Unauthorized" }, 401);
const db = useDrizzle();
// Retry up to 5 times in case of collision
for (let attempt = 0; attempt < 5; attempt++) {
const newTag = generateIdTag();
const existing = await db
.select({ idTag: idTag.idTag })
.from(idTag)
.where(eq(idTag.idTag, newTag))
.limit(1);
if (existing.length > 0) continue;
const [created] = await db
.insert(idTag)
.values({
idTag: newTag,
status: "Accepted",
userId: currentUser.id,
balance: 0,
})
.returning();
return c.json(created, 201);
}
return c.json({ error: "Failed to generate unique idTag, please retry" }, 500);
});
/** PATCH /api/id-tags/:id — admin only */
app.patch("/:id", async (c) => {
if (c.get("user")?.role !== "admin") return c.json({ error: "Forbidden" }, 403);
const db = useDrizzle();
const tagId = c.req.param("id");
const body = c.req.valid("json");
const body = await c.req.json().catch(() => {});
const parsed = idTagUpdateSchema.safeParse(body);
if (!parsed.success) return c.json({ error: parsed.error.issues }, 400);
const [updated] = await db
.update(idTag)
.set({
...body,
expiryDate: body.expiryDate ? new Date(body.expiryDate) : undefined,
...parsed.data,
expiryDate: parsed.data.expiryDate ? new Date(parsed.data.expiryDate) : undefined,
updatedAt: new Date(),
})
.where(eq(idTag.idTag, tagId))
@@ -68,8 +130,9 @@ app.patch("/:id", zValidator("json", idTagUpdateSchema), async (c) => {
return c.json(updated);
});
/** DELETE /api/id-tags/:id */
/** DELETE /api/id-tags/:id — admin only */
app.delete("/:id", async (c) => {
if (c.get("user")?.role !== "admin") return c.json({ error: "Forbidden" }, 403);
const db = useDrizzle();
const tagId = c.req.param("id");

View File

@@ -1,44 +1,81 @@
import { Hono } from "hono";
import { isNull, sql } from "drizzle-orm";
import { eq, isNull, sql } from "drizzle-orm";
import { useDrizzle } from "@/lib/db.js";
import { chargePoint, transaction, idTag } from "@/db/schema.js";
import type { HonoEnv } from "@/types/hono.ts";
const app = new Hono();
const app = new Hono<HonoEnv>();
app.get("/", async (c) => {
const db = useDrizzle();
const currentUser = c.get("user");
const isAdmin = currentUser?.role === "admin";
const [totalChargePoints, onlineChargePoints, activeTransactions, totalIdTags, todayEnergy] =
await Promise.all([
// Total charge points
db.select({ count: sql<number>`count(*)::int` }).from(chargePoint),
// Online charge points (received heartbeat in last 2×heartbeat interval, default 120s)
db
.select({ count: sql<number>`count(*)::int` })
.from(chargePoint)
.where(sql`${chargePoint.lastHeartbeatAt} > now() - interval '120 seconds'`),
// Active (in-progress) transactions
db
.select({ count: sql<number>`count(*)::int` })
.from(transaction)
.where(isNull(transaction.stopTimestamp)),
// Total id tags
db.select({ count: sql<number>`count(*)::int` }).from(idTag),
// Energy dispensed today (sum of stopMeterValue - startMeterValue for transactions ending today)
db
.select({
total: sql<number>`coalesce(sum(${transaction.stopMeterValue} - ${transaction.startMeterValue}), 0)::int`,
})
.from(transaction)
.where(sql`${transaction.stopTimestamp} >= date_trunc('day', now())`),
]);
if (isAdmin) {
const [totalChargePoints, onlineChargePoints, activeTransactions, totalIdTags, todayEnergy] =
await Promise.all([
db.select({ count: sql<number>`count(*)::int` }).from(chargePoint),
db
.select({ count: sql<number>`count(*)::int` })
.from(chargePoint)
.where(sql`${chargePoint.lastHeartbeatAt} > now() - interval '120 seconds'`),
db
.select({ count: sql<number>`count(*)::int` })
.from(transaction)
.where(isNull(transaction.stopTimestamp)),
db.select({ count: sql<number>`count(*)::int` }).from(idTag),
db
.select({
total: sql<number>`coalesce(sum(${transaction.stopMeterValue} - ${transaction.startMeterValue}), 0)::int`,
})
.from(transaction)
.where(sql`${transaction.stopTimestamp} >= date_trunc('day', now())`),
]);
return c.json({
totalChargePoints: totalChargePoints[0].count,
onlineChargePoints: onlineChargePoints[0].count,
activeTransactions: activeTransactions[0].count,
totalIdTags: totalIdTags[0].count,
todayEnergyWh: todayEnergy[0].total,
});
}
// User-scoped stats
if (!currentUser) return c.json({ error: "Unauthorized" }, 401);
const userId = currentUser.id;
const [userIdTags, totalBalance, activeCount, totalTxCount] = await Promise.all([
// Cards belonging to this user
db
.select({ count: sql<number>`count(*)::int` })
.from(idTag)
.where(eq(idTag.userId, userId)),
// Sum of balances
db
.select({ total: sql<number>`coalesce(sum(${idTag.balance}), 0)::int` })
.from(idTag)
.where(eq(idTag.userId, userId)),
// Active transactions for user's cards
db
.select({ count: sql<number>`count(*)::int` })
.from(transaction)
.innerJoin(idTag, eq(transaction.idTag, idTag.idTag))
.where(sql`${isNull(transaction.stopTimestamp)} and ${idTag.userId} = ${userId}`),
// Total transactions for user's cards
db
.select({ count: sql<number>`count(*)::int` })
.from(transaction)
.innerJoin(idTag, eq(transaction.idTag, idTag.idTag))
.where(eq(idTag.userId, userId)),
]);
return c.json({
totalChargePoints: totalChargePoints[0].count,
onlineChargePoints: onlineChargePoints[0].count,
activeTransactions: activeTransactions[0].count,
totalIdTags: totalIdTags[0].count,
todayEnergyWh: todayEnergy[0].total,
totalIdTags: userIdTags[0].count,
totalBalance: totalBalance[0].total,
activeTransactions: activeCount[0].count,
totalTransactions: totalTxCount[0].count,
});
});

View File

@@ -4,10 +4,11 @@ import { useDrizzle } from "@/lib/db.js";
import { transaction, chargePoint, connector, idTag } from "@/db/schema.js";
import { ocppConnections } from "@/ocpp/handler.js";
import { OCPP_MESSAGE_TYPE } from "@/ocpp/types.js";
import type { HonoEnv } from "@/types/hono.ts";
const app = new Hono();
const app = new Hono<HonoEnv>();
/** GET /api/transactions?page=1&limit=20&status=active|completed */
/** GET /api/transactions?page=1&limit=20&status=active|completed&chargePointId=... */
app.get("/", async (c) => {
const page = Math.max(1, Number(c.req.query("page") ?? 1));
const limit = Math.min(100, Math.max(1, Number(c.req.query("limit") ?? 20)));
@@ -16,6 +17,8 @@ app.get("/", async (c) => {
const offset = (page - 1) * limit;
const db = useDrizzle();
const currentUser = c.get("user");
const isAdmin = currentUser?.role === "admin";
const statusCondition =
status === "active"
@@ -24,11 +27,15 @@ app.get("/", async (c) => {
? isNotNull(transaction.stopTimestamp)
: undefined;
const whereClause = chargePointId
? statusCondition
? and(statusCondition, eq(transaction.chargePointId, chargePointId))
: eq(transaction.chargePointId, chargePointId)
: statusCondition;
// For non-admin users, restrict to transactions matching their id-tags
const userCondition =
!isAdmin && currentUser
? sql`${transaction.idTag} in (select id_tag from id_tag where user_id = ${currentUser.id})`
: undefined;
const cpCondition = chargePointId ? eq(transaction.chargePointId, chargePointId) : undefined;
const whereClause = and(statusCondition, userCondition, cpCondition);
const [{ total }] = await db
.select({ total: sql<number>`count(*)::int` })
@@ -174,6 +181,7 @@ app.post("/:id/stop", async (c) => {
/** DELETE /api/transactions/:id — delete a transaction record */
app.delete("/:id", async (c) => {
if (c.get("user")?.role !== "admin") return c.json({ error: "Forbidden" }, 403);
const db = useDrizzle();
const id = Number(c.req.param("id"));