Implement Planner-Journal handoff (Group 9)

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) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-03-23 22:33:37 +01:00 committed by GitHub
parent f47eca565c
commit 40e541fcce
12 changed files with 287 additions and 11 deletions

View file

@ -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<string> {
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[],
};
}

View file

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

View file

@ -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 });
}
}

View file

@ -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) {
</div>
{isOwner && (
<div className="flex gap-2">
<form method="post">
<input type="hidden" name="intent" value="edit-in-planner" />
<button
type="submit"
className="rounded-md bg-blue-600 px-3 py-1.5 text-sm text-white hover:bg-blue-700"
>
Edit in Planner
</button>
</form>
<a
href={`/routes/${route.id}/edit`}
className="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50"

View file

@ -23,6 +23,7 @@
"@trails-cool/ui": "workspace:*",
"drizzle-orm": "catalog:",
"isbot": "^5.1.0",
"jose": "^6.2.2",
"react": "catalog:",
"react-dom": "catalog:",
"react-router": "catalog:"

View file

@ -0,0 +1,93 @@
import { useState, useCallback } from "react";
import * as Y from "yjs";
import type { YjsState } from "~/lib/use-yjs";
import { generateGpx } from "@trails-cool/gpx";
import type { TrackPoint } from "@trails-cool/gpx";
interface SaveToJournalButtonProps {
yjs: YjsState;
callbackUrl: string;
callbackToken: string;
returnUrl?: string;
}
export function SaveToJournalButton({ yjs, callbackUrl, callbackToken, returnUrl }: SaveToJournalButtonProps) {
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [error, setError] = useState<string | null>(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<unknown>) => ({
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 (
<div className="flex items-center gap-2">
<span className="text-sm text-green-600">Saved!</span>
{returnUrl && (
<a href={returnUrl} className="rounded bg-gray-100 px-3 py-1 text-sm text-gray-700 hover:bg-gray-200">
Return to Journal
</a>
)}
</div>
);
}
return (
<div className="flex items-center gap-2">
<button
onClick={handleSave}
disabled={saving}
className="rounded bg-green-600 px-3 py-1 text-sm text-white hover:bg-green-700 disabled:opacity-50"
>
{saving ? "Saving..." : "Save to Journal"}
</button>
{error && <span className="text-xs text-red-600">{error}</span>}
</div>
);
}

View file

@ -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 }) {
<ProfileSelector yjs={yjs} />
</div>
<div className="flex items-center gap-3">
{callbackUrl && callbackToken && (
<SaveToJournalButton
yjs={yjs}
callbackUrl={callbackUrl}
callbackToken={callbackToken}
returnUrl={returnUrl}
/>
)}
<ExportButton yjs={yjs} />
{computing && (
<span className="text-xs text-blue-600">Computing route...</span>

View file

@ -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"),

View file

@ -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);
}

View file

@ -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 (
<div className="flex h-full flex-col">
@ -44,7 +47,12 @@ export default function SessionPage() {
</div>
}
>
<SessionView sessionId={id!} />
<SessionView
sessionId={id!}
callbackUrl={loaderData.callbackUrl ?? undefined}
callbackToken={loaderData.callbackToken ?? undefined}
returnUrl={returnUrl}
/>
</Suspense>
)}
</ClientOnly>

View file

@ -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

8
pnpm-lock.yaml generated
View file

@ -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: