feat: RBAC controlling
This commit is contained in:
@@ -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");
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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"));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user