Add transactional emails (SMTP) and planner features (no-go areas, notes, crash recovery)

Transactional emails:
- Add nodemailer SMTP email module with dev-mode console logging
- Magic link template and welcome template with HTML + plain text
- Wire sendMagicLink into login flow, sendWelcome into registration
- Update privacy page and deploy docs for SMTP configuration

Planner features:
- No-go areas: draw polygons on map (leaflet-geoman), synced via Yjs,
  passed to BRouter as nogos parameter, route recomputes on change
- Session notes: collaborative Y.Text textarea in sidebar tab
- Crash recovery: periodic localStorage save of Yjs state, restore on reconnect
- Rate limit session creation (10/IP/hour) in /new route

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-03-26 01:00:42 +01:00
parent 05b5f6febf
commit 0a8dd0b766
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
27 changed files with 1030 additions and 69 deletions

View file

@ -0,0 +1,65 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// Mock nodemailer before importing
vi.mock("nodemailer", () => ({
createTransport: vi.fn().mockReturnValue({
sendMail: vi.fn().mockResolvedValue({ messageId: "test-id" }),
}),
}));
describe("email.server", () => {
const originalEnv = process.env.NODE_ENV;
beforeEach(() => {
vi.resetModules();
});
afterEach(() => {
process.env.NODE_ENV = originalEnv;
delete process.env.SMTP_URL;
});
it("logs to console in dev mode", async () => {
process.env.NODE_ENV = "development";
const { sendEmail } = await import("./email.server");
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
await sendEmail("test@example.com", "Test Subject", "<p>Hello</p>", "Hello");
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining("test@example.com"),
);
consoleSpy.mockRestore();
});
it("does not call SMTP in dev mode", async () => {
process.env.NODE_ENV = "development";
const nodemailer = await import("nodemailer");
const { sendEmail } = await import("./email.server");
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
await sendEmail("test@example.com", "Test", "<p>Hi</p>", "Hi");
expect(nodemailer.createTransport).not.toHaveBeenCalled();
consoleSpy.mockRestore();
});
it("magicLinkTemplate includes link and expiry note", async () => {
const { magicLinkTemplate } = await import("./email.server");
const { html, text } = magicLinkTemplate("https://trails.cool/auth/verify?token=abc");
expect(html).toContain("https://trails.cool/auth/verify?token=abc");
expect(html).toContain("15 minutes");
expect(text).toContain("https://trails.cool/auth/verify?token=abc");
expect(text).toContain("15 minutes");
});
it("welcomeTemplate includes username", async () => {
const { welcomeTemplate } = await import("./email.server");
const { html, text } = welcomeTemplate("Alice");
expect(html).toContain("Alice");
expect(text).toContain("Alice");
expect(html).toContain("trails.cool");
});
});

View file

