Merge pull request #219 from trails-cool/feat/journal-rest-api

Implement Journal REST API v1 endpoints
This commit is contained in:
Ullrich Schäfer 2026-04-13 01:13:05 +02:00 committed by GitHub
commit 05659bcfc1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 798 additions and 23 deletions

View file

@ -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 });
}

View file

@ -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(),
});

View file

@ -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;

View file

@ -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 });
}

View file

@ -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 });
}

View file

@ -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 });
}

View file

@ -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,
})),
});
}

View file

@ -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 });
}

View file

@ -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 });
}

View file

@ -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");
}
}

View file

@ -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<typeof import("~/lib/oauth.server")>();
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<string, string> ?? {}),
},
});
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function routeArgs(request: Request, params: Record<string, string>, 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: "<gpx/>", ownerId: "user-1",
tags: null, plannerState: null, createdAt: now, updatedAt: now,
versions: [{ id: "v1", routeId: "r1", version: 1, gpx: "<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("<gpx/>");
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);
});
});

View file

@ -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: "<gpx>...</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: "<gpx/>" });
expect(result.gpx).toBe("<gpx/>");
});
});
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: "<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"]);
});
});

View file

@ -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,
});
}

View file

@ -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);

View file

@ -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"),
},
},
}));

View file

@ -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