From 61a2d0085b3ccc70fa76c6ad6ac68e20fbc144d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Wed, 10 Jun 2026 07:39:50 +0200 Subject: [PATCH] one source of truth for Route/Activity shapes; enforce api contracts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Route and Activity existed three times: hand-written interfaces in packages/types, Zod contracts in packages/api, and Drizzle columns in packages/db — each with different fields and nullability. The hand-written ones had drifted so far they had zero importers; the Zod contracts were advisory because v1 handlers hand-rolled Response.json shapes nothing validated. - packages/types keeps only what both apps actually share (Waypoint, WaypointPoiTags) and documents where row types and wire contracts live; the dead Route/RouteMetadata/RouteVersion/Activity interfaces are gone - packages/db exports canonical inferred row types (RouteRow, ActivityRow, RouteVersionRow, UserRow) - packages/api contracts are reconciled with the real wire format (RouteVersionSchema gains the id and createdBy fields the endpoint has always returned) and gain Create*ResponseSchemas - apiJson(schema, payload) in api-guard parses every v1 response through its contract: drift is now a thrown ZodError in tests/CI, unknown keys are stripped, and payloads are compile-checked as z.input of the schema Enforcement immediately caught two real drifts: nullable DB descriptions could ship null where the contract promises string (now coalesced at the boundary), and GET /api/v1/activities/:id was missing the routeName and photos fields its contract declares. Co-Authored-By: Claude Fable 5 --- apps/journal/app/lib/api-guard.server.ts | 17 ++++++ .../app/routes/api.v1.activities.$id.ts | 11 ++-- .../app/routes/api.v1.activities._index.ts | 10 ++-- apps/journal/app/routes/api.v1.routes.$id.ts | 8 +-- .../app/routes/api.v1.routes._index.ts | 10 ++-- apps/journal/app/routes/api.v1.routes.test.ts | 19 ++++--- packages/api/src/activities.ts | 4 ++ packages/api/src/index.ts | 6 +- packages/api/src/routes.ts | 6 ++ packages/db/src/schema/journal.ts | 10 ++++ packages/types/src/index.ts | 55 +++---------------- 11 files changed, 83 insertions(+), 73 deletions(-) diff --git a/apps/journal/app/lib/api-guard.server.ts b/apps/journal/app/lib/api-guard.server.ts index 18c6be9..7ac8466 100644 --- a/apps/journal/app/lib/api-guard.server.ts +++ b/apps/journal/app/lib/api-guard.server.ts @@ -1,3 +1,4 @@ +import type { z } from "zod"; import { getAuthenticatedUser } from "./oauth.server.ts"; import { TERMS_VERSION } from "./legal.ts"; import { ERROR_CODES } from "@trails-cool/api"; @@ -36,3 +37,19 @@ export async function requireApiUser(request: Request) { export function apiError(status: number, code: string, message: string, fields?: Array<{ field: string; message: string }>) { return Response.json({ error: message, code, fields }, { status }); } + +/** + * Respond with a payload validated against its @trails-cool/api + * contract. The schema is enforced, not advisory: a handler whose + * payload drifts from the contract fails its unit tests / e2e run with + * a ZodError instead of silently shipping a different wire shape. + * Parsing also strips unknown keys, so the response is exactly the + * contract — nothing extra leaks. + */ +export function apiJson( + schema: S, + payload: z.input, + init?: ResponseInit, +): Response { + return Response.json(schema.parse(payload), init); +} diff --git a/apps/journal/app/routes/api.v1.activities.$id.ts b/apps/journal/app/routes/api.v1.activities.$id.ts index 41c36c1..c0c16d5 100644 --- a/apps/journal/app/routes/api.v1.activities.$id.ts +++ b/apps/journal/app/routes/api.v1.activities.$id.ts @@ -1,8 +1,8 @@ import type { Route } from "./+types/api.v1.activities.$id"; -import { requireApiUser, apiError } from "~/lib/api-guard.server"; +import { requireApiUser, apiError, apiJson } from "~/lib/api-guard.server"; import { getActivity, deleteActivity } from "~/lib/activities.server"; import { loadOwnedActivity } from "~/lib/ownership.server"; -import { ERROR_CODES } from "@trails-cool/api"; +import { ERROR_CODES, ActivityDetailSchema } from "@trails-cool/api"; /** GET /api/v1/activities/:id — full activity detail */ export async function loader({ request, params }: Route.LoaderArgs) { @@ -13,11 +13,14 @@ export async function loader({ request, params }: Route.LoaderArgs) { return apiError(404, ERROR_CODES.NOT_FOUND, "Activity not found"); } - return Response.json({ + return apiJson(ActivityDetailSchema, { id: activity.id, name: activity.name, - description: activity.description, + description: activity.description ?? "", routeId: activity.routeId, + routeName: null, // TODO: join route name (matches the list endpoint) + photos: [], // no photos on this surface yet; contract field + distance: activity.distance, duration: activity.duration, elevationGain: activity.elevationGain, diff --git a/apps/journal/app/routes/api.v1.activities._index.ts b/apps/journal/app/routes/api.v1.activities._index.ts index a323e88..13810e6 100644 --- a/apps/journal/app/routes/api.v1.activities._index.ts +++ b/apps/journal/app/routes/api.v1.activities._index.ts @@ -1,11 +1,13 @@ import type { Route } from "./+types/api.v1.activities._index"; -import { requireApiUser, apiError } from "~/lib/api-guard.server"; +import { requireApiUser, apiError, apiJson } from "~/lib/api-guard.server"; import { listActivities, createActivity } from "~/lib/activities.server"; import { PaginationQuerySchema, CreateActivityRequestSchema, ERROR_CODES, zodIssuesToFieldErrors, + ActivityListResponseSchema, + CreateActivityResponseSchema, } from "@trails-cool/api"; /** GET /api/v1/activities — paginated activity list */ @@ -32,11 +34,11 @@ export async function loader({ request }: Route.LoaderArgs) { const page = allActivities.slice(startIdx, startIdx + limit); const nextCursor = startIdx + limit < allActivities.length ? page[page.length - 1]?.id ?? null : null; - return Response.json({ + return apiJson(ActivityListResponseSchema, { activities: page.map((a) => ({ id: a.id, name: a.name, - description: a.description, + description: a.description ?? "", routeId: a.routeId, routeName: null, // TODO: join route name distance: a.distance, @@ -67,5 +69,5 @@ export async function action({ request }: Route.ActionArgs) { ...parsed.data, startedAt: parsed.data.startedAt ? new Date(parsed.data.startedAt) : null, }); - return Response.json({ id }, { status: 201 }); + return apiJson(CreateActivityResponseSchema, { id }, { status: 201 }); } diff --git a/apps/journal/app/routes/api.v1.routes.$id.ts b/apps/journal/app/routes/api.v1.routes.$id.ts index c2c41a1..74a3ef3 100644 --- a/apps/journal/app/routes/api.v1.routes.$id.ts +++ b/apps/journal/app/routes/api.v1.routes.$id.ts @@ -1,8 +1,8 @@ import type { Route } from "./+types/api.v1.routes.$id"; -import { requireApiUser, apiError } from "~/lib/api-guard.server"; +import { requireApiUser, apiError, apiJson } from "~/lib/api-guard.server"; import { getRouteWithVersions, updateRoute, deleteRoute } from "~/lib/routes.server"; import { loadOwnedRoute } from "~/lib/ownership.server"; -import { UpdateRouteRequestSchema, ERROR_CODES, zodIssuesToFieldErrors } from "@trails-cool/api"; +import { UpdateRouteRequestSchema, ERROR_CODES, zodIssuesToFieldErrors, RouteDetailSchema } from "@trails-cool/api"; /** GET /api/v1/routes/:id — full route detail */ export async function loader({ request, params }: Route.LoaderArgs) { @@ -13,10 +13,10 @@ export async function loader({ request, params }: Route.LoaderArgs) { return apiError(404, ERROR_CODES.NOT_FOUND, "Route not found"); } - return Response.json({ + return apiJson(RouteDetailSchema, { id: route.id, name: route.name, - description: route.description, + description: route.description ?? "", distance: route.distance, elevationGain: route.elevationGain, elevationLoss: route.elevationLoss, diff --git a/apps/journal/app/routes/api.v1.routes._index.ts b/apps/journal/app/routes/api.v1.routes._index.ts index 5f72767..817dae8 100644 --- a/apps/journal/app/routes/api.v1.routes._index.ts +++ b/apps/journal/app/routes/api.v1.routes._index.ts @@ -1,11 +1,13 @@ import type { Route } from "./+types/api.v1.routes._index"; -import { requireApiUser, apiError } from "~/lib/api-guard.server"; +import { requireApiUser, apiError, apiJson } from "~/lib/api-guard.server"; import { listRoutes, createRoute } from "~/lib/routes.server"; import { PaginationQuerySchema, CreateRouteRequestSchema, ERROR_CODES, zodIssuesToFieldErrors, + RouteListResponseSchema, + CreateRouteResponseSchema, } from "@trails-cool/api"; /** GET /api/v1/routes — paginated route list */ @@ -32,11 +34,11 @@ export async function loader({ request }: Route.LoaderArgs) { const page = allRoutes.slice(startIdx, startIdx + limit); const nextCursor = startIdx + limit < allRoutes.length ? page[page.length - 1]?.id ?? null : null; - return Response.json({ + return apiJson(RouteListResponseSchema, { routes: page.map((r) => ({ id: r.id, name: r.name, - description: r.description, + description: r.description ?? "", distance: r.distance, elevationGain: r.elevationGain, elevationLoss: r.elevationLoss, @@ -63,5 +65,5 @@ export async function action({ request }: Route.ActionArgs) { } const id = await createRoute(user.id, parsed.data); - return Response.json({ id }, { status: 201 }); + return apiJson(CreateRouteResponseSchema, { id }, { status: 201 }); } diff --git a/apps/journal/app/routes/api.v1.routes.test.ts b/apps/journal/app/routes/api.v1.routes.test.ts index 88f88ac..9304ac7 100644 --- a/apps/journal/app/routes/api.v1.routes.test.ts +++ b/apps/journal/app/routes/api.v1.routes.test.ts @@ -3,6 +3,9 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { TERMS_VERSION } from "~/lib/legal"; const mockUser = { id: "user-1", email: "test@test.com", username: "test", domain: "localhost", displayName: null, bio: null, createdAt: new Date(), termsVersion: TERMS_VERSION }; +const ROUTE_ID = "11111111-1111-4111-8111-111111111111"; +const VERSION_ID = "22222222-2222-4222-8222-222222222222"; +const NEW_ID = "33333333-3333-4333-8333-333333333333"; const mockGetAuthenticatedUser = vi.fn(); const mockListRoutes = vi.fn(); const mockCreateRoute = vi.fn(); @@ -51,7 +54,7 @@ describe("GET /api/v1/routes", () => { const now = new Date(); mockListRoutes.mockResolvedValue([ { - id: "r1", name: "Tour", description: "", distance: 42000, + id: ROUTE_ID, 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, @@ -63,7 +66,7 @@ describe("GET /api/v1/routes", () => { const data = await resp.json(); expect(data.routes).toHaveLength(1); - expect(data.routes[0].id).toBe("r1"); + expect(data.routes[0].id).toBe(ROUTE_ID); expect(data.nextCursor).toBeNull(); }); @@ -83,7 +86,7 @@ describe("GET /api/v1/routes", () => { describe("POST /api/v1/routes", () => { it("creates a route with valid body", async () => { - mockCreateRoute.mockResolvedValue("new-id"); + mockCreateRoute.mockResolvedValue(NEW_ID); const { action } = await import("./api.v1.routes._index.ts"); const resp = await action(routeArgs( @@ -95,7 +98,7 @@ describe("POST /api/v1/routes", () => { expect(resp.status).toBe(201); const data = await resp.json(); - expect(data.id).toBe("new-id"); + expect(data.id).toBe(NEW_ID); }); it("returns 400 on validation error", async () => { @@ -117,20 +120,20 @@ describe("GET /api/v1/routes/:id", () => { it("returns route detail", async () => { const now = new Date(); mockGetRouteWithVersions.mockResolvedValue({ - id: "r1", name: "Tour", description: "", distance: 42000, + id: ROUTE_ID, 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 }], + versions: [{ id: VERSION_ID, routeId: ROUTE_ID, 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", + authRequest(`/api/v1/routes/${ROUTE_ID}`), { id: ROUTE_ID }, "api/v1/routes/:id", )) as Response; const data = await resp.json(); - expect(data.id).toBe("r1"); + expect(data.id).toBe(ROUTE_ID); expect(data.gpx).toBe(""); expect(data.versions).toHaveLength(1); }); diff --git a/packages/api/src/activities.ts b/packages/api/src/activities.ts index e4f2420..8da6e39 100644 --- a/packages/api/src/activities.ts +++ b/packages/api/src/activities.ts @@ -39,7 +39,11 @@ export const CreateActivityRequestSchema = z.object({ distance: z.number().optional(), }); +/** Response to POST /api/v1/activities */ +export const CreateActivityResponseSchema = z.object({ id: z.uuid() }); + export type ActivitySummary = z.infer; +export type CreateActivityResponse = z.infer; export type ActivityDetail = z.infer; export type ActivityListResponse = z.infer; export type CreateActivityRequest = z.infer; diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index a6e14a6..3d50b31 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -33,10 +33,12 @@ export { RouteSummarySchema, RouteDetailSchema, RouteVersionSchema, RouteListResponseSchema, CreateRouteRequestSchema, UpdateRouteRequestSchema, + CreateRouteResponseSchema, ComputeRouteRequestSchema, type RouteSummary, type RouteDetail, type RouteVersion, type RouteListResponse, type CreateRouteRequest, type UpdateRouteRequest, + type CreateRouteResponse, type ComputeRouteRequest, } from "./routes.ts"; @@ -44,10 +46,10 @@ export { export { ActivitySummarySchema, ActivityDetailSchema, ActivityListResponseSchema, - CreateActivityRequestSchema, + CreateActivityRequestSchema, CreateActivityResponseSchema, type ActivitySummary, type ActivityDetail, type ActivityListResponse, - type CreateActivityRequest, + type CreateActivityRequest, type CreateActivityResponse, } from "./activities.ts"; // Uploads diff --git a/packages/api/src/routes.ts b/packages/api/src/routes.ts index dc83071..a64140e 100644 --- a/packages/api/src/routes.ts +++ b/packages/api/src/routes.ts @@ -17,7 +17,9 @@ export const RouteSummarySchema = z.object({ /** Route version info */ export const RouteVersionSchema = z.object({ + id: z.uuid(), version: z.number(), + createdBy: z.string().nullable(), changeDescription: z.string().nullable(), createdAt: z.iso.datetime(), }); @@ -64,7 +66,11 @@ export const ComputeRouteRequestSchema = z.object({ })).optional(), }); +/** Response to POST /api/v1/routes */ +export const CreateRouteResponseSchema = z.object({ id: z.uuid() }); + export type RouteSummary = z.infer; +export type CreateRouteResponse = z.infer; export type RouteVersion = z.infer; export type RouteDetail = z.infer; export type RouteListResponse = z.infer; diff --git a/packages/db/src/schema/journal.ts b/packages/db/src/schema/journal.ts index 7167368..e4b7b68 100644 --- a/packages/db/src/schema/journal.ts +++ b/packages/db/src/schema/journal.ts @@ -463,3 +463,13 @@ export const consumedJwtJti = journalSchema.table("consumed_jwt_jti", { // Sweep runs `DELETE WHERE expires_at < now()` on a daily schedule. expiresAtIdx: index("consumed_jwt_jti_expires_at_idx").on(t.expiresAt), })); + +// --------------------------------------------------------------------------- +// Canonical row types — derive from the schema, never re-declare by hand. +// API wire shapes live in @trails-cool/api; these are the database truth. +// --------------------------------------------------------------------------- + +export type RouteRow = typeof routes.$inferSelect; +export type RouteVersionRow = typeof routeVersions.$inferSelect; +export type ActivityRow = typeof activities.$inferSelect; +export type UserRow = typeof users.$inferSelect; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index a48d332..c647a2e 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -1,7 +1,13 @@ /** - * Shared TypeScript types for trails.cool + * Shared TypeScript types for trails.cool — the Waypoint wire format + * used by both the Planner and Journal apps (Yjs document, GPX + * extensions, handoff payloads). * - * These types are used by both the Planner and Journal apps. + * Not here on purpose: database row types are derived from the Drizzle + * schema (@trails-cool/db, e.g. RouteRow), and API response shapes are + * the Zod contracts in @trails-cool/api. Earlier hand-written Route / + * Activity interfaces in this file drifted from both and had zero + * importers when they were removed. */ export interface WaypointPoiTags { @@ -26,48 +32,3 @@ export interface Waypoint { osmId?: number; poiTags?: WaypointPoiTags; } - -export interface RouteMetadata { - created: Date; - updated: Date; - owner: string; - contributors: string[]; - routingProfile: string; - dayBreaks: number[]; - distance: number; - elevation: { - gain: number; - loss: number; - }; - tags: string[]; -} - -export interface Route { - id: string; - name: string; - description: string; - gpx: string; - metadata: RouteMetadata; - plannerState?: Uint8Array; - versions: RouteVersion[]; -} - -export interface RouteVersion { - version: number; - gpx: string; - createdAt: Date; - createdBy: string; - changeDescription?: string; -} - -export interface Activity { - id: string; - routeId?: string; - name: string; - description: string; - gpx: string; - startedAt: Date; - duration: number; - photos: string[]; - participants: string[]; -}