@ -0,0 +1,99 @@
import { createTransport, type Transporter } from "nodemailer";
const FROM = process.env.SMTP_FROM ?? "trails.cool <noreply@trails.cool>";
let transporter: Transporter | null = null;
function getTransporter(): Transporter {
if (!transporter) {
const url = process.env.SMTP_URL;
if (!url) throw new Error("SMTP_URL is not set");
transporter = createTransport(url);
}
return transporter;
}
export async function sendEmail(
to: string,
subject: string,
html: string,
text: string,
): Promise<void> {
if (process.env.NODE_ENV !== "production") {
console.log(`[Email] To: ${to} | Subject: ${subject}`);
console.log(`[Email] Text:\n${text}`);
return;
}
await getTransporter().sendMail({ from: FROM, to, subject, html, text });
}
// --- Templates ---
export function magicLinkTemplate(link: string): { html: string; text: string } {
const html = `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 480px; margin: 0 auto; padding: 40px 20px;">
<h1 style="font-size: 24px; font-weight: 600; color: #111;">Sign in to trails.cool</h1>
<p style="color: #555; line-height: 1.6;">Click the button below to sign in to your account. This link expires in 15 minutes.</p>
<a href="${link}" style="display: inline-block; margin: 24px 0; padding: 12px 24px; background: #2563eb; color: white; text-decoration: none; border-radius: 8px; font-weight: 500;">Sign In</a>
<p style="color: #888; font-size: 14px;">If the button doesn't work, copy and paste this link:<br/><a href="${link}" style="color: #2563eb;">${link}</a></p>
<hr style="border: none; border-top: 1px solid #eee; margin: 32px 0;" />
<p style="color: #aaa; font-size: 12px;">If you didn't request this link, you can safely ignore this email.</p>
</div>
`.trim();
const text = [
"Sign in to trails.cool",
"",
`Click here to sign in: ${link}`,
"",
"This link expires in 15 minutes.",
"",
"If you didn't request this link, you can safely ignore this email.",
].join("\n");
return { html, text };
}
export function welcomeTemplate(username: string): { html: string; text: string } {
const html = `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 480px; margin: 0 auto; padding: 40px 20px;">
<h1 style="font-size: 24px; font-weight: 600; color: #111;">Welcome to trails.cool, ${username}!</h1>
<p style="color: #555; line-height: 1.6;">Your account is ready. Here's what you can do:</p>
<ul style="color: #555; line-height: 1.8; padding-left: 20px;">
<li>Save and manage your routes</li>
<li>Track outdoor activities</li>
<li>Plan routes collaboratively in the Planner</li>
<li>Export routes as GPX for any GPS device</li>
</ul>
<a href="https://trails.cool/routes" style="display: inline-block; margin: 24px 0; padding: 12px 24px; background: #2563eb; color: white; text-decoration: none; border-radius: 8px; font-weight: 500;">View Your Routes</a>
<hr style="border: none; border-top: 1px solid #eee; margin: 32px 0;" />
<p style="color: #aaa; font-size: 12px;">trails.cool Your outdoor activity journal</p>
</div>
`.trim();
const text = [
`Welcome to trails.cool, ${username}!`,
"",
"Your account is ready. Here's what you can do:",
"- Save and manage your routes",
"- Track outdoor activities",
"- Plan routes collaboratively in the Planner",
"- Export routes as GPX for any GPS device",
"",
"View your routes: https://trails.cool/routes",
].join("\n");
return { html, text };
}
// --- Convenience wrappers ---
export async function sendMagicLink(email: string, link: string): Promise<void> {
const { html, text } = magicLinkTemplate(link);
await sendEmail(email, "Sign in to trails.cool", html, text);
}
export async function sendWelcome(email: string, username: string): Promise<void> {
const { html, text } = welcomeTemplate(username);
await sendEmail(email, "Welcome to trails.cool!", html, text);
}

View file

