Merge pull request #219 from trails-cool/feat/journal-rest-api
Implement Journal REST API v1 endpoints
This commit is contained in:
commit
05659bcfc1
16 changed files with 798 additions and 23 deletions
23
apps/journal/app/lib/api-guard.server.ts
Normal file
23
apps/journal/app/lib/api-guard.server.ts
Normal 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 });
|
||||
}
|
||||
|
|
@ -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(),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
41
apps/journal/app/routes/api.v1.activities.$id.ts
Normal file
41
apps/journal/app/routes/api.v1.activities.$id.ts
Normal 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 });
|
||||
}
|
||||
70
apps/journal/app/routes/api.v1.activities._index.ts
Normal file
70
apps/journal/app/routes/api.v1.activities._index.ts
Normal 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 });
|
||||
}
|
||||
35
apps/journal/app/routes/api.v1.auth.devices.$id.ts
Normal file
35
apps/journal/app/routes/api.v1.auth.devices.$id.ts
Normal 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 });
|
||||
}
|
||||
49
apps/journal/app/routes/api.v1.auth.devices.ts
Normal file
49
apps/journal/app/routes/api.v1.auth.devices.ts
Normal 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,
|
||||
})),
|
||||
});
|
||||
}
|
||||
64
apps/journal/app/routes/api.v1.routes.$id.ts
Normal file
64
apps/journal/app/routes/api.v1.routes.$id.ts
Normal 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 });
|
||||
}
|
||||
66
apps/journal/app/routes/api.v1.routes._index.ts
Normal file
66
apps/journal/app/routes/api.v1.routes._index.ts
Normal 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 });
|
||||
}
|
||||
34
apps/journal/app/routes/api.v1.routes.compute.ts
Normal file
34
apps/journal/app/routes/api.v1.routes.compute.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
165
apps/journal/app/routes/api.v1.routes.test.ts
Normal file
165
apps/journal/app/routes/api.v1.routes.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
173
apps/journal/app/routes/api.v1.test.ts
Normal file
173
apps/journal/app/routes/api.v1.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
31
apps/journal/app/routes/api.v1.uploads.ts
Normal file
31
apps/journal/app/routes/api.v1.uploads.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue