# API Reference Base URL: `http://localhost:3000` **Auth:** GET endpoints are public. POST, PUT, PATCH, and DELETE require authentication via session cookie or `X-API-Key` header. See [authentication.md](authentication.md) for details. **Prices** are stored and returned in cents (e.g., `2500` = $25.00). **Weights** are in grams. **Timestamps** are unix epoch integers. --- ## Table of Contents - [Health](#health) - [Items](#items) - [Categories](#categories) - [Threads](#threads) - [Setups](#setups) - [Images](#images) - [Settings](#settings) - [Totals](#totals) - [Auth](#auth) --- ## Health ### `GET /api/health` Returns server status. Always public. **Response:** ```json { "status": "ok" } ``` --- ## Items ### `GET /api/items` List all items in the collection. **Response:** ```json [ { "id": 1, "name": "Revelate Tangle Frame Bag", "categoryId": 2, "weightGrams": 185, "priceCents": 12000, "notes": "Medium size, fits 2020 Surly Straggler", "productUrl": "https://revelatedesigns.com/...", "imageFilename": "1710000000000-uuid.jpg", "imageSourceUrl": "https://example.com/image.jpg", "createdAt": 1710000000, "updatedAt": 1710000000 } ] ``` --- ### `GET /api/items/:id` Get a single item by ID. **Response:** item object (see above), or `404 { "error": "Item not found" }`. --- ### `POST /api/items` Create a new item. Auth required. **Request:** ```json { "name": "Revelate Tangle Frame Bag", "categoryId": 2, "weightGrams": 185, "priceCents": 12000, "notes": "Medium size", "productUrl": "https://revelatedesigns.com/...", "imageFilename": "1710000000000-uuid.jpg", "imageSourceUrl": "https://example.com/image.jpg" } ``` | Field | Type | Required | Description | |-----------------|---------|----------|--------------------------------------| | `name` | string | yes | Item name | | `categoryId` | integer | yes | ID of an existing category | | `weightGrams` | number | no | Weight in grams (non-negative) | | `priceCents` | integer | no | Price in cents (non-negative) | | `notes` | string | no | Free-text notes | | `productUrl` | string | no | URL to product page | | `imageFilename` | string | no | Filename from a prior image upload | | `imageSourceUrl`| string | no | Original URL the image came from | **Response:** `201` — created item object. --- ### `PUT /api/items/:id` Update an existing item. All fields are optional. Auth required. **Request:** same fields as POST, all optional. **Response:** updated item object, or `404`. --- ### `DELETE /api/items/:id` Delete an item and clean up its image file if one exists. Auth required. **Response:** ```json { "success": true } ``` Returns `404` if item not found. --- ## Categories ### `GET /api/categories` List all categories. **Response:** ```json [ { "id": 1, "name": "Uncategorized", "icon": "package" }, { "id": 2, "name": "Bags", "icon": "backpack" } ] ``` --- ### `POST /api/categories` Create a new category. Auth required. **Request:** ```json { "name": "Bags", "icon": "backpack" } ``` | Field | Type | Required | Description | |--------|--------|----------|--------------------------------------| | `name` | string | yes | Category name | | `icon` | string | no | Icon name (defaults to `"package"`) | **Response:** `201` — created category object. --- ### `PUT /api/categories/:id` Update a category. Auth required. **Request:** ```json { "name": "Carry", "icon": "bag" } ``` **Response:** updated category object, or `404`. --- ### `DELETE /api/categories/:id` Delete a category. Auth required. **Response:** ```json { "success": true } ``` --- ## Threads Research threads track multiple candidate options for a single gear slot before committing to a purchase. ### `GET /api/threads` List threads. By default only active (unresolved) threads are returned. **Query parameters:** | Parameter | Type | Default | Description | |-------------------|---------|---------|------------------------------------| | `includeResolved` | boolean | `false` | Set to `true` to include resolved threads | **Example:** `GET /api/threads?includeResolved=true` **Response:** ```json [ { "id": 1, "name": "Handlebar bag", "categoryId": 2, "status": "active", "resolvedCandidateId": null, "createdAt": 1710000000, "updatedAt": 1710000000 } ] ``` --- ### `POST /api/threads` Create a new research thread. Auth required. **Request:** ```json { "name": "Handlebar bag", "categoryId": 2 } ``` **Response:** `201` — created thread object. --- ### `GET /api/threads/:id` Get a thread with all its candidates. **Response:** ```json { "id": 1, "name": "Handlebar bag", "categoryId": 2, "status": "active", "resolvedCandidateId": null, "candidates": [ { "id": 10, "threadId": 1, "name": "Revelate Sweetroll", "categoryId": 2, "weightGrams": 290, "priceCents": 13500, "notes": "16L, fits most drop bars", "productUrl": "https://revelatedesigns.com/...", "imageFilename": null, "imageSourceUrl": null, "status": "researching", "pros": "Waterproof, large capacity", "cons": "Pricey", "sortOrder": 0, "createdAt": 1710000000, "updatedAt": 1710000000 } ] } ``` Returns `404` if thread not found. --- ### `PUT /api/threads/:id` Update a thread's name or category. Auth required. **Request:** ```json { "name": "Bar bag", "categoryId": 2 } ``` **Response:** updated thread object, or `404`. --- ### `DELETE /api/threads/:id` Delete a thread and all its candidates. Cleans up any candidate image files. Auth required. **Response:** ```json { "success": true } ``` --- ### `POST /api/threads/:id/candidates` Add a candidate to a thread. Auth required. **Request:** ```json { "name": "Revelate Sweetroll", "categoryId": 2, "weightGrams": 290, "priceCents": 13500, "notes": "16L capacity", "productUrl": "https://revelatedesigns.com/...", "imageFilename": "1710000000000-uuid.jpg", "imageSourceUrl": "https://example.com/image.jpg", "status": "researching", "pros": "Waterproof, large capacity", "cons": "Expensive" } ``` | Field | Type | Required | Description | |-----------------|---------|----------|----------------------------------------------------| | `name` | string | yes | Candidate name | | `categoryId` | integer | yes | Category ID | | `weightGrams` | number | no | Weight in grams | | `priceCents` | integer | no | Price in cents | | `notes` | string | no | Notes | | `productUrl` | string | no | Product URL | | `imageFilename` | string | no | Filename from a prior image upload | | `imageSourceUrl`| string | no | Original image URL | | `status` | string | no | `"researching"`, `"ordered"`, or `"arrived"` | | `pros` | string | no | Pros of this option | | `cons` | string | no | Cons of this option | **Response:** `201` — created candidate object. Returns `404` if thread not found. --- ### `PUT /api/threads/:threadId/candidates/:candidateId` Update a candidate. All fields optional. Auth required. **Request:** same fields as POST, all optional. **Response:** updated candidate object, or `404`. --- ### `DELETE /api/threads/:threadId/candidates/:candidateId` Delete a candidate and clean up its image. Auth required. **Response:** ```json { "success": true } ``` --- ### `PATCH /api/threads/:id/candidates/reorder` Reorder candidates within a thread. Auth required. **Request:** ```json { "orderedIds": [12, 10, 11] } ``` `orderedIds` is an array of candidate IDs in the desired display order. All IDs must belong to the thread. **Response:** ```json { "success": true } ``` Returns `400` with an error message if validation fails. --- ### `POST /api/threads/:id/resolve` Resolve a thread by selecting the winning candidate. Auth required. The winning candidate's data is copied into a new item in the collection. The thread status is set to `"resolved"` and `resolvedCandidateId` is set. **Request:** ```json { "candidateId": 10 } ``` **Response:** ```json { "success": true, "item": { "id": 42, "name": "Revelate Sweetroll", "categoryId": 2, "weightGrams": 290, "priceCents": 13500 } } ``` Returns `400` if the candidate does not belong to the thread or another error occurs. --- ## Setups Setups are named collections of items (e.g., "Bikepacking weekend", "Commute loadout"). ### `GET /api/setups` List all setups with item counts and weight/cost totals. **Response:** ```json [ { "id": 1, "name": "Bikepacking weekend", "itemCount": 12, "totalWeightGrams": 4800, "totalPriceCents": 285000, "createdAt": 1710000000, "updatedAt": 1710000000 } ] ``` --- ### `POST /api/setups` Create a new setup. Auth required. **Request:** ```json { "name": "Bikepacking weekend" } ``` **Response:** `201` — created setup object. --- ### `GET /api/setups/:id` Get a setup with its full item list. **Response:** ```json { "id": 1, "name": "Bikepacking weekend", "items": [ { "id": 1, "name": "Revelate Tangle Frame Bag", "weightGrams": 185, "priceCents": 12000, "categoryId": 2, "classification": "base" } ], "totalWeightGrams": 185, "totalPriceCents": 12000 } ``` Returns `404` if setup not found. --- ### `PUT /api/setups/:id` Update a setup's name. Auth required. **Request:** ```json { "name": "Bikepacking overnighter" } ``` **Response:** updated setup object, or `404`. --- ### `PUT /api/setups/:id/items` Replace all items in a setup atomically. Deletes all existing setup-item associations and re-inserts with the provided IDs. Auth required. **Request:** ```json { "itemIds": [1, 5, 12, 17] } ``` Pass an empty array to remove all items from the setup. **Response:** ```json { "success": true } ``` --- ### `DELETE /api/setups/:id` Delete a setup. Does not delete the items themselves. Auth required. **Response:** ```json { "success": true } ``` --- ## Images ### `POST /api/images` Upload an image file. Auth required. **Request:** `multipart/form-data` with an `image` field. ``` POST /api/images Content-Type: multipart/form-data; boundary=... --boundary Content-Disposition: form-data; name="image"; filename="bag.jpg" Content-Type: image/jpeg --boundary-- ``` Constraints: - Accepted types: `image/jpeg`, `image/png`, `image/webp` - Maximum size: 5MB - Files are saved to `./uploads/` with a UUID-based filename **Response:** `201` ```json { "filename": "1710000000000-550e8400-e29b-41d4-a716-446655440000.jpg" } ``` Use the returned `filename` as `imageFilename` when creating or updating items or candidates. --- ### `POST /api/images/from-url` Fetch an image from a remote URL and save it locally. Auth required. **Request:** ```json { "url": "https://example.com/product-image.jpg" } ``` **Response:** `201` ```json { "filename": "1710000000000-uuid.jpg", "sourceUrl": "https://example.com/product-image.jpg" } ``` Returns `400` for invalid URLs, unsupported content types, files over 5MB, or non-200 HTTP responses. --- ## Settings Key-value store for application settings. ### `GET /api/settings/:key` Get a setting by key. Public. **Response:** ```json { "key": "collectionName", "value": "My Bikepacking Gear" } ``` Returns `404` if the key does not exist. --- ### `PUT /api/settings/:key` Create or update a setting. Auth required. **Request:** ```json { "value": "My Bikepacking Gear" } ``` **Response:** updated setting object. --- ## Totals ### `GET /api/totals` Global and per-category weight and cost totals. Computed from current item data. Public. **Response:** ```json { "global": { "totalWeightGrams": 12500, "totalPriceCents": 450000, "itemCount": 34 }, "categories": [ { "categoryId": 2, "name": "Bags", "icon": "backpack", "totalWeightGrams": 1200, "totalPriceCents": 78000, "itemCount": 5 } ] } ``` --- ## Auth Authentication endpoints are documented in [authentication.md](authentication.md). | Method | Path | Auth | Description | |--------|-------------------------|----------|---------------------------| | GET | `/api/auth/me` | public | Current session state | | POST | `/api/auth/setup` | public | Create first admin user | | POST | `/api/auth/login` | public | Log in, receive cookie | | POST | `/api/auth/logout` | public | Clear session | | PUT | `/api/auth/password` | session | Change password | | GET | `/api/auth/keys` | required | List API keys | | POST | `/api/auth/keys` | required | Create API key | | DELETE | `/api/auth/keys/:id` | required | Revoke API key |