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:
2026-03-10 15:17:32 +08:00
parent 9a2668fae5
commit 2cb89c74b3
32 changed files with 4648 additions and 83 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -16,11 +16,13 @@
"dependencies": {
"@hono/node-server": "^1.19.6",
"@hono/node-ws": "^1.2.0",
"@hono/zod-validator": "^0.7.6",
"better-auth": "^1.3.34",
"dotenv": "^17.2.3",
"drizzle-orm": "^0.44.7",
"hono": "^4.10.6",
"pg": "^8.16.3"
"pg": "^8.16.3",
"zod": "^4.3.6"
},
"devDependencies": {
"@better-auth/cli": "^1.3.34",

View File

@@ -66,6 +66,12 @@ export const chargePoint = pgTable('charge_point', {
lastBootNotificationAt: timestamp('last_boot_notification_at', {
withTimezone: true,
}),
/**
* 电价(单位:分/kWh即 0.01 CNY/kWh
* 交易结束时按实际用电量从储值卡扣费fee = ceil(energyWh * feePerKwh / 1000)
* 默认为 0即不计费
*/
feePerKwh: integer('fee_per_kwh').notNull().default(0),
createdAt: timestamp('created_at', { withTimezone: true })
.notNull()
.defaultNow(),
@@ -271,6 +277,11 @@ export const idTag = pgTable('id_tag', {
* 允许将 RFID 卡与注册用户绑定,支持 Web/App 远程查询充电记录
*/
userId: text('user_id').references(() => user.id, { onDelete: 'set null' }),
/**
* 储值卡余额(单位:分)
* 以整数存储1 分 = 0.01 CNY前端显示时除以 100
*/
balance: integer('balance').notNull().default(0),
createdAt: timestamp('created_at', { withTimezone: true })
.notNull()
.defaultNow(),
@@ -361,6 +372,12 @@ export const transaction = pgTable(
'DeAuthorized',
],
}),
/**
* 本次充电扣费金额(单位:分)
* 由 StopTransaction 处理时根据实际用电量和充电桩电价计算写入
* null 表示未计费(如免费充电桩或交易异常终止)
*/
chargeAmount: integer('charge_amount'),
/**
* 关联的预约 ID若本次充电由预约触发
* StartTransaction.req.reservationIdoptional

View File

@@ -8,13 +8,15 @@ import { logger } from 'hono/logger'
import { showRoutes } from 'hono/dev'
import { auth } from './lib/auth.ts'
import { createOcppHandler } from './ocpp/handler.ts'
import statsRoutes from './routes/stats.ts'
import chargePointRoutes from './routes/charge-points.ts'
import transactionRoutes from './routes/transactions.ts'
import idTagRoutes from './routes/id-tags.ts'
import userRoutes from './routes/users.ts'
const app = new Hono<{
Variables: {
user: typeof auth.$Infer.Session.user | null
session: typeof auth.$Infer.Session.session | null
}
}>()
import type { HonoEnv } from './types/hono.ts'
const app = new Hono<HonoEnv>()
const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app })
@@ -34,10 +36,10 @@ app.use('*', async (c, next) => {
})
app.use(
'/api/auth/*',
'/api/*',
cors({
origin: '*',
allowMethods: ['GET', 'POST', 'OPTIONS'],
origin: process.env.WEB_ORIGIN ?? '*',
allowMethods: ['GET', 'POST', 'PATCH', 'DELETE', 'OPTIONS'],
allowHeaders: ['Content-Type', 'Authorization'],
exposeHeaders: ['Content-Length'],
credentials: true,
@@ -46,6 +48,13 @@ app.use(
app.on(['POST', 'GET'], '/api/auth/*', (c) => auth.handler(c.req.raw))
// REST API routes
app.route('/api/stats', statsRoutes)
app.route('/api/charge-points', chargePointRoutes)
app.route('/api/transactions', transactionRoutes)
app.route('/api/id-tags', idTagRoutes)
app.route('/api/users', userRoutes)
app.get('/api', (c) => {
const user = c.get('user')
const session = c.get('session')

View File

@@ -2,7 +2,7 @@ import { betterAuth } from 'better-auth'
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
import { useDrizzle } from './db.js'
import * as schema from '@/db/schema.ts'
import { admin, bearer, jwt, username } from 'better-auth/plugins'
import { admin, bearer, username } from 'better-auth/plugins'
export const auth = betterAuth({
database: drizzleAdapter(useDrizzle(), {
@@ -11,13 +11,16 @@ export const auth = betterAuth({
...schema,
},
}),
trustedOrigins: [
process.env.WEB_ORIGIN ?? 'http://localhost:3000',
],
user: {
additionalFields: {},
},
emailAndPassword: {
enabled: true,
},
plugins: [admin(), username(), bearer(), jwt()],
plugins: [admin(), username(), bearer()],
advanced: {
cookiePrefix: 'helios_auth',
},

View File

@@ -0,0 +1,51 @@
import { eq } from "drizzle-orm";
import { useDrizzle } from "@/lib/db.js";
import { idTag } from "@/db/schema.js";
import type {
AuthorizeRequest,
AuthorizeResponse,
IdTagInfo,
OcppConnectionContext,
} from "../types.ts";
/**
* Shared helper — resolves idTagInfo from the database.
* Used by Authorize, StartTransaction, and StopTransaction.
*
* @param checkBalance When true (default), rejects tags with balance ≤ 0.
* Pass false for StopTransaction where charging has already occurred.
*/
export async function resolveIdTagInfo(
idTagValue: string,
checkBalance = true,
): Promise<IdTagInfo> {
const db = useDrizzle();
const [tag] = await db.select().from(idTag).where(eq(idTag.idTag, idTagValue)).limit(1);
if (!tag) return { status: "Invalid" };
if (tag.status === "Blocked") return { status: "Blocked" };
if (tag.expiryDate && tag.expiryDate < new Date()) {
return { status: "Expired", expiryDate: tag.expiryDate.toISOString() };
}
if (tag.status !== "Accepted") {
return { status: tag.status as IdTagInfo["status"] };
}
// Reject if balance is zero or negative
if (checkBalance && tag.balance <= 0) {
return { status: "Blocked" };
}
return {
status: "Accepted",
expiryDate: tag.expiryDate?.toISOString(),
parentIdTag: tag.parentIdTag ?? undefined,
};
}
export async function handleAuthorize(
payload: AuthorizeRequest,
_ctx: OcppConnectionContext,
): Promise<AuthorizeResponse> {
const idTagInfo = await resolveIdTagInfo(payload.idTag);
console.log(`[OCPP] Authorize idTag=${payload.idTag} -> ${idTagInfo.status}`);
return { idTagInfo };
}

View File

@@ -0,0 +1,51 @@
import { and, eq } from "drizzle-orm";
import { useDrizzle } from "@/lib/db.js";
import { chargePoint, connector, meterValue } from "@/db/schema.js";
import type { MeterValuesRequest, MeterValuesResponse, OcppConnectionContext } from "../types.ts";
export async function handleMeterValues(
payload: MeterValuesRequest,
ctx: OcppConnectionContext,
): Promise<MeterValuesResponse> {
const db = useDrizzle();
const [cp] = await db
.select({ id: chargePoint.id })
.from(chargePoint)
.where(eq(chargePoint.chargePointIdentifier, ctx.chargePointIdentifier))
.limit(1);
if (!cp) throw new Error(`ChargePoint not found: ${ctx.chargePointIdentifier}`);
const [conn] = await db
.select({ id: connector.id })
.from(connector)
.where(and(eq(connector.chargePointId, cp.id), eq(connector.connectorId, payload.connectorId)))
.limit(1);
if (!conn) {
console.warn(
`[OCPP] MeterValues: connector ${payload.connectorId} not found for ${ctx.chargePointIdentifier}`,
);
return {};
}
const records = payload.meterValue
.filter((mv) => mv.sampledValue?.length)
.map((mv) => ({
id: crypto.randomUUID(),
transactionId: payload.transactionId ?? null,
connectorId: conn.id,
chargePointId: cp.id,
connectorNumber: payload.connectorId,
timestamp: new Date(mv.timestamp),
sampledValues:
mv.sampledValue as unknown as (typeof meterValue.$inferInsert)["sampledValues"],
}));
if (records.length) {
await db.insert(meterValue).values(records);
}
return {};
}

View File

@@ -0,0 +1,85 @@
import { eq } from "drizzle-orm";
import { useDrizzle } from "@/lib/db.js";
import { chargePoint, connector, transaction } from "@/db/schema.js";
import type {
StartTransactionRequest,
StartTransactionResponse,
OcppConnectionContext,
} from "../types.ts";
import { resolveIdTagInfo } from "./authorize.ts";
export async function handleStartTransaction(
payload: StartTransactionRequest,
ctx: OcppConnectionContext,
): Promise<StartTransactionResponse> {
const db = useDrizzle();
// Resolve idTag authorization
const idTagInfo = await resolveIdTagInfo(payload.idTag);
// Find charge point
const [cp] = await db
.select({ id: chargePoint.id })
.from(chargePoint)
.where(eq(chargePoint.chargePointIdentifier, ctx.chargePointIdentifier))
.limit(1);
if (!cp) throw new Error(`ChargePoint not found: ${ctx.chargePointIdentifier}`);
// Upsert connector — it may not exist yet if StatusNotification was skipped
const [conn] = await db
.insert(connector)
.values({
id: crypto.randomUUID(),
chargePointId: cp.id,
connectorId: payload.connectorId,
status: "Charging",
errorCode: "NoError",
lastStatusAt: new Date(),
})
.onConflictDoUpdate({
target: [connector.chargePointId, connector.connectorId],
set: { status: "Charging", updatedAt: new Date() },
})
.returning({ id: connector.id });
const rejected = idTagInfo.status !== "Accepted";
const now = new Date();
// Insert transaction record regardless of auth status (OCPP spec requirement)
const [tx] = await db
.insert(transaction)
.values({
chargePointId: cp.id,
connectorId: conn.id,
connectorNumber: payload.connectorId,
idTag: payload.idTag,
idTagStatus: idTagInfo.status,
startTimestamp: new Date(payload.timestamp),
startMeterValue: payload.meterStart,
reservationId: payload.reservationId ?? null,
// If rejected, immediately close the transaction so it doesn't appear as in-progress
...(rejected && {
stopTimestamp: now,
stopMeterValue: payload.meterStart,
chargeAmount: 0,
stopReason: "DeAuthorized",
}),
})
.returning({ id: transaction.id });
// If rejected, reset connector back to Available
if (rejected) {
await db
.update(connector)
.set({ status: "Available", updatedAt: now })
.where(eq(connector.id, conn.id));
}
console.log(
`[OCPP] StartTransaction cp=${ctx.chargePointIdentifier} connector=${payload.connectorId} ` +
`idTag=${payload.idTag} status=${idTagInfo.status} txId=${tx.id}`,
);
return { transactionId: tx.id, idTagInfo };
}

View File

@@ -1,4 +1,4 @@
import { eq, and } from 'drizzle-orm'
import { eq } from 'drizzle-orm'
import { useDrizzle } from '@/lib/db.js'
import { chargePoint, connector, connectorStatusHistory } from '@/db/schema.js'
import type {

View File

@@ -0,0 +1,101 @@
import { eq, sql } from "drizzle-orm";
import { useDrizzle } from "@/lib/db.js";
import { chargePoint, connector, idTag, meterValue, transaction } from "@/db/schema.js";
import type {
StopTransactionRequest,
StopTransactionResponse,
OcppConnectionContext,
} from "../types.ts";
import { resolveIdTagInfo } from "./authorize.ts";
export async function handleStopTransaction(
payload: StopTransactionRequest,
_ctx: OcppConnectionContext,
): Promise<StopTransactionResponse> {
const db = useDrizzle();
// Update the transaction record
const [tx] = await db
.update(transaction)
.set({
stopTimestamp: new Date(payload.timestamp),
stopMeterValue: payload.meterStop,
stopIdTag: payload.idTag ?? null,
stopReason: (payload.reason as (typeof transaction.$inferSelect)["stopReason"]) ?? null,
updatedAt: new Date(),
})
.where(eq(transaction.id, payload.transactionId))
.returning();
if (!tx) {
console.warn(`[OCPP] StopTransaction: transaction ${payload.transactionId} not found`);
return {};
}
// Set connector back to Available
await db
.update(connector)
.set({ status: "Available", updatedAt: new Date() })
.where(eq(connector.id, tx.connectorId));
// Store embedded meter values (transactionData)
if (payload.transactionData?.length) {
const records = payload.transactionData.flatMap((mv) =>
mv.sampledValue?.length
? [
{
id: crypto.randomUUID(),
transactionId: tx.id,
connectorId: tx.connectorId,
chargePointId: tx.chargePointId,
connectorNumber: tx.connectorNumber,
timestamp: new Date(mv.timestamp),
sampledValues:
mv.sampledValue as unknown as (typeof meterValue.$inferInsert)["sampledValues"],
},
]
: [],
);
if (records.length) {
await db.insert(meterValue).values(records);
}
}
const energyWh = payload.meterStop - tx.startMeterValue;
// Deduct balance from the idTag based on actual energy consumed
const [cp] = await db
.select({ feePerKwh: chargePoint.feePerKwh })
.from(chargePoint)
.where(eq(chargePoint.id, tx.chargePointId))
.limit(1);
const feeFen =
cp && cp.feePerKwh > 0 && energyWh > 0 ? Math.ceil((energyWh * cp.feePerKwh) / 1000) : 0;
// Always record the charge amount (0 if free)
await db
.update(transaction)
.set({ chargeAmount: feeFen, updatedAt: new Date() })
.where(eq(transaction.id, tx.id));
if (feeFen > 0) {
await db
.update(idTag)
.set({
balance: sql`${idTag.balance} - ${feeFen}`,
updatedAt: new Date(),
})
.where(eq(idTag.idTag, tx.idTag));
}
console.log(
`[OCPP] StopTransaction txId=${payload.transactionId} ` +
`reason=${payload.reason ?? "none"} energyWh=${energyWh} feeFen=${feeFen}`,
);
// Resolve idTagInfo for the stop tag if provided (no balance check — charging already occurred)
const idTagInfo = payload.idTag ? await resolveIdTagInfo(payload.idTag, false) : undefined;
return { idTagInfo };
}

View File

@@ -6,19 +6,41 @@ import {
type OcppErrorCode,
type OcppMessage,
type OcppConnectionContext,
type AuthorizeRequest,
type AuthorizeResponse,
type BootNotificationRequest,
type BootNotificationResponse,
type HeartbeatRequest,
type HeartbeatResponse,
type MeterValuesRequest,
type MeterValuesResponse,
type StartTransactionRequest,
type StartTransactionResponse,
type StatusNotificationRequest,
type StatusNotificationResponse,
type StopTransactionRequest,
type StopTransactionResponse,
} from './types.ts'
/**
* Global registry of active OCPP WebSocket connections.
* Key = chargePointIdentifier, Value = WSContext
*/
export const ocppConnections = new Map<string, WSContext>()
import { handleAuthorize } from './actions/authorize.ts'
import { handleBootNotification } from './actions/boot-notification.ts'
import { handleHeartbeat } from './actions/heartbeat.ts'
import { handleMeterValues } from './actions/meter-values.ts'
import { handleStartTransaction } from './actions/start-transaction.ts'
import { handleStatusNotification } from './actions/status-notification.ts'
import { handleStopTransaction } from './actions/stop-transaction.ts'
// Typed dispatch map — only registered actions are accepted
type ActionHandlerMap = {
Authorize: (
payload: AuthorizeRequest,
ctx: OcppConnectionContext,
) => Promise<AuthorizeResponse>
BootNotification: (
payload: BootNotificationRequest,
ctx: OcppConnectionContext,
@@ -27,16 +49,32 @@ type ActionHandlerMap = {
payload: HeartbeatRequest,
ctx: OcppConnectionContext,
) => Promise<HeartbeatResponse>
MeterValues: (
payload: MeterValuesRequest,
ctx: OcppConnectionContext,
) => Promise<MeterValuesResponse>
StartTransaction: (
payload: StartTransactionRequest,
ctx: OcppConnectionContext,
) => Promise<StartTransactionResponse>
StatusNotification: (
payload: StatusNotificationRequest,
ctx: OcppConnectionContext,
) => Promise<StatusNotificationResponse>
StopTransaction: (
payload: StopTransactionRequest,
ctx: OcppConnectionContext,
) => Promise<StopTransactionResponse>
}
const actionHandlers: ActionHandlerMap = {
Authorize: handleAuthorize,
BootNotification: handleBootNotification,
Heartbeat: handleHeartbeat,
MeterValues: handleMeterValues,
StartTransaction: handleStartTransaction,
StatusNotification: handleStatusNotification,
StopTransaction: handleStopTransaction,
}
function sendCallResult(ws: WSContext, uniqueId: string, payload: unknown): void {
@@ -74,6 +112,7 @@ export function createOcppHandler(chargePointIdentifier: string, remoteAddr?: st
ws.close(1002, 'Unsupported subprotocol')
return
}
ocppConnections.set(chargePointIdentifier, ws)
console.log(
`[OCPP] ${chargePointIdentifier} connected` +
(remoteAddr ? ` from ${remoteAddr}` : ''),
@@ -136,6 +175,7 @@ export function createOcppHandler(chargePointIdentifier: string, remoteAddr?: st
},
onClose(evt: CloseEvent, _ws: WSContext) {
ocppConnections.delete(chargePointIdentifier)
console.log(`[OCPP] ${chargePointIdentifier} disconnected (code=${evt.code})`)
},
}

View File

@@ -82,10 +82,64 @@ export type AuthorizeRequest = {
idTag: string // CiString20Type
}
export type AuthorizeResponse = {
idTagInfo: {
status: 'Accepted' | 'Blocked' | 'Expired' | 'Invalid' | 'ConcurrentTx'
expiryDate?: string
parentIdTag?: string
}
export type IdTagInfo = {
status: 'Accepted' | 'Blocked' | 'Expired' | 'Invalid' | 'ConcurrentTx'
expiryDate?: string
parentIdTag?: string
}
export type AuthorizeResponse = {
idTagInfo: IdTagInfo
}
// Section 4.3 StartTransaction
export type StartTransactionRequest = {
connectorId: number // ≥1
idTag: string // CiString20Type
meterStart: number // Wh
timestamp: string // UTC ISO 8601
reservationId?: number
}
export type StartTransactionResponse = {
transactionId: number
idTagInfo: IdTagInfo
}
// Section 4.4 StopTransaction
export type StopTransactionRequest = {
transactionId: number
meterStop: number // Wh
timestamp: string // UTC ISO 8601
reason?: string
idTag?: string // CiString20Type, optional (staff override)
transactionData?: MeterValue[]
}
export type StopTransactionResponse = {
idTagInfo?: IdTagInfo
}
// Section 4.7 MeterValues
export type SampledValue = {
value: string
context?: string
format?: string
measurand?: string
phase?: string
location?: string
unit?: string
}
export type MeterValue = {
timestamp: string
sampledValue: SampledValue[]
}
export type MeterValuesRequest = {
connectorId: number
transactionId?: number
meterValue: MeterValue[]
}
export type MeterValuesResponse = Record<string, never>

View File

@@ -0,0 +1,92 @@
import { Hono } from "hono";
import { desc, eq, sql } from "drizzle-orm";
import { useDrizzle } from "@/lib/db.js";
import { chargePoint, connector } from "@/db/schema.js";
const app = new Hono();
/** GET /api/charge-points — list all charge points with connectors */
app.get("/", async (c) => {
const db = useDrizzle();
const cps = await db.select().from(chargePoint).orderBy(desc(chargePoint.createdAt));
// Attach connectors (connectorId > 0 only, excludes the main-controller row)
const connectors = cps.length
? await db
.select()
.from(connector)
.where(
sql`${connector.chargePointId} = any(${sql.raw(`array[${cps.map((cp) => `'${cp.id}'`).join(",")}]`)}) 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);
}
return c.json(
cps.map((cp) => ({
...cp,
connectors: connectorsByCP[cp.id] ?? [],
})),
);
});
/** 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 connectors = await db.select().from(connector).where(eq(connector.chargePointId, id));
return c.json({ ...cp, connectors });
});
/** PATCH /api/charge-points/:id — update feePerKwh */
app.patch("/:id", async (c) => {
const db = useDrizzle();
const id = c.req.param("id");
const body = await c.req.json<{ feePerKwh?: number }>();
if (
typeof body.feePerKwh !== "number" ||
body.feePerKwh < 0 ||
!Number.isInteger(body.feePerKwh)
) {
return c.json({ error: "feePerKwh must be a non-negative integer (unit: fen/kWh)" }, 400);
}
const [updated] = await db
.update(chargePoint)
.set({ feePerKwh: body.feePerKwh, updatedAt: new Date() })
.where(eq(chargePoint.id, id))
.returning();
if (!updated) return c.json({ error: "Not found" }, 404);
return c.json({ feePerKwh: updated.feePerKwh });
});
/** DELETE /api/charge-points/:id — delete a charge point (cascades to connectors, transactions, meter values) */
app.delete("/:id", async (c) => {
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;

View File

@@ -0,0 +1,85 @@
import { Hono } from "hono";
import { desc, eq } from "drizzle-orm";
import { useDrizzle } from "@/lib/db.js";
import { idTag } from "@/db/schema.js";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";
const app = new Hono();
const idTagSchema = z.object({
idTag: z.string().min(1).max(20),
parentIdTag: z.string().max(20).optional().nullable(),
status: z.enum(["Accepted", "Blocked", "Expired", "Invalid", "ConcurrentTx"]).default("Accepted"),
expiryDate: z.string().date().optional().nullable(),
userId: z.string().optional().nullable(),
balance: z.number().int().min(0).default(0),
});
const idTagUpdateSchema = idTagSchema.partial().omit({ idTag: true });
/** GET /api/id-tags */
app.get("/", async (c) => {
const db = useDrizzle();
const tags = await db.select().from(idTag).orderBy(desc(idTag.createdAt));
return c.json(tags);
});
/** GET /api/id-tags/:id */
app.get("/:id", async (c) => {
const db = useDrizzle();
const tagId = c.req.param("id");
const [tag] = await db.select().from(idTag).where(eq(idTag.idTag, tagId)).limit(1);
if (!tag) return c.json({ error: "Not found" }, 404);
return c.json(tag);
});
/** POST /api/id-tags */
app.post("/", zValidator("json", idTagSchema), async (c) => {
const db = useDrizzle();
const body = c.req.valid("json");
const [created] = await db
.insert(idTag)
.values({
...body,
expiryDate: body.expiryDate ? new Date(body.expiryDate) : null,
})
.returning();
return c.json(created, 201);
});
/** PATCH /api/id-tags/:id */
app.patch("/:id", zValidator("json", idTagUpdateSchema), async (c) => {
const db = useDrizzle();
const tagId = c.req.param("id");
const body = c.req.valid("json");
const [updated] = await db
.update(idTag)
.set({
...body,
expiryDate: body.expiryDate ? new Date(body.expiryDate) : undefined,
updatedAt: new Date(),
})
.where(eq(idTag.idTag, tagId))
.returning();
if (!updated) return c.json({ error: "Not found" }, 404);
return c.json(updated);
});
/** DELETE /api/id-tags/:id */
app.delete("/:id", async (c) => {
const db = useDrizzle();
const tagId = c.req.param("id");
const [deleted] = await db
.delete(idTag)
.where(eq(idTag.idTag, tagId))
.returning({ idTag: idTag.idTag });
if (!deleted) return c.json({ error: "Not found" }, 404);
return c.json({ success: true });
});
export default app;

View File

@@ -0,0 +1,45 @@
import { Hono } from "hono";
import { isNull, sql } from "drizzle-orm";
import { useDrizzle } from "@/lib/db.js";
import { chargePoint, transaction, idTag } from "@/db/schema.js";
const app = new Hono();
app.get("/", async (c) => {
const db = useDrizzle();
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())`),
]);
return c.json({
totalChargePoints: totalChargePoints[0].count,
onlineChargePoints: onlineChargePoints[0].count,
activeTransactions: activeTransactions[0].count,
totalIdTags: totalIdTags[0].count,
todayEnergyWh: todayEnergy[0].total,
});
});
export default app;

View 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;

View File

@@ -0,0 +1,76 @@
import { Hono } from "hono";
import { desc, eq } from "drizzle-orm";
import { useDrizzle } from "@/lib/db.js";
import { user } 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<HonoEnv>();
const userUpdateSchema = z.object({
name: z.string().min(1).max(100).optional(),
username: z.string().min(1).max(50).optional().nullable(),
role: z.enum(["user", "admin"]).optional(),
banned: z.boolean().optional(),
banReason: z.string().max(200).optional().nullable(),
});
/** GET /api/users — admin only */
app.get("/", async (c) => {
const currentUser = c.get("user");
if (currentUser?.role !== "admin") {
return c.json({ error: "Forbidden" }, 403);
}
const db = useDrizzle();
const users = await db
.select({
id: user.id,
name: user.name,
email: user.email,
emailVerified: user.emailVerified,
username: user.username,
role: user.role,
banned: user.banned,
banReason: user.banReason,
createdAt: user.createdAt,
})
.from(user)
.orderBy(desc(user.createdAt));
return c.json(users);
});
/** PATCH /api/users/:id — update role/ban status, admin only */
app.patch("/:id", zValidator("json", userUpdateSchema), async (c) => {
const currentUser = c.get("user");
if (currentUser?.role !== "admin") {
return c.json({ error: "Forbidden" }, 403);
}
const db = useDrizzle();
const userId = c.req.param("id");
const body = c.req.valid("json");
const [updated] = await db
.update(user)
.set({
...body,
updatedAt: new Date(),
})
.where(eq(user.id, userId))
.returning({
id: user.id,
name: user.name,
email: user.email,
role: user.role,
banned: user.banned,
banReason: user.banReason,
});
if (!updated) return c.json({ error: "Not found" }, 404);
return c.json(updated);
});
export default app;

View File

@@ -0,0 +1,8 @@
import type { auth } from "@/lib/auth.ts";
export type HonoEnv = {
Variables: {
user: typeof auth.$Infer.Session.user | null;
session: typeof auth.$Infer.Session.session | null;
};
};