# Grasshopper Lens API

Backend for the Grasshopper Lens mobile app. Two jobs: sign the user in with an email code, and email the user the CSV and ZIP the app produced from a scan session.

- **Base URL (local):** `http://localhost:4000`
- **Base URL (AWS):** set after deployment, see README
- **Format:** JSON in, JSON out. Uploads use `multipart/form-data`.
- **Auth:** optional shared secret. When the server has `API_KEY` set, send `X-Api-Key: <key>` on every `/api/*` request. Locally it is off.
- **Version:** all endpoints live under `/api/v1`.

Every response has this shape:

```json
{ "status": "ok",    "data": { ... } }
{ "status": "error", "error": { "code": "invalid_email", "message": "A valid email address is required", "details": { } } }
```

Error `code` values you should handle: `invalid_email`, `rate_limited` (429, has `details.retry_after_seconds`), `missing_csv`, `missing_zip`, `invalid_csv`, `invalid_zip`, `upload_error` (413 when a file is over the size limit), `unauthorized` (401, bad `X-Api-Key`), `not_found`.

---

## 1. Request a login code

`POST /api/v1/auth/otp`

Emails a one-time code to the address and **returns the same code** to the app. The app keeps it in memory and compares it with what the user types. There is no second server call.

**Request** (`Content-Type: application/json`)

```json
{ "email": "ori@grasshopperlabs.io" }
```

**Response 200**

```json
{
  "status": "ok",
  "data": {
    "email": "ori@grasshopperlabs.io",
    "code": "482913",
    "expires_at": "2026-09-18T15:42:10.000Z",
    "ttl_seconds": 600,
    "message_id": "<...@grasshopperlabs.io>"
  }
}
```

- `code` is 6 digits, may start with 0. Compare as a string.
- `expires_at` is when the app should stop accepting the code (10 minutes). The server does not track it.
- The email address is normalized (trimmed, lower-cased); use `data.email` as the user identity.
- Rate limit: 5 codes per email and 30 per IP every 15 minutes. On 429 show the wait time from `details.retry_after_seconds`.

**Suggested app flow**

1. User enters email. App calls this endpoint.
2. App shows the code screen. User types the 6 digits.
3. If typed code equals `data.code` and now is before `expires_at`, the user is signed in. Store `data.email` for the submission step.
4. "Resend code" calls the endpoint again and replaces the stored code.

**curl**

```bash
curl -X POST http://localhost:4000/api/v1/auth/otp \
  -H 'Content-Type: application/json' \
  -d '{"email":"ori@grasshopperlabs.io"}'
```

---

## 2. Submit a scan session

`POST /api/v1/submissions`

Uploads the CSV and the ZIP (JSON + images) built by the app. The server emails both files to the user and BCCs `magicbol@grasshopperlabs.io`. The email body lists the CSV row count and columns, the files inside the ZIP, and pretty-prints the JSON found in the ZIP.

**Request** (`Content-Type: multipart/form-data`)

| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `email` | text | yes | The signed-in user's email (from step 1) |
| `csv` | file | yes | The orders CSV. Name it `*.csv` |
| `zip` | file | yes | ZIP with the session JSON and the images. Name it `*.zip`. The first `*.json` inside is parsed for the email summary |
| any other text field | text | no | Included in the email as extra info. Suggested: `device`, `app_version`, `session_id`, `notes`. A `subject` field overrides the email subject |

Size limit: 100 MB per file (server setting `MAX_UPLOAD_MB`). Over the limit returns 413 `upload_error`.

**Response 201**

```json
{
  "status": "ok",
  "data": {
    "submission_id": "lens_20260918_9f3a1c2b",
    "email": "ori@grasshopperlabs.io",
    "sent_to": "ori@grasshopperlabs.io",
    "bcc": ["magicbol@grasshopperlabs.io"],
    "subject": "Grasshopper Lens scan from ori@grasshopperlabs.io (12 rows)",
    "csv":  { "filename": "orders.csv", "size_bytes": 4812, "rows": 12, "columns": 9, "header": ["ref_order_number", "customer_name", "..."] },
    "zip":  { "filename": "scan.zip", "size_bytes": 8123004, "files": 13, "images": 12, "json_file": "scan.json", "json_parsed": true, "json_error": null },
    "meta": { "device": "iPhone 16 Pro", "app_version": "2.1.0" },
    "message_id": "<...@grasshopperlabs.io>",
    "transport": "smtp"
  }
}
```

Show `submission_id` to the user as the reference for the email. `transport` is `file` on a development server (email written to disk, not sent) and `ses` in production.

**curl**

```bash
curl -X POST http://localhost:4000/api/v1/submissions \
  -F 'email=ori@grasshopperlabs.io' \
  -F 'device=iPhone 16 Pro' \
  -F 'app_version=2.1.0' \
  -F 'csv=@orders.csv;type=text/csv' \
  -F 'zip=@scan.zip;type=application/zip'
```

**Swift (URLSession) sketch**

```swift
var req = URLRequest(url: base.appendingPathComponent("/api/v1/submissions"))
req.httpMethod = "POST"
let boundary = "Boundary-\(UUID().uuidString)"
req.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
// if API_KEY is configured on the server:
// req.setValue(apiKey, forHTTPHeaderField: "X-Api-Key")
var body = Data()
func field(_ name: String, _ value: String) { body.append("--\(boundary)\r\nContent-Disposition: form-data; name=\"\(name)\"\r\n\r\n\(value)\r\n".data(using: .utf8)!) }
func file(_ name: String, _ filename: String, _ mime: String, _ data: Data) {
  body.append("--\(boundary)\r\nContent-Disposition: form-data; name=\"\(name)\"; filename=\"\(filename)\"\r\nContent-Type: \(mime)\r\n\r\n".data(using: .utf8)!)
  body.append(data); body.append("\r\n".data(using: .utf8)!)
}
field("email", email); field("device", UIDevice.current.model); field("app_version", appVersion)
file("csv", "orders.csv", "text/csv", csvData)
file("zip", "scan.zip", "application/zip", zipData)
body.append("--\(boundary)--\r\n".data(using: .utf8)!)
req.httpBody = body
```

---

## 3. Health

`GET /api/v1/health` (no API key needed)

```json
{ "status": "ok", "data": { "service": "grasshopper-lens-api", "version": "2.0.0", "time": "...", "uptime_s": 120, "mail": { "ok": true, "transport": "ses", "region": "us-east-1", "production_access": true }, "api_key_required": false } }
```

Use it to confirm the base URL is right and that mail is configured (`mail.ok`).

---

## Development helpers (only when the server runs with `MAIL_TRANSPORT=file`)

- `GET /dev/outbox` lists emails the server "sent" (newest first) with `to`, `bcc`, `subject`, `url`.
- `GET /dev/outbox/<file>` returns the raw `.eml` (open it in Mail.app or read it as text). Attachments are inside.

This lets the mobile app be developed end to end without a real mailbox.

---

## Changelog

- **2.0.0 (2026-09-18)** Fresh start. Two endpoints: email OTP and scan submission by email. Optional `X-Api-Key`.
