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:
parent
f47eca565c
commit
40e541fcce
12 changed files with 287 additions and 11 deletions
27
apps/journal/app/lib/jwt.server.ts
Normal file
27
apps/journal/app/lib/jwt.server.ts
Normal 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[],
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
52
apps/journal/app/routes/api.routes.$id.callback.ts
Normal file
52
apps/journal/app/routes/api.routes.$id.callback.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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:"
|
||||
|
|
|
|||
93
apps/planner/app/components/SaveToJournalButton.tsx
Normal file
93
apps/planner/app/components/SaveToJournalButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
36
apps/planner/app/routes/new.tsx
Normal file
36
apps/planner/app/routes/new.tsx
Normal 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);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
8
pnpm-lock.yaml
generated
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue