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:
@@ -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'}`);
|
||||
});
|
||||
@@ -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' });
|
||||
}
|
||||
@@ -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' });
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user