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:
1667
apps/csms/drizzle/meta/0003_snapshot.json
Normal file
1667
apps/csms/drizzle/meta/0003_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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.reservationId(optional)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
51
apps/csms/src/ocpp/actions/authorize.ts
Normal file
51
apps/csms/src/ocpp/actions/authorize.ts
Normal 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 };
|
||||
}
|
||||
51
apps/csms/src/ocpp/actions/meter-values.ts
Normal file
51
apps/csms/src/ocpp/actions/meter-values.ts
Normal 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 {};
|
||||
}
|
||||
85
apps/csms/src/ocpp/actions/start-transaction.ts
Normal file
85
apps/csms/src/ocpp/actions/start-transaction.ts
Normal 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 };
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
101
apps/csms/src/ocpp/actions/stop-transaction.ts
Normal file
101
apps/csms/src/ocpp/actions/stop-transaction.ts
Normal 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 };
|
||||
}
|
||||
@@ -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})`)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
92
apps/csms/src/routes/charge-points.ts
Normal file
92
apps/csms/src/routes/charge-points.ts
Normal 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;
|
||||
85
apps/csms/src/routes/id-tags.ts
Normal file
85
apps/csms/src/routes/id-tags.ts
Normal 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;
|
||||
45
apps/csms/src/routes/stats.ts
Normal file
45
apps/csms/src/routes/stats.ts
Normal 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;
|
||||
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;
|
||||
76
apps/csms/src/routes/users.ts
Normal file
76
apps/csms/src/routes/users.ts
Normal 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;
|
||||
8
apps/csms/src/types/hono.ts
Normal file
8
apps/csms/src/types/hono.ts
Normal 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;
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user