# 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

<binary data>
--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            |
