From 2cb89c74b32b7ac819237f7c4c951d7ef949e9f6 Mon Sep 17 00:00:00 2001 From: Timothy Yin Date: Tue, 10 Mar 2026 15:17:32 +0800 Subject: [PATCH] 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 --- apps/csms/drizzle/meta/0003_snapshot.json | 1667 +++++++++++++++++ apps/csms/package.json | 4 +- apps/csms/src/db/ocpp-schema.ts | 17 + apps/csms/src/index.ts | 27 +- apps/csms/src/lib/auth.ts | 7 +- apps/csms/src/ocpp/actions/authorize.ts | 51 + apps/csms/src/ocpp/actions/meter-values.ts | 51 + .../src/ocpp/actions/start-transaction.ts | 85 + .../src/ocpp/actions/status-notification.ts | 2 +- .../csms/src/ocpp/actions/stop-transaction.ts | 101 + apps/csms/src/ocpp/handler.ts | 40 + apps/csms/src/ocpp/types.ts | 66 +- apps/csms/src/routes/charge-points.ts | 92 + apps/csms/src/routes/id-tags.ts | 85 + apps/csms/src/routes/stats.ts | 45 + apps/csms/src/routes/transactions.ts | 194 ++ apps/csms/src/routes/users.ts | 76 + apps/csms/src/types/hono.ts | 8 + apps/web/app/dashboard/charge-points/page.tsx | 275 +++ apps/web/app/dashboard/id-tags/page.tsx | 484 +++++ apps/web/app/dashboard/layout.tsx | 19 + apps/web/app/dashboard/page.tsx | 126 ++ apps/web/app/dashboard/transactions/page.tsx | 298 +++ apps/web/app/dashboard/users/page.tsx | 363 ++++ apps/web/app/login/page.tsx | 96 + apps/web/app/page.tsx | 66 +- apps/web/components/sidebar-footer.tsx | 48 + apps/web/components/sidebar.tsx | 124 ++ apps/web/lib/api.ts | 175 ++ apps/web/lib/auth-client.ts | 9 + apps/web/middleware.ts | 27 + apps/web/package.json | 3 + 32 files changed, 4648 insertions(+), 83 deletions(-) create mode 100644 apps/csms/drizzle/meta/0003_snapshot.json create mode 100644 apps/csms/src/ocpp/actions/authorize.ts create mode 100644 apps/csms/src/ocpp/actions/meter-values.ts create mode 100644 apps/csms/src/ocpp/actions/start-transaction.ts create mode 100644 apps/csms/src/ocpp/actions/stop-transaction.ts create mode 100644 apps/csms/src/routes/charge-points.ts create mode 100644 apps/csms/src/routes/id-tags.ts create mode 100644 apps/csms/src/routes/stats.ts create mode 100644 apps/csms/src/routes/transactions.ts create mode 100644 apps/csms/src/routes/users.ts create mode 100644 apps/csms/src/types/hono.ts create mode 100644 apps/web/app/dashboard/charge-points/page.tsx create mode 100644 apps/web/app/dashboard/id-tags/page.tsx create mode 100644 apps/web/app/dashboard/layout.tsx create mode 100644 apps/web/app/dashboard/page.tsx create mode 100644 apps/web/app/dashboard/transactions/page.tsx create mode 100644 apps/web/app/dashboard/users/page.tsx create mode 100644 apps/web/app/login/page.tsx create mode 100644 apps/web/components/sidebar-footer.tsx create mode 100644 apps/web/components/sidebar.tsx create mode 100644 apps/web/lib/api.ts create mode 100644 apps/web/lib/auth-client.ts create mode 100644 apps/web/middleware.ts diff --git a/apps/csms/drizzle/meta/0003_snapshot.json b/apps/csms/drizzle/meta/0003_snapshot.json new file mode 100644 index 0000000..6f3724b --- /dev/null +++ b/apps/csms/drizzle/meta/0003_snapshot.json @@ -0,0 +1,1667 @@ +{ + "id": "7a1c94ab-46cd-48cb-b84f-622da7a036c1", + "prevId": "19bd6e2d-1648-4da6-a8db-4f66712febde", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.jwks": { + "name": "jwks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_username": { + "name": "display_username", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "user_username_unique": { + "name": "user_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.charge_point": { + "name": "charge_point", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "charge_point_identifier": { + "name": "charge_point_identifier", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "charge_point_serial_number": { + "name": "charge_point_serial_number", + "type": "varchar(25)", + "primaryKey": false, + "notNull": false + }, + "charge_point_model": { + "name": "charge_point_model", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "charge_point_vendor": { + "name": "charge_point_vendor", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "firmware_version": { + "name": "firmware_version", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "iccid": { + "name": "iccid", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "imsi": { + "name": "imsi", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "meter_serial_number": { + "name": "meter_serial_number", + "type": "varchar(25)", + "primaryKey": false, + "notNull": false + }, + "meter_type": { + "name": "meter_type", + "type": "varchar(25)", + "primaryKey": false, + "notNull": false + }, + "registration_status": { + "name": "registration_status", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'Pending'" + }, + "heartbeat_interval": { + "name": "heartbeat_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 60 + }, + "last_heartbeat_at": { + "name": "last_heartbeat_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_boot_notification_at": { + "name": "last_boot_notification_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "charge_point_charge_point_identifier_unique": { + "name": "charge_point_charge_point_identifier_unique", + "nullsNotDistinct": false, + "columns": [ + "charge_point_identifier" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.charging_profile": { + "name": "charging_profile", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "charge_point_id": { + "name": "charge_point_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "connector_id": { + "name": "connector_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "connector_number": { + "name": "connector_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "transaction_id": { + "name": "transaction_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "stack_level": { + "name": "stack_level", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "charging_profile_purpose": { + "name": "charging_profile_purpose", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "charging_profile_kind": { + "name": "charging_profile_kind", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "recurrency_kind": { + "name": "recurrency_kind", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "valid_from": { + "name": "valid_from", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "valid_to": { + "name": "valid_to", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "charging_schedule": { + "name": "charging_schedule", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_charging_profile_charge_point_id": { + "name": "idx_charging_profile_charge_point_id", + "columns": [ + { + "expression": "charge_point_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_charging_profile_connector_id": { + "name": "idx_charging_profile_connector_id", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_charging_profile_purpose_stack": { + "name": "idx_charging_profile_purpose_stack", + "columns": [ + { + "expression": "connector_number", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "charging_profile_purpose", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stack_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "charging_profile_charge_point_id_charge_point_id_fk": { + "name": "charging_profile_charge_point_id_charge_point_id_fk", + "tableFrom": "charging_profile", + "tableTo": "charge_point", + "columnsFrom": [ + "charge_point_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "charging_profile_connector_id_connector_id_fk": { + "name": "charging_profile_connector_id_connector_id_fk", + "tableFrom": "charging_profile", + "tableTo": "connector", + "columnsFrom": [ + "connector_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "charging_profile_transaction_id_transaction_id_fk": { + "name": "charging_profile_transaction_id_transaction_id_fk", + "tableFrom": "charging_profile", + "tableTo": "transaction", + "columnsFrom": [ + "transaction_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.connector": { + "name": "connector", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "charge_point_id": { + "name": "charge_point_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "connector_id": { + "name": "connector_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'Unavailable'" + }, + "error_code": { + "name": "error_code", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'NoError'" + }, + "info": { + "name": "info", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "vendor_id": { + "name": "vendor_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "vendor_error_code": { + "name": "vendor_error_code", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "last_status_at": { + "name": "last_status_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_connector_charge_point_id": { + "name": "idx_connector_charge_point_id", + "columns": [ + { + "expression": "charge_point_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_connector_charge_point_connector": { + "name": "idx_connector_charge_point_connector", + "columns": [ + { + "expression": "charge_point_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "connector_charge_point_id_charge_point_id_fk": { + "name": "connector_charge_point_id_charge_point_id_fk", + "tableFrom": "connector", + "tableTo": "charge_point", + "columnsFrom": [ + "charge_point_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.connector_status_history": { + "name": "connector_status_history", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "connector_id": { + "name": "connector_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "connector_number": { + "name": "connector_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "error_code": { + "name": "error_code", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "info": { + "name": "info", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "vendor_id": { + "name": "vendor_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "vendor_error_code": { + "name": "vendor_error_code", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "status_timestamp": { + "name": "status_timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "received_at": { + "name": "received_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_status_history_connector_id": { + "name": "idx_status_history_connector_id", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_status_history_timestamp": { + "name": "idx_status_history_timestamp", + "columns": [ + { + "expression": "status_timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_status_history_received_at": { + "name": "idx_status_history_received_at", + "columns": [ + { + "expression": "received_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "connector_status_history_connector_id_connector_id_fk": { + "name": "connector_status_history_connector_id_connector_id_fk", + "tableFrom": "connector_status_history", + "tableTo": "connector", + "columnsFrom": [ + "connector_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.id_tag": { + "name": "id_tag", + "schema": "", + "columns": { + "id_tag": { + "name": "id_tag", + "type": "varchar(20)", + "primaryKey": true, + "notNull": true + }, + "parent_id_tag": { + "name": "parent_id_tag", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'Accepted'" + }, + "expiry_date": { + "name": "expiry_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "balance": { + "name": "balance", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": "0" + } + }, + "indexes": {}, + "foreignKeys": { + "id_tag_user_id_user_id_fk": { + "name": "id_tag_user_id_user_id_fk", + "tableFrom": "id_tag", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.local_auth_list": { + "name": "local_auth_list", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "charge_point_id": { + "name": "charge_point_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "list_version": { + "name": "list_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "id_tag": { + "name": "id_tag", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "parent_id_tag": { + "name": "parent_id_tag", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "id_tag_status": { + "name": "id_tag_status", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "expiry_date": { + "name": "expiry_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_local_auth_list_charge_point_id_tag": { + "name": "idx_local_auth_list_charge_point_id_tag", + "columns": [ + { + "expression": "charge_point_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id_tag", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "local_auth_list_charge_point_id_charge_point_id_fk": { + "name": "local_auth_list_charge_point_id_charge_point_id_fk", + "tableFrom": "local_auth_list", + "tableTo": "charge_point", + "columnsFrom": [ + "charge_point_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.meter_value": { + "name": "meter_value", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "transaction_id": { + "name": "transaction_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "connector_id": { + "name": "connector_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "charge_point_id": { + "name": "charge_point_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "connector_number": { + "name": "connector_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "sampled_values": { + "name": "sampled_values", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "received_at": { + "name": "received_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_meter_value_transaction_id": { + "name": "idx_meter_value_transaction_id", + "columns": [ + { + "expression": "transaction_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_meter_value_connector_id": { + "name": "idx_meter_value_connector_id", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_meter_value_timestamp": { + "name": "idx_meter_value_timestamp", + "columns": [ + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "meter_value_transaction_id_transaction_id_fk": { + "name": "meter_value_transaction_id_transaction_id_fk", + "tableFrom": "meter_value", + "tableTo": "transaction", + "columnsFrom": [ + "transaction_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "meter_value_connector_id_connector_id_fk": { + "name": "meter_value_connector_id_connector_id_fk", + "tableFrom": "meter_value", + "tableTo": "connector", + "columnsFrom": [ + "connector_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "meter_value_charge_point_id_charge_point_id_fk": { + "name": "meter_value_charge_point_id_charge_point_id_fk", + "tableFrom": "meter_value", + "tableTo": "charge_point", + "columnsFrom": [ + "charge_point_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reservation": { + "name": "reservation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "charge_point_id": { + "name": "charge_point_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "connector_id": { + "name": "connector_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "connector_number": { + "name": "connector_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "expiry_date": { + "name": "expiry_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "id_tag": { + "name": "id_tag", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "parent_id_tag": { + "name": "parent_id_tag", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'Active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_reservation_charge_point_id": { + "name": "idx_reservation_charge_point_id", + "columns": [ + { + "expression": "charge_point_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_reservation_status": { + "name": "idx_reservation_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_reservation_expiry_date": { + "name": "idx_reservation_expiry_date", + "columns": [ + { + "expression": "expiry_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "reservation_charge_point_id_charge_point_id_fk": { + "name": "reservation_charge_point_id_charge_point_id_fk", + "tableFrom": "reservation", + "tableTo": "charge_point", + "columnsFrom": [ + "charge_point_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reservation_connector_id_connector_id_fk": { + "name": "reservation_connector_id_connector_id_fk", + "tableFrom": "reservation", + "tableTo": "connector", + "columnsFrom": [ + "connector_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.transaction": { + "name": "transaction", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "charge_point_id": { + "name": "charge_point_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "connector_id": { + "name": "connector_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "connector_number": { + "name": "connector_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "id_tag": { + "name": "id_tag", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "id_tag_status": { + "name": "id_tag_status", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "start_timestamp": { + "name": "start_timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "start_meter_value": { + "name": "start_meter_value", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "stop_id_tag": { + "name": "stop_id_tag", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "stop_timestamp": { + "name": "stop_timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "stop_meter_value": { + "name": "stop_meter_value", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "stop_reason": { + "name": "stop_reason", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "reservation_id": { + "name": "reservation_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_transaction_charge_point_id": { + "name": "idx_transaction_charge_point_id", + "columns": [ + { + "expression": "charge_point_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_transaction_connector_id": { + "name": "idx_transaction_connector_id", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_transaction_id_tag": { + "name": "idx_transaction_id_tag", + "columns": [ + { + "expression": "id_tag", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_transaction_start_timestamp": { + "name": "idx_transaction_start_timestamp", + "columns": [ + { + "expression": "start_timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "transaction_charge_point_id_charge_point_id_fk": { + "name": "transaction_charge_point_id_charge_point_id_fk", + "tableFrom": "transaction", + "tableTo": "charge_point", + "columnsFrom": [ + "charge_point_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "transaction_connector_id_connector_id_fk": { + "name": "transaction_connector_id_connector_id_fk", + "tableFrom": "transaction", + "tableTo": "connector", + "columnsFrom": [ + "connector_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/csms/package.json b/apps/csms/package.json index d928c46..e67b550 100644 --- a/apps/csms/package.json +++ b/apps/csms/package.json @@ -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", diff --git a/apps/csms/src/db/ocpp-schema.ts b/apps/csms/src/db/ocpp-schema.ts index cb4f89c..c597cdb 100644 --- a/apps/csms/src/db/ocpp-schema.ts +++ b/apps/csms/src/db/ocpp-schema.ts @@ -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) diff --git a/apps/csms/src/index.ts b/apps/csms/src/index.ts index fdf83f1..bb95f16 100644 --- a/apps/csms/src/index.ts +++ b/apps/csms/src/index.ts @@ -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() 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') diff --git a/apps/csms/src/lib/auth.ts b/apps/csms/src/lib/auth.ts index dfffd8c..941da73 100644 --- a/apps/csms/src/lib/auth.ts +++ b/apps/csms/src/lib/auth.ts @@ -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', }, diff --git a/apps/csms/src/ocpp/actions/authorize.ts b/apps/csms/src/ocpp/actions/authorize.ts new file mode 100644 index 0000000..5569879 --- /dev/null +++ b/apps/csms/src/ocpp/actions/authorize.ts @@ -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 { + 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 { + const idTagInfo = await resolveIdTagInfo(payload.idTag); + console.log(`[OCPP] Authorize idTag=${payload.idTag} -> ${idTagInfo.status}`); + return { idTagInfo }; +} diff --git a/apps/csms/src/ocpp/actions/meter-values.ts b/apps/csms/src/ocpp/actions/meter-values.ts new file mode 100644 index 0000000..5e4d83b --- /dev/null +++ b/apps/csms/src/ocpp/actions/meter-values.ts @@ -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 { + 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 {}; +} diff --git a/apps/csms/src/ocpp/actions/start-transaction.ts b/apps/csms/src/ocpp/actions/start-transaction.ts new file mode 100644 index 0000000..2b6ec25 --- /dev/null +++ b/apps/csms/src/ocpp/actions/start-transaction.ts @@ -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 { + 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 }; +} diff --git a/apps/csms/src/ocpp/actions/status-notification.ts b/apps/csms/src/ocpp/actions/status-notification.ts index 523afaf..2689a17 100644 --- a/apps/csms/src/ocpp/actions/status-notification.ts +++ b/apps/csms/src/ocpp/actions/status-notification.ts @@ -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 { diff --git a/apps/csms/src/ocpp/actions/stop-transaction.ts b/apps/csms/src/ocpp/actions/stop-transaction.ts new file mode 100644 index 0000000..f341c9d --- /dev/null +++ b/apps/csms/src/ocpp/actions/stop-transaction.ts @@ -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 { + 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 }; +} diff --git a/apps/csms/src/ocpp/handler.ts b/apps/csms/src/ocpp/handler.ts index 8786f51..dfe2988 100644 --- a/apps/csms/src/ocpp/handler.ts +++ b/apps/csms/src/ocpp/handler.ts @@ -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() +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 BootNotification: ( payload: BootNotificationRequest, ctx: OcppConnectionContext, @@ -27,16 +49,32 @@ type ActionHandlerMap = { payload: HeartbeatRequest, ctx: OcppConnectionContext, ) => Promise + MeterValues: ( + payload: MeterValuesRequest, + ctx: OcppConnectionContext, + ) => Promise + StartTransaction: ( + payload: StartTransactionRequest, + ctx: OcppConnectionContext, + ) => Promise StatusNotification: ( payload: StatusNotificationRequest, ctx: OcppConnectionContext, ) => Promise + StopTransaction: ( + payload: StopTransactionRequest, + ctx: OcppConnectionContext, + ) => Promise } 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})`) }, } diff --git a/apps/csms/src/ocpp/types.ts b/apps/csms/src/ocpp/types.ts index 27f96c9..29b9f24 100644 --- a/apps/csms/src/ocpp/types.ts +++ b/apps/csms/src/ocpp/types.ts @@ -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 diff --git a/apps/csms/src/routes/charge-points.ts b/apps/csms/src/routes/charge-points.ts new file mode 100644 index 0000000..b1a12f3 --- /dev/null +++ b/apps/csms/src/routes/charge-points.ts @@ -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 = {}; + 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; diff --git a/apps/csms/src/routes/id-tags.ts b/apps/csms/src/routes/id-tags.ts new file mode 100644 index 0000000..0270f90 --- /dev/null +++ b/apps/csms/src/routes/id-tags.ts @@ -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; diff --git a/apps/csms/src/routes/stats.ts b/apps/csms/src/routes/stats.ts new file mode 100644 index 0000000..d876d55 --- /dev/null +++ b/apps/csms/src/routes/stats.ts @@ -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`count(*)::int` }).from(chargePoint), + // Online charge points (received heartbeat in last 2×heartbeat interval, default 120s) + db + .select({ count: sql`count(*)::int` }) + .from(chargePoint) + .where(sql`${chargePoint.lastHeartbeatAt} > now() - interval '120 seconds'`), + // Active (in-progress) transactions + db + .select({ count: sql`count(*)::int` }) + .from(transaction) + .where(isNull(transaction.stopTimestamp)), + // Total id tags + db.select({ count: sql`count(*)::int` }).from(idTag), + // Energy dispensed today (sum of stopMeterValue - startMeterValue for transactions ending today) + db + .select({ + total: sql`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; diff --git a/apps/csms/src/routes/transactions.ts b/apps/csms/src/routes/transactions.ts new file mode 100644 index 0000000..5701d3d --- /dev/null +++ b/apps/csms/src/routes/transactions.ts @@ -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`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; diff --git a/apps/csms/src/routes/users.ts b/apps/csms/src/routes/users.ts new file mode 100644 index 0000000..79e7047 --- /dev/null +++ b/apps/csms/src/routes/users.ts @@ -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(); + +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; diff --git a/apps/csms/src/types/hono.ts b/apps/csms/src/types/hono.ts new file mode 100644 index 0000000..2a5e048 --- /dev/null +++ b/apps/csms/src/types/hono.ts @@ -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; + }; +}; diff --git a/apps/web/app/dashboard/charge-points/page.tsx b/apps/web/app/dashboard/charge-points/page.tsx new file mode 100644 index 0000000..4b1a6ed --- /dev/null +++ b/apps/web/app/dashboard/charge-points/page.tsx @@ -0,0 +1,275 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { Button, Chip, Input, Label, Modal, Spinner, Table, TextField } from "@heroui/react"; +import { Pencil, TrashBin } from "@gravity-ui/icons"; +import { api, type ChargePoint } from "@/lib/api"; + +const statusColorMap: Record = { + Available: "success", + Charging: "success", + Occupied: "warning", + Reserved: "warning", + Faulted: "danger", + Unavailable: "danger", + Preparing: "warning", + Finishing: "warning", + SuspendedEV: "warning", + SuspendedEVSE: "warning", +}; + +const registrationColorMap: Record = { + Accepted: "success", + Pending: "warning", + Rejected: "danger", +}; + +export default function ChargePointsPage() { + const [chargePoints, setChargePoints] = useState([]); + const [editTarget, setEditTarget] = useState(null); + const [feeInput, setFeeInput] = useState("0"); + const [saving, setSaving] = useState(false); + const [deleteTarget, setDeleteTarget] = useState(null); + const [deleting, setDeleting] = useState(false); + const hasFetched = useRef(false); + + const load = useCallback(async () => { + const data = await api.chargePoints.list().catch(() => []); + setChargePoints(data); + }, []); + + useEffect(() => { + if (!hasFetched.current) { + hasFetched.current = true; + load(); + } + }, [load]); + + const openEdit = (cp: ChargePoint) => { + setEditTarget(cp); + setFeeInput(String(cp.feePerKwh)); + }; + + const handleSave = async () => { + if (!editTarget) return; + const fee = Math.max(0, Math.round(Number(feeInput) || 0)); + setSaving(true); + try { + await api.chargePoints.update(String(editTarget.id), { feePerKwh: fee }); + setChargePoints((prev) => + prev.map((cp) => (cp.id === editTarget.id ? { ...cp, feePerKwh: fee } : cp)), + ); + } finally { + setSaving(false); + } + }; + + const handleDelete = async () => { + if (!deleteTarget) return; + setDeleting(true); + try { + await api.chargePoints.delete(String(deleteTarget.id)); + setChargePoints((prev) => prev.filter((cp) => cp.id !== deleteTarget.id)); + setDeleteTarget(null); + } finally { + setDeleting(false); + } + }; + + return ( +
+
+

充电桩管理

+

共 {chargePoints.length} 台设备

+
+ + + + + + 标识符 + 品牌 / 型号 + 注册状态 + 电价(分/kWh) + 最后心跳 + 接口状态 + {""} + + + {chargePoints.length === 0 && ( + + + 暂无设备 + + {""} + {""} + {""} + {""} + {""} + {""} + + )} + {chargePoints.map((cp) => ( + + + {cp.chargePointIdentifier} + + + {cp.chargePointVendor && cp.chargePointModel ? ( + `${cp.chargePointVendor} / ${cp.chargePointModel}` + ) : ( + + )} + + + + {cp.registrationStatus} + + + + {cp.feePerKwh > 0 ? ( + + {cp.feePerKwh} 分 + + (¥{(cp.feePerKwh / 100).toFixed(2)}/kWh) + + + ) : ( + 免费 + )} + + + {cp.lastHeartbeatAt ? ( + new Date(cp.lastHeartbeatAt).toLocaleString("zh-CN") + ) : ( + + )} + + +
+ {cp.connectors.length === 0 ? ( + + ) : ( + cp.connectors.map((conn) => ( + + #{conn.connectorId} {conn.status} + + )) + )} +
+
+ +
+ + + + + + + + 配置电价 + + +

+ 充电桩: + + {cp.chargePointIdentifier} + +

+ + + setFeeInput(e.target.value)} + /> + +

+ 设为 0 则免费充电。当前:¥ + {((Number(feeInput) || 0) / 100).toFixed(2)}/kWh +

+
+ + + + +
+
+
+
+ + + + + + + + 确认删除充电桩 + + +

+ 将删除充电桩{" "} + + {cp.chargePointIdentifier} + + 及其所有连接器和充电记录,此操作不可恢复。 +

+
+ + + + +
+
+
+
+
+
+
+ ))} +
+
+
+
+
+ ); +} diff --git a/apps/web/app/dashboard/id-tags/page.tsx b/apps/web/app/dashboard/id-tags/page.tsx new file mode 100644 index 0000000..f50ccd4 --- /dev/null +++ b/apps/web/app/dashboard/id-tags/page.tsx @@ -0,0 +1,484 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { + Autocomplete, + Button, + Calendar, + Chip, + DateField, + DatePicker, + EmptyState, + Input, + Label, + ListBox, + Modal, + SearchField, + Select, + Spinner, + Table, + TextField, + useFilter, +} from "@heroui/react"; +import { parseDate } from "@internationalized/date"; +import { Pencil, TrashBin } from "@gravity-ui/icons"; +import { api, type IdTag, type UserRow } from "@/lib/api"; + +const statusColorMap: Record = { + Accepted: "success", + Blocked: "danger", + Expired: "warning", + Invalid: "danger", + ConcurrentTx: "warning", +}; + +type FormState = { + idTag: string; + status: string; + expiryDate: string; + parentIdTag: string; + userId: string; + balance: string; +}; + +const emptyForm: FormState = { + idTag: "", + status: "Accepted", + expiryDate: "", + parentIdTag: "", + userId: "", + balance: "0", +}; + +const STATUS_OPTIONS = ["Accepted", "Blocked", "Expired", "Invalid", "ConcurrentTx"] as const; + +/** 将「元」字符串转为分(整数),无效时返回 0 */ +function yuanToFen(yuan: string): number { + const n = parseFloat(yuan); + return isNaN(n) ? 0 : Math.round(n * 100); +} + +/** 将分(整数)格式化为「元」字符串 */ +function fenToYuan(fen: number): string { + return (fen / 100).toFixed(2); +} + +function UserAutocomplete({ + userId, + onChange, + users, +}: { + userId: string; + onChange: (id: string) => void; + users: UserRow[]; +}) { + const { contains } = useFilter({ sensitivity: "base" }); + return ( + onChange(key ? String(key) : "")} + > + + + {({ isPlaceholder, state }: any) => { + if (isPlaceholder || !state.selectedItems?.length) + return 不分配; + const u = users.find((u) => u.id === state.selectedItems[0]?.key); + return u ? {u.name ?? u.username ?? u.email} : null; + }} + + + + + + + + + + + + + + 无匹配用户}> + {users.map((u) => ( + + {u.name ?? u.username ?? u.email} + {u.email} + + + ))} + + + + + ); +} + +function TagFormBody({ + form, + setForm, + isEdit, + users, +}: { + form: FormState; + setForm: (f: FormState) => void; + isEdit: boolean; + users: UserRow[]; +}) { + return ( + <> + + + setForm({ ...form, idTag: e.target.value })} + /> + +
+ + +
+
+ + setForm({ ...form, expiryDate: date ? date.toString() : "" })} + > + + + + {(segment) => } + + + + + + + + + + + + + + + + + + {(day) => {day}} + + + {(date) => ( + + {({ formattedDate }) => ( + <> + {formattedDate} + + + )} + + )} + + + + + +
+ + + setForm({ ...form, parentIdTag: e.target.value })} + /> + + + + setForm({ ...form, balance: e.target.value })} + /> + +
+ + setForm({ ...form, userId: id })} + users={users} + /> +
+ + ); +} + +export default function IdTagsPage() { + const [tags, setTags] = useState([]); + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(true); + const [editing, setEditing] = useState(null); + const [form, setForm] = useState(emptyForm); + const [saving, setSaving] = useState(false); + const [deletingTag, setDeletingTag] = useState(null); + + const load = async () => { + setLoading(true); + try { + const [tagList, userList] = await Promise.all([ + api.idTags.list(), + api.users.list().catch(() => [] as UserRow[]), + ]); + setTags(tagList); + setUsers(userList); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + load(); + }, []); + + const openCreate = () => { + setEditing(null); + setForm(emptyForm); + }; + + const openEdit = (tag: IdTag) => { + setEditing(tag); + setForm({ + idTag: tag.idTag, + status: tag.status, + expiryDate: tag.expiryDate ? tag.expiryDate.slice(0, 10) : "", + parentIdTag: tag.parentIdTag ?? "", + userId: tag.userId ?? "", + balance: fenToYuan(tag.balance), + }); + }; + + const handleSave = async () => { + setSaving(true); + try { + if (editing) { + await api.idTags.update(editing.idTag, { + status: form.status, + expiryDate: form.expiryDate || null, + parentIdTag: form.parentIdTag || null, + userId: form.userId || null, + balance: yuanToFen(form.balance), + }); + } else { + await api.idTags.create({ + idTag: form.idTag, + status: form.status, + expiryDate: form.expiryDate || undefined, + parentIdTag: form.parentIdTag || undefined, + userId: form.userId || undefined, + balance: yuanToFen(form.balance), + }); + } + await load(); + } finally { + setSaving(false); + } + }; + + const handleDelete = async (idTag: string) => { + setDeletingTag(idTag); + try { + await api.idTags.delete(idTag); + await load(); + } finally { + setDeletingTag(null); + } + }; + + return ( +
+
+
+

储值卡管理

+

共 {tags.length} 张

+
+ + + + + + + + 新增储值卡 + + + + + + + + + + + + +
+ + + + + + 卡号 + 状态 + 余额 + 关联用户 + 有效期 + 父卡号 + 创建时间 + 操作 + + ( +
+ {loading ? "加载中…" : "暂无储值卡"} +
+ )} + > + {tags.map((tag) => { + const owner = users.find((u) => u.id === tag.userId); + return ( + + {tag.idTag} + + + {tag.status} + + + ¥{fenToYuan(tag.balance)} + + {owner ? ( + + {owner.name ?? owner.username ?? owner.email} + + ) : ( + + )} + + + {tag.expiryDate ? ( + new Date(tag.expiryDate).toLocaleDateString("zh-CN") + ) : ( + + )} + + + {tag.parentIdTag ?? } + + + {new Date(tag.createdAt).toLocaleString("zh-CN")} + + +
+ {/* Edit button */} + + + + + + + + 编辑储值卡 + + + + + + + + + + + + + {/* Delete button */} + +
+
+
+ ); + })} +
+
+
+
+
+ ); +} diff --git a/apps/web/app/dashboard/layout.tsx b/apps/web/app/dashboard/layout.tsx new file mode 100644 index 0000000..623a74d --- /dev/null +++ b/apps/web/app/dashboard/layout.tsx @@ -0,0 +1,19 @@ +import { ReactNode } from 'react' +import Sidebar from '@/components/sidebar' + +export default function DashboardLayout({ children }: { children: ReactNode }) { + return ( +
+ + + {/* Main content */} +
+
+
+ {children} +
+
+
+
+ ) +} diff --git a/apps/web/app/dashboard/page.tsx b/apps/web/app/dashboard/page.tsx new file mode 100644 index 0000000..652bd8b --- /dev/null +++ b/apps/web/app/dashboard/page.tsx @@ -0,0 +1,126 @@ +import { Card } from "@heroui/react"; +import { Thunderbolt, PlugConnection, CreditCard, ChartColumn } from "@gravity-ui/icons"; +import { api } from "@/lib/api"; + +export const dynamic = "force-dynamic"; + +type CardColor = "accent" | "success" | "warning" | "default"; + +const colorStyles: Record = { + accent: { border: "border-accent", bg: "bg-accent/10", icon: "text-accent" }, + success: { border: "border-success", bg: "bg-success/10", icon: "text-success" }, + warning: { border: "border-warning", bg: "bg-warning/10", icon: "text-warning" }, + default: { border: "border-border", bg: "bg-default", icon: "text-muted" }, +}; + +function StatusDot({ color }: { color: "success" | "warning" | "muted" }) { + const cls = + color === "success" ? "bg-success" : color === "warning" ? "bg-warning" : "bg-muted/40"; + return ; +} + +function StatCard({ + title, + value, + footer, + icon: Icon, + color = "default", +}: { + title: string; + value: string | number; + footer?: React.ReactNode; + icon?: React.ComponentType<{ className?: string }>; + color?: CardColor; +}) { + const s = colorStyles[color]; + return ( + + +
+

{title}

+ {Icon && ( +
+ +
+ )} +
+

{value}

+ {footer && ( +
+ {footer} +
+ )} +
+
+ ); +} + +export default async function DashboardPage() { + const stats = await api.stats.get().catch(() => null); + + const todayKwh = stats ? (stats.todayEnergyWh / 1000).toFixed(1) : "—"; + const offlineCount = (stats?.totalChargePoints ?? 0) - (stats?.onlineChargePoints ?? 0); + + return ( +
+
+

概览

+

实时运营状态

+
+ +
+ + + + {stats?.onlineChargePoints ?? 0} 在线 + + · + {offlineCount} 离线 + + } + /> + 最近 2 分钟有心跳} + /> + + + + {stats?.activeTransactions ? "活跃中" : "当前空闲"} + + + } + /> + 已注册卡片总量} + /> + 当日 00:00 起累计} + /> +
+
+ ); +} diff --git a/apps/web/app/dashboard/transactions/page.tsx b/apps/web/app/dashboard/transactions/page.tsx new file mode 100644 index 0000000..4489902 --- /dev/null +++ b/apps/web/app/dashboard/transactions/page.tsx @@ -0,0 +1,298 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { Button, Chip, Modal, Pagination, Spinner, Table } from "@heroui/react"; +import { TrashBin } from "@gravity-ui/icons"; +import { api, type PaginatedTransactions } from "@/lib/api"; + +const LIMIT = 15; + +function formatDuration(start: string, stop: string | null): string { + if (!stop) return "进行中"; + const ms = new Date(stop).getTime() - new Date(start).getTime(); + const min = Math.floor(ms / 60000); + if (min < 60) return `${min} 分钟`; + const h = Math.floor(min / 60); + const m = min % 60; + return `${h}h ${m}m`; +} + +export default function TransactionsPage() { + const [data, setData] = useState(null); + const [page, setPage] = useState(1); + const [status, setStatus] = useState<"all" | "active" | "completed">("all"); + const [loading, setLoading] = useState(true); + const [stoppingId, setStoppingId] = useState(null); + const [deletingId, setDeletingId] = useState(null); + + const load = useCallback(async (p: number, s: typeof status) => { + setLoading(true); + try { + const res = await api.transactions.list({ + page: p, + limit: LIMIT, + status: s === "all" ? undefined : s, + }); + setData(res); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + load(page, status); + }, [page, status, load]); + + const handleStatusChange = (s: typeof status) => { + setStatus(s); + setPage(1); + }; + + const handleStop = async (id: number) => { + setStoppingId(id); + try { + await api.transactions.stop(id); + await load(page, status); + } finally { + setStoppingId(null); + } + }; + + const handleDelete = async (id: number) => { + setDeletingId(id); + try { + await api.transactions.delete(id); + await load(page, status); + } finally { + setDeletingId(null); + } + }; + + const pages = data ? Array.from({ length: data.totalPages }, (_, i) => i + 1) : []; + + return ( +
+
+
+

充电记录

+

共 {data?.total ?? "—"} 条

+
+
+ {(["all", "active", "completed"] as const).map((s) => ( + + ))} +
+
+ + + + + + ID + 充电桩 + 接口 + 储值卡 + 状态 + 开始时间 + 时长 + 电量 (kWh) + 费用 (元) + 停止原因 + {""} + + ( +
+ {loading ? "加载中…" : "暂无记录"} +
+ )} + > + {(data?.data ?? []).map((tx) => ( + + {tx.id} + {tx.chargePointIdentifier ?? "—"} + {tx.connectorNumber ?? "—"} + {tx.idTag} + + {tx.stopTimestamp ? ( + + 已完成 + + ) : ( + + 进行中 + + )} + + + {new Date(tx.startTimestamp).toLocaleString("zh-CN")} + + + {formatDuration(tx.startTimestamp, tx.stopTimestamp)} + + + {tx.energyWh != null ? (tx.energyWh / 1000).toFixed(3) : "—"} + + + {tx.chargeAmount != null ? `¥${(tx.chargeAmount / 100).toFixed(2)}` : "—"} + + + {tx.stopReason ? ( + + {tx.stopReason} + + ) : tx.stopTimestamp ? ( + + Local + + ) : ( + "—" + )} + + +
+ {!tx.stopTimestamp && ( + + + + + + + + 确认中止充电 + + +

+ 将强制结束交易{" "} + + #{tx.id} + + (储值卡:{tx.idTag})。 + 如果充电桩在线,将发送远程结算指令。 +

+
+ + + + +
+
+
+
+ )} + + + + + + + + 确认删除记录 + + +

+ 将永久删除充电记录{" "} + + #{tx.id} + + (储值卡:{tx.idTag})。 + {!tx.stopTimestamp && "该记录仍进行中,删除同时将重置接口状态。"} +

+
+ + + + +
+
+
+
+
+
+
+ ))} +
+
+
+ {data && data.totalPages > 1 && ( + + + + 第 {(page - 1) * LIMIT + 1}–{Math.min(page * LIMIT, data.total)} 条,共 {data.total}{" "} + 条 + + + + setPage((p) => Math.max(1, p - 1))} + > + + 上一页 + + + {pages.map((p) => ( + + setPage(p)}> + {p} + + + ))} + + setPage((p) => Math.min(data.totalPages, p + 1))} + > + 下一页 + + + + + + + )} +
+
+ ); +} diff --git a/apps/web/app/dashboard/users/page.tsx b/apps/web/app/dashboard/users/page.tsx new file mode 100644 index 0000000..84e6996 --- /dev/null +++ b/apps/web/app/dashboard/users/page.tsx @@ -0,0 +1,363 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { + Button, + Chip, + Input, + Label, + ListBox, + Modal, + Select, + Spinner, + Table, + TextField, +} from "@heroui/react"; +import { Pencil } from "@gravity-ui/icons"; +import { api, type UserRow } from "@/lib/api"; +import { useSession } from "@/lib/auth-client"; + +type CreateForm = { + name: string; + email: string; + username: string; + password: string; + role: string; +}; + +type EditForm = { + name: string; + username: string; + role: string; +}; + +const emptyCreate: CreateForm = { name: "", email: "", username: "", password: "", role: "user" }; + +const ROLE_OPTIONS = [ + { key: "user", label: "用户" }, + { key: "admin", label: "管理员" }, +]; + +export default function UsersPage() { + const { data: session } = useSession(); + const currentUserId = session?.user?.id; + + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(true); + const [updating, setUpdating] = useState(null); + + const [createForm, setCreateForm] = useState(emptyCreate); + const [editTarget, setEditTarget] = useState(null); + const [editForm, setEditForm] = useState({ name: "", username: "", role: "user" }); + const [saving, setSaving] = useState(false); + + const load = async () => { + setLoading(true); + try { + setUsers(await api.users.list()); + } catch { + // possibly not admin — show empty + } finally { + setLoading(false); + } + }; + + useEffect(() => { + load(); + }, []); + + const openEdit = (u: UserRow) => { + setEditTarget(u); + setEditForm({ name: u.name ?? "", username: u.username ?? "", role: u.role ?? "user" }); + }; + + const handleCreate = async () => { + setSaving(true); + try { + await api.users.create({ + name: createForm.name, + email: createForm.email, + password: createForm.password, + username: createForm.username || undefined, + role: createForm.role, + }); + setCreateForm(emptyCreate); + await load(); + } finally { + setSaving(false); + } + }; + + const handleEdit = async () => { + if (!editTarget) return; + setSaving(true); + try { + await api.users.update(editTarget.id, { + name: editForm.name || undefined, + username: editForm.username || null, + role: editForm.role, + }); + await load(); + } finally { + setSaving(false); + } + }; + + const toggleBan = async (u: UserRow) => { + setUpdating(u.id); + try { + await api.users.update(u.id, { + banned: !u.banned, + banReason: u.banned ? null : "管理员封禁", + }); + await load(); + } finally { + setUpdating(null); + } + }; + + const isEditingSelf = editTarget?.id === currentUserId; + + return ( +
+
+
+

用户管理

+

共 {users.length} 位用户(仅管理员可见)

+
+ + + + + + + + 新增用户 + + + + + setCreateForm({ ...createForm, name: e.target.value })} + /> + + + + setCreateForm({ ...createForm, email: e.target.value })} + /> + + + + setCreateForm({ ...createForm, username: e.target.value })} + /> + + + + setCreateForm({ ...createForm, password: e.target.value })} + /> + +
+ + +
+
+ + + + +
+
+
+
+
+ + + + + + 用户 + 邮箱 + 用户名 + 角色 + 状态 + 注册时间 + 操作 + + ( +
+ {loading ? "加载中…" : "暂无用户或无权限"} +
+ )} + > + {users.map((u) => ( + + +
{u.name ?? "—"}
+
+ {u.email} + {u.username ?? "—"} + + + {u.role === "admin" ? "管理员" : "用户"} + + + + {u.banned ? ( + + 已封禁 + + ) : ( + + 正常 + + )} + + + {new Date(u.createdAt).toLocaleString("zh-CN")} + + +
+ {/* Edit button */} + + + + + + + + 编辑用户 + + + + + + setEditForm({ ...editForm, name: e.target.value }) + } + /> + + + + + setEditForm({ ...editForm, username: e.target.value }) + } + /> + + {!isEditingSelf && ( +
+ + +
+ )} +
+ + + + +
+
+
+
+ {/* Ban / Unban button — disabled for current account */} + +
+
+
+ ))} +
+
+
+
+
+ ); +} diff --git a/apps/web/app/login/page.tsx b/apps/web/app/login/page.tsx new file mode 100644 index 0000000..c0f3f4f --- /dev/null +++ b/apps/web/app/login/page.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { Alert, Button, Card, CloseButton, Input, Label, TextField } from "@heroui/react"; +import { Thunderbolt } from "@gravity-ui/icons"; +import { authClient } from "@/lib/auth-client"; + +export default function LoginPage() { + const router = useRouter(); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(""); + setLoading(true); + try { + const res = await authClient.signIn.username({ + username, + password, + fetchOptions: { credentials: "include" }, + }); + if (res.error) { + setError(res.error.message ?? "登录失败,请检查用户名和密码"); + } else { + router.push("/dashboard"); + router.refresh(); + } + } catch { + setError("网络错误,请稍后重试"); + } finally { + setLoading(false); + } + }; + + return ( +
+ {/* Brand */} +
+
+ +
+
+

Helios EVCS

+

电动车充电站管理系统

+
+
+ + + +
+ + + setUsername(e.target.value)} + /> + + + + setPassword(e.target.value)} + /> + + {error && ( + + + + 登录失败 + {error} + + setError("")} /> + + )} + +
+
+
+ +

OCPP 1.6-J Protocol • v0.1.0

+
+ ); +} diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index 26f3fee..28c5ca1 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -1,67 +1,5 @@ -import { Button } from "@heroui/react"; -import Image from "next/image"; +import { redirect } from 'next/navigation' export default function Home() { - return ( -
-
- Next.js logo -
-

- To get started, edit the page.tsx file. -

-

- Looking for a starting point or more instructions? Head over to{" "} - - Templates - {" "} - or the{" "} - - Learning - {" "} - center. -

-
- -
-
- ); + redirect('/dashboard') } diff --git a/apps/web/components/sidebar-footer.tsx b/apps/web/components/sidebar-footer.tsx new file mode 100644 index 0000000..26b0f43 --- /dev/null +++ b/apps/web/components/sidebar-footer.tsx @@ -0,0 +1,48 @@ +'use client' + +import { useRouter } from 'next/navigation' +import { ArrowRightFromSquare, PersonFill } from '@gravity-ui/icons' +import { signOut, useSession } from '@/lib/auth-client' + +export default function SidebarFooter() { + const router = useRouter() + const { data: session } = useSession() + + const handleSignOut = async () => { + await signOut({ fetchOptions: { credentials: 'include' } }) + router.push('/login') + router.refresh() + } + + return ( +
+ {/* User info */} + {session?.user && ( +
+
+ +
+
+

+ {session.user.name || session.user.email} +

+

+ {session.user.role ?? 'user'} +

+
+
+ )} + + + +

OCPP 1.6-J • v0.1.0

+
+ ) +} diff --git a/apps/web/components/sidebar.tsx b/apps/web/components/sidebar.tsx new file mode 100644 index 0000000..2f19677 --- /dev/null +++ b/apps/web/components/sidebar.tsx @@ -0,0 +1,124 @@ +'use client' + +import Link from 'next/link' +import { usePathname } from 'next/navigation' +import { useState } from 'react' +import { CreditCard, ListCheck, Person, PlugConnection, Thunderbolt, Xmark, Bars } from '@gravity-ui/icons' +import SidebarFooter from '@/components/sidebar-footer' + +const navItems = [ + { href: '/dashboard', label: '概览', icon: Thunderbolt, exact: true }, + { href: '/dashboard/charge-points', label: '充电桩', icon: PlugConnection }, + { href: '/dashboard/transactions', label: '充电记录', icon: ListCheck }, + { href: '/dashboard/id-tags', label: '储值卡', icon: CreditCard }, + { href: '/dashboard/users', label: '用户管理', icon: Person }, +] + +function NavContent({ pathname, onNavigate }: { pathname: string; onNavigate?: () => void }) { + return ( + <> + {/* Logo */} +
+
+ +
+
+ Helios EVCS +
+
+ + {/* Navigation */} + + + {/* Footer */} + + + ) +} + +export default function Sidebar() { + const pathname = usePathname() + const [open, setOpen] = useState(false) + + return ( + <> + {/* Mobile top bar */} +
+ +
+
+ +
+ Helios EVCS +
+
+ + {/* Mobile drawer overlay */} + {open && ( +
setOpen(false)} + /> + )} + + {/* Mobile drawer */} + + + {/* Desktop sidebar */} + + + ) +} diff --git a/apps/web/lib/api.ts b/apps/web/lib/api.ts new file mode 100644 index 0000000..52582a5 --- /dev/null +++ b/apps/web/lib/api.ts @@ -0,0 +1,175 @@ +const CSMS_URL = process.env.NEXT_PUBLIC_CSMS_URL ?? "http://localhost:3001"; + +async function apiFetch(path: string, init?: RequestInit): Promise { + const res = await fetch(`${CSMS_URL}${path}`, { + ...init, + headers: { + "Content-Type": "application/json", + ...init?.headers, + }, + credentials: "include", + }); + if (!res.ok) { + const text = await res.text().catch(() => res.statusText); + throw new Error(`API ${path} failed (${res.status}): ${text}`); + } + return res.json() as Promise; +} + +// ── Types ────────────────────────────────────────────────────────────────── + +export type Stats = { + totalChargePoints: number; + onlineChargePoints: number; + activeTransactions: number; + totalIdTags: number; + todayEnergyWh: number; +}; + +export type ConnectorSummary = { + id: number; + connectorId: number; + status: string; + lastStatusAt: string | null; +}; + +export type ChargePoint = { + id: number; + chargePointIdentifier: string; + chargePointVendor: string | null; + chargePointModel: string | null; + registrationStatus: string; + lastHeartbeatAt: string | null; + lastBootNotificationAt: string | null; + feePerKwh: number; + connectors: ConnectorSummary[]; +}; + +export type Transaction = { + id: number; + chargePointIdentifier: string | null; + connectorNumber: number | null; + idTag: string; + idTagStatus: string | null; + startTimestamp: string; + stopTimestamp: string | null; + startMeterValue: number | null; + stopMeterValue: number | null; + energyWh: number | null; + stopIdTag: string | null; + stopReason: string | null; + chargeAmount: number | null; +}; + +export type IdTag = { + idTag: string; + status: string; + expiryDate: string | null; + parentIdTag: string | null; + userId: string | null; + balance: number; + createdAt: string; +}; + +export type UserRow = { + id: string; + name: string | null; + email: string; + emailVerified: boolean; + username: string | null; + role: string | null; + banned: boolean | null; + banReason: string | null; + createdAt: string; +}; + +export type PaginatedTransactions = { + data: Transaction[]; + total: number; + page: number; + totalPages: number; +}; + +// ── API functions ────────────────────────────────────────────────────────── + +export const api = { + stats: { + get: () => apiFetch("/api/stats"), + }, + chargePoints: { + list: () => apiFetch("/api/charge-points"), + get: (id: number) => apiFetch(`/api/charge-points/${id}`), + update: (id: string, data: { feePerKwh: number }) => + apiFetch<{ feePerKwh: number }>(`/api/charge-points/${id}`, { + method: "PATCH", + body: JSON.stringify(data), + }), + delete: (id: string) => + apiFetch<{ success: true }>(`/api/charge-points/${id}`, { method: "DELETE" }), + }, + transactions: { + list: (params?: { page?: number; limit?: number; status?: "active" | "completed" }) => { + const q = new URLSearchParams(); + if (params?.page) q.set("page", String(params.page)); + if (params?.limit) q.set("limit", String(params.limit)); + if (params?.status) q.set("status", params.status); + const qs = q.toString(); + return apiFetch(`/api/transactions${qs ? "?" + qs : ""}`); + }, + get: (id: number) => apiFetch(`/api/transactions/${id}`), + stop: (id: number) => + apiFetch(`/api/transactions/${id}/stop`, { + method: "POST", + }), + delete: (id: number) => + apiFetch<{ success: true }>(`/api/transactions/${id}`, { method: "DELETE" }), + }, + idTags: { + list: () => apiFetch("/api/id-tags"), + get: (idTag: string) => apiFetch(`/api/id-tags/${idTag}`), + create: (data: { + idTag: string; + status?: string; + expiryDate?: string; + parentIdTag?: string; + userId?: string | null; + balance?: number; + }) => apiFetch("/api/id-tags", { method: "POST", body: JSON.stringify(data) }), + update: ( + idTag: string, + data: { + status?: string; + expiryDate?: string | null; + parentIdTag?: string | null; + userId?: string | null; + balance?: number; + }, + ) => apiFetch(`/api/id-tags/${idTag}`, { method: "PATCH", body: JSON.stringify(data) }), + delete: (idTag: string) => + apiFetch<{ success: true }>(`/api/id-tags/${idTag}`, { method: "DELETE" }), + }, + users: { + list: () => apiFetch("/api/users"), + create: (data: { + name: string; + email: string; + password: string; + username?: string; + role?: string; + }) => + apiFetch<{ user: UserRow }>("/api/auth/admin/create-user", { + method: "POST", + body: JSON.stringify(data), + }), + update: ( + id: string, + data: { + name?: string; + username?: string | null; + role?: string; + banned?: boolean; + banReason?: string | null; + }, + ) => apiFetch(`/api/users/${id}`, { method: "PATCH", body: JSON.stringify(data) }), + }, +}; diff --git a/apps/web/lib/auth-client.ts b/apps/web/lib/auth-client.ts new file mode 100644 index 0000000..10f0c81 --- /dev/null +++ b/apps/web/lib/auth-client.ts @@ -0,0 +1,9 @@ +import { createAuthClient } from "better-auth/react"; +import { adminClient, usernameClient } from "better-auth/client/plugins"; + +export const authClient = createAuthClient({ + baseURL: process.env.NEXT_PUBLIC_CSMS_URL ?? "http://localhost:3001", + plugins: [usernameClient(), adminClient()], +}); + +export const { signIn, signOut, signUp, useSession } = authClient; diff --git a/apps/web/middleware.ts b/apps/web/middleware.ts new file mode 100644 index 0000000..2702c43 --- /dev/null +++ b/apps/web/middleware.ts @@ -0,0 +1,27 @@ +import { NextRequest, NextResponse } from "next/server"; + +export async function middleware(request: NextRequest) { + const { pathname } = request.nextUrl; + + // 只保护 /dashboard 路由 + if (!pathname.startsWith("/dashboard")) { + return NextResponse.next(); + } + + // 检查 better-auth session cookie(cookie 前缀是 helios_auth) + const sessionCookie = + request.cookies.get("helios_auth.session_token") ?? + request.cookies.get("__Secure-helios_auth.session_token"); + + if (!sessionCookie) { + const loginUrl = new URL("/login", request.url); + loginUrl.searchParams.set("from", pathname); + return NextResponse.redirect(loginUrl); + } + + return NextResponse.next(); +} + +export const config = { + matcher: ["/dashboard/:path*"], +}; diff --git a/apps/web/package.json b/apps/web/package.json index e9447f2..2b6b3e5 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -10,11 +10,14 @@ "dependencies": { "@heroui/react": "3.0.0-beta.8", "@heroui/styles": "3.0.0-beta.8", + "@internationalized/date": "^3.12.0", + "better-auth": "^1.3.34", "next": "16.1.6", "react": "19.2.3", "react-dom": "19.2.3" }, "devDependencies": { + "@gravity-ui/icons": "^2.18.0", "@tailwindcss/postcss": "^4", "@types/node": "^20", "@types/react": "^19",