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.routes.test.ts b/apps/journal/app/routes/api.v1.routes.test.ts new file mode 100644 index 0000000..a8c4664 --- /dev/null +++ b/apps/journal/app/routes/api.v1.routes.test.ts @@ -0,0 +1,165 @@ +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 ?? {}), + }, + }); +} + +// 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); +}); + +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(routeArgs(authRequest("/api/v1/routes"), {}, "api/v1/routes")) 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(routeArgs(new Request("http://localhost:3000/api/v1/routes"), {}, "api/v1/routes")); + 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(routeArgs( + authRequest("/api/v1/routes", { + method: "POST", + body: JSON.stringify({ name: "New Route" }), + }), {}, "api/v1/routes", + )) 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(routeArgs( + authRequest("/api/v1/routes", { + method: "POST", + body: JSON.stringify({ name: "" }), + }), {}, "api/v1/routes", + )) 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(routeArgs( + authRequest("/api/v1/routes/r1"), { id: "r1" }, "api/v1/routes/:id", + )) 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(routeArgs( + authRequest("/api/v1/routes/missing"), { id: "missing" }, "api/v1/routes/:id", + )) 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(routeArgs( + authRequest("/api/v1/routes/r1", { method: "DELETE" }), { id: "r1" }, "api/v1/routes/:id", + )) 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(routeArgs( + authRequest("/api/v1/routes/missing", { method: "DELETE" }), { id: "missing" }, "api/v1/routes/:id", + )) as Response; + + expect(resp.status).toBe(404); + }); +}); 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"]); + }); +}); 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/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"), + }, + }, +})); 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