diff --git a/packages/api/package.json b/packages/api/package.json new file mode 100644 index 0000000..42c7511 --- /dev/null +++ b/packages/api/package.json @@ -0,0 +1,10 @@ +{ + "name": "@trails-cool/api", + "private": true, + "version": "0.0.1", + "type": "module", + "main": "src/index.ts", + "dependencies": { + "zod": "^3.25.0" + } +} diff --git a/packages/api/src/activities.ts b/packages/api/src/activities.ts new file mode 100644 index 0000000..479fbc6 --- /dev/null +++ b/packages/api/src/activities.ts @@ -0,0 +1,45 @@ +import { z } from "zod"; + +/** Activity summary for list views */ +export const ActivitySummarySchema = z.object({ + id: z.string().uuid(), + name: z.string(), + description: z.string(), + routeId: z.string().uuid().nullable(), + routeName: z.string().nullable(), + distance: z.number().nullable(), + duration: z.number().nullable(), + elevationGain: z.number().nullable(), + elevationLoss: z.number().nullable(), + startedAt: z.string().datetime().nullable(), + geojson: z.string().nullable(), + createdAt: z.string().datetime(), +}); + +/** Full activity detail */ +export const ActivityDetailSchema = ActivitySummarySchema.extend({ + gpx: z.string().nullable(), + photos: z.array(z.string().url()), +}); + +/** Paginated activity list response */ +export const ActivityListResponseSchema = z.object({ + activities: z.array(ActivitySummarySchema), + nextCursor: z.string().nullable(), +}); + +/** Create activity request */ +export const CreateActivityRequestSchema = z.object({ + name: z.string().min(1).max(200), + description: z.string().max(5000).default(""), + gpx: z.string().optional(), + routeId: z.string().uuid().optional(), + startedAt: z.string().datetime().optional(), + duration: z.number().optional(), + distance: z.number().optional(), +}); + +export type ActivitySummary = z.infer; +export type ActivityDetail = z.infer; +export type ActivityListResponse = z.infer; +export type CreateActivityRequest = z.infer; diff --git a/packages/api/src/auth.ts b/packages/api/src/auth.ts new file mode 100644 index 0000000..8440738 --- /dev/null +++ b/packages/api/src/auth.ts @@ -0,0 +1,35 @@ +import { z } from "zod"; + +export const TokenExchangeRequestSchema = z.object({ + grant_type: z.enum(["authorization_code", "refresh_token"]), + code: z.string().optional(), + code_verifier: z.string().optional(), + refresh_token: z.string().optional(), + client_id: z.string(), + redirect_uri: z.string().optional(), + device_name: z.string().max(100).optional(), +}); + +export const TokenResponseSchema = z.object({ + access_token: z.string(), + refresh_token: z.string(), + token_type: z.literal("Bearer"), + expires_in: z.number(), +}); + +export const DeviceSchema = z.object({ + id: z.string(), + deviceName: z.string().nullable(), + lastActiveAt: z.string().datetime(), + createdAt: z.string().datetime(), + isCurrent: z.boolean(), +}); + +export const DeviceListResponseSchema = z.object({ + devices: z.array(DeviceSchema), +}); + +export type TokenExchangeRequest = z.infer; +export type TokenResponse = z.infer; +export type Device = z.infer; +export type DeviceListResponse = z.infer; diff --git a/packages/api/src/discovery.ts b/packages/api/src/discovery.ts new file mode 100644 index 0000000..9680a9a --- /dev/null +++ b/packages/api/src/discovery.ts @@ -0,0 +1,10 @@ +import { z } from "zod"; + +export const DiscoveryResponseSchema = z.object({ + apiVersion: z.string(), + instanceName: z.string(), + apiBaseUrl: z.string().url(), + tileUrl: z.string().url().optional(), +}); + +export type DiscoveryResponse = z.infer; diff --git a/packages/api/src/endpoints.ts b/packages/api/src/endpoints.ts new file mode 100644 index 0000000..a45d84e --- /dev/null +++ b/packages/api/src/endpoints.ts @@ -0,0 +1,35 @@ +/** Typed API endpoint path constants */ +export const ENDPOINTS = { + discovery: "/.well-known/trails-cool", + + auth: { + token: "/api/v1/auth/token", + devices: "/api/v1/auth/devices", + device: (id: string) => `/api/v1/auth/devices/${id}` as const, + }, + + routes: { + list: "/api/v1/routes", + create: "/api/v1/routes", + detail: (id: string) => `/api/v1/routes/${id}` as const, + update: (id: string) => `/api/v1/routes/${id}` as const, + delete: (id: string) => `/api/v1/routes/${id}` as const, + compute: "/api/v1/routes/compute", + }, + + activities: { + list: "/api/v1/activities", + create: "/api/v1/activities", + detail: (id: string) => `/api/v1/activities/${id}` as const, + delete: (id: string) => `/api/v1/activities/${id}` as const, + }, + + uploads: { + presign: "/api/v1/uploads", + }, + + push: { + subscribe: "/api/v1/push/subscribe", + unsubscribe: "/api/v1/push/unsubscribe", + }, +} as const; diff --git a/packages/api/src/errors.ts b/packages/api/src/errors.ts new file mode 100644 index 0000000..7d7fb88 --- /dev/null +++ b/packages/api/src/errors.ts @@ -0,0 +1,26 @@ +import { z } from "zod"; + +export const FieldErrorSchema = z.object({ + field: z.string(), + message: z.string(), +}); + +export const ApiErrorResponseSchema = z.object({ + error: z.string(), + code: z.string(), + fields: z.array(FieldErrorSchema).optional(), +}); + +export type FieldError = z.infer; +export type ApiErrorResponse = z.infer; + +/** Standard error codes */ +export const ERROR_CODES = { + VALIDATION_ERROR: "VALIDATION_ERROR", + NOT_FOUND: "NOT_FOUND", + UNAUTHORIZED: "UNAUTHORIZED", + FORBIDDEN: "FORBIDDEN", + CONFLICT: "CONFLICT", + RATE_LIMITED: "RATE_LIMITED", + INTERNAL_ERROR: "INTERNAL_ERROR", +} as const; diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts new file mode 100644 index 0000000..8f7e100 --- /dev/null +++ b/packages/api/src/index.ts @@ -0,0 +1,56 @@ +export { API_VERSION } from "./version.ts"; +export { ENDPOINTS } from "./endpoints.ts"; + +// Error schemas +export { + ApiErrorResponseSchema, FieldErrorSchema, ERROR_CODES, + type ApiErrorResponse, type FieldError, +} from "./errors.ts"; + +// Pagination +export { + PaginationQuerySchema, PaginatedResponseSchema, + type PaginationQuery, type PaginatedResponse, +} from "./pagination.ts"; + +// Discovery +export { + DiscoveryResponseSchema, + type DiscoveryResponse, +} from "./discovery.ts"; + +// Auth +export { + TokenExchangeRequestSchema, TokenResponseSchema, + DeviceSchema, DeviceListResponseSchema, + type TokenExchangeRequest, type TokenResponse, + type Device, type DeviceListResponse, +} from "./auth.ts"; + +// Routes +export { + RouteSummarySchema, RouteDetailSchema, RouteVersionSchema, + RouteListResponseSchema, + CreateRouteRequestSchema, UpdateRouteRequestSchema, + ComputeRouteRequestSchema, + type RouteSummary, type RouteDetail, type RouteVersion, + type RouteListResponse, + type CreateRouteRequest, type UpdateRouteRequest, + type ComputeRouteRequest, +} from "./routes.ts"; + +// Activities +export { + ActivitySummarySchema, ActivityDetailSchema, + ActivityListResponseSchema, + CreateActivityRequestSchema, + type ActivitySummary, type ActivityDetail, + type ActivityListResponse, + type CreateActivityRequest, +} from "./activities.ts"; + +// Uploads +export { + PresignedUploadRequestSchema, PresignedUploadResponseSchema, + type PresignedUploadRequest, type PresignedUploadResponse, +} from "./uploads.ts"; diff --git a/packages/api/src/pagination.ts b/packages/api/src/pagination.ts new file mode 100644 index 0000000..649792f --- /dev/null +++ b/packages/api/src/pagination.ts @@ -0,0 +1,16 @@ +import { z } from "zod"; + +/** Cursor-based pagination query params */ +export const PaginationQuerySchema = z.object({ + cursor: z.string().optional(), + limit: z.coerce.number().min(1).max(100).default(20), +}); + +export type PaginationQuery = z.infer; + +/** Paginated response wrapper — extend with your items */ +export const PaginatedResponseSchema = z.object({ + nextCursor: z.string().nullable(), +}); + +export type PaginatedResponse = z.infer; diff --git a/packages/api/src/routes.ts b/packages/api/src/routes.ts new file mode 100644 index 0000000..b9e64e5 --- /dev/null +++ b/packages/api/src/routes.ts @@ -0,0 +1,73 @@ +import { z } from "zod"; + +/** Route summary for list views */ +export const RouteSummarySchema = z.object({ + id: z.string().uuid(), + name: z.string(), + description: z.string(), + distance: z.number().nullable(), + elevationGain: z.number().nullable(), + elevationLoss: z.number().nullable(), + routingProfile: z.string().nullable(), + dayBreaks: z.array(z.number()), + geojson: z.string().nullable(), + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), +}); + +/** Route version info */ +export const RouteVersionSchema = z.object({ + version: z.number(), + changeDescription: z.string().nullable(), + createdAt: z.string().datetime(), +}); + +/** Full route detail */ +export const RouteDetailSchema = RouteSummarySchema.extend({ + gpx: z.string().nullable(), + versions: z.array(RouteVersionSchema), +}); + +/** Paginated route list response */ +export const RouteListResponseSchema = z.object({ + routes: z.array(RouteSummarySchema), + nextCursor: z.string().nullable(), +}); + +/** Create route request */ +export const CreateRouteRequestSchema = z.object({ + name: z.string().min(1).max(200), + description: z.string().max(5000).default(""), + gpx: z.string().optional(), + routingProfile: z.string().optional(), +}); + +/** Update route request */ +export const UpdateRouteRequestSchema = z.object({ + name: z.string().min(1).max(200).optional(), + description: z.string().max(5000).optional(), + gpx: z.string().optional(), +}); + +/** Compute route request (BRouter proxy) */ +export const ComputeRouteRequestSchema = z.object({ + waypoints: z.array(z.object({ + lat: z.number(), + lon: z.number(), + })).min(2), + profile: z.string().default("fastbike"), + noGoAreas: z.array(z.object({ + points: z.array(z.object({ + lat: z.number(), + lon: z.number(), + })), + })).optional(), +}); + +export type RouteSummary = z.infer; +export type RouteVersion = z.infer; +export type RouteDetail = z.infer; +export type RouteListResponse = z.infer; +export type CreateRouteRequest = z.infer; +export type UpdateRouteRequest = z.infer; +export type ComputeRouteRequest = z.infer; diff --git a/packages/api/src/uploads.ts b/packages/api/src/uploads.ts new file mode 100644 index 0000000..98f8927 --- /dev/null +++ b/packages/api/src/uploads.ts @@ -0,0 +1,17 @@ +import { z } from "zod"; + +export const PresignedUploadRequestSchema = z.object({ + filename: z.string(), + contentType: z.string(), + resourceType: z.enum(["route", "activity"]), + resourceId: z.string().uuid(), +}); + +export const PresignedUploadResponseSchema = z.object({ + uploadUrl: z.string().url(), + storageKey: z.string(), + expiresAt: z.string().datetime(), +}); + +export type PresignedUploadRequest = z.infer; +export type PresignedUploadResponse = z.infer; diff --git a/packages/api/src/version.ts b/packages/api/src/version.ts new file mode 100644 index 0000000..5ef45ad --- /dev/null +++ b/packages/api/src/version.ts @@ -0,0 +1,7 @@ +/** + * API version following semver. + * - Patch: bug fixes + * - Minor: new endpoints, new optional fields (backwards compatible) + * - Major: breaking changes + */ +export const API_VERSION = "1.0.0"; diff --git a/packages/api/tsconfig.json b/packages/api/tsconfig.json new file mode 100644 index 0000000..5285d28 --- /dev/null +++ b/packages/api/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9f75fd4..42d9ac7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -410,6 +410,12 @@ importers: specifier: 'catalog:' version: 8.0.8(@types/node@25.5.2)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0) + packages/api: + dependencies: + zod: + specifier: ^3.25.0 + version: 3.25.76 + packages/db: dependencies: drizzle-orm: @@ -4102,6 +4108,9 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + snapshots: '@adobe/css-tools@4.4.4': {} @@ -7620,3 +7629,5 @@ snapshots: lib0: 0.2.117 yocto-queue@0.1.0: {} + + zod@3.25.76: {}