Initial commit: booking-ewa v1.0.0

Embedded Web App for EV charging slot bookings. Express backend with JWT
auth and AMPECO Public API proxy. React SPA with booking CRUD, availability
checking, and runtime design token theming.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Kaloyan Danchev
2026-03-18 19:34:13 +02:00
commit 87dadf1def
44 changed files with 8126 additions and 0 deletions
+54
View File
@@ -0,0 +1,54 @@
import 'dotenv/config';
import express from 'express';
import cors from 'cors';
import { verifyJwt } from './jwt.js';
import { router } from './routes.js';
import type { SessionData } from './types.js';
const app = express();
const PORT = parseInt(process.env.PORT || '3001', 10);
// CORS for dev
app.use(cors({
origin: 'http://localhost:5173',
credentials: true,
}));
// JSON body parser
app.use(express.json());
// JWT middleware
app.use((req, _res, next) => {
const token = req.headers['x-payload'] as string | undefined;
if (token) {
try {
req.session = verifyJwt(token);
} catch (err) {
console.warn('JWT verification failed:', (err as Error).message);
// Fall through to dev fallback
}
}
// Dev fallback: if no session and in development mode, use default session
if (!req.session && process.env.NODE_ENV === 'development') {
const devSession: SessionData = {
userId: 775,
operatorId: 1,
appLanguage: 'en',
appCountry: 'US',
appTheme: 'LIGHT',
};
req.session = devSession;
}
next();
});
// Mount routes
app.use(router);
app.listen(PORT, () => {
console.log(`Backend server running on http://localhost:${PORT}`);
console.log(`Environment: ${process.env.NODE_ENV || 'production'}`);
});
+55
View File
@@ -0,0 +1,55 @@
import jwt from 'jsonwebtoken';
import type { JwtPayload, SessionData } from './types.js';
const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-for-local-testing';
export function verifyJwt(token: string): SessionData {
const decoded = jwt.verify(token, JWT_SECRET, {
algorithms: ['HS256'],
}) as JwtPayload;
const params = decoded.payload.parameters;
return {
userId: params.userId ?? null,
operatorId: params.operatorId,
appLanguage: params.appLanguage,
appCountry: params.appCountry,
appTheme: params.appTheme,
designTokens: params.designTokens,
};
}
export function createDevJwt(overrides: Partial<SessionData> = {}): string {
const session: SessionData = {
userId: 775,
operatorId: 1,
appLanguage: 'en',
appCountry: 'US',
appTheme: 'LIGHT',
...overrides,
};
const payload: JwtPayload = {
iss: 'dev',
aud: 'booking-ewa',
iat: Math.floor(Date.now() / 1000),
nbf: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + 86400,
payload: {
type: 'ewa',
timestamp: new Date().toISOString(),
appVersionCode: '1.0.0',
parameters: {
userId: session.userId,
operatorId: session.operatorId,
appLanguage: session.appLanguage,
appCountry: session.appCountry,
appTheme: session.appTheme,
designTokens: session.designTokens,
},
},
};
return jwt.sign(payload, JWT_SECRET, { algorithm: 'HS256' });
}
+62
View File
@@ -0,0 +1,62 @@
import type { Request, Response } from 'express';
const API_BASE_URL = process.env.API_BASE_URL || '';
const API_TOKEN = process.env.API_TOKEN || '';
interface ProxyOptions {
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
path: string;
mapParams?: (req: Request) => Record<string, string>;
}
export async function proxyRequest(req: Request, res: Response, options: ProxyOptions): Promise<void> {
const url = new URL(`${API_BASE_URL}${options.path}`);
// Forward query params
if (options.method === 'GET') {
for (const [key, value] of Object.entries(req.query)) {
if (typeof value === 'string') {
url.searchParams.set(key, value);
}
}
}
// Map route params if provided
if (options.mapParams) {
const mapped = options.mapParams(req);
for (const [key, value] of Object.entries(mapped)) {
url.searchParams.set(key, value);
}
}
const headers: Record<string, string> = {
'Authorization': `Bearer ${API_TOKEN}`,
'Accept': 'application/json',
'Content-Type': 'application/json',
};
const fetchOptions: RequestInit = {
method: options.method,
headers,
};
if (options.method !== 'GET' && req.body && Object.keys(req.body).length > 0) {
fetchOptions.body = JSON.stringify(req.body);
}
try {
const response = await fetch(url.toString(), fetchOptions);
const contentType = response.headers.get('content-type') || '';
if (contentType.includes('application/json')) {
const data = await response.json();
res.status(response.status).json(data);
} else {
const text = await response.text();
res.status(response.status).send(text);
}
} catch (error) {
console.error('Proxy error:', error);
res.status(502).json({ error: 'Bad Gateway', message: 'Failed to reach upstream API' });
}
}
+66
View File
@@ -0,0 +1,66 @@
import { Router } from 'express';
import type { Request, Response } from 'express';
import { proxyRequest } from './proxy.js';
import { createDevJwt } from './jwt.js';
import type { SessionData } from './types.js';
declare global {
namespace Express {
interface Request {
session?: SessionData;
}
}
}
const router = Router();
// Session endpoint - returns session data from JWT (no proxy)
router.get('/api/session', (req: Request, res: Response) => {
if (!req.session) {
res.status(401).json({ error: 'No session' });
return;
}
res.json(req.session);
});
// List bookings
router.get('/api/bookings', (req: Request, res: Response) => {
void proxyRequest(req, res, {
method: 'GET',
path: '/resources/bookings/v1.0',
});
});
// Get single booking
router.get('/api/bookings/:id', (req: Request, res: Response) => {
void proxyRequest(req, res, {
method: 'GET',
path: `/resources/bookings/v1.0/${req.params.id}`,
});
});
// Create booking request
router.post('/api/booking-requests', (req: Request, res: Response) => {
void proxyRequest(req, res, {
method: 'POST',
path: '/resources/booking-requests/v1.0',
});
});
// Check availability
router.post('/api/locations/:id/check-availability', (req: Request, res: Response) => {
void proxyRequest(req, res, {
method: 'POST',
path: `/actions/locations/v2.0/${req.params.id}/check-booking-availability`,
});
});
// Dev-only JWT creation
if (process.env.NODE_ENV === 'development') {
router.post('/dev/jwt', (req: Request, res: Response) => {
const token = createDevJwt(req.body || {});
res.json({ token });
});
}
export { router };
+29
View File
@@ -0,0 +1,29 @@
export interface JwtPayload {
iss: string;
aud: string;
iat: number;
nbf: number;
exp: number;
payload: {
type: string;
timestamp: string;
appVersionCode: string;
parameters: {
userId?: number | null;
operatorId: number;
appLanguage: string;
appCountry: string;
appTheme: 'LIGHT' | 'DARK';
designTokens?: Record<string, string>;
};
};
}
export interface SessionData {
userId: number | null;
operatorId: number;
appLanguage: string;
appCountry: string;
appTheme: 'LIGHT' | 'DARK';
designTokens?: Record<string, string>;
}
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"outDir": "../dist/backend",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src"],
"exclude": ["node_modules"]
}