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
+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;