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

@ -94,4 +94,30 @@ test.describe("Integration: BRouter routing", () => {
});
expect(response.status()).toBe(400);
});
test("accepts no-go areas parameter", async ({ request }) => {
const response = await request.post(`${PLANNER}/api/route`, {
data: {
waypoints: [
{ lat: 52.516, lon: 13.377 },
{ lat: 52.515, lon: 13.351 },
],
profile: "trekking",
noGoAreas: [
{
points: [
{ lat: 52.516, lon: 13.365 },
{ lat: 52.514, lon: 13.365 },
{ lat: 52.514, lon: 13.370 },
{ lat: 52.516, lon: 13.370 },
],
},
],
},
});
expect(response.ok()).toBeTruthy();
const geojson = await response.json();
expect(geojson.features).toHaveLength(1);
expect(geojson.features[0].geometry.type).toBe("LineString");
});
});

View file

@ -62,6 +62,35 @@ test.describe("Planner", () => {
await expect(page.getByText("Click on the map to add waypoints")).toBeVisible();
});
test("session has sidebar tabs (waypoints and notes)", async ({ page, request }) => {
const response = await request.post("/api/sessions", { data: {} });
const { url } = await response.json();
await page.goto(url);
await expect(page.locator(".leaflet-container")).toBeVisible({ timeout: 10000 });
// Waypoints tab is active by default
await expect(page.getByRole("button", { name: "Waypoints" })).toBeVisible();
await expect(page.getByRole("button", { name: "Notes" })).toBeVisible();
// Switch to Notes tab
await page.getByRole("button", { name: "Notes" }).click();
await expect(page.getByPlaceholder("Add notes for this session...")).toBeVisible();
// Switch back to Waypoints tab
await page.getByRole("button", { name: "Waypoints" }).click();
await expect(page.getByText("Waypoints (0)")).toBeVisible();
});
test("session has no-go area button", async ({ page, request }) => {
const response = await request.post("/api/sessions", { data: {} });
const { url } = await response.json();
await page.goto(url);
await expect(page.locator(".leaflet-container")).toBeVisible({ timeout: 10000 });
await expect(page.getByTitle("Draw no-go area")).toBeVisible();
});
test("can create session with initial waypoints", async ({ request }) => {
const response = await request.post("/api/sessions", {
data: {