From 40e541fcce502ee6fe2d2d6519dada6d67bd2828 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Mon, 23 Mar 2026 22:33:37 +0100 Subject: [PATCH] Implement Planner-Journal handoff (Group 9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JWT-based handoff between Journal and Planner: Journal side: - JWT token generation scoped to route_id with 7-day expiry (jose) - "Edit in Planner" button on route detail page — generates JWT, redirects to Planner /new with callback URL, token, and GPX - Callback endpoint (POST /api/routes/:id/callback) validates JWT and creates new route version from received GPX Planner side: - /new route accepts callback, token, returnUrl, gpx params - Creates session with callback metadata, initializes with GPX - "Save to Journal" button POSTs GPX with Bearer token to callback - "Return to Journal" link shown after successful save All 6 Group 9 tasks complete. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/journal/app/lib/jwt.server.ts | 27 ++++++ apps/journal/app/routes.ts | 1 + .../app/routes/api.routes.$id.callback.ts | 52 +++++++++++ apps/journal/app/routes/routes.$id.tsx | 33 +++++++ apps/journal/package.json | 1 + .../app/components/SaveToJournalButton.tsx | 93 +++++++++++++++++++ apps/planner/app/components/SessionView.tsx | 18 +++- apps/planner/app/routes.ts | 1 + apps/planner/app/routes/new.tsx | 36 +++++++ apps/planner/app/routes/session.$id.tsx | 16 +++- openspec/changes/phase-1-mvp/tasks.md | 12 +-- pnpm-lock.yaml | 8 ++ 12 files changed, 287 insertions(+), 11 deletions(-) create mode 100644 apps/journal/app/lib/jwt.server.ts create mode 100644 apps/journal/app/routes/api.routes.$id.callback.ts create mode 100644 apps/planner/app/components/SaveToJournalButton.tsx create mode 100644 apps/planner/app/routes/new.tsx diff --git a/apps/journal/app/lib/jwt.server.ts b/apps/journal/app/lib/jwt.server.ts new file mode 100644 index 0000000..5bf62a4 --- /dev/null +++ b/apps/journal/app/lib/jwt.server.ts @@ -0,0 +1,27 @@ +import { SignJWT, jwtVerify } from "jose"; + +const JWT_SECRET = new TextEncoder().encode( + process.env.JWT_SECRET ?? "dev-jwt-secret-change-in-production", +); + +const ISSUER = process.env.ORIGIN ?? "http://localhost:3000"; + +export async function createRouteToken(routeId: string, permissions: string[] = ["read", "write"]): Promise { + return new SignJWT({ route_id: routeId, permissions }) + .setProtectedHeader({ alg: "HS256" }) + .setIssuer(ISSUER) + .setExpirationTime("7d") + .setIssuedAt() + .sign(JWT_SECRET); +} + +export async function verifyRouteToken(token: string): Promise<{ routeId: string; permissions: string[] }> { + const { payload } = await jwtVerify(token, JWT_SECRET, { + issuer: ISSUER, + }); + + return { + routeId: payload.route_id as string, + permissions: payload.permissions as string[], + }; +} diff --git a/apps/journal/app/routes.ts b/apps/journal/app/routes.ts index 8f69788..eb4200b 100644 --- a/apps/journal/app/routes.ts +++ b/apps/journal/app/routes.ts @@ -12,5 +12,6 @@ export default [ route("routes/new", "routes/routes.new.tsx"), route("routes/:id", "routes/routes.$id.tsx"), route("routes/:id/edit", "routes/routes.$id.edit.tsx"), + route("api/routes/:id/callback", "routes/api.routes.$id.callback.ts"), route("users/:username", "routes/users.$username.tsx"), ] satisfies RouteConfig; diff --git a/apps/journal/app/routes/api.routes.$id.callback.ts b/apps/journal/app/routes/api.routes.$id.callback.ts new file mode 100644 index 0000000..e656e38 --- /dev/null +++ b/apps/journal/app/routes/api.routes.$id.callback.ts @@ -0,0 +1,52 @@ +import { data } from "react-router"; +import type { Route } from "./+types/api.routes.$id.callback"; +import { verifyRouteToken } from "~/lib/jwt.server"; +import { updateRoute, getRoute } from "~/lib/routes.server"; + +export async function action({ params, request }: Route.ActionArgs) { + if (request.method !== "POST") { + return data({ error: "Method not allowed" }, { status: 405 }); + } + + // Verify JWT token from Authorization header or body + const authHeader = request.headers.get("Authorization"); + const token = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null; + + if (!token) { + return data({ error: "Missing authorization token" }, { status: 401 }); + } + + try { + const { routeId, permissions } = await verifyRouteToken(token); + + // Verify token is for this route + if (routeId !== params.id) { + return data({ error: "Token not valid for this route" }, { status: 403 }); + } + + if (!permissions.includes("write")) { + return data({ error: "Token does not have write permission" }, { status: 403 }); + } + + // Get route to verify it exists + const route = await getRoute(params.id); + if (!route) { + return data({ error: "Route not found" }, { status: 404 }); + } + + // Parse GPX from request body + const body = await request.json(); + const { gpx } = body as { gpx: string }; + + if (!gpx) { + return data({ error: "Missing GPX data" }, { status: 400 }); + } + + // Update route with new GPX (creates new version) + await updateRoute(params.id, route.ownerId, { gpx }); + + return data({ success: true, routeId: params.id }); + } catch (e) { + return data({ error: (e as Error).message }, { status: 401 }); + } +} diff --git a/apps/journal/app/routes/routes.$id.tsx b/apps/journal/app/routes/routes.$id.tsx index 75a4ee9..dc83feb 100644 --- a/apps/journal/app/routes/routes.$id.tsx +++ b/apps/journal/app/routes/routes.$id.tsx @@ -2,6 +2,7 @@ import { data, redirect } from "react-router"; import type { Route } from "./+types/routes.$id"; import { getSessionUser } from "~/lib/auth.server"; import { getRouteWithVersions, deleteRoute, updateRoute } from "~/lib/routes.server"; +import { createRouteToken } from "~/lib/jwt.server"; export async function loader({ params, request }: Route.LoaderArgs) { @@ -73,6 +74,29 @@ export async function action({ params, request }: Route.ActionArgs) { }); } + if (intent === "edit-in-planner") { + const route = await getRouteWithVersions(params.id); + if (!route) return data({ error: "Route not found" }, { status: 404 }); + + const token = await createRouteToken(params.id); + const origin = process.env.ORIGIN ?? "http://localhost:3000"; + const callbackUrl = `${origin}/api/routes/${params.id}/callback`; + const plannerUrl = process.env.PLANNER_URL ?? "http://localhost:3001"; + + const plannerParams = new URLSearchParams({ + callback: callbackUrl, + token, + returnUrl: `${origin}/routes/${params.id}`, + }); + + // If route has GPX, include it + if (route.gpx) { + plannerParams.set("gpx", encodeURIComponent(route.gpx)); + } + + return redirect(`${plannerUrl}/new?${plannerParams}`); + } + return data({ error: "Unknown action" }, { status: 400 }); } @@ -95,6 +119,15 @@ export default function RouteDetailPage({ loaderData }: Route.ComponentProps) { {isOwner && (
+
+ + +
(null); + + const handleSave = useCallback(async () => { + setSaving(true); + setError(null); + + try { + // Build GPX from current Yjs state + const waypoints = yjs.waypoints.toArray().map((yMap: Y.Map) => ({ + lat: yMap.get("lat") as number, + lon: yMap.get("lon") as number, + name: yMap.get("name") as string | undefined, + })); + + let tracks: TrackPoint[][] = []; + const geojsonStr = yjs.routeData.get("geojson") as string | undefined; + if (geojsonStr) { + try { + const geojson = JSON.parse(geojsonStr); + const coords: number[][] = geojson.features?.[0]?.geometry?.coordinates ?? []; + if (coords.length > 0) { + tracks = [coords.map((c) => ({ lat: c[1]!, lon: c[0]!, ele: c[2] }))]; + } + } catch { /* invalid geojson */ } + } + + const gpx = generateGpx({ name: "trails.cool route", waypoints, tracks }); + + // POST to Journal callback + const response = await fetch(callbackUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${callbackToken}`, + }, + body: JSON.stringify({ gpx }), + }); + + if (!response.ok) { + const result = await response.json(); + throw new Error(result.error ?? "Save failed"); + } + + setSaved(true); + } catch (err) { + setError((err as Error).message); + } finally { + setSaving(false); + } + }, [yjs, callbackUrl, callbackToken]); + + if (saved) { + return ( +
+ Saved! + {returnUrl && ( + + Return to Journal + + )} +
+ ); + } + + return ( +
+ + {error && {error}} +
+ ); +} diff --git a/apps/planner/app/components/SessionView.tsx b/apps/planner/app/components/SessionView.tsx index 17bee15..93bf228 100644 --- a/apps/planner/app/components/SessionView.tsx +++ b/apps/planner/app/components/SessionView.tsx @@ -3,6 +3,7 @@ import { useYjs } from "~/lib/use-yjs"; import { useRouting } from "~/lib/use-routing"; import { ProfileSelector } from "~/components/ProfileSelector"; import { ExportButton } from "~/components/ExportButton"; +import { SaveToJournalButton } from "~/components/SaveToJournalButton"; import { YjsDebugPanel } from "~/components/YjsDebugPanel"; const PlannerMap = lazy(() => @@ -15,7 +16,14 @@ const ElevationChart = lazy(() => import("~/components/ElevationChart").then((m) => ({ default: m.ElevationChart })), ); -export function SessionView({ sessionId }: { sessionId: string }) { +interface SessionViewProps { + sessionId: string; + callbackUrl?: string; + callbackToken?: string; + returnUrl?: string; +} + +export function SessionView({ sessionId, callbackUrl, callbackToken, returnUrl }: SessionViewProps) { const yjs = useYjs(sessionId); const { isHost, computing, routeStats, requestRoute } = useRouting(yjs); const [highlightPosition, setHighlightPosition] = useState<[number, number] | null>(null); @@ -40,6 +48,14 @@ export function SessionView({ sessionId }: { sessionId: string }) {
+ {callbackUrl && callbackToken && ( + + )} {computing && ( Computing route... diff --git a/apps/planner/app/routes.ts b/apps/planner/app/routes.ts index 584332b..1abbf77 100644 --- a/apps/planner/app/routes.ts +++ b/apps/planner/app/routes.ts @@ -2,6 +2,7 @@ import { type RouteConfig, index, route } from "@react-router/dev/routes"; export default [ index("routes/home.tsx"), + route("new", "routes/new.tsx"), route("api/sessions", "routes/api.sessions.ts"), route("api/route", "routes/api.route.ts"), route("session/:id", "routes/session.$id.tsx"), diff --git a/apps/planner/app/routes/new.tsx b/apps/planner/app/routes/new.tsx new file mode 100644 index 0000000..7b11d2c --- /dev/null +++ b/apps/planner/app/routes/new.tsx @@ -0,0 +1,36 @@ +import { redirect } from "react-router"; +import type { Route } from "./+types/new"; +import { createSession, initializeSessionWithWaypoints } from "~/lib/sessions"; +import { parseGpx } from "@trails-cool/gpx"; + +export async function loader({ request }: Route.LoaderArgs) { + const url = new URL(request.url); + const callbackUrl = url.searchParams.get("callback"); + const token = url.searchParams.get("token"); + const returnUrl = url.searchParams.get("returnUrl"); + const gpxEncoded = url.searchParams.get("gpx"); + + // Create a session with callback info + const session = await createSession({ + callbackUrl: callbackUrl ?? undefined, + callbackToken: token ?? undefined, + }); + + // Initialize with GPX waypoints if provided + if (gpxEncoded) { + try { + const gpx = decodeURIComponent(gpxEncoded); + const gpxData = parseGpx(gpx); + initializeSessionWithWaypoints(session.id, gpxData.waypoints); + } catch { + // Continue with empty session if GPX is invalid + } + } + + // Store returnUrl in the session URL for later + const sessionUrl = returnUrl + ? `/session/${session.id}?returnUrl=${encodeURIComponent(returnUrl)}` + : `/session/${session.id}`; + + return redirect(sessionUrl); +} diff --git a/apps/planner/app/routes/session.$id.tsx b/apps/planner/app/routes/session.$id.tsx index e99de06..fca4983 100644 --- a/apps/planner/app/routes/session.$id.tsx +++ b/apps/planner/app/routes/session.$id.tsx @@ -1,4 +1,4 @@ -import { useParams } from "react-router"; +import { useParams, useSearchParams } from "react-router"; import type { Route } from "./+types/session.$id"; import { getSession } from "~/lib/sessions"; import { data } from "react-router"; @@ -20,12 +20,15 @@ export async function loader({ params }: Route.LoaderArgs) { } return data({ sessionId: session.id, - hasCallback: !!session.callbackUrl, + callbackUrl: session.callbackUrl ?? null, + callbackToken: session.callbackToken ?? null, }); } -export default function SessionPage() { +export default function SessionPage({ loaderData }: Route.ComponentProps) { const { id } = useParams(); + const [searchParams] = useSearchParams(); + const returnUrl = searchParams.get("returnUrl") ?? undefined; return (
@@ -44,7 +47,12 @@ export default function SessionPage() {
} > - + )} diff --git a/openspec/changes/phase-1-mvp/tasks.md b/openspec/changes/phase-1-mvp/tasks.md index 5411fb1..d86ccec 100644 --- a/openspec/changes/phase-1-mvp/tasks.md +++ b/openspec/changes/phase-1-mvp/tasks.md @@ -89,12 +89,12 @@ ## 9. Planner-Journal Handoff -- [ ] 9.1 Implement JWT token generation in Journal (scoped to route_id, with expiry) -- [ ] 9.2 Implement "Edit in Planner" button on Journal route detail page (redirect with callback + token + GPX) -- [ ] 9.3 Implement callback URL handling in Planner (store callback URL in session metadata) -- [ ] 9.4 Implement "Save to Journal" button in Planner (POST GPX + JWT to callback URL) -- [ ] 9.5 Implement callback endpoint in Journal (POST /api/routes/:id/callback — validate JWT, save new version) -- [ ] 9.6 Implement "Return to Journal" link after successful save +- [x] 9.1 Implement JWT token generation in Journal (scoped to route_id, with expiry) +- [x] 9.2 Implement "Edit in Planner" button on Journal route detail page (redirect with callback + token + GPX) +- [x] 9.3 Implement callback URL handling in Planner (store callback URL in session metadata) +- [x] 9.4 Implement "Save to Journal" button in Planner (POST GPX + JWT to callback URL) +- [x] 9.5 Implement callback endpoint in Journal (POST /api/routes/:id/callback — validate JWT, save new version) +- [x] 9.6 Implement "Return to Journal" link after successful save ## 10. Journal — Activity Feed diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cfd3763..04cd0cd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -197,6 +197,9 @@ importers: isbot: specifier: ^5.1.0 version: 5.1.36 + jose: + specifier: ^6.2.2 + version: 6.2.2 react: specifier: 'catalog:' version: 19.2.4 @@ -2177,6 +2180,9 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + jose@6.2.2: + resolution: {integrity: sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -4624,6 +4630,8 @@ snapshots: jiti@2.6.1: {} + jose@6.2.2: {} + js-tokens@4.0.0: {} jsdom@29.0.1: