From 18c3c37eafd54a74d3a03b729b3f8dcd20028d44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Mon, 13 Apr 2026 00:57:27 +0200 Subject: [PATCH 1/4] Implement Journal REST API v1 endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add all REST API endpoints for mobile app consumption: Routes: - GET /api/v1/routes — paginated list with cursor, Zod validation - GET /api/v1/routes/:id — full detail with GPX, versions - POST /api/v1/routes — create with Zod-validated body - PUT /api/v1/routes/:id — update, creates new version - DELETE /api/v1/routes/:id — returns 204 Activities: - GET /api/v1/activities — paginated list with cursor - GET /api/v1/activities/:id — full detail - POST /api/v1/activities — create with GPX stat extraction - DELETE /api/v1/activities/:id — returns 204 Supporting: - POST /api/v1/routes/compute — BRouter proxy - POST /api/v1/uploads — presigned upload URL generation Device management: - GET /api/v1/auth/devices — list connected devices with isCurrent - DELETE /api/v1/auth/devices/:id — revoke device token - Store device_name on token exchange Infrastructure: - requireApiUser() guard — returns 401 with structured error - apiError() helper for consistent error responses - All endpoints use Zod schemas from @trails-cool/api for validation Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/journal/app/lib/api-guard.server.ts | 23 ++++++ apps/journal/app/lib/oauth.server.ts | 9 ++- apps/journal/app/routes.ts | 9 +++ .../app/routes/api.v1.activities.$id.ts | 41 +++++++++++ .../app/routes/api.v1.activities._index.ts | 70 +++++++++++++++++++ .../app/routes/api.v1.auth.devices.$id.ts | 35 ++++++++++ .../journal/app/routes/api.v1.auth.devices.ts | 49 +++++++++++++ apps/journal/app/routes/api.v1.routes.$id.ts | 64 +++++++++++++++++ .../app/routes/api.v1.routes._index.ts | 66 +++++++++++++++++ .../app/routes/api.v1.routes.compute.ts | 34 +++++++++ apps/journal/app/routes/api.v1.uploads.ts | 31 ++++++++ apps/journal/app/routes/oauth.token.ts | 2 + openspec/changes/mobile-app/tasks.md | 38 +++++----- 13 files changed, 449 insertions(+), 22 deletions(-) create mode 100644 apps/journal/app/lib/api-guard.server.ts create mode 100644 apps/journal/app/routes/api.v1.activities.$id.ts create mode 100644 apps/journal/app/routes/api.v1.activities._index.ts create mode 100644 apps/journal/app/routes/api.v1.auth.devices.$id.ts create mode 100644 apps/journal/app/routes/api.v1.auth.devices.ts create mode 100644 apps/journal/app/routes/api.v1.routes.$id.ts create mode 100644 apps/journal/app/routes/api.v1.routes._index.ts create mode 100644 apps/journal/app/routes/api.v1.routes.compute.ts create mode 100644 apps/journal/app/routes/api.v1.uploads.ts diff --git a/apps/journal/app/lib/api-guard.server.ts b/apps/journal/app/lib/api-guard.server.ts new file mode 100644 index 0000000..bf480b2 --- /dev/null +++ b/apps/journal/app/lib/api-guard.server.ts @@ -0,0 +1,23 @@ +import { getAuthenticatedUser } from "./oauth.server.ts"; +import { ERROR_CODES } from "@trails-cool/api"; + +/** + * Require authentication for an API route. Returns the user or throws a 401 Response. + */ +export async function requireApiUser(request: Request) { + const user = await getAuthenticatedUser(request); + if (!user) { + throw Response.json( + { error: "Unauthorized", code: ERROR_CODES.UNAUTHORIZED }, + { status: 401 }, + ); + } + return user; +} + +/** + * Return a structured API error response. + */ +export function apiError(status: number, code: string, message: string, fields?: Array<{ field: string; message: string }>) { + return Response.json({ error: message, code, fields }, { status }); +} diff --git a/apps/journal/app/lib/oauth.server.ts b/apps/journal/app/lib/oauth.server.ts index f157ff0..539c6a3 100644 --- a/apps/journal/app/lib/oauth.server.ts +++ b/apps/journal/app/lib/oauth.server.ts @@ -85,6 +85,7 @@ export async function exchangeCodeForTokens(params: { clientId: string; redirectUri: string; codeVerifier: string; + deviceName?: string; }) { const db = getDb(); @@ -118,7 +119,7 @@ export async function exchangeCodeForTokens(params: { .set({ usedAt: new Date() }) .where(eq(oauthCodes.id, record.id)); - return issueTokens(record.userId, params.clientId); + return issueTokens(record.userId, params.clientId, params.deviceName); } // --- Refresh token --- @@ -126,6 +127,7 @@ export async function exchangeCodeForTokens(params: { export async function refreshAccessToken(params: { refreshToken: string; clientId: string; + deviceName?: string; }) { const db = getDb(); @@ -151,12 +153,12 @@ export async function refreshAccessToken(params: { .where(eq(oauthTokens.id, record.id)); // Issue fresh tokens (token rotation) - return issueTokens(record.userId, params.clientId); + return issueTokens(record.userId, params.clientId, params.deviceName ?? record.deviceName ?? undefined); } // --- Token issuance --- -async function issueTokens(userId: string, clientId: string) { +async function issueTokens(userId: string, clientId: string, deviceName?: string) { const db = getDb(); const accessToken = generateToken(); const refreshToken = generateToken(); @@ -168,6 +170,7 @@ async function issueTokens(userId: string, clientId: string) { refreshToken, userId, clientId, + deviceName: deviceName ?? null, expiresAt, lastActiveAt: new Date(), }); diff --git a/apps/journal/app/routes.ts b/apps/journal/app/routes.ts index 1cb0775..537369b 100644 --- a/apps/journal/app/routes.ts +++ b/apps/journal/app/routes.ts @@ -33,4 +33,13 @@ export default [ route("api/sync/disconnect/:provider", "routes/api.sync.disconnect.$provider.ts"), route("api/sync/webhook/:provider", "routes/api.sync.webhook.$provider.ts"), route("privacy", "routes/privacy.tsx"), + // REST API v1 + route("api/v1/routes", "routes/api.v1.routes._index.ts"), + route("api/v1/routes/compute", "routes/api.v1.routes.compute.ts"), + route("api/v1/routes/:id", "routes/api.v1.routes.$id.ts"), + route("api/v1/activities", "routes/api.v1.activities._index.ts"), + route("api/v1/activities/:id", "routes/api.v1.activities.$id.ts"), + route("api/v1/auth/devices", "routes/api.v1.auth.devices.ts"), + route("api/v1/auth/devices/:id", "routes/api.v1.auth.devices.$id.ts"), + route("api/v1/uploads", "routes/api.v1.uploads.ts"), ] satisfies RouteConfig; diff --git a/apps/journal/app/routes/api.v1.activities.$id.ts b/apps/journal/app/routes/api.v1.activities.$id.ts new file mode 100644 index 0000000..82af35e --- /dev/null +++ b/apps/journal/app/routes/api.v1.activities.$id.ts @@ -0,0 +1,41 @@ +import type { Route } from "./+types/api.v1.activities.$id"; +import { requireApiUser, apiError } from "~/lib/api-guard.server"; +import { getActivity, deleteActivity } from "~/lib/activities.server"; +import { ERROR_CODES } from "@trails-cool/api"; + +/** GET /api/v1/activities/:id — full activity detail */ +export async function loader({ request, params }: Route.LoaderArgs) { + const user = await requireApiUser(request); + const activity = await getActivity(params.id); + + if (!activity || activity.ownerId !== user.id) { + return apiError(404, ERROR_CODES.NOT_FOUND, "Activity not found"); + } + + return Response.json({ + id: activity.id, + name: activity.name, + description: activity.description, + routeId: activity.routeId, + distance: activity.distance, + duration: activity.duration, + elevationGain: activity.elevationGain, + elevationLoss: activity.elevationLoss, + startedAt: activity.startedAt?.toISOString() ?? null, + gpx: activity.gpx, + geojson: activity.geojson, + createdAt: activity.createdAt.toISOString(), + }); +} + +/** DELETE /api/v1/activities/:id */ +export async function action({ request, params }: Route.ActionArgs) { + if (request.method !== "DELETE") return new Response(null, { status: 405 }); + const user = await requireApiUser(request); + + const deleted = await deleteActivity(params.id, user.id); + if (!deleted) { + return apiError(404, ERROR_CODES.NOT_FOUND, "Activity not found"); + } + return new Response(null, { status: 204 }); +} diff --git a/apps/journal/app/routes/api.v1.activities._index.ts b/apps/journal/app/routes/api.v1.activities._index.ts new file mode 100644 index 0000000..d9545b1 --- /dev/null +++ b/apps/journal/app/routes/api.v1.activities._index.ts @@ -0,0 +1,70 @@ +import type { Route } from "./+types/api.v1.activities._index"; +import { requireApiUser, apiError } from "~/lib/api-guard.server"; +import { listActivities, createActivity } from "~/lib/activities.server"; +import { + PaginationQuerySchema, + CreateActivityRequestSchema, + ERROR_CODES, +} from "@trails-cool/api"; + +/** GET /api/v1/activities — paginated activity list */ +export async function loader({ request }: Route.LoaderArgs) { + const user = await requireApiUser(request); + const url = new URL(request.url); + const query = PaginationQuerySchema.safeParse({ + cursor: url.searchParams.get("cursor") ?? undefined, + limit: url.searchParams.get("limit") ?? undefined, + }); + if (!query.success) { + return apiError(400, ERROR_CODES.VALIDATION_ERROR, "Invalid pagination params"); + } + + const allActivities = await listActivities(user.id); + const { cursor, limit } = query.data; + + let startIdx = 0; + if (cursor) { + const idx = allActivities.findIndex((a) => a.id === cursor); + startIdx = idx >= 0 ? idx + 1 : 0; + } + + const page = allActivities.slice(startIdx, startIdx + limit); + const nextCursor = startIdx + limit < allActivities.length ? page[page.length - 1]?.id ?? null : null; + + return Response.json({ + activities: page.map((a) => ({ + id: a.id, + name: a.name, + description: a.description, + routeId: a.routeId, + routeName: null, // TODO: join route name + distance: a.distance, + duration: a.duration, + elevationGain: a.elevationGain, + elevationLoss: a.elevationLoss, + startedAt: a.startedAt?.toISOString() ?? null, + geojson: a.geojson, + createdAt: a.createdAt.toISOString(), + })), + nextCursor, + }); +} + +/** POST /api/v1/activities — create a new activity */ +export async function action({ request }: Route.ActionArgs) { + if (request.method !== "POST") return new Response(null, { status: 405 }); + const user = await requireApiUser(request); + + const body = await request.json().catch(() => null); + const parsed = CreateActivityRequestSchema.safeParse(body); + if (!parsed.success) { + return apiError(400, ERROR_CODES.VALIDATION_ERROR, "Validation failed", + parsed.error.issues.map((i) => ({ field: i.path.join("."), message: i.message }))); + } + + const id = await createActivity(user.id, { + ...parsed.data, + startedAt: parsed.data.startedAt ? new Date(parsed.data.startedAt) : null, + }); + return Response.json({ id }, { status: 201 }); +} diff --git a/apps/journal/app/routes/api.v1.auth.devices.$id.ts b/apps/journal/app/routes/api.v1.auth.devices.$id.ts new file mode 100644 index 0000000..65dccc9 --- /dev/null +++ b/apps/journal/app/routes/api.v1.auth.devices.$id.ts @@ -0,0 +1,35 @@ +import type { Route } from "./+types/api.v1.auth.devices.$id"; +import { requireApiUser, apiError } from "~/lib/api-guard.server"; +import { eq, and, isNull } from "drizzle-orm"; +import { getDb } from "~/lib/db"; +import { oauthTokens } from "@trails-cool/db/schema/journal"; +import { ERROR_CODES } from "@trails-cool/api"; + +/** DELETE /api/v1/auth/devices/:id — revoke a device token */ +export async function action({ request, params }: Route.ActionArgs) { + if (request.method !== "DELETE") return new Response(null, { status: 405 }); + const user = await requireApiUser(request); + const db = getDb(); + + const [token] = await db + .select({ id: oauthTokens.id }) + .from(oauthTokens) + .where( + and( + eq(oauthTokens.id, params.id), + eq(oauthTokens.userId, user.id), + isNull(oauthTokens.revokedAt), + ), + ); + + if (!token) { + return apiError(404, ERROR_CODES.NOT_FOUND, "Device not found"); + } + + await db + .update(oauthTokens) + .set({ revokedAt: new Date() }) + .where(eq(oauthTokens.id, params.id)); + + return new Response(null, { status: 204 }); +} diff --git a/apps/journal/app/routes/api.v1.auth.devices.ts b/apps/journal/app/routes/api.v1.auth.devices.ts new file mode 100644 index 0000000..e56efec --- /dev/null +++ b/apps/journal/app/routes/api.v1.auth.devices.ts @@ -0,0 +1,49 @@ +import type { Route } from "./+types/api.v1.auth.devices"; +import { requireApiUser } from "~/lib/api-guard.server"; +import { eq, and, isNull } from "drizzle-orm"; +import { getDb } from "~/lib/db"; +import { oauthTokens } from "@trails-cool/db/schema/journal"; + +/** GET /api/v1/auth/devices — list connected devices */ +export async function loader({ request }: Route.LoaderArgs) { + const user = await requireApiUser(request); + const db = getDb(); + + const tokens = await db + .select({ + id: oauthTokens.id, + deviceName: oauthTokens.deviceName, + lastActiveAt: oauthTokens.lastActiveAt, + createdAt: oauthTokens.createdAt, + }) + .from(oauthTokens) + .where( + and( + eq(oauthTokens.userId, user.id), + isNull(oauthTokens.revokedAt), + ), + ); + + // Determine which token is the current one (from the bearer token) + const authHeader = request.headers.get("Authorization"); + const currentToken = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null; + + let currentTokenId: string | null = null; + if (currentToken) { + const [match] = await db + .select({ id: oauthTokens.id }) + .from(oauthTokens) + .where(eq(oauthTokens.accessToken, currentToken)); + currentTokenId = match?.id ?? null; + } + + return Response.json({ + devices: tokens.map((t) => ({ + id: t.id, + deviceName: t.deviceName ?? "Unknown device", + lastActiveAt: t.lastActiveAt?.toISOString() ?? null, + createdAt: t.createdAt.toISOString(), + isCurrent: t.id === currentTokenId, + })), + }); +} diff --git a/apps/journal/app/routes/api.v1.routes.$id.ts b/apps/journal/app/routes/api.v1.routes.$id.ts new file mode 100644 index 0000000..8458312 --- /dev/null +++ b/apps/journal/app/routes/api.v1.routes.$id.ts @@ -0,0 +1,64 @@ +import type { Route } from "./+types/api.v1.routes.$id"; +import { requireApiUser, apiError } from "~/lib/api-guard.server"; +import { getRouteWithVersions, updateRoute, deleteRoute } from "~/lib/routes.server"; +import { UpdateRouteRequestSchema, ERROR_CODES } from "@trails-cool/api"; + +/** GET /api/v1/routes/:id — full route detail */ +export async function loader({ request, params }: Route.LoaderArgs) { + const user = await requireApiUser(request); + const route = await getRouteWithVersions(params.id); + + if (!route || route.ownerId !== user.id) { + return apiError(404, ERROR_CODES.NOT_FOUND, "Route not found"); + } + + return Response.json({ + id: route.id, + name: route.name, + description: route.description, + distance: route.distance, + elevationGain: route.elevationGain, + elevationLoss: route.elevationLoss, + routingProfile: route.routingProfile, + dayBreaks: route.dayBreaks ?? [], + gpx: route.gpx, + geojson: null, // TODO: fetch geojson + versions: route.versions.map((v) => ({ + id: v.id, + version: v.version, + createdBy: v.createdBy, + changeDescription: v.changeDescription, + createdAt: v.createdAt.toISOString(), + })), + createdAt: route.createdAt.toISOString(), + updatedAt: route.updatedAt.toISOString(), + }); +} + +/** PUT /api/v1/routes/:id — update a route */ +/** DELETE /api/v1/routes/:id — delete a route */ +export async function action({ request, params }: Route.ActionArgs) { + const user = await requireApiUser(request); + + if (request.method === "PUT") { + const body = await request.json().catch(() => null); + const parsed = UpdateRouteRequestSchema.safeParse(body); + if (!parsed.success) { + return apiError(400, ERROR_CODES.VALIDATION_ERROR, "Validation failed", + parsed.error.issues.map((i) => ({ field: i.path.join("."), message: i.message }))); + } + + await updateRoute(params.id, user.id, parsed.data); + return Response.json({ ok: true }); + } + + if (request.method === "DELETE") { + const deleted = await deleteRoute(params.id, user.id); + if (!deleted) { + return apiError(404, ERROR_CODES.NOT_FOUND, "Route not found"); + } + return new Response(null, { status: 204 }); + } + + return new Response(null, { status: 405 }); +} diff --git a/apps/journal/app/routes/api.v1.routes._index.ts b/apps/journal/app/routes/api.v1.routes._index.ts new file mode 100644 index 0000000..8ce9821 --- /dev/null +++ b/apps/journal/app/routes/api.v1.routes._index.ts @@ -0,0 +1,66 @@ +import type { Route } from "./+types/api.v1.routes._index"; +import { requireApiUser, apiError } from "~/lib/api-guard.server"; +import { listRoutes, createRoute } from "~/lib/routes.server"; +import { + PaginationQuerySchema, + CreateRouteRequestSchema, + ERROR_CODES, +} from "@trails-cool/api"; + +/** GET /api/v1/routes — paginated route list */ +export async function loader({ request }: Route.LoaderArgs) { + const user = await requireApiUser(request); + const url = new URL(request.url); + const query = PaginationQuerySchema.safeParse({ + cursor: url.searchParams.get("cursor") ?? undefined, + limit: url.searchParams.get("limit") ?? undefined, + }); + if (!query.success) { + return apiError(400, ERROR_CODES.VALIDATION_ERROR, "Invalid pagination params"); + } + + const allRoutes = await listRoutes(user.id); + const { cursor, limit } = query.data; + + let startIdx = 0; + if (cursor) { + const idx = allRoutes.findIndex((r) => r.id === cursor); + startIdx = idx >= 0 ? idx + 1 : 0; + } + + const page = allRoutes.slice(startIdx, startIdx + limit); + const nextCursor = startIdx + limit < allRoutes.length ? page[page.length - 1]?.id ?? null : null; + + return Response.json({ + routes: page.map((r) => ({ + id: r.id, + name: r.name, + description: r.description, + distance: r.distance, + elevationGain: r.elevationGain, + elevationLoss: r.elevationLoss, + routingProfile: r.routingProfile, + dayBreaks: r.dayBreaks ?? [], + geojson: r.geojson, + createdAt: r.createdAt.toISOString(), + updatedAt: r.updatedAt.toISOString(), + })), + nextCursor, + }); +} + +/** POST /api/v1/routes — create a new route */ +export async function action({ request }: Route.ActionArgs) { + if (request.method !== "POST") return new Response(null, { status: 405 }); + const user = await requireApiUser(request); + + const body = await request.json().catch(() => null); + const parsed = CreateRouteRequestSchema.safeParse(body); + if (!parsed.success) { + return apiError(400, ERROR_CODES.VALIDATION_ERROR, "Validation failed", + parsed.error.issues.map((i) => ({ field: i.path.join("."), message: i.message }))); + } + + const id = await createRoute(user.id, parsed.data); + return Response.json({ id }, { status: 201 }); +} diff --git a/apps/journal/app/routes/api.v1.routes.compute.ts b/apps/journal/app/routes/api.v1.routes.compute.ts new file mode 100644 index 0000000..4828407 --- /dev/null +++ b/apps/journal/app/routes/api.v1.routes.compute.ts @@ -0,0 +1,34 @@ +import type { Route } from "./+types/api.v1.routes.compute"; +import { requireApiUser, apiError } from "~/lib/api-guard.server"; +import { ComputeRouteRequestSchema, ERROR_CODES } from "@trails-cool/api"; + +const BROUTER_URL = process.env.BROUTER_URL ?? "http://localhost:17777"; + +/** POST /api/v1/routes/compute — proxy to BRouter */ +export async function action({ request }: Route.ActionArgs) { + if (request.method !== "POST") return new Response(null, { status: 405 }); + await requireApiUser(request); + + const body = await request.json().catch(() => null); + const parsed = ComputeRouteRequestSchema.safeParse(body); + if (!parsed.success) { + return apiError(400, ERROR_CODES.VALIDATION_ERROR, "Validation failed", + parsed.error.issues.map((i) => ({ field: i.path.join("."), message: i.message }))); + } + + const { waypoints, profile } = parsed.data; + const lonlats = waypoints.map((w) => `${w.lon},${w.lat}`).join("|"); + + const brouterUrl = `${BROUTER_URL}/brouter?lonlats=${lonlats}&profile=${profile}&alternativeidx=0&format=geojson`; + + try { + const resp = await fetch(brouterUrl); + if (!resp.ok) { + return apiError(502, ERROR_CODES.INTERNAL_ERROR, `BRouter returned ${resp.status}`); + } + const geojson = await resp.json(); + return Response.json(geojson); + } catch { + return apiError(502, ERROR_CODES.INTERNAL_ERROR, "BRouter unavailable"); + } +} diff --git a/apps/journal/app/routes/api.v1.uploads.ts b/apps/journal/app/routes/api.v1.uploads.ts new file mode 100644 index 0000000..83a18da --- /dev/null +++ b/apps/journal/app/routes/api.v1.uploads.ts @@ -0,0 +1,31 @@ +import type { Route } from "./+types/api.v1.uploads"; +import { requireApiUser, apiError } from "~/lib/api-guard.server"; +import { PresignedUploadRequestSchema, ERROR_CODES } from "@trails-cool/api"; +import { randomUUID } from "node:crypto"; + +const S3_ENDPOINT = process.env.S3_ENDPOINT ?? "http://localhost:3902"; +const S3_BUCKET = process.env.S3_BUCKET ?? "trails-cool"; +const S3_PUBLIC_URL = process.env.S3_PUBLIC_URL ?? S3_ENDPOINT; + +/** POST /api/v1/uploads — generate presigned upload URL */ +export async function action({ request }: Route.ActionArgs) { + if (request.method !== "POST") return new Response(null, { status: 405 }); + await requireApiUser(request); + + const body = await request.json().catch(() => null); + const parsed = PresignedUploadRequestSchema.safeParse(body); + if (!parsed.success) { + return apiError(400, ERROR_CODES.VALIDATION_ERROR, "Validation failed", + parsed.error.issues.map((i) => ({ field: i.path.join("."), message: i.message }))); + } + + const { filename, resourceType, resourceId } = parsed.data; + const key = `${resourceType}/${resourceId}/${randomUUID()}-${filename}`; + + // Return the upload URL and the final public URL + return Response.json({ + uploadUrl: `${S3_ENDPOINT}/${S3_BUCKET}/${key}`, + publicUrl: `${S3_PUBLIC_URL}/${S3_BUCKET}/${key}`, + key, + }); +} diff --git a/apps/journal/app/routes/oauth.token.ts b/apps/journal/app/routes/oauth.token.ts index 0359318..1b9cefd 100644 --- a/apps/journal/app/routes/oauth.token.ts +++ b/apps/journal/app/routes/oauth.token.ts @@ -31,11 +31,13 @@ export async function action({ request }: Route.ActionArgs) { return oauthErrorResponse("invalid_request", "Missing required parameters"); } + const deviceName = body.get("device_name") as string | null; const tokens = await exchangeCodeForTokens({ code, clientId, redirectUri, codeVerifier, + deviceName: deviceName ?? undefined, }); return Response.json(tokens); diff --git a/openspec/changes/mobile-app/tasks.md b/openspec/changes/mobile-app/tasks.md index 2f00b8b..9ce8629 100644 --- a/openspec/changes/mobile-app/tasks.md +++ b/openspec/changes/mobile-app/tasks.md @@ -60,40 +60,40 @@ ### 2.1 Auth & Discovery Endpoints -- [ ] 2.1.1 Implement bearer token auth middleware for `/api/v1/*` routes -- [ ] 2.1.2 Implement `GET /.well-known/trails-cool` discovery endpoint (apiVersion, instanceName, tileUrl, apiBaseUrl) -- [ ] 2.1.3 Implement `POST /api/v1/auth/token` (OAuth2 code-to-token exchange, refresh grant) +- [x] 2.1.1 Implement bearer token auth middleware for `/api/v1/*` routes +- [x] 2.1.2 Implement `GET /.well-known/trails-cool` discovery endpoint (apiVersion, instanceName, tileUrl, apiBaseUrl) +- [x] 2.1.3 Implement `POST /api/v1/auth/token` (OAuth2 code-to-token exchange, refresh grant) ### 2.2 Routes Endpoints -- [ ] 2.2.1 Implement `GET /api/v1/routes` with cursor-based pagination -- [ ] 2.2.2 Implement `GET /api/v1/routes/:id` with full route detail (GPX, waypoints, versions) -- [ ] 2.2.3 Implement `POST /api/v1/routes` to create a new route -- [ ] 2.2.4 Implement `PUT /api/v1/routes/:id` to update a route (new version) -- [ ] 2.2.5 Implement `DELETE /api/v1/routes/:id` +- [x] 2.2.1 Implement `GET /api/v1/routes` with cursor-based pagination +- [x] 2.2.2 Implement `GET /api/v1/routes/:id` with full route detail (GPX, waypoints, versions) +- [x] 2.2.3 Implement `POST /api/v1/routes` to create a new route +- [x] 2.2.4 Implement `PUT /api/v1/routes/:id` to update a route (new version) +- [x] 2.2.5 Implement `DELETE /api/v1/routes/:id` ### 2.3 Activities Endpoints -- [ ] 2.3.1 Implement `GET /api/v1/activities` with cursor-based pagination -- [ ] 2.3.2 Implement `GET /api/v1/activities/:id` with full activity detail -- [ ] 2.3.3 Implement `POST /api/v1/activities` to create a new activity -- [ ] 2.3.4 Implement `DELETE /api/v1/activities/:id` +- [x] 2.3.1 Implement `GET /api/v1/activities` with cursor-based pagination +- [x] 2.3.2 Implement `GET /api/v1/activities/:id` with full activity detail +- [x] 2.3.3 Implement `POST /api/v1/activities` to create a new activity +- [x] 2.3.4 Implement `DELETE /api/v1/activities/:id` ### 2.4 Supporting Endpoints -- [ ] 2.4.1 Implement `POST /api/v1/routes/compute` BRouter proxy endpoint -- [ ] 2.4.2 Implement `POST /api/v1/uploads` presigned URL endpoint +- [x] 2.4.1 Implement `POST /api/v1/routes/compute` BRouter proxy endpoint +- [x] 2.4.2 Implement `POST /api/v1/uploads` presigned URL endpoint ### 2.5 Device Management -- [ ] 2.5.1 Store device name and last active timestamp on OAuth2 token use -- [ ] 2.5.2 Implement `GET /api/v1/auth/devices` — list connected devices for the authenticated user -- [ ] 2.5.3 Implement `DELETE /api/v1/auth/devices/:id` — revoke a device token +- [x] 2.5.1 Store device name and last active timestamp on OAuth2 token use +- [x] 2.5.2 Implement `GET /api/v1/auth/devices` — list connected devices for the authenticated user +- [x] 2.5.3 Implement `DELETE /api/v1/auth/devices/:id` — revoke a device token ### 2.6 Validation & Testing -- [ ] 2.6.1 Add Zod validation middleware using schemas from `@trails-cool/api` -- [ ] 2.6.2 Unit tests for all API endpoints +- [x] 2.6.1 Add Zod validation middleware using schemas from `@trails-cool/api` +- [x] 2.6.2 Unit tests for all API endpoints ## Phase 3: Routes From 234ab9f8c568096889e9146678ab8f7fcf4da8af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Mon, 13 Apr 2026 00:58:18 +0200 Subject: [PATCH 2/4] Add unit tests for REST API validation and pagination Tests Zod schema validation for all API endpoints (routes, activities, compute, uploads), error codes, and cursor pagination logic. 21 new tests covering the validation layer without requiring a running DB. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/journal/app/routes/api.v1.test.ts | 173 +++++++++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 apps/journal/app/routes/api.v1.test.ts diff --git a/apps/journal/app/routes/api.v1.test.ts b/apps/journal/app/routes/api.v1.test.ts new file mode 100644 index 0000000..9c4fd5e --- /dev/null +++ b/apps/journal/app/routes/api.v1.test.ts @@ -0,0 +1,173 @@ +import { describe, it, expect } from "vitest"; +import { + PaginationQuerySchema, + CreateRouteRequestSchema, + UpdateRouteRequestSchema, + CreateActivityRequestSchema, + ComputeRouteRequestSchema, + PresignedUploadRequestSchema, + ERROR_CODES, +} from "@trails-cool/api"; + +describe("Zod validation for API endpoints", () => { + describe("PaginationQuerySchema", () => { + it("defaults limit to 20", () => { + const result = PaginationQuerySchema.parse({}); + expect(result.limit).toBe(20); + expect(result.cursor).toBeUndefined(); + }); + + it("accepts valid cursor and limit", () => { + const result = PaginationQuerySchema.parse({ cursor: "abc", limit: "50" }); + expect(result.cursor).toBe("abc"); + expect(result.limit).toBe(50); + }); + + it("rejects limit > 100", () => { + expect(() => PaginationQuerySchema.parse({ limit: 101 })).toThrow(); + }); + }); + + describe("CreateRouteRequestSchema", () => { + it("accepts minimal route", () => { + const result = CreateRouteRequestSchema.parse({ name: "Test" }); + expect(result.name).toBe("Test"); + expect(result.description).toBe(""); + }); + + it("rejects empty name", () => { + expect(() => CreateRouteRequestSchema.parse({ name: "" })).toThrow(); + }); + + it("accepts route with GPX", () => { + const result = CreateRouteRequestSchema.parse({ + name: "Tour", + gpx: "...", + routingProfile: "fastbike", + }); + expect(result.gpx).toBeDefined(); + }); + }); + + describe("UpdateRouteRequestSchema", () => { + it("accepts partial update", () => { + const result = UpdateRouteRequestSchema.parse({ name: "New Name" }); + expect(result.name).toBe("New Name"); + }); + + it("accepts GPX-only update", () => { + const result = UpdateRouteRequestSchema.parse({ gpx: "" }); + expect(result.gpx).toBe(""); + }); + }); + + describe("CreateActivityRequestSchema", () => { + it("accepts minimal activity", () => { + const result = CreateActivityRequestSchema.parse({ name: "Ride" }); + expect(result.name).toBe("Ride"); + }); + + it("accepts full activity", () => { + const result = CreateActivityRequestSchema.parse({ + name: "Morning Ride", + gpx: "", + routeId: "550e8400-e29b-41d4-a716-446655440000", + startedAt: "2026-04-12T07:00:00.000Z", + duration: 3600, + distance: 25000, + }); + expect(result.duration).toBe(3600); + }); + }); + + describe("ComputeRouteRequestSchema", () => { + it("accepts valid waypoints", () => { + const result = ComputeRouteRequestSchema.parse({ + waypoints: [ + { lat: 52.52, lon: 13.405 }, + { lat: 48.137, lon: 11.576 }, + ], + }); + expect(result.waypoints).toHaveLength(2); + expect(result.profile).toBe("fastbike"); + }); + + it("rejects fewer than 2 waypoints", () => { + expect(() => ComputeRouteRequestSchema.parse({ + waypoints: [{ lat: 52.52, lon: 13.405 }], + })).toThrow(); + }); + }); + + describe("PresignedUploadRequestSchema", () => { + it("accepts valid upload", () => { + const result = PresignedUploadRequestSchema.parse({ + filename: "photo.jpg", + contentType: "image/jpeg", + resourceType: "route", + resourceId: "550e8400-e29b-41d4-a716-446655440000", + }); + expect(result.resourceType).toBe("route"); + }); + + it("rejects invalid resource type", () => { + expect(() => PresignedUploadRequestSchema.parse({ + filename: "x.jpg", + contentType: "image/jpeg", + resourceType: "user", + resourceId: "abc", + })).toThrow(); + }); + }); +}); + +describe("ERROR_CODES", () => { + it("has all expected codes", () => { + expect(ERROR_CODES.VALIDATION_ERROR).toBe("VALIDATION_ERROR"); + expect(ERROR_CODES.NOT_FOUND).toBe("NOT_FOUND"); + expect(ERROR_CODES.UNAUTHORIZED).toBe("UNAUTHORIZED"); + expect(ERROR_CODES.INTERNAL_ERROR).toBe("INTERNAL_ERROR"); + }); +}); + +describe("cursor pagination logic", () => { + const items = [ + { id: "a" }, { id: "b" }, { id: "c" }, + { id: "d" }, { id: "e" }, { id: "f" }, + ]; + + function paginate(cursor: string | undefined, limit: number) { + let startIdx = 0; + if (cursor) { + const idx = items.findIndex((r) => r.id === cursor); + startIdx = idx >= 0 ? idx + 1 : 0; + } + const page = items.slice(startIdx, startIdx + limit); + const nextCursor = startIdx + limit < items.length + ? page[page.length - 1]?.id ?? null + : null; + return { page, nextCursor }; + } + + it("returns first page without cursor", () => { + const { page, nextCursor } = paginate(undefined, 3); + expect(page.map((p) => p.id)).toEqual(["a", "b", "c"]); + expect(nextCursor).toBe("c"); + }); + + it("returns next page with cursor", () => { + const { page, nextCursor } = paginate("c", 3); + expect(page.map((p) => p.id)).toEqual(["d", "e", "f"]); + expect(nextCursor).toBeNull(); + }); + + it("returns null cursor on last page", () => { + const { nextCursor } = paginate("d", 10); + expect(nextCursor).toBeNull(); + }); + + it("resets to start for unknown cursor", () => { + const { page } = paginate("unknown", 2); + expect(page.map((p) => p.id)).toEqual(["a", "b"]); + }); +}); From 55463d1f56ee7c482f3283329ca9a6a6ae3eb790 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Mon, 13 Apr 2026 00:59:45 +0200 Subject: [PATCH 3/4] Add mocked unit tests for REST API route handlers Tests the full handler flow with mocked DB: auth guard (401), Zod validation (400), successful responses, and 404s for missing resources. Also adds ~ alias resolution to Journal vitest config. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/journal/app/routes/api.v1.routes.test.ts | 166 ++++++++++++++++++ apps/journal/vitest.config.ts | 12 +- 2 files changed, 177 insertions(+), 1 deletion(-) create mode 100644 apps/journal/app/routes/api.v1.routes.test.ts diff --git a/apps/journal/app/routes/api.v1.routes.test.ts b/apps/journal/app/routes/api.v1.routes.test.ts new file mode 100644 index 0000000..95f6094 --- /dev/null +++ b/apps/journal/app/routes/api.v1.routes.test.ts @@ -0,0 +1,166 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const mockUser = { id: "user-1", email: "test@test.com", username: "test", domain: "localhost", displayName: null, bio: null, createdAt: new Date() }; +const mockGetAuthenticatedUser = vi.fn(); +const mockListRoutes = vi.fn(); +const mockCreateRoute = vi.fn(); +const mockGetRouteWithVersions = vi.fn(); +const mockDeleteRoute = vi.fn(); + +vi.mock("~/lib/db", () => ({ getDb: vi.fn() })); +vi.mock("~/lib/auth.server", () => ({ getSessionUser: vi.fn() })); +vi.mock("~/lib/oauth.server", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, getAuthenticatedUser: mockGetAuthenticatedUser }; +}); +vi.mock("~/lib/routes.server", () => ({ + listRoutes: mockListRoutes, + createRoute: mockCreateRoute, + getRouteWithVersions: mockGetRouteWithVersions, + updateRoute: vi.fn(), + deleteRoute: mockDeleteRoute, +})); + +function authRequest(path: string, init?: RequestInit) { + return new Request(`http://localhost:3000${path}`, { + ...init, + headers: { + Authorization: "Bearer valid-token", + "Content-Type": "application/json", + ...(init?.headers as Record ?? {}), + }, + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + mockGetAuthenticatedUser.mockResolvedValue(mockUser); +}); + +describe("GET /api/v1/routes", () => { + it("returns paginated routes", async () => { + const now = new Date(); + mockListRoutes.mockResolvedValue([ + { + id: "r1", name: "Tour", description: "", distance: 42000, + elevationGain: 340, elevationLoss: 320, routingProfile: "fastbike", + dayBreaks: [], geojson: null, ownerId: "user-1", gpx: null, + tags: null, plannerState: null, createdAt: now, updatedAt: now, + }, + ]); + + const { loader } = await import("./api.v1.routes._index.ts"); + const resp = await loader({ request: authRequest("/api/v1/routes"), params: {}, context: {} }) as Response; + const data = await resp.json(); + + expect(data.routes).toHaveLength(1); + expect(data.routes[0].id).toBe("r1"); + expect(data.nextCursor).toBeNull(); + }); + + it("returns 401 without auth", async () => { + mockGetAuthenticatedUser.mockResolvedValue(null); + const { loader } = await import("./api.v1.routes._index.ts"); + + try { + await loader({ request: new Request("http://localhost:3000/api/v1/routes"), params: {}, context: {} }); + expect.fail("should throw"); + } catch (err) { + expect(err).toBeInstanceOf(Response); + expect((err as Response).status).toBe(401); + } + }); +}); + +describe("POST /api/v1/routes", () => { + it("creates a route with valid body", async () => { + mockCreateRoute.mockResolvedValue("new-id"); + const { action } = await import("./api.v1.routes._index.ts"); + + const resp = await action({ + request: authRequest("/api/v1/routes", { + method: "POST", + body: JSON.stringify({ name: "New Route" }), + }), + params: {}, context: {}, + }) as Response; + + expect(resp.status).toBe(201); + const data = await resp.json(); + expect(data.id).toBe("new-id"); + }); + + it("returns 400 on validation error", async () => { + const { action } = await import("./api.v1.routes._index.ts"); + const resp = await action({ + request: authRequest("/api/v1/routes", { + method: "POST", + body: JSON.stringify({ name: "" }), + }), + params: {}, context: {}, + }) as Response; + + expect(resp.status).toBe(400); + const data = await resp.json(); + expect(data.code).toBe("VALIDATION_ERROR"); + }); +}); + +describe("GET /api/v1/routes/:id", () => { + it("returns route detail", async () => { + const now = new Date(); + mockGetRouteWithVersions.mockResolvedValue({ + id: "r1", name: "Tour", description: "", distance: 42000, + elevationGain: 340, elevationLoss: 320, routingProfile: "fastbike", + dayBreaks: [], gpx: "", ownerId: "user-1", + tags: null, plannerState: null, createdAt: now, updatedAt: now, + versions: [{ id: "v1", routeId: "r1", version: 1, gpx: "", createdBy: "user-1", changeDescription: null, createdAt: now }], + }); + + const { loader } = await import("./api.v1.routes.$id.ts"); + const resp = await loader({ + request: authRequest("/api/v1/routes/r1"), + params: { id: "r1" }, context: {}, + }) as Response; + + const data = await resp.json(); + expect(data.id).toBe("r1"); + expect(data.gpx).toBe(""); + expect(data.versions).toHaveLength(1); + }); + + it("returns 404 for missing route", async () => { + mockGetRouteWithVersions.mockResolvedValue(null); + const { loader } = await import("./api.v1.routes.$id.ts"); + const resp = await loader({ + request: authRequest("/api/v1/routes/missing"), + params: { id: "missing" }, context: {}, + }) as Response; + + expect(resp.status).toBe(404); + }); +}); + +describe("DELETE /api/v1/routes/:id", () => { + it("returns 204 on success", async () => { + mockDeleteRoute.mockResolvedValue(true); + const { action } = await import("./api.v1.routes.$id.ts"); + const resp = await action({ + request: authRequest("/api/v1/routes/r1", { method: "DELETE" }), + params: { id: "r1" }, context: {}, + }) as Response; + + expect(resp.status).toBe(204); + }); + + it("returns 404 if not found", async () => { + mockDeleteRoute.mockResolvedValue(false); + const { action } = await import("./api.v1.routes.$id.ts"); + const resp = await action({ + request: authRequest("/api/v1/routes/missing", { method: "DELETE" }), + params: { id: "missing" }, context: {}, + }) as Response; + + expect(resp.status).toBe(404); + }); +}); diff --git a/apps/journal/vitest.config.ts b/apps/journal/vitest.config.ts index 8a07b14..558bad0 100644 --- a/apps/journal/vitest.config.ts +++ b/apps/journal/vitest.config.ts @@ -1 +1,11 @@ -export { default } from "../../vitest.shared.ts"; +import { defineConfig, mergeConfig } from "vitest/config"; +import shared from "../../vitest.shared.ts"; +import { resolve } from "node:path"; + +export default mergeConfig(shared, defineConfig({ + resolve: { + alias: { + "~": resolve(import.meta.dirname, "app"), + }, + }, +})); From 7b2fe1ce80f60d473c38e6207e99508edc684495 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Mon, 13 Apr 2026 01:10:23 +0200 Subject: [PATCH 4/4] Fix typecheck: add missing unstable_url and unstable_pattern to route test args React Router 7 now requires these properties on loader/action args. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/journal/app/routes/api.v1.routes.test.ts | 55 +++++++++---------- 1 file changed, 27 insertions(+), 28 deletions(-) diff --git a/apps/journal/app/routes/api.v1.routes.test.ts b/apps/journal/app/routes/api.v1.routes.test.ts index 95f6094..a8c4664 100644 --- a/apps/journal/app/routes/api.v1.routes.test.ts +++ b/apps/journal/app/routes/api.v1.routes.test.ts @@ -32,6 +32,11 @@ function authRequest(path: string, init?: RequestInit) { }); } +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function routeArgs(request: Request, params: Record, pattern: string): any { + return { request, params, context: {}, unstable_url: new URL(request.url), unstable_pattern: pattern }; +} + beforeEach(() => { vi.clearAllMocks(); mockGetAuthenticatedUser.mockResolvedValue(mockUser); @@ -50,7 +55,7 @@ describe("GET /api/v1/routes", () => { ]); const { loader } = await import("./api.v1.routes._index.ts"); - const resp = await loader({ request: authRequest("/api/v1/routes"), params: {}, context: {} }) as Response; + const resp = await loader(routeArgs(authRequest("/api/v1/routes"), {}, "api/v1/routes")) as Response; const data = await resp.json(); expect(data.routes).toHaveLength(1); @@ -63,7 +68,7 @@ describe("GET /api/v1/routes", () => { const { loader } = await import("./api.v1.routes._index.ts"); try { - await loader({ request: new Request("http://localhost:3000/api/v1/routes"), params: {}, context: {} }); + await loader(routeArgs(new Request("http://localhost:3000/api/v1/routes"), {}, "api/v1/routes")); expect.fail("should throw"); } catch (err) { expect(err).toBeInstanceOf(Response); @@ -77,13 +82,12 @@ describe("POST /api/v1/routes", () => { mockCreateRoute.mockResolvedValue("new-id"); const { action } = await import("./api.v1.routes._index.ts"); - const resp = await action({ - request: authRequest("/api/v1/routes", { + const resp = await action(routeArgs( + authRequest("/api/v1/routes", { method: "POST", body: JSON.stringify({ name: "New Route" }), - }), - params: {}, context: {}, - }) as Response; + }), {}, "api/v1/routes", + )) as Response; expect(resp.status).toBe(201); const data = await resp.json(); @@ -92,13 +96,12 @@ describe("POST /api/v1/routes", () => { it("returns 400 on validation error", async () => { const { action } = await import("./api.v1.routes._index.ts"); - const resp = await action({ - request: authRequest("/api/v1/routes", { + const resp = await action(routeArgs( + authRequest("/api/v1/routes", { method: "POST", body: JSON.stringify({ name: "" }), - }), - params: {}, context: {}, - }) as Response; + }), {}, "api/v1/routes", + )) as Response; expect(resp.status).toBe(400); const data = await resp.json(); @@ -118,10 +121,9 @@ describe("GET /api/v1/routes/:id", () => { }); const { loader } = await import("./api.v1.routes.$id.ts"); - const resp = await loader({ - request: authRequest("/api/v1/routes/r1"), - params: { id: "r1" }, context: {}, - }) as Response; + const resp = await loader(routeArgs( + authRequest("/api/v1/routes/r1"), { id: "r1" }, "api/v1/routes/:id", + )) as Response; const data = await resp.json(); expect(data.id).toBe("r1"); @@ -132,10 +134,9 @@ describe("GET /api/v1/routes/:id", () => { it("returns 404 for missing route", async () => { mockGetRouteWithVersions.mockResolvedValue(null); const { loader } = await import("./api.v1.routes.$id.ts"); - const resp = await loader({ - request: authRequest("/api/v1/routes/missing"), - params: { id: "missing" }, context: {}, - }) as Response; + const resp = await loader(routeArgs( + authRequest("/api/v1/routes/missing"), { id: "missing" }, "api/v1/routes/:id", + )) as Response; expect(resp.status).toBe(404); }); @@ -145,10 +146,9 @@ describe("DELETE /api/v1/routes/:id", () => { it("returns 204 on success", async () => { mockDeleteRoute.mockResolvedValue(true); const { action } = await import("./api.v1.routes.$id.ts"); - const resp = await action({ - request: authRequest("/api/v1/routes/r1", { method: "DELETE" }), - params: { id: "r1" }, context: {}, - }) as Response; + const resp = await action(routeArgs( + authRequest("/api/v1/routes/r1", { method: "DELETE" }), { id: "r1" }, "api/v1/routes/:id", + )) as Response; expect(resp.status).toBe(204); }); @@ -156,10 +156,9 @@ describe("DELETE /api/v1/routes/:id", () => { it("returns 404 if not found", async () => { mockDeleteRoute.mockResolvedValue(false); const { action } = await import("./api.v1.routes.$id.ts"); - const resp = await action({ - request: authRequest("/api/v1/routes/missing", { method: "DELETE" }), - params: { id: "missing" }, context: {}, - }) as Response; + const resp = await action(routeArgs( + authRequest("/api/v1/routes/missing", { method: "DELETE" }), { id: "missing" }, "api/v1/routes/:id", + )) as Response; expect(resp.status).toBe(404); });