feat: travel dates + sea-crossing chooser, Tesla in-car polish, Fleet API stub

- Travel dates: TopBar chip + popover (outbound/return/travellers); sent to
  Grok prompt; itinerary.needsTravelDates drives a nudge banner; cache and
  prefetch ledger invalidate when dates change
- Sea crossings: CrossingOption schema (Eurotunnel, DFDS, P&O, Brittany,
  Stena Line); CrossingSwapBlock under tunnel/ferry/crossing stops with
  trip-impact deltas and Book links; prompt requires 3-5 real options for
  every UK ↔ mainland route; picking a crossing triggers silent re-plan
- Tesla in-car polish: UA + heuristic detection sets <html class="incar">;
  CSS overrides kill backdrop-filter, scale fonts, enforce 44px tap targets,
  disable hover flicker; geolocation + reverse geocode + crosshair button
  inside the From input; up/down arrow reorder buttons replace touch-broken
  HTML5 drag-and-drop
- Tesla Fleet API stub: /.well-known/appspecific/com.tesla.3p.public-key.pem
  served from TESLA_FLEET_PUBLIC_KEY for partner domain verification;
  OAuth callback + vehicle_data stub return 503 until partner approval
- Dockerfile + .dockerignore for Dokku deployment; server now serves
  client/dist in production
This commit is contained in:
2026-05-31 21:38:27 +01:00
parent 88fc86dc32
commit cff52b4b9e
11 changed files with 885 additions and 24 deletions
+10 -4
View File
@@ -15,6 +15,11 @@ const ChatRequestSchema = z.object({
selectedVariant: z.enum(['fast', 'scenic', 'cheap']).optional(),
origin: z.string().optional(),
destination: z.string().optional(),
travelDates: z.object({
outbound: z.string().nullable().optional(),
return: z.string().nullable().optional(),
travellers: z.number().int().min(1).max(8).optional(),
}).optional(),
});
router.post('/chat', async (req, res) => {
@@ -30,7 +35,7 @@ router.post('/chat', async (req, res) => {
return res.status(400).json({ error: 'Invalid request' });
}
const { message, vehicle, itinerary, history = [], selectedVariant = 'fast', origin, destination } = parsed.data;
const { message, vehicle, itinerary, history = [], selectedVariant = 'fast', origin, destination, travelDates } = parsed.data;
log.info({
requestId,
@@ -41,6 +46,7 @@ router.post('/chat', async (req, res) => {
selectedVariant,
origin,
destination,
travelDates,
}, 'Parsed chat request');
// Call Grok (this will produce very detailed logs inside GrokHeadlessClient)
@@ -49,7 +55,7 @@ router.post('/chat', async (req, res) => {
itinerary,
vehicle,
selectedVariant,
{ origin, destination },
{ origin, destination, travelDates },
);
const duration = Date.now() - start;
@@ -91,7 +97,7 @@ router.post('/chat/stream', async (req, res) => {
if (!parsed.success) {
return res.status(400).json({ error: 'Invalid request', issues: parsed.error.format() });
}
const { message, vehicle, itinerary, history = [], selectedVariant = 'fast', origin, destination } = parsed.data;
const { message, vehicle, itinerary, history = [], selectedVariant = 'fast', origin, destination, travelDates } = parsed.data;
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache, no-transform');
@@ -124,7 +130,7 @@ router.post('/chat/stream', async (req, res) => {
itinerary,
vehicle,
selectedVariant,
{ origin, destination },
{ origin, destination, travelDates },
);
for await (const ev of stream) {
if (cancelled) break;
+72
View File
@@ -0,0 +1,72 @@
import { Router } from 'express';
import { env } from '../config/env.js';
import { createLogger } from '../lib/logger.js';
const log = createLogger('tesla');
const router = Router();
// ─── Domain verification ────────────────────────────────────────────────────
// Tesla fetches this path to confirm you own the domain registered with the
// Fleet API partner account. The body must be the EXACT PEM the partner key
// is registered with (the EC public key from your prime256v1 keypair).
//
// Set TESLA_FLEET_PUBLIC_KEY in Dokku config to the full PEM contents —
// including the BEGIN/END lines. Multi-line env vars work fine with Dokku
// when set via `dokku config:set roadtrip TESLA_FLEET_PUBLIC_KEY="$(cat key.pem)"`.
router.get('/.well-known/appspecific/com.tesla.3p.public-key.pem', (_req, res) => {
if (!env.tesla.publicKey) {
log.warn('Tesla public key requested but TESLA_FLEET_PUBLIC_KEY is empty');
res.status(404).type('text/plain').send('Public key not configured');
return;
}
res.type('application/x-pem-file');
res.set('Cache-Control', 'public, max-age=300');
res.send(env.tesla.publicKey);
});
// ─── OAuth callback (stub) ──────────────────────────────────────────────────
// Tesla redirects here with ?code=… after the user grants access. We exchange
// the code for a refresh token and store it against the logged-in user.
// Full implementation lands once partner approval is granted.
router.get('/api/auth/tesla/callback', async (req, res) => {
const { code, state, error } = req.query as Record<string, string | undefined>;
if (error) {
log.warn({ error, state }, 'Tesla OAuth error returned to callback');
res.redirect(`/?tesla_error=${encodeURIComponent(error)}`);
return;
}
if (!code) {
res.status(400).type('text/plain').send('Missing ?code from Tesla');
return;
}
if (!env.tesla.clientId || !env.tesla.clientSecret) {
log.warn('Tesla OAuth callback hit but client credentials not configured');
res.status(503).type('text/plain').send('Tesla integration not yet configured');
return;
}
// TODO once Tesla approves partner registration:
// 1. POST to https://auth.tesla.com/oauth2/v3/token with grant_type=authorization_code
// 2. Decode the id_token, persist refresh_token against req.auth.userId
// 3. Optional: enrol the vehicle via /api/1/partner_accounts/public_key
// 4. Redirect to / with a success flag
log.info({ codeLen: code.length, state }, 'Tesla OAuth callback received (stub)');
res.redirect('/?tesla_connected=pending');
});
// ─── Vehicle state (stub) ───────────────────────────────────────────────────
// Returns the current battery %, range and location for the connected vehicle.
// Until partner approval, returns 503 so the client can hide the integration UI.
router.get('/api/tesla/state', async (_req, res) => {
if (!env.tesla.clientId) {
res.status(503).json({ connected: false, reason: 'pending_partner_approval' });
return;
}
// TODO: look up the user's stored refresh token, exchange for access token,
// call https://fleet-api.prd.eu.vn.cloud.tesla.com/api/1/vehicles/{id}/vehicle_data,
// and shape the response into { battery, range, lat, lng, state, etc. }.
res.status(501).json({ connected: false, reason: 'not_implemented' });
});
export default router;