@ -1,6 +1,7 @@
import { data } from "react-router";
import type { Route } from "./+types/api.auth.login";
import { startAuthentication, finishAuthentication, createMagicToken, createSession } from "~/lib/auth.server";
import { sendMagicLink } from "~/lib/email.server";
export async function action({ request }: Route.ActionArgs) {
const body = await request.json();
@ -22,12 +23,13 @@ export async function action({ request }: Route.ActionArgs) {
const token = await createMagicToken(email);
const origin = process.env.ORIGIN ?? "http://localhost:3000";
const link = `${origin}/auth/verify?token=${token}`;
console.log(`[Magic Link] ${email}: ${link}`);
// In dev, return the link directly so the client can auto-redirect
if (process.env.NODE_ENV !== "production") {
console.log(`[Magic Link] ${email}: ${link}`);
return data({ step: "magic-link-sent", devLink: link });
}
await sendMagicLink(email, link);
return data({ step: "magic-link-sent" });
}

View file

@ -1,6 +1,7 @@
import { data } from "react-router";
import type { Route } from "./+types/api.auth.register";
import { startRegistration, finishRegistration, createSession, addPasskeyStart, addPasskeyFinish } from "~/lib/auth.server";
import { sendWelcome } from "~/lib/email.server";
export async function action({ request }: Route.ActionArgs) {
const body = await request.json();
@ -15,6 +16,10 @@ export async function action({ request }: Route.ActionArgs) {
if (step === "finish") {
const newUserId = await finishRegistration(userId, email, username, response, challenge);
const cookie = await createSession(newUserId, request);
// Send welcome email (fire-and-forget — don't block registration on email)
sendWelcome(email, username).catch((err) =>
console.error("[Email] Failed to send welcome email:", err),
);
return data({ step: "done" }, { headers: { "Set-Cookie": cookie } });
}

View file

@ -70,12 +70,30 @@ export default function PrivacyPage() {
</p>
</section>
<section className="mt-10">
<h2 className="text-xl font-semibold text-gray-900">Email</h2>
<p className="mt-2 text-gray-600">
The Journal sends transactional emails for magic link login and welcome messages
via SMTP. On the official instance, emails are sent through our own mail server.
</p>
<h3 className="mt-4 font-medium text-gray-800">What is sent via email:</h3>
<ul className="mt-2 list-disc pl-6 text-gray-600 space-y-1">
<li><strong>Magic link</strong>: a one-time login link sent to your email address</li>
<li><strong>Welcome email</strong>: a greeting after registration</li>
</ul>
<p className="mt-3 text-gray-600">
Self-hosted instances can configure their own SMTP server. No email content
is stored beyond what your mail server retains.
</p>
</section>
<section className="mt-10">
<h2 className="text-xl font-semibold text-gray-900">Third Parties</h2>
<ul className="mt-3 list-disc pl-6 text-gray-600 space-y-1">
<li><strong>Sentry</strong> (Functional Software Inc.) error tracking, as described above</li>
<li><strong>OpenStreetMap</strong> map tiles are loaded from OSM tile servers. OSM&apos;s <a href="https://wiki.osmfoundation.org/wiki/Privacy_Policy" className="text-blue-600 hover:underline">privacy policy</a> applies to tile requests.</li>
<li><strong>BRouter</strong> routing requests are processed by our self-hosted BRouter instance. No data is sent to third parties for routing.</li>
<li><strong>SMTP provider</strong> transactional emails (magic link, welcome) are delivered via SMTP. Self-hosters configure their own provider.</li>
</ul>
</section>

View file

@ -26,6 +26,7 @@
"drizzle-orm": "catalog:",
"isbot": "^5.1.0",
"jose": "^6.2.2",
"nodemailer": "^8.0.4",
"react": "catalog:",
"react-dom": "catalog:",
"react-router": "catalog:"
@ -34,6 +35,7 @@
"@react-router/dev": "catalog:",
"@simplewebauthn/types": "^12.0.0",
"@tailwindcss/vite": "catalog:",
"@types/nodemailer": "^7.0.11",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"tailwindcss": "catalog:",

View file

@ -0,0 +1,119 @@
import { useEffect, useRef, useCallback } from "react";
import { useMap } from "react-leaflet";
import L from "leaflet";
import * as Y from "yjs";
import "@geoman-io/leaflet-geoman-free";
import "@geoman-io/leaflet-geoman-free/dist/leaflet-geoman.css";
interface NoGoAreaLayerProps {
noGoAreas: Y.Array<Y.Map<unknown>>;
doc: Y.Doc;
enabled: boolean;
onToggle: () => void;
}
const NO_GO_STYLE: L.PathOptions = {
color: "#ef4444",
fillColor: "#ef4444",
fillOpacity: 0.2,
weight: 2,
};
function yMapToLatLngs(yMap: Y.Map<unknown>): L.LatLng[] {
const points = yMap.get("points") as Array<{ lat: number; lon: number }> | undefined;
if (!points) return [];
return points.map((p) => L.latLng(p.lat, p.lon));
}
export function NoGoAreaLayer({ noGoAreas, doc, enabled, onToggle }: NoGoAreaLayerProps) {
const map = useMap();
const layerGroupRef = useRef<L.LayerGroup>(L.layerGroup());
const polygonMapRef = useRef<Map<number, L.Polygon>>(new Map());
const suppressObserverRef = useRef(false);
// Sync Yjs → Leaflet layers
const syncLayers = useCallback(() => {
const lg = layerGroupRef.current;
// Clear all existing polygons
lg.clearLayers();
polygonMapRef.current.clear();
for (let i = 0; i < noGoAreas.length; i++) {
const yMap = noGoAreas.get(i);
const latLngs = yMapToLatLngs(yMap);
if (latLngs.length < 3) continue;
const polygon = L.polygon(latLngs, NO_GO_STYLE);
polygon.on("contextmenu", (e) => {
L.DomEvent.preventDefault(e as unknown as Event);
suppressObserverRef.current = true;
noGoAreas.delete(i, 1);
suppressObserverRef.current = false;
syncLayers();
});
lg.addLayer(polygon);
polygonMapRef.current.set(i, polygon);
}
}, [noGoAreas]);
// Add/remove layer group from map
useEffect(() => {
const lg = layerGroupRef.current;
lg.addTo(map);
return () => {
lg.removeFrom(map);
};
}, [map]);
// Observe Yjs changes
useEffect(() => {
const observer = () => {
if (!suppressObserverRef.current) {
syncLayers();
}
};
noGoAreas.observeDeep(observer);
syncLayers();
return () => noGoAreas.unobserveDeep(observer);
}, [noGoAreas, syncLayers]);
// Geoman drawing controls
useEffect(() => {
if (enabled) {
map.pm.enableDraw("Polygon", {
pathOptions: NO_GO_STYLE,
snappable: false,
});
} else {
map.pm.disableDraw();
}
const handleCreate = (e: { layer: L.Layer }) => {
const layer = e.layer as L.Polygon;
const latLngs = (layer.getLatLngs()[0] as L.LatLng[]);
const points = latLngs.map((ll) => ({ lat: ll.lat, lon: ll.lng }));
// Remove the geoman-created layer (we'll render from Yjs)
map.removeLayer(layer);
// Add to Yjs
const yMap = new Y.Map();
yMap.set("points", points);
doc.transact(() => {
noGoAreas.push([yMap]);
});
// Exit draw mode
onToggle();
};
map.on("pm:create", handleCreate);
return () => {
map.pm.disableDraw();
map.off("pm:create", handleCreate);
};
}, [map, enabled, noGoAreas, doc, onToggle]);
return null;
}

View file

@ -0,0 +1,66 @@
import { useEffect, useRef, useCallback } from "react";
import type { YjsState } from "~/lib/use-yjs";
interface NotesPanelProps {
yjs: YjsState;
}
export function NotesPanel({ yjs }: NotesPanelProps) {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const isLocalChange = useRef(false);
// Sync Y.Text → textarea
useEffect(() => {
const textarea = textareaRef.current;
if (!textarea) return;
// Set initial value
textarea.value = yjs.notes.toString();
const observer = () => {
if (isLocalChange.current) return;
const pos = textarea.selectionStart;
textarea.value = yjs.notes.toString();
textarea.selectionStart = pos;
textarea.selectionEnd = pos;
};
yjs.notes.observe(observer);
return () => yjs.notes.unobserve(observer);
}, [yjs.notes]);
// textarea → Y.Text
const handleInput = useCallback(
(e: React.FormEvent<HTMLTextAreaElement>) => {
const textarea = e.currentTarget;
const newValue = textarea.value;
const currentValue = yjs.notes.toString();
if (newValue === currentValue) return;
isLocalChange.current = true;
yjs.doc.transact(() => {
yjs.notes.delete(0, yjs.notes.length);
yjs.notes.insert(0, newValue);
});
isLocalChange.current = false;
},
[yjs.notes, yjs.doc],
);
return (
<div className="flex h-full flex-col">
<div className="border-b border-gray-200 px-4 py-3">
<h2 className="text-sm font-medium text-gray-900">Notes</h2>
</div>
<div className="flex-1 p-2">
<textarea
ref={textareaRef}
onInput={handleInput}
className="h-full w-full resize-none rounded border border-gray-200 p-3 text-sm text-gray-700 placeholder-gray-400 focus:border-blue-300 focus:outline-none"
placeholder="Add notes for this session..."
/>
</div>
</div>
);
}

View file

@ -4,6 +4,7 @@ import L from "leaflet";
import * as Y from "yjs";
import type { YjsState } from "~/lib/use-yjs";
import { baseLayers } from "@trails-cool/map";
import { NoGoAreaLayer } from "./NoGoAreaLayer";
import "leaflet/dist/leaflet.css";
function waypointIcon(index: number): L.DivIcon {
@ -126,9 +127,38 @@ function CursorTracker({ awareness }: { awareness: YjsState["awareness"] }) {
);
}
function NoGoAreaButton({ active, onClick }: { active: boolean; onClick: () => void }) {
return (
<div className="leaflet-top leaflet-left" style={{ marginTop: 80 }}>
<div className="leaflet-control leaflet-bar">
<a
href="#"
role="button"
title={active ? "Cancel no-go area" : "Draw no-go area"}
onClick={(e) => { e.preventDefault(); onClick(); }}
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 30,
height: 30,
fontSize: 16,
background: active ? "#fecaca" : "white",
color: active ? "#dc2626" : "#333",
}}
>
</a>
</div>
</div>
);
}
export function PlannerMap({ yjs, onRouteRequest, highlightPosition }: PlannerMapProps) {
const [waypoints, setWaypoints] = useState<WaypointData[]>([]);
const [routeGeoJson, setRouteGeoJson] = useState<L.LatLngExpression[] | null>(null);
const [noGoDrawing, setNoGoDrawing] = useState(false);
const toggleNoGoDraw = useCallback(() => setNoGoDrawing((v) => !v), []);
// Sync waypoints from Yjs
useEffect(() => {
@ -215,8 +245,10 @@ export function PlannerMap({ yjs, onRouteRequest, highlightPosition }: PlannerMa
))}
</LayersControl>
<MapClickHandler onAdd={addWaypoint} />
<MapClickHandler onAdd={noGoDrawing ? () => {} : addWaypoint} />
<CursorTracker awareness={yjs.awareness} />
<NoGoAreaLayer noGoAreas={yjs.noGoAreas} doc={yjs.doc} enabled={noGoDrawing} onToggle={toggleNoGoDraw} />
<NoGoAreaButton active={noGoDrawing} onClick={toggleNoGoDraw} />
{waypoints.map((wp, i) => (
<Marker

View file

@ -10,6 +10,7 @@ import { ExportButton } from "~/components/ExportButton";
import { SaveToJournalButton } from "~/components/SaveToJournalButton";
import { YjsDebugPanel } from "~/components/YjsDebugPanel";
import { ParticipantList } from "~/components/ParticipantList";
import { NotesPanel } from "~/components/NotesPanel";
const PlannerMap = lazy(() =>
import("~/components/PlannerMap").then((m) => ({ default: m.PlannerMap })),
@ -104,6 +105,39 @@ function useAwarenessToasts(yjs: YjsState | null, t: TFunction) {
return toasts;
}
function SidebarTabs({ yjs, routeStats }: { yjs: YjsState; routeStats: ReturnType<typeof useRouting>["routeStats"] }) {
const { t } = useTranslation("planner");
const [tab, setTab] = useState<"waypoints" | "notes">("waypoints");
return (
<aside className="hidden w-72 border-l border-gray-200 bg-white md:flex md:flex-col">
<div className="flex border-b border-gray-200">
<button
onClick={() => setTab("waypoints")}
className={`flex-1 px-3 py-2 text-xs font-medium ${tab === "waypoints" ? "border-b-2 border-blue-500 text-blue-600" : "text-gray-500 hover:text-gray-700"}`}
>
{t("sidebar.waypoints", "Waypoints")}
</button>
<button
onClick={() => setTab("notes")}
className={`flex-1 px-3 py-2 text-xs font-medium ${tab === "notes" ? "border-b-2 border-blue-500 text-blue-600" : "text-gray-500 hover:text-gray-700"}`}
>
{t("sidebar.notes", "Notes")}
</button>
</div>
<div className="flex-1 overflow-hidden">
{tab === "waypoints" ? (
<Suspense fallback={null}>
<WaypointSidebar yjs={yjs} routeStats={routeStats} />
</Suspense>
) : (
<NotesPanel yjs={yjs} />
)}
</div>
</aside>
);
}
interface SessionViewProps {
sessionId: string;
callbackUrl?: string;
@ -184,11 +218,7 @@ export function SessionView({ sessionId, callbackUrl, callbackToken, returnUrl,
<ElevationChart yjs={yjs} onHover={handleElevationHover} />
</Suspense>
</main>
<aside className="hidden w-72 border-l border-gray-200 bg-white md:block">
<Suspense fallback={null}>
<WaypointSidebar yjs={yjs} routeStats={routeStats} />
</Suspense>
</aside>
<SidebarTabs yjs={yjs} routeStats={routeStats} />
</div>
<YjsDebugPanel yjs={yjs} />
{toasts.length > 0 && (

View file

@ -1,10 +1,15 @@
const BROUTER_URL = process.env.BROUTER_URL ?? "http://localhost:17777";
export interface NoGoArea {
points: Array<{ lat: number; lon: number }>;
}
export interface RouteRequest {
waypoints: Array<{ lat: number; lon: number }>;
profile?: string;
alternativeIdx?: number;
format?: string;
noGoAreas?: NoGoArea[];
}
/**
@ -20,6 +25,11 @@ export async function computeRoute(request: RouteRequest): Promise<unknown> {
const profile = request.profile ?? "trekking";
const format = request.format ?? "geojson";
// Build no-go parameter if areas exist
const nogoParam = request.noGoAreas?.length
? noGoAreasToParam(request.noGoAreas)
: undefined;
// Route each segment independently
const segments = await Promise.all(
request.waypoints.slice(0, -1).map((wp, i) => {
@ -31,6 +41,7 @@ export async function computeRoute(request: RouteRequest): Promise<unknown> {
alternativeidx: "0",
format,
});
if (nogoParam) params.set("nogos", nogoParam);
return fetchSegment(`${BROUTER_URL}/brouter?${params}`);
}),
);
@ -104,6 +115,41 @@ function mergeGeoJsonSegments(segments: Record<string, unknown>[]): GeoJsonColle
};
}
/**
* Convert no-go area polygons to BRouter's `nogos` parameter format.
* BRouter accepts `lon,lat,radius` per no-go circle, separated by `|`.
* We approximate each polygon as a circle: centroid + max distance to any vertex.
*/
function noGoAreasToParam(areas: NoGoArea[]): string {
return areas
.map((area) => {
const pts = area.points;
if (pts.length === 0) return null;
// Centroid
const cLat = pts.reduce((s, p) => s + p.lat, 0) / pts.length;
const cLon = pts.reduce((s, p) => s + p.lon, 0) / pts.length;
// Max distance from centroid in meters (approximate)
const maxDist = Math.max(
...pts.map((p) => haversineMeters(cLat, cLon, p.lat, p.lon)),
);
return `${cLon},${cLat},${Math.ceil(maxDist)}`;
})
.filter(Boolean)
.join("|");
}
function haversineMeters(lat1: number, lon1: number, lat2: number, lon2: number): number {
const R = 6371000;
const dLat = ((lat2 - lat1) * Math.PI) / 180;
const dLon = ((lon2 - lon1) * Math.PI) / 180;
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos((lat1 * Math.PI) / 180) *
Math.cos((lat2 * Math.PI) / 180) *
Math.sin(dLon / 2) ** 2;
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
export function getBRouterUrl(): string {
return BROUTER_URL;
}

View file

@ -0,0 +1,56 @@
import { describe, it, expect } from "vitest";
import * as Y from "yjs";
/**
* Tests the crash recovery encode/decode logic used in use-yjs.ts.
* The actual hook wiring is tested via E2E; these verify the data round-trip.
*/
describe("crash recovery", () => {
it("round-trips Yjs state through base64 localStorage format", () => {
const doc = new Y.Doc();
const waypoints = doc.getArray("waypoints");
const wp = new Y.Map();
wp.set("lat", 52.52);
wp.set("lon", 13.405);
waypoints.push([wp]);
// Encode (same as use-yjs.ts save)
const state = Y.encodeStateAsUpdate(doc);
const b64 = btoa(String.fromCharCode(...state));
// Decode (same as use-yjs.ts restore)
const restored = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
const doc2 = new Y.Doc();
Y.applyUpdate(doc2, restored);
const restoredWaypoints = doc2.getArray("waypoints");
expect(restoredWaypoints.length).toBe(1);
const restoredWp = restoredWaypoints.get(0) as Y.Map<unknown>;
expect(restoredWp.get("lat")).toBe(52.52);
expect(restoredWp.get("lon")).toBe(13.405);
});
it("merges local and remote state without conflict", () => {
// Simulate: local has waypoint A, server has waypoint B
const local = new Y.Doc();
const localWps = local.getArray("waypoints");
const wpA = new Y.Map();
wpA.set("lat", 52.0);
wpA.set("lon", 13.0);
localWps.push([wpA]);
const remote = new Y.Doc();
const remoteWps = remote.getArray("waypoints");
const wpB = new Y.Map();
wpB.set("lat", 48.0);
wpB.set("lon", 11.0);
remoteWps.push([wpB]);
// Merge remote into local (simulates reconnect)
const remoteState = Y.encodeStateAsUpdate(remote);
Y.applyUpdate(local, remoteState);
// Both waypoints should exist
expect(localWps.length).toBe(2);
});
});

View file

@ -51,6 +51,11 @@ export function useRouting(yjs: YjsState | null) {
async (waypoints: WaypointData[]) => {
if (!yjs || !isHost || waypoints.length < 2) return;
// Collect no-go areas from Yjs
const noGoAreas = yjs.noGoAreas.toArray().map((yMap) => ({
points: (yMap.get("points") as Array<{ lat: number; lon: number }>) ?? [],
})).filter((a) => a.points.length >= 3);
setComputing(true);
try {
const response = await fetch("/api/route", {
@ -59,9 +64,14 @@ export function useRouting(yjs: YjsState | null) {
body: JSON.stringify({
waypoints,
profile: (yjs.routeData.get("profile") as string) ?? "trekking",
noGoAreas: noGoAreas.length > 0 ? noGoAreas : undefined,
}),
});
if (response.status === 429) {
console.warn("[Rate Limit] Route computation rate limit exceeded");
return;
}
if (!response.ok) return;
const geojson = await response.json();
@ -100,7 +110,7 @@ export function useRouting(yjs: YjsState | null) {
useEffect(() => {
if (!yjs || !isHost) return;
const onProfileChange = () => {
const triggerRecompute = () => {
const wps = getWaypointsFromYjs(yjs.waypoints);
if (wps.length >= 2) {
requestRoute(wps);
@ -108,14 +118,23 @@ export function useRouting(yjs: YjsState | null) {
};
// Observe routeData for profile changes
const observer = (event: Y.YMapEvent<unknown>) => {
const profileObserver = (event: Y.YMapEvent<unknown>) => {
if (event.keysChanged.has("profile")) {
onProfileChange();
triggerRecompute();
}
};
yjs.routeData.observe(observer);
return () => yjs.routeData.unobserve(observer);
// Observe noGoAreas for changes
const noGoObserver = () => {
triggerRecompute();
};
yjs.routeData.observe(profileObserver);
yjs.noGoAreas.observeDeep(noGoObserver);
return () => {
yjs.routeData.unobserve(profileObserver);
yjs.noGoAreas.unobserveDeep(noGoObserver);
};
}, [yjs, isHost, requestRoute]);
return { isHost, computing, routeStats, requestRoute };

View file

@ -32,6 +32,8 @@ export interface YjsState {
provider: WebsocketProvider;
waypoints: Y.Array<Y.Map<unknown>>;
routeData: Y.Map<unknown>;
noGoAreas: Y.Array<Y.Map<unknown>>;
notes: Y.Text;
awareness: WebsocketProvider["awareness"];
connected: boolean;
setUserName: (name: string) => void;
@ -57,6 +59,27 @@ export function useYjs(
const waypoints = doc.getArray<Y.Map<unknown>>("waypoints");
const routeData = doc.getMap("routeData");
const noGoAreas = doc.getArray<Y.Map<unknown>>("noGoAreas");
const notes = doc.getText("notes");
// --- Crash Recovery: restore state from localStorage ---
const storageKey = `trails:session:${sessionId}`;
try {
const saved = localStorage.getItem(storageKey);
if (saved) {
const update = Uint8Array.from(atob(saved), (c) => c.charCodeAt(0));
Y.applyUpdate(doc, update);
}
} catch { /* localStorage unavailable or corrupted */ }
// --- Crash Recovery: periodic save to localStorage ---
const saveInterval = setInterval(() => {
try {
const state = Y.encodeStateAsUpdate(doc);
const b64 = btoa(String.fromCharCode(...state));
localStorage.setItem(storageKey, b64);
} catch { /* localStorage unavailable */ }
}, 10_000);
const identity = getOrCreateUserIdentity();
identityRef.current = identity;
@ -94,6 +117,8 @@ export function useYjs(
provider,
waypoints,
routeData,
noGoAreas,
notes,
awareness: provider.awareness,
connected,
setUserName,
@ -107,6 +132,9 @@ export function useYjs(
updateState(false);
return () => {
clearInterval(saveInterval);
// Clear localStorage on clean disconnect (session close)
try { localStorage.removeItem(storageKey); } catch { /* ignore */ }
provider.destroy();
doc.destroy();
providerRef.current = null;

View file

@ -9,10 +9,11 @@ export async function action({ request }: Route.ActionArgs) {
}
const body = await request.json();
const { waypoints, profile, sessionId } = body as {
const { waypoints, profile, sessionId, noGoAreas } = body as {
waypoints: Array<{ lat: number; lon: number }>;
profile?: string;
sessionId?: string;
noGoAreas?: Array<{ points: Array<{ lat: number; lon: number }> }>;
};
if (!waypoints || waypoints.length < 2) {
@ -34,7 +35,7 @@ export async function action({ request }: Route.ActionArgs) {
}
try {
const route = await computeRoute({ waypoints, profile });
const route = await computeRoute({ waypoints, profile, noGoAreas });
return data(route, {
headers: { "X-RateLimit-Remaining": String(limit.remaining) },
});

View file

@ -1,10 +1,25 @@
import { redirect } from "react-router";
import { redirect, data } from "react-router";
import type { Route } from "./+types/new";
import { createSession, initializeSessionWithWaypoints } from "~/lib/sessions";
import { parseGpx } from "@trails-cool/gpx";
import { checkRateLimit } from "~/lib/rate-limit";
export async function loader({ request }: Route.LoaderArgs) {
const url = new URL(request.url);
// Rate limit session creation by IP
const ip = request.headers.get("x-forwarded-for") ?? "unknown";
const limit = checkRateLimit(`session-create:${ip}`, { maxRequests: 10 });
if (!limit.allowed) {
throw data(
{ error: "Too many sessions created. Please try again later." },
{
status: 429,
headers: { "Retry-After": String(limit.retryAfterSeconds) },
},
);
}
const callbackUrl = url.searchParams.get("callback");
const token = url.searchParams.get("token");
const returnUrl = url.searchParams.get("returnUrl");

View file

@ -11,6 +11,7 @@
"lint": "eslint ."
},
"dependencies": {
"@geoman-io/leaflet-geoman-free": "^2.19.2",
"@react-router/node": "catalog:",
"@react-router/serve": "catalog:",
"@sentry/node": "^10.45.0",