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,393 @@
|
||||
# Architecture
|
||||
|
||||
## System Overview
|
||||
|
||||
The Booking EWA is a two-tier application:
|
||||
|
||||
1. **Express Backend** (Node.js, TypeScript) -- Runs on port 3001. Handles JWT authentication, session management, and proxies all API calls to the AMPECO public API. The frontend never communicates with the public API directly.
|
||||
|
||||
2. **React SPA Frontend** (Vite, TypeScript, Tailwind CSS) -- Runs on port 5173 in development. A single-page application using `HashRouter` for client-side navigation. Styled with `@ampeco/design-tokens` CSS custom properties and `@ampeco/ewa-ui` Tailwind plugin.
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
MobileApp["Mobile App"] -->|JWT in X-Payload header| Backend["Express Backend :3001"]
|
||||
Backend -->|Bearer token auth| PublicAPI["AMPECO Public API"]
|
||||
Browser["Browser / WebView"] -->|fetch /api/*| ViteDev["Vite Dev Server :5173"]
|
||||
ViteDev -->|proxy| Backend
|
||||
```
|
||||
|
||||
In production, the Vite dev server is replaced by static file serving. The built frontend assets are in `dist/`.
|
||||
|
||||
---
|
||||
|
||||
## Backend Architecture
|
||||
|
||||
### Entry Point
|
||||
|
||||
**File:** `backend/src/index.ts`
|
||||
|
||||
The Express server configures CORS (for `localhost:5173` during dev), JSON body parsing, JWT middleware, and mounts the route handler.
|
||||
|
||||
### JWT Middleware Flow
|
||||
|
||||
Every incoming request passes through the JWT middleware:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Request arrives] --> B{X-Payload header present?}
|
||||
B -->|Yes| C[Verify JWT with HS256]
|
||||
C -->|Valid| D[Set req.session from JWT payload]
|
||||
C -->|Invalid| E{NODE_ENV === development?}
|
||||
B -->|No| E
|
||||
E -->|Yes| F[Set req.session to dev fallback]
|
||||
E -->|No| G[req.session remains undefined]
|
||||
D --> H[next]
|
||||
F --> H
|
||||
G --> H
|
||||
```
|
||||
|
||||
The JWT token is expected in the `X-Payload` HTTP header (not `Authorization`). This is the convention used by the mobile app when embedding the EWA in a WebView.
|
||||
|
||||
**JWT Payload Structure** (`backend/src/types.ts`):
|
||||
|
||||
```typescript
|
||||
interface JwtPayload {
|
||||
iss: string;
|
||||
aud: string;
|
||||
iat: number;
|
||||
nbf: number;
|
||||
exp: number;
|
||||
payload: {
|
||||
type: string; // "ewa"
|
||||
timestamp: string;
|
||||
appVersionCode: string;
|
||||
parameters: {
|
||||
userId?: number | null;
|
||||
operatorId: number;
|
||||
appLanguage: string; // e.g. "en"
|
||||
appCountry: string; // e.g. "US"
|
||||
appTheme: 'LIGHT' | 'DARK';
|
||||
designTokens?: Record<string, string>;
|
||||
};
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
The middleware extracts `parameters` into a `SessionData` object stored on `req.session`.
|
||||
|
||||
**Dev Fallback Session:**
|
||||
|
||||
When `NODE_ENV=development` and no valid JWT is present, the backend injects a default session:
|
||||
|
||||
```typescript
|
||||
{
|
||||
userId: 775,
|
||||
operatorId: 1,
|
||||
appLanguage: 'en',
|
||||
appCountry: 'US',
|
||||
appTheme: 'LIGHT',
|
||||
}
|
||||
```
|
||||
|
||||
### API Proxy Pattern
|
||||
|
||||
**File:** `backend/src/proxy.ts`
|
||||
|
||||
The `proxyRequest` function forwards requests from the frontend to the AMPECO public API. It:
|
||||
|
||||
1. Constructs the full URL from `API_BASE_URL` + the target path.
|
||||
2. Forwards query parameters (for GET requests) and maps route parameters.
|
||||
3. Attaches the `Authorization: Bearer <API_TOKEN>` header.
|
||||
4. Forwards the request body for non-GET methods.
|
||||
5. Returns the upstream response status and body to the client.
|
||||
6. Returns `502 Bad Gateway` on network errors.
|
||||
|
||||
The frontend never sees the `API_TOKEN` -- it is server-side only.
|
||||
|
||||
### Route Mapping
|
||||
|
||||
**File:** `backend/src/routes.ts`
|
||||
|
||||
| Frontend Path | Method | Upstream Public API Path |
|
||||
|---------------------------------------|--------|--------------------------------------------------------------|
|
||||
| `GET /api/session` | -- | No proxy. Returns `req.session` directly. |
|
||||
| `GET /api/bookings` | GET | `/resources/bookings/v1.0` |
|
||||
| `GET /api/bookings/:id` | GET | `/resources/bookings/v1.0/:id` |
|
||||
| `POST /api/booking-requests` | POST | `/resources/booking-requests/v1.0` |
|
||||
| `POST /api/locations/:id/check-availability` | POST | `/actions/locations/v2.0/:id/check-booking-availability` |
|
||||
| `POST /dev/jwt` (dev only) | -- | No proxy. Creates and returns a dev JWT token. |
|
||||
|
||||
### Session Data Flow
|
||||
|
||||
The `/api/session` endpoint does not proxy to the upstream API. It returns the session data extracted from the JWT by the middleware, which the frontend uses to:
|
||||
|
||||
- Identify the current user (`userId`).
|
||||
- Apply the correct theme (`appTheme` + `designTokens`).
|
||||
- Set the UI language (`appLanguage`).
|
||||
|
||||
---
|
||||
|
||||
## Frontend Architecture
|
||||
|
||||
### Application Bootstrap
|
||||
|
||||
**File:** `src/main.tsx`
|
||||
|
||||
1. i18next is initialized (`src/i18n/init.ts`).
|
||||
2. Global CSS is loaded (`src/app.css` -- imports `@ampeco/design-tokens/css/variables.css` and Tailwind).
|
||||
3. `<App />` is rendered into `#root`.
|
||||
|
||||
### Component Tree
|
||||
|
||||
```
|
||||
<App>
|
||||
<SessionProvider> -- Fetches /api/session, applies theme, provides context
|
||||
<AppContent> -- Guards on loading/error/userId
|
||||
<AppRouter> -- HashRouter with Layout wrapper
|
||||
<Layout> -- App shell with bottom nav (Home | Book | My Bookings)
|
||||
<Home /> -- Route: /
|
||||
<CreateBooking /> -- Route: /create
|
||||
<MyBookings /> -- Route: /bookings
|
||||
<BookingDetail /> -- Route: /bookings/:id
|
||||
<UpdateBooking /> -- Route: /bookings/:id/update
|
||||
</Layout>
|
||||
</AppRouter>
|
||||
</AppContent>
|
||||
-- OR (if no userId) --
|
||||
<UserIdFallback /> -- Manual user ID entry screen
|
||||
</SessionProvider>
|
||||
</App>
|
||||
```
|
||||
|
||||
### Page Routing
|
||||
|
||||
**File:** `src/router/index.tsx`
|
||||
|
||||
Uses `HashRouter` (URLs like `/#/bookings/123`) for compatibility with WebView environments that may not support HTML5 history API.
|
||||
|
||||
| Route | Component | Description |
|
||||
|-------------------------|------------------|---------------------------------|
|
||||
| `/#/` | `Home` | Dashboard with upcoming bookings |
|
||||
| `/#/create` | `CreateBooking` | Two-step booking creation |
|
||||
| `/#/bookings` | `MyBookings` | Upcoming/Past booking tabs |
|
||||
| `/#/bookings/:id` | `BookingDetail` | Single booking detail + actions |
|
||||
| `/#/bookings/:id/update`| `UpdateBooking` | Edit booking time range |
|
||||
|
||||
All routes are wrapped in the `<Layout>` component which provides a fixed bottom navigation bar with three tabs: Home, Book a Charger, and My Bookings.
|
||||
|
||||
### State Management
|
||||
|
||||
State is managed through React Context and local component state. There is no external state management library.
|
||||
|
||||
**SessionContext** (`src/context/SessionContext.tsx`):
|
||||
|
||||
- Fetches session data from `/api/session` on mount.
|
||||
- Applies the theme by dynamically importing `@ampeco/design-tokens` and calling `applyTheme(appTheme, designTokens)`.
|
||||
- Provides `session`, `loading`, `error`, and `setManualUserId` to all child components.
|
||||
- `setManualUserId` allows overriding the `userId` in the session (used by `UserIdFallback` in dev mode).
|
||||
|
||||
**useSession** (`src/hooks/useSession.ts`):
|
||||
|
||||
Convenience hook that calls `useContext(SessionContext)`.
|
||||
|
||||
### Data Fetching Hooks
|
||||
|
||||
**useBookings** (`src/hooks/useBookings.ts`):
|
||||
|
||||
- `useBookings(params)` -- Fetches a list of bookings with optional filter parameters (`userId`, `status`, date range, pagination). Loads once on mount.
|
||||
- `useBookingDetail(id)` -- Fetches a single booking by ID. Includes a **polling mechanism** that refreshes every 30 seconds. Polling:
|
||||
- Pauses when the browser tab is hidden (`document.hidden`).
|
||||
- Resumes immediately when the tab becomes visible.
|
||||
- Stops permanently when the booking reaches a terminal status (`completed`, `cancelled`, `no-show`, `failed`).
|
||||
|
||||
**useAvailability** (`src/hooks/useAvailability.ts`):
|
||||
|
||||
- Provides a `check(locationId, startAfter, endBefore)` function for on-demand availability queries.
|
||||
- Returns `slots`, `loading`, `error`, and a `reset` function.
|
||||
- No automatic polling.
|
||||
|
||||
### API Client Layer
|
||||
|
||||
**File:** `src/api/client.ts`
|
||||
|
||||
A thin `fetch` wrapper that:
|
||||
|
||||
1. Prepends `/api` to all paths (which Vite proxies to the backend).
|
||||
2. Sets `Accept: application/json` and `Content-Type: application/json` headers.
|
||||
3. Throws `ApiRequestError` on non-2xx responses, preserving the HTTP status and parsed response body.
|
||||
|
||||
```typescript
|
||||
export class ApiRequestError extends Error {
|
||||
status: number;
|
||||
body: Record<string, unknown> | null;
|
||||
|
||||
get isNotFound(): boolean;
|
||||
get isForbidden(): boolean;
|
||||
get isValidationError(): boolean; // status === 422
|
||||
get isUnauthorized(): boolean;
|
||||
get validationErrors(): Record<string, string[]>; // body.errors
|
||||
}
|
||||
```
|
||||
|
||||
**Endpoint modules:**
|
||||
|
||||
| Module | Functions |
|
||||
|---------------------------------|-----------------------------------------------------|
|
||||
| `src/api/bookings.ts` | `fetchBookings(params)`, `fetchBooking(id)` |
|
||||
| `src/api/bookingRequests.ts` | `createBookingRequest(payload)` -- handles create, update, cancel |
|
||||
| `src/api/availability.ts` | `checkAvailability(locationId, payload)` |
|
||||
|
||||
The `createBookingRequest` function accepts a discriminated union payload:
|
||||
|
||||
```typescript
|
||||
type BookingRequestPayload =
|
||||
| { type: 'create'; userId: number; locationId: number; startAt: string; endAt: string; evseCriteria?: {...} }
|
||||
| { type: 'update'; bookingId: number; startAt: string; endAt: string }
|
||||
| { type: 'cancel'; bookingId: number }
|
||||
```
|
||||
|
||||
### Design Token Integration
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[JWT payload] -->|designTokens + appTheme| B[SessionContext]
|
||||
B -->|applyTheme| C["@ampeco/design-tokens"]
|
||||
C -->|Sets CSS custom properties| D["document.documentElement.style"]
|
||||
D -->|var(--btn-primary-bg), etc.| E[All components]
|
||||
```
|
||||
|
||||
1. The JWT payload carries `appTheme` (`'LIGHT'` or `'DARK'`) and an optional `designTokens` map of CSS custom property overrides.
|
||||
2. `SessionContext` calls `applyTheme(appTheme, designTokens)` from `@ampeco/design-tokens` after fetching the session.
|
||||
3. `applyTheme` sets CSS custom properties on the document root element.
|
||||
4. All components reference these variables via inline styles (e.g., `var(--btn-primary-bg, #2563eb)`) with hardcoded fallback values.
|
||||
5. The base CSS variables are imported in `src/app.css` from `@ampeco/design-tokens/css/variables.css`.
|
||||
|
||||
Key CSS custom properties used throughout the UI:
|
||||
|
||||
| Variable | Usage |
|
||||
|-----------------------------|--------------------------------|
|
||||
| `--surface-elevation-0` | Page background |
|
||||
| `--surface-elevation-1` | Card/input backgrounds |
|
||||
| `--surface-elevation-2` | Elevated slot buttons |
|
||||
| `--text-primary` | Primary text color |
|
||||
| `--text-secondary` | Secondary/muted text |
|
||||
| `--btn-primary-bg` | Primary button background |
|
||||
| `--btn-primary-text` | Primary button text color |
|
||||
| `--border-default` | Border color for cards, inputs |
|
||||
|
||||
### Internationalization
|
||||
|
||||
**File:** `src/i18n/init.ts`
|
||||
|
||||
Uses `i18next` with `react-i18next`. Currently ships with English (`en`) translations only. Translation strings are organized by feature in `src/i18n/locales/en.json`.
|
||||
|
||||
Components access translations via the `useTranslation()` hook:
|
||||
|
||||
```typescript
|
||||
const { t } = useTranslation();
|
||||
// t('createBooking.title') --> "Book a Charger"
|
||||
```
|
||||
|
||||
### Tailwind CSS Configuration
|
||||
|
||||
**File:** `tailwind.config.ts`
|
||||
|
||||
- Content sources: `index.html`, all `src/**/*.{ts,tsx}`, and compiled `@ampeco/ewa-ui` dist files.
|
||||
- Plugins: `ewaUiPlugin` from `@ampeco/ewa-ui/plugin` (provides shared component styles).
|
||||
|
||||
---
|
||||
|
||||
## Data Flow Diagrams
|
||||
|
||||
### JWT to Session to Theme
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant App as Mobile App
|
||||
participant WV as WebView
|
||||
participant BE as Express Backend
|
||||
participant API as AMPECO API
|
||||
|
||||
App->>WV: Load EWA URL with X-Payload JWT header
|
||||
WV->>BE: GET /api/session (X-Payload: <jwt>)
|
||||
BE->>BE: Verify JWT (HS256)
|
||||
BE->>WV: 200 { userId, operatorId, appTheme, designTokens, ... }
|
||||
WV->>WV: SessionContext.applyTheme(appTheme, designTokens)
|
||||
WV->>WV: Set CSS custom properties on :root
|
||||
WV->>WV: Render app with themed components
|
||||
```
|
||||
|
||||
### Booking CRUD Flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant UI as React Frontend
|
||||
participant BE as Express Backend
|
||||
participant API as AMPECO Public API
|
||||
|
||||
Note over UI: Create Booking (2-step)
|
||||
UI->>BE: POST /api/locations/:id/check-availability
|
||||
BE->>API: POST /actions/locations/v2.0/:id/check-booking-availability
|
||||
API-->>BE: { data: AvailabilitySlot[] }
|
||||
BE-->>UI: Available slots
|
||||
|
||||
UI->>BE: POST /api/booking-requests { type: "create", ... }
|
||||
BE->>API: POST /resources/booking-requests/v1.0
|
||||
API-->>BE: { data: BookingRequest { status: "approved", bookingId } }
|
||||
BE-->>UI: Booking confirmed
|
||||
|
||||
Note over UI: Read / Poll
|
||||
UI->>BE: GET /api/bookings/:id
|
||||
BE->>API: GET /resources/bookings/v1.0/:id
|
||||
API-->>BE: { data: Booking }
|
||||
BE-->>UI: Booking detail (polls every 30s)
|
||||
|
||||
Note over UI: Update Booking
|
||||
UI->>BE: POST /api/booking-requests { type: "update", bookingId, startAt, endAt }
|
||||
BE->>API: POST /resources/booking-requests/v1.0
|
||||
API-->>BE: { data: BookingRequest { status: "approved" } }
|
||||
BE-->>UI: Update confirmed
|
||||
|
||||
Note over UI: Cancel Booking
|
||||
UI->>BE: POST /api/booking-requests { type: "cancel", bookingId }
|
||||
BE->>API: POST /resources/booking-requests/v1.0
|
||||
API-->>BE: { data: BookingRequest }
|
||||
BE-->>UI: Cancellation confirmed
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Type System
|
||||
|
||||
### Backend Types (`backend/src/types.ts`)
|
||||
|
||||
- `JwtPayload` -- Full JWT structure with nested `payload.parameters`.
|
||||
- `SessionData` -- Flattened session data extracted from the JWT.
|
||||
|
||||
### Frontend Types (`src/types/index.ts`)
|
||||
|
||||
- `Booking` -- Core booking entity with `id`, `userId`, `locationId`, `startAt`, `endAt`, `status`, optional `bookedEvses`, `authorizedTokens`, `accessMethods`, `sessionId`.
|
||||
- `BookingStatus` -- Union type: `'accepted' | 'reserved' | 'completed' | 'cancelled' | 'no-show' | 'failed'`.
|
||||
- `BookingRequest` -- Result of a booking request operation (`type: 'create' | 'update' | 'cancel'`, `status: 'approved' | 'rejected'`).
|
||||
- `AvailabilitySlot` -- EVSE availability data with `evseId` and array of `{ startAt, endAt }` slots.
|
||||
- `SessionData` -- Mirror of the backend `SessionData` type.
|
||||
|
||||
---
|
||||
|
||||
## Build and Deployment
|
||||
|
||||
### Development
|
||||
|
||||
`npm run dev` runs two processes concurrently:
|
||||
|
||||
1. `vite` -- Frontend dev server on port 5173 with HMR. Proxies `/api/*` and `/dev/*` to `localhost:3001`.
|
||||
2. `tsx watch backend/src/index.ts` -- Backend with hot-reload via `tsx`.
|
||||
|
||||
### Production Build
|
||||
|
||||
`npm run build` executes:
|
||||
|
||||
1. `vite build` -- Bundles the React frontend into `dist/` (HTML, JS, CSS assets).
|
||||
2. `tsc -p backend/tsconfig.json` -- Compiles the backend TypeScript into `dist/backend/`.
|
||||
|
||||
The production deployment serves `dist/` as static files and runs the compiled backend.
|
||||
Reference in New Issue
Block a user