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:
+10
-4
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user