feat(dashboard): add transactions and users management pages with CRUD functionality
feat(auth): implement login page and authentication middleware feat(sidebar): create sidebar component with user info and navigation links feat(api): establish API client for interacting with backend services
This commit is contained in:
194
apps/csms/src/routes/transactions.ts
Normal file
194
apps/csms/src/routes/transactions.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import { Hono } from "hono";
|
||||
import { desc, eq, isNull, isNotNull, sql } from "drizzle-orm";
|
||||
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";
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
/** GET /api/transactions?page=1&limit=20&status=active|completed */
|
||||
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)));
|
||||
const status = c.req.query("status"); // 'active' | 'completed'
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const db = useDrizzle();
|
||||
|
||||
const whereClause =
|
||||
status === "active"
|
||||
? isNull(transaction.stopTimestamp)
|
||||
: status === "completed"
|
||||
? isNotNull(transaction.stopTimestamp)
|
||||
: undefined;
|
||||
|
||||
const [{ total }] = await db
|
||||
.select({ total: sql<number>`count(*)::int` })
|
||||
.from(transaction)
|
||||
.where(whereClause);
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
transaction,
|
||||
chargePointIdentifier: chargePoint.chargePointIdentifier,
|
||||
connectorNumber: connector.connectorId,
|
||||
})
|
||||
.from(transaction)
|
||||
.leftJoin(chargePoint, eq(transaction.chargePointId, chargePoint.id))
|
||||
.leftJoin(connector, eq(transaction.connectorId, connector.id))
|
||||
.where(whereClause)
|
||||
.orderBy(desc(transaction.startTimestamp))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
return c.json({
|
||||
data: rows.map((r) => ({
|
||||
...r.transaction,
|
||||
chargePointIdentifier: r.chargePointIdentifier,
|
||||
connectorNumber: r.connectorNumber,
|
||||
energyWh:
|
||||
r.transaction.stopMeterValue != null
|
||||
? r.transaction.stopMeterValue - r.transaction.startMeterValue
|
||||
: null,
|
||||
})),
|
||||
total,
|
||||
page,
|
||||
totalPages: Math.max(1, Math.ceil(total / limit)),
|
||||
});
|
||||
});
|
||||
|
||||
/** GET /api/transactions/:id */
|
||||
app.get("/:id", async (c) => {
|
||||
const db = useDrizzle();
|
||||
const id = Number(c.req.param("id"));
|
||||
|
||||
const [row] = await db
|
||||
.select({
|
||||
transaction,
|
||||
chargePointIdentifier: chargePoint.chargePointIdentifier,
|
||||
})
|
||||
.from(transaction)
|
||||
.leftJoin(chargePoint, eq(transaction.chargePointId, chargePoint.id))
|
||||
.where(eq(transaction.id, id))
|
||||
.limit(1);
|
||||
|
||||
if (!row) return c.json({ error: "Not found" }, 404);
|
||||
|
||||
return c.json({
|
||||
...row.transaction,
|
||||
chargePointIdentifier: row.chargePointIdentifier,
|
||||
energyWh:
|
||||
row.transaction.stopMeterValue != null
|
||||
? row.transaction.stopMeterValue - row.transaction.startMeterValue
|
||||
: null,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/transactions/:id/stop
|
||||
* Manually stop an active transaction.
|
||||
* 1. If the charge point is connected, send OCPP RemoteStopTransaction.
|
||||
* 2. In either case (online or offline), settle the transaction in the DB immediately
|
||||
* so the record is always finalised from the admin side.
|
||||
*/
|
||||
app.post("/:id/stop", async (c) => {
|
||||
const db = useDrizzle();
|
||||
const id = Number(c.req.param("id"));
|
||||
|
||||
// Load the transaction
|
||||
const [row] = await db
|
||||
.select({
|
||||
transaction,
|
||||
chargePointIdentifier: chargePoint.chargePointIdentifier,
|
||||
feePerKwh: chargePoint.feePerKwh,
|
||||
})
|
||||
.from(transaction)
|
||||
.leftJoin(chargePoint, eq(transaction.chargePointId, chargePoint.id))
|
||||
.where(eq(transaction.id, id))
|
||||
.limit(1);
|
||||
|
||||
if (!row) return c.json({ error: "Not found" }, 404);
|
||||
if (row.transaction.stopTimestamp) return c.json({ error: "Transaction already stopped" }, 409);
|
||||
|
||||
const now = new Date();
|
||||
|
||||
// Try to send RemoteStopTransaction via OCPP if the charge point is online
|
||||
const ws = row.chargePointIdentifier ? ocppConnections.get(row.chargePointIdentifier) : null;
|
||||
if (ws) {
|
||||
const uniqueId = crypto.randomUUID();
|
||||
ws.send(
|
||||
JSON.stringify([
|
||||
OCPP_MESSAGE_TYPE.CALL,
|
||||
uniqueId,
|
||||
"RemoteStopTransaction",
|
||||
{ transactionId: row.transaction.id },
|
||||
]),
|
||||
);
|
||||
console.log(`[OCPP] Sent RemoteStopTransaction txId=${id} to ${row.chargePointIdentifier}`);
|
||||
}
|
||||
|
||||
// Settle in DB regardless (charge point may be offline or slow to respond)
|
||||
// Use startMeterValue as stopMeterValue when the real value is unknown (offline case)
|
||||
const stopMeterValue = row.transaction.startMeterValue;
|
||||
const energyWh = 0; // cannot know actual energy without stop meter value
|
||||
const feePerKwh = row.feePerKwh ?? 0;
|
||||
const feeFen = feePerKwh > 0 && energyWh > 0 ? Math.ceil((energyWh * feePerKwh) / 1000) : 0;
|
||||
|
||||
const [updated] = await db
|
||||
.update(transaction)
|
||||
.set({
|
||||
stopTimestamp: now,
|
||||
stopMeterValue,
|
||||
stopReason: "Remote",
|
||||
chargeAmount: feeFen,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(transaction.id, id))
|
||||
.returning();
|
||||
|
||||
if (feeFen > 0) {
|
||||
await db
|
||||
.update(idTag)
|
||||
.set({
|
||||
balance: sql`GREATEST(0, ${idTag.balance} - ${feeFen})`,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(idTag.idTag, row.transaction.idTag));
|
||||
}
|
||||
|
||||
return c.json({
|
||||
...updated,
|
||||
chargePointIdentifier: row.chargePointIdentifier,
|
||||
online: !!ws,
|
||||
energyWh,
|
||||
});
|
||||
});
|
||||
|
||||
/** DELETE /api/transactions/:id — delete a transaction record */
|
||||
app.delete("/:id", async (c) => {
|
||||
const db = useDrizzle();
|
||||
const id = Number(c.req.param("id"));
|
||||
|
||||
const [row] = await db
|
||||
.select({ transaction, connectorId: transaction.connectorId })
|
||||
.from(transaction)
|
||||
.where(eq(transaction.id, id))
|
||||
.limit(1);
|
||||
|
||||
if (!row) return c.json({ error: "Not found" }, 404);
|
||||
|
||||
// If the transaction is still active, reset the connector to Available
|
||||
if (!row.transaction.stopTimestamp) {
|
||||
await db
|
||||
.update(connector)
|
||||
.set({ status: "Available", updatedAt: new Date() })
|
||||
.where(eq(connector.id, row.transaction.connectorId));
|
||||
}
|
||||
|
||||
await db.delete(transaction).where(eq(transaction.id, id));
|
||||
|
||||
return c.json({ success: true });
|
||||
});
|
||||
|
||||
export default app;
|
||||
Reference in New Issue
Block a user