{
  "openapi": "3.1.0",
  "info": {
    "title": "Foods API",
    "version": "1.0.0",
    "summary": "A Greek food catalog: search, barcode lookup, and nutrition per 100 g.",
    "description": "Everything is JSON, everything is authenticated with a bearer token, and every\nnutrition figure is **per 100 grams** regardless of what `serving_size_g` says.\n\nAll endpoints are prefixed `/api` except the two health checks (`/` and `/up`).\n\n### Quick start\n\nLog in, keep the token, search. Three commands from nothing to results:\n\n```bash\nTOKEN=$(curl -sS -X POST https://api.food-lib.gr/api/auth/login \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"email\":\"you@example.com\",\"password\":\"…\"}' | jq -r .access_token)\n\ncurl -sS -H \"Authorization: Bearer $TOKEN\" \\\n  'https://api.food-lib.gr/api/foods/search?q=φαβα'\n```\n\nThat is the whole shape of it. Everything else is filters on the same idea.\n\n### Nutrition is per 100 g. Always.\n\nServing size is metadata for display, not the basis of the numbers. To show a\nportion, do the arithmetic yourself:\n\n```\ncalories_for_portion = food.calories * (grams / 100)\n```\n\nNullable macros come back as `null` rather than `0` — the difference between\n\"no trans fat\" and \"nobody recorded it\" is real, so don't coerce it.\n\n### Foods are addressable by id or slug\n\nAnywhere you see `{idOrSlug}`, both work: `/api/foods/812` and\n`/api/foods/fava-santorinis` return the same food. Numeric strings are treated\nas ids. Slugs are generated from the name (Greek is transliterated: *Φάβα* →\n`fava`) and are stable unless someone renames the food.\n\n### Responses are wrapped\n\nEvery food response is wrapped in `data` — single resources included. List\nresponses add `links` and `meta`. Page with `?page=` and `?per_page=`\n(max 100); `meta.total` and `meta.last_page` are the two worth reading.\n\n### Send `Accept: application/json`\n\nWithout it, a client that isn't recognised as wanting JSON can be handed an\nHTML error page instead of a JSON body.\n\n### Caching\n\nReads are cached in Redis and served from it on a repeat. Writes flush the\nwhole `foods` tag, so an edit is visible on the very next read — you never have\nto wait a TTL out. That includes barcode and image writes, not just edits to\nthe food itself. 404s are never cached, so a food you just created is findable\nimmediately.\n\n| What | Cached for |\n|---|---|\n| Categories, brands, sources | 1 hour |\n| Single food, barcode lookup | 10 minutes |\n| Food list | 2 minutes |\n| Search | **not cached** — always live against the index |\n\nThat is all server-side; it saves the database, not your bandwidth.\n\n### Don't re-download what hasn't changed\n\nEvery cached read returns an `ETag`. Send it back as `If-None-Match` and an\nunchanged body comes back as a **`304` with no payload** — which is most of\nthem, on a catalog that barely moves. On a 50-item page over mobile data this is\nthe difference worth having.\n\n```bash\ncurl -sS -H \"Authorization: Bearer $TOKEN\" \\\n  -H 'If-None-Match: \"2e5997c7e5110fc8e63636c6c2ece65e\"' \\\n  https://api.food-lib.gr/api/foods/812\n# 304, empty body — keep the copy you have\n```\n\n**There is no freshness window, deliberately.** Responses are `no-cache`, so you\nrevalidate every time and an edit reaches you on your very next read. Nothing\nhere ever tells you to hold a body for N seconds without asking — that would be\nstaleness this API could not flush. Cheap to always send `If-None-Match`;\nnever assume a cached copy is current without it.\n\n`/api/foods/search` carries no `ETag`: free text has a long unique tail, so\nrevalidating it would nearly always miss.",
    "contact": { "name": "Repository", "url": "https://github.com/Drivakos/food-lib-api" }
  },
  "externalDocs": { "description": "Human-readable reference", "url": "https://api.food-lib.gr/docs" },
  "servers": [
    { "url": "https://api.food-lib.gr", "description": "Production" },
    { "url": "http://127.0.0.1:8000", "description": "Local development" }
  ],
  "security": [
    {
      "bearerAuth": []
    }
  ],
  "tags": [
    {
      "name": "Auth",
      "description": "Every endpoint needs `Authorization: Bearer <token>` except `POST /api/auth/login` and the two health checks. There is **no public signup**, and no self-service signup of any kind: access to this API is sold, so accounts are created by an administrator once whatever takes the money says so. Holding a valid token does not let you create another account — `POST /api/auth/register` answers an ordinary account with `403`.\n\n| Fact | Value | Why you care |\n|---|---|---|\n| Token lifetime | **60 minutes** | `expires_in: 3600` in the login response |\n| Refresh window | **14 days** | Past this, log in again with a password |\n| Algorithm | HS256 | Opaque to you — do not parse it |\n| Logout | Blacklists server-side | A logged-out token 401s immediately, not in 60 min |\n\n**Don't log in per request.** A service calling this API should cache the token and refresh it on a 401, not exchange credentials every time. Login is rate limited to 10 attempts per minute per IP, so a per-request login will start 429ing under any real load."
    },
    {
      "name": "Foods",
      "description": "The catalog. Greek foods, nutrition per 100 g, with photos and barcodes where they exist."
    },
    { "name": "Barcodes", "description": "A barcode maps to at most one food, enforced by a unique index." },
    {
      "name": "Images",
      "description": "Product shots. Paths are stored relative and served absolute, so `image_url` is always directly fetchable — no base URL to assemble client-side."
    },
    {
      "name": "Contributions",
      "description": "The moderation queue for user-submitted foods. `POST /api/food-contributions` is the **only write in this API an ordinary token may make**; reading, editing, approving and rejecting are administrators-only.\n\nThat split is the point. A submission lands with `approved: false` and touches nothing — the catalog changes only when an operator approves it, which creates the food in the same transaction. So the queue tolerates what the catalog cannot: a duplicate name is accepted (the submitter has no way to know it exists) and barcode claims stay claims until approval."
    },
    { "name": "Reference", "description": "Read-only lookup data." },
    { "name": "Users", "description": "The operator roster. Reading it and granting/revoking the admin bit are administrators-only; an ordinary token gets a 403. Admin is the right to create other users and manage this roster." },
    { "name": "Keys", "description": "API keys — the credential a server-side integration authenticates with, alongside the JWT the app uses. A key (bearer token starting `flk_`) works on every route a JWT does and acts as its owning account, inheriting that account's admin rights and nothing more. Minting, listing and revoking are administrators-only: issuing a key is an operator action, like creating an account, so the control plane does it. The secret is shown once, at creation." },
    { "name": "Usage", "description": "The account's monthly request quota. Every response carries the meter live in `X-Quota-*` headers; `GET /api/usage` reads it without spending a request. The ceiling is the plan's `monthly` allowance (`free` is 10,000), separate from and on top of the per-minute burst limit. Administrator accounts are exempt." },
    {
      "name": "Health",
      "description": "Unauthenticated liveness checks. They touch no database, so they stay up even if MariaDB or Meilisearch are down — that is the point, and also why they leak nothing."
    },
    {
      "name": "Demo",
      "description": "The **only unauthenticated reads** in this API. They exist so the marketing site's search box can show real hits from the real index to somebody who has no key yet — the alternatives being faked results, or a live key sitting in a JavaScript bundle for anyone to lift.\n\nThey are not a free tier, and the shape of them is what says so:\n\n| | Demo | `GET /api/foods/search` |\n|---|---|---|\n| Auth | none | bearer token |\n| Results | first **12**, page 1 only | up to 100 per page, all pages |\n| `?page=` | **ignored** — the page is pinned server-side | honoured |\n| Filters | none | category, brand, source |\n| Rate limit | per IP, tighter than any plan | your plan's `per_minute` |\n| Monthly quota | not metered (no account to bill) | counted |\n| `source` / `external_id` | never | administrators |\n\nEvery hit is a **whole food** — the same object, images and all twenty nutrition fields, that the paid endpoint returns. That is the point of demoing against the real thing: what you evaluate is what you would integrate.\n\nA deployment can turn both routes off (`FOODS_DEMO_ENDPOINTS=false`), in which case they answer `404` — not `403`, because they genuinely do not exist there. Do not build anything on these; they are a shop window, and the contract that comes with a key is `/api/foods/search`."
    }
  ],
  "paths": {
    "/api/auth/login": {
      "post": {
        "tags": ["Auth"],
        "summary": "Log in",
        "operationId": "login",
        "description": "Exchange credentials for a bearer token. The only endpoint in the API that does not need one.\n\nRate limited to **10 attempts per minute per IP**, separately from and in addition to the API's per-minute limit (set by the account's plan).",
        "x-keywords": "token auth password credentials bearer signin",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["email", "password"],
                "properties": {
                  "email": { "type": "string", "format": "email" },
                  "password": { "type": "string" }
                }
              },
              "example": { "email": "you@example.com", "password": "correct-horse-battery" }
            }
          }
        },
        "responses": {
          "200": { "$ref": "#/components/responses/Token" },
          "401": {
            "description": "Invalid credentials.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Error" },
                "example": { "message": "Invalid credentials." }
              }
            }
          },
          "422": { "$ref": "#/components/responses/ValidationError" },
          "429": { "$ref": "#/components/responses/TooManyRequests" }
        }
      }
    },
    "/api/auth/register": {
      "post": {
        "tags": ["Auth"],
        "summary": "Create a user",
        "operationId": "register",
        "description": "**Administrators only.** There is no public signup, and a token is not enough — an ordinary account calling this gets a `403`.\n\nAccess to this API is sold, so creating an account is an operator action. Whether someone *may* have one is a question about payment, and this service knows nothing about payment: whatever takes the money decides, then calls this as an admin.\n\nThe account you create is **never** an admin — the privilege is not inheritable, so an account cannot hand out what it was given.\n\nReturns a token for the newly created user, not for you. Your own token is unaffected and you are still yourself afterwards.",
        "x-keywords": "signup account new user register admin 403 paid",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["name", "email", "password"],
                "properties": {
                  "name": { "type": "string", "maxLength": 255 },
                  "email": { "type": "string", "format": "email", "maxLength": 255, "description": "Must be unique." },
                  "password": { "type": "string", "minLength": 8 }
                }
              },
              "example": { "name": "Ada", "email": "ada@example.com", "password": "at-least-8-chars" }
            }
          }
        },
        "responses": {
          "201": { "$ref": "#/components/responses/Token" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": {
            "description": "Your token is valid, but the account behind it is not an administrator. This is not something you can fix by refreshing.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Error" },
                "example": { "message": "Only an administrator can create users." }
              }
            }
          },
          "422": { "$ref": "#/components/responses/ValidationError" },
          "429": { "$ref": "#/components/responses/TooManyRequests" }
        }
      }
    },
    "/api/auth/me": {
      "get": {
        "tags": ["Auth"],
        "summary": "Current user",
        "operationId": "me",
        "description": "The user behind the token. Also the cheapest way to check a token is still valid.\n\nNot wrapped in `data` — this is the raw user record, not a food resource.\n\n`is_admin` tells you whether this account may create users. It is `false` for every account the API hands out, and no request can change it.",
        "x-keywords": "profile whoami account token valid is_admin",
        "responses": {
          "200": {
            "description": "The authenticated user.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/User" },
                "example": {
                  "id": 1,
                  "name": "You",
                  "email": "you@example.com",
                  "is_admin": true,
                  "email_verified_at": null,
                  "created_at": "2026-07-14T09:12:44.000000Z",
                  "updated_at": "2026-07-14T09:12:44.000000Z"
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "429": { "$ref": "#/components/responses/TooManyRequests" }
        }
      },
      "patch": {
        "tags": ["Auth"],
        "summary": "Change email or password",
        "operationId": "updateMe",
        "description": "Partial update, which is why this is PATCH and PUT is unrouted (405).\n\n`current_password` is **always required**, even when only changing the email.\n\nChanging your password does **not** invalidate your current token — it stays valid until it expires. Log out if you need it dead now.",
        "x-keywords": "profile update password email account put",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["current_password"],
                "properties": {
                  "current_password": { "type": "string", "description": "Your existing password. Always required." },
                  "email": {
                    "type": "string",
                    "format": "email",
                    "maxLength": 255,
                    "description": "Optional; must be unique."
                  },
                  "password": { "type": "string", "minLength": 8, "description": "Optional; the new password." }
                }
              },
              "example": { "current_password": "old-password", "email": "new@example.com" }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The updated user.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/User" }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "422": {
            "description": "Validation failed, or `current_password` was wrong.",
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    { "$ref": "#/components/schemas/ValidationError" },
                    { "$ref": "#/components/schemas/Error" }
                  ]
                },
                "example": { "message": "Current password is incorrect." }
              }
            }
          },
          "429": { "$ref": "#/components/responses/TooManyRequests" }
        }
      }
    },
    "/api/auth/refresh": {
      "post": {
        "tags": ["Auth"],
        "summary": "Refresh token",
        "operationId": "refresh",
        "description": "Trade an expiring token for a fresh one. This — not a re-login — is the correct response to a 401.\n\nWorks for **14 days** from the token's issue and costs no password. Past that window, log in again. The old token is blacklisted.",
        "x-keywords": "token expired renew rotate 401",
        "responses": {
          "200": { "$ref": "#/components/responses/Token" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "429": { "$ref": "#/components/responses/TooManyRequests" }
        }
      }
    },
    "/api/auth/logout": {
      "post": {
        "tags": ["Auth"],
        "summary": "Log out",
        "operationId": "logout",
        "description": "Blacklists the token server-side. It 401s immediately afterwards rather than staying alive until it expires.",
        "x-keywords": "token invalidate blacklist signout",
        "responses": {
          "200": {
            "description": "Logged out.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Error" },
                "example": { "message": "Logged out." }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "429": { "$ref": "#/components/responses/TooManyRequests" }
        }
      }
    },
    "/api/foods/search": {
      "get": {
        "tags": ["Foods"],
        "summary": "Search foods",
        "operationId": "searchFoods",
        "description": "Free-text search, Meilisearch-backed. **This is the one a food picker should call.**\n\nUnlike the other reads, this is **not cached** — every call hits the index live, so a newly indexed food shows up immediately. Search-as-you-type is fine; just debounce it, and mind your plan's per-minute limit.\n\nAccents and typos are handled for you, so all of these find the right thing:\n\n| You type | You get |\n|---|---|\n| `φαβα` | **Φάβα** — accents are folded both ways |\n| `coca` | **Coca-Cola** — partial words match |\n| `coca cola` | **Coca-Cola Zero** — all terms, any order |\n| `chiken` | **Chicken Breast** — typo tolerance |\n| `kotopoulo` | **Κοτόπουλο** — greeklish; Latin spelling finds a Greek name |\n| `bira` | **Μπύρα** — greeklish, collapsed spelling |\n\nGreeklish works for the catalog's Greek-script names: type them in Latin letters and common spelling variants still match. Searching in Greek keeps working too.\n\n**Every hit is a whole food** — the same object `GET /api/foods/{idOrSlug}` returns, images and macros included. A picker can render a result row straight from the search response; there is no need to follow up with a request per hit.\n\nResults are relevance-ranked. Use `GET /api/foods` instead when you're filtering by facet rather than typing words — search always requires a `q`.\n\n`source` (and `external_id`) are **administrators only** — the fields *and* the `source` filter. A non-admin token still gets every hit, just without those keys, and its `source` parameter is ignored: provenance can be neither seen nor filtered on without an admin token.",
        "x-keywords": "query text find lookup meilisearch typo accent greek fuzzy",
        "parameters": [
          {
            "name": "q",
            "in": "query",
            "required": true,
            "description": "The search text.",
            "schema": { "type": "string", "maxLength": 255 },
            "example": "φαβα"
          },
          {
            "name": "category",
            "in": "query",
            "required": false,
            "description": "Restrict to one category slug.",
            "schema": { "$ref": "#/components/schemas/CategorySlug" }
          },
          {
            "name": "brand",
            "in": "query",
            "required": false,
            "description": "Restrict to one brand (exact match). Use `GET /api/brands` for the list of values.",
            "schema": { "type": "string" },
            "example": "Coca-Cola"
          },
          {
            "name": "source",
            "in": "query",
            "required": false,
            "description": "**Administrators only.** Restrict to one source (exact match). Ignored for non-admin tokens — the parameter is dropped, so a non-admin cannot filter by provenance any more than they can see it.",
            "schema": { "type": "string" },
            "example": "user"
          },
          { "$ref": "#/components/parameters/Page" },
          {
            "name": "per_page",
            "in": "query",
            "required": false,
            "description": "Results per page.",
            "schema": { "type": "integer", "minimum": 1, "maximum": 100, "default": 20 }
          },
          {
            "name": "semantic",
            "in": "query",
            "required": false,
            "description": "Add meaning-based matching to the keyword search (Meilisearch hybrid search).\n\nKeyword search matches the words you typed. Semantic search also matches what *means* the same, which is what finds `Γιαούρτι στραγγιστό` for `greek yoghurt` — not one shared character, same product. Both halves run and the hits are merged by relevance. Keyword matching is never switched off, because it is what serves exact product names, brands and greeklish — so `semantic=1` only ever adds candidates, it does not replace the ones you would have got.\n\n**Use it as a second pass, not as your default.** Embedding the query text costs an outbound call to an embedding provider, so a semantic search is slower and more expensive than a keyword one. Search-as-you-type should send plain queries and retry with `semantic=1` only when the first pass returns nothing worth showing.\n\nGreeklish is *not* what this is for — `kotopoulo` finding `Κοτόπουλο` is keyword matching and needs no flag.\n\n**An empty result still means empty.** Meaning-based matching has no natural notion of a miss — left alone it returns the nearest catalog entry however unrelated, so a query for a food we don't stock would come back with a confident, wrong product. Low-relevance hits are therefore dropped server-side, and `semantic=1` returning nothing is a real answer you can act on.\n\n**Not every deployment configures this, and not every plan includes it.** In both cases the parameter is accepted and ignored, and you get the keyword results you would have got anyway — never an error, never a 403. That is deliberate: a client cannot know how an instance is deployed or which tier its key is on, so sending `semantic=1` is always safe. Read `meta.semantic` on the response to find out whether you actually got it.",
            "schema": { "type": "boolean", "default": false },
            "example": true
          }
        ],
        "responses": {
          "200": { "$ref": "#/components/responses/FoodSearchList" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "422": { "$ref": "#/components/responses/ValidationError" },
          "429": { "$ref": "#/components/responses/TooManyRequests" }
        }
      }
    },
    "/api/foods/barcode/{barcode}": {
      "get": {
        "tags": ["Foods"],
        "summary": "Look up by barcode",
        "operationId": "getFoodByBarcode",
        "description": "The scanner endpoint: barcode in, the one food that owns it out.\n\nA barcode maps to at most one food, enforced by a unique index — so this returns a single food, never a list.",
        "x-keywords": "scan scanner ean upc product code",
        "parameters": [
          { "$ref": "#/components/parameters/IfNoneMatch" },
          {
            "name": "barcode",
            "in": "path",
            "required": true,
            "description": "The scanned code, as printed.",
            "schema": { "type": "string", "maxLength": 32 },
            "example": "5201234567890"
          }
        ],
        "responses": {
          "200": { "$ref": "#/components/responses/SingleFoodRevalidated" },
          "304": { "$ref": "#/components/responses/NotModified" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": {
            "description": "No food carries that barcode.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Error" },
                "example": { "message": "No food for that barcode." }
              }
            }
          },
          "429": { "$ref": "#/components/responses/TooManyRequests" }
        }
      }
    },
    "/api/foods": {
      "get": {
        "tags": ["Foods"],
        "summary": "List foods",
        "operationId": "listFoods",
        "description": "Paginated browse by facet, ordered by name. Straight SQL — no free text.\n\nFilters are AND-ed. Alongside the categorical facets there are macro range filters (`calories_min`/`calories_max` and the same for `protein`, `carbs`, `fat`, all per 100 g), an image-presence filter (`has_image`), and `no_macros` for rows with all four core macros at `0`. For fuzzy or brand matching use `GET /api/foods/search`.\n\nTwo filters are here for clients that hold food ids of their own rather than for browsing: **`ids`** hydrates a known set (a favorites list, a meal plan's items) in one round-trip, and **`category_not`** excludes slugs, which `category` cannot express. Read `ids` before using it — a short answer is normal, and results are not in the order you asked for.\n\nNote every range filter is AND-ed, so a disjunction (\"protein ≥ 2 **or** carbs ≥ 5\") cannot be expressed here and has to be applied by the caller.",
        "x-keywords": "browse filter paginate ids batch hydrate category category_not exclude diet vegan brand source verified has_image no_macros calories protein carbs fat min max macros index",
        "parameters": [
          { "$ref": "#/components/parameters/IfNoneMatch" },
          {
            "name": "ids",
            "in": "query",
            "required": false,
            "description": "Hydrate a known set of foods: a comma-separated list of food ids, at most 100. This is for a client that already holds ids of its own — a favorites list, the items of a saved meal plan — rather than for browsing.\n\n**A short answer is normal and must be handled.** An id matching nothing is simply omitted — there is no `404` and no null placeholder — and a soft-deleted food is absent for a non-admin caller. Results come back in `name` order, not the order the ids were given. So match results back by their `id`; never zip them positionally against the list you sent, or one deleted food shifts every association after it.\n\nMore than 100 ids is a `422` rather than a truncated page, so a caller can never silently receive less than it asked for — chunk the list instead. `per_page` defaults to 100 when `ids` is given, so a full set fits one page without asking.",
            "schema": { "type": "string", "pattern": "^\\d+(,\\d+)*$" },
            "example": "12,940,3311"
          },
          {
            "name": "category",
            "in": "query",
            "required": false,
            "description": "Restrict to one or more category slugs, comma-separated. A single slug is a list of one, so `?category=dairy` is unchanged. Unknown slugs match nothing rather than erroring, and slug order is not significant.",
            "schema": { "type": "string" },
            "example": "meat,dairy"
          },
          {
            "name": "category_not",
            "in": "query",
            "required": false,
            "description": "Exclude one or more category slugs, comma-separated — the complement of `category`, which can only name what to include. This is what a diet filter needs: a vegan excludes `meat` **and** `dairy`, which the inclusive filter can only express as one request per remaining category.\n\n**Foods with no category survive an exclusion**, since they are not in the excluded set. Composes with `category`; when both name the same slug, the exclusion wins.",
            "schema": { "type": "string" },
            "example": "meat,dairy"
          },
          {
            "name": "brand",
            "in": "query",
            "required": false,
            "description": "**Exact match**, not a search.",
            "schema": { "type": "string" }
          },
          {
            "name": "source",
            "in": "query",
            "required": false,
            "description": "**Administrators only.** Where the row came from (exact match). Ignored for non-admin tokens — the parameter is dropped, so a non-admin cannot filter by provenance any more than they can see it.",
            "schema": { "type": "string" },
            "example": "user"
          },
          {
            "name": "verified",
            "in": "query",
            "required": false,
            "description": "Only verified (or only unverified) foods.",
            "schema": { "type": "boolean" }
          },
          {
            "name": "has_image",
            "in": "query",
            "required": false,
            "description": "Only foods that have (`true`) or lack (`false`) at least one product image.",
            "schema": { "type": "boolean" }
          },
          {
            "name": "no_macros",
            "in": "query",
            "required": false,
            "description": "When `true`, only foods whose core macros are all exactly `0` — i.e. never given nutrition. The core four (`calories`, `protein_g`, `carbs_g`, `fat_g`) are NOT NULL, so this, not a null check, is how empty rows are found.",
            "schema": { "type": "boolean" }
          },
          {
            "name": "deleted",
            "in": "query",
            "required": false,
            "description": "The operator trash bin. `only` returns just soft-deleted foods, `with` returns live and deleted together; omit for the default (live only). **Administrators only** — a non-admin's `deleted` is silently ignored (the parameter is dropped and the normal live catalog is returned), never a `403`, so the endpoint stays open to any token. Applies to this browse path only, not `GET /api/foods/search`: a deleted food is dropped from the search index, so search never returns one anyway. Each deleted row carries a non-null `deleted_at`.",
            "schema": { "type": "string", "enum": ["with", "only"] }
          },
          {
            "name": "calories_min",
            "in": "query",
            "required": false,
            "description": "Lower bound (inclusive) on `calories` per 100 g.",
            "schema": { "type": "number", "minimum": 0 }
          },
          {
            "name": "calories_max",
            "in": "query",
            "required": false,
            "description": "Upper bound (inclusive) on `calories` per 100 g.",
            "schema": { "type": "number", "minimum": 0 }
          },
          {
            "name": "protein_min",
            "in": "query",
            "required": false,
            "description": "Lower bound (inclusive) on `protein_g` per 100 g.",
            "schema": { "type": "number", "minimum": 0 }
          },
          {
            "name": "protein_max",
            "in": "query",
            "required": false,
            "description": "Upper bound (inclusive) on `protein_g` per 100 g.",
            "schema": { "type": "number", "minimum": 0 }
          },
          {
            "name": "carbs_min",
            "in": "query",
            "required": false,
            "description": "Lower bound (inclusive) on `carbs_g` per 100 g.",
            "schema": { "type": "number", "minimum": 0 }
          },
          {
            "name": "carbs_max",
            "in": "query",
            "required": false,
            "description": "Upper bound (inclusive) on `carbs_g` per 100 g.",
            "schema": { "type": "number", "minimum": 0 }
          },
          {
            "name": "fat_min",
            "in": "query",
            "required": false,
            "description": "Lower bound (inclusive) on `fat_g` per 100 g.",
            "schema": { "type": "number", "minimum": 0 }
          },
          {
            "name": "fat_max",
            "in": "query",
            "required": false,
            "description": "Upper bound (inclusive) on `fat_g` per 100 g.",
            "schema": { "type": "number", "minimum": 0 }
          },
          { "$ref": "#/components/parameters/Page" },
          {
            "name": "per_page",
            "in": "query",
            "required": false,
            "description": "Results per page. Defaults to 50, or to 100 when `ids` is given, so that a full id set fits one page.",
            "schema": { "type": "integer", "minimum": 1, "maximum": 100, "default": 50 }
          }
        ],
        "responses": {
          "200": { "$ref": "#/components/responses/FoodListRevalidated" },
          "304": { "$ref": "#/components/responses/NotModified" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "422": { "$ref": "#/components/responses/ValidationError" },
          "429": { "$ref": "#/components/responses/TooManyRequests" }
        }
      },
      "post": {
        "tags": ["Foods"],
        "summary": "Create a food",
        "operationId": "createFood",
        "description": "Add a product to the catalog. Nutrition is per 100 g.\n\nA duplicate `(name, brand)` is rejected as a **422**, not a 500 — and the message names the food that already exists, since your next move is almost always to PATCH that one instead. Matching is case-insensitive and brandless foods collide with each other.\n\nBarcodes passed in the `barcodes` array that already belong to another food are **skipped silently**, so one bad code can't fail an otherwise valid write. Use `POST /api/foods/{idOrSlug}/barcodes` when you need to know where a code went.",
        "x-keywords": "add new insert contribute store product",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/FoodCreate" },
              "example": {
                "name": "Greek Yogurt 2%",
                "brand": "Total",
                "category": "dairy",
                "calories": 73,
                "protein_g": 10.3,
                "carbs_g": 3.6,
                "fat_g": 2,
                "serving_size_g": 170,
                "serving_description": "1 κεσεδάκι",
                "barcodes": ["5201054001234"]
              }
            }
          }
        },
        "responses": {
          "201": { "$ref": "#/components/responses/SingleFood" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/AdminOnly" },
          "422": {
            "description": "Validation failed, or `(name, brand)` already exists.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/ValidationError" },
                "example": {
                  "message": "\"Greek Yogurt 2%\" from \"Total\" already exists (id 44, slug greek-yogurt-2).",
                  "errors": {
                    "name": [
                      "\"Greek Yogurt 2%\" from \"Total\" already exists (id 44, slug greek-yogurt-2)."
                    ]
                  }
                }
              }
            }
          },
          "429": { "$ref": "#/components/responses/TooManyRequests" }
        }
      }
    },
    "/api/foods/bulk-delete": {
      "post": {
        "tags": ["Foods"],
        "summary": "Delete many foods",
        "operationId": "bulkDeleteFoods",
        "description": "Delete a list of foods in one call.\n\nThe batch is **not a transaction**: ids that match no food come back in `missing` rather than failing the request, so a stale selection still deletes the rows that do exist. Each delete is a **soft delete**, the same path as `DELETE /api/foods/{idOrSlug}` — the row is marked deleted (not removed) and drops out of every read, the `foods` cache tag is flushed, and the search index updated per row.\n\nOnly numeric ids are accepted here, not slugs.",
        "x-keywords": "bulk mass delete remove batch multiple selection",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["ids"],
                "properties": {
                  "ids": {
                    "type": "array",
                    "minItems": 1,
                    "items": { "type": "integer" },
                    "description": "Numeric food ids to delete."
                  }
                }
              },
              "example": { "ids": [812, 813, 44] }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Per-item summary. `deleted` counts the ids that existed; `missing` lists the ones that matched nothing.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["deleted", "missing"],
                  "properties": {
                    "deleted": { "type": "integer" },
                    "missing": { "type": "array", "items": { "type": "integer" } }
                  }
                },
                "example": { "deleted": 2, "missing": [44] }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/AdminOnly" },
          "422": { "$ref": "#/components/responses/ValidationError" },
          "429": { "$ref": "#/components/responses/TooManyRequests" }
        }
      }
    },
    "/api/foods/bulk-update": {
      "post": {
        "tags": ["Foods"],
        "summary": "Update many foods",
        "operationId": "bulkUpdateFoods",
        "description": "Apply one set of fields — any of `category`, `brand`, `verified` — to a list of foods. At least one field is required, or the call is a **422**.\n\nAs with bulk-delete the batch is **not a transaction**: a food that can't take the change comes back in `failed` with a reason while the rest succeed. The expected failure is assigning a `brand` that would make a food a duplicate of another on the case-insensitive `(name, brand)` index. Send `brand: null` to clear it.\n\nEach write goes through the model, so the search index and the `foods` cache are kept in step per row.",
        "x-keywords": "bulk mass update assign category brand verified batch multiple selection",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["ids"],
                "properties": {
                  "ids": {
                    "type": "array",
                    "minItems": 1,
                    "items": { "type": "integer" }
                  },
                  "category": { "$ref": "#/components/schemas/CategorySlug" },
                  "brand": { "type": ["string", "null"], "maxLength": 255 },
                  "verified": { "type": "boolean" }
                }
              },
              "example": { "ids": [812, 813], "category": "dairy", "verified": true }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Per-item summary. `updated` counts the rows changed; `failed` lists the ones that couldn't be, each with a reason.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["updated", "failed"],
                  "properties": {
                    "updated": { "type": "integer" },
                    "failed": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "id": { "type": "integer" },
                          "message": { "type": "string" }
                        }
                      }
                    }
                  }
                },
                "example": {
                  "updated": 2,
                  "failed": [
                    { "id": 44, "message": "\"Greek Yogurt 2%\" would duplicate another food with this brand." }
                  ]
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/AdminOnly" },
          "422": { "$ref": "#/components/responses/ValidationError" },
          "429": { "$ref": "#/components/responses/TooManyRequests" }
        }
      }
    },
    "/api/foods/search-batch": {
      "post": {
        "tags": ["Foods"],
        "summary": "Search many terms at once",
        "operationId": "searchFoodsBatch",
        "description": "Administrators only. Runs `GET /api/foods/search` over a list of terms and answers with a hit count plus the top hits for each — one round trip instead of one per term.\n\nBuilt for the moderation queue: it answers \"does the catalog already have something like this?\" for a whole page of pending contributions at once. The queue's own `duplicate_of` flag is an **exact** clash on the case-insensitive `(name, brand)` index; this is the looser question, so it also catches a typo, a brand spelled differently, or a Greek name against a greeklish one.\n\nPOST because the terms are a list, not a query string. It is still a read — nothing is written, and like `foods/search` nothing is cached or ETagged.\n\nBlank, whitespace-only and duplicate terms are dropped rather than rejected, so `data` holds one entry per **distinct** non-empty query and may be shorter than what you sent, in order of first appearance. **Match results back by `query`, never positionally.**\n\n`total` is the full match count; `hits` is capped at `per_query`. Render the count without opening anything, and fetch nothing further to show the hits — each one is a whole food, the same object `GET /api/foods/{idOrSlug}` returns.\n\nThere is no `semantic` option here: hybrid search costs an embedding call per query, and a batch would multiply it by the batch size. Call `foods/search` for the one term that needs it.\n\nOne request against your plan's rate limit and monthly quota, whatever the batch size — but the search engine still does one query per term, so keep batches to a page.",
        "x-keywords": "batch bulk multi search many queries duplicates moderation contributions similar",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["queries"],
                "properties": {
                  "queries": {
                    "type": "array",
                    "minItems": 1,
                    "maxItems": 100,
                    "items": { "type": ["string", "null"], "maxLength": 255 },
                    "description": "The search texts. Same matching as `foods/search` — accents, typos and greeklish included. An empty or whitespace-only entry is skipped, not an error."
                  },
                  "per_query": {
                    "type": "integer",
                    "minimum": 1,
                    "maximum": 20,
                    "default": 5,
                    "description": "How many hits to return per query. `total` is unaffected."
                  }
                }
              },
              "example": { "queries": ["Γάλα ΔΕΛΤΑ 1.5%", "coca cola zero"], "per_query": 5 }
            }
          }
        },
        "responses": {
          "200": {
            "description": "One entry per distinct non-empty query.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "required": ["query", "total", "hits"],
                        "properties": {
                          "query": {
                            "type": "string",
                            "description": "The query these results are for, trimmed. Match on this."
                          },
                          "total": {
                            "type": "integer",
                            "description": "How many foods matched in all — may exceed the length of `hits`."
                          },
                          "hits": {
                            "type": "array",
                            "items": { "$ref": "#/components/schemas/Food" },
                            "description": "The best matches, most relevant first, at most `per_query` of them."
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/AdminOnly" },
          "422": { "$ref": "#/components/responses/ValidationError" },
          "429": { "$ref": "#/components/responses/TooManyRequests" }
        }
      }
    },
    "/api/foods/{idOrSlug}": {
      "parameters": [
        { "$ref": "#/components/parameters/IdOrSlug" }
      ],
      "get": {
        "tags": ["Foods"],
        "summary": "Get a food",
        "operationId": "getFood",
        "description": "One food, by id or slug.\n\n`image_url` is the primary image (position 1); `image_urls` is all of them, primary first. Both are absolute and directly fetchable. `image_url` is `null` for a food that has no image yet — the catalog is imported before its images are, so this says nothing about the food being valid.",
        "x-keywords": "show single detail id slug fetch",
        "responses": {
          "200": { "$ref": "#/components/responses/SingleFoodRevalidated" },
          "304": { "$ref": "#/components/responses/NotModified" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/FoodNotFound" },
          "429": { "$ref": "#/components/responses/TooManyRequests" }
        },
        "parameters": [
          { "$ref": "#/components/parameters/IfNoneMatch" }
        ]
      },
      "patch": {
        "tags": ["Foods"],
        "summary": "Update a food",
        "operationId": "updateFood",
        "description": "Partial update — send only the fields you're changing; omitted fields keep their stored value.\n\n**PATCH, not PUT.** Every field validates as `sometimes` here, which is partial-update semantics. Serving that under PUT would be a lie: PUT means \"replace the resource with this representation\", and a food carries ~20 nutrition columns that would all have to null out. `PUT` is left unrouted and returns **405**.\n\nSending `barcodes` **replaces** the whole set — codes not in the array are detached.",
        "x-keywords": "edit patch put modify change fix",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/FoodUpdate" },
              "example": { "verified": true, "calories": 74 }
            }
          }
        },
        "responses": {
          "200": { "$ref": "#/components/responses/SingleFood" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/AdminOnly" },
          "404": { "$ref": "#/components/responses/FoodNotFound" },
          "422": { "$ref": "#/components/responses/ValidationError" },
          "429": { "$ref": "#/components/responses/TooManyRequests" }
        }
      },
      "delete": {
        "tags": ["Foods"],
        "summary": "Delete a food",
        "operationId": "deleteFood",
        "description": "Soft-deletes the food: it is marked deleted, not removed, and disappears from every read (list, show, barcode lookup and search). Its barcodes and image rows are left in place but become unreachable with it. The food keeps holding its slug and (name, brand), so recreating an identical food is rejected until the original is restored.",
        "x-keywords": "remove destroy drop",
        "responses": {
          "200": {
            "description": "Deleted.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Error" },
                "example": { "message": "Deleted." }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/AdminOnly" },
          "404": { "$ref": "#/components/responses/FoodNotFound" },
          "429": { "$ref": "#/components/responses/TooManyRequests" }
        }
      }
    },
    "/api/foods/{idOrSlug}/restore": {
      "parameters": [
        { "$ref": "#/components/parameters/IdOrSlug" }
      ],
      "post": {
        "tags": ["Foods"],
        "summary": "Restore a deleted food",
        "operationId": "restoreFood",
        "description": "Undo a soft delete. The food reappears in list, show, barcode lookup and search on the next request, and it releases nothing in the meantime — its slug and `(name, brand)` were held while deleted, so a restore never collides.\n\nFind the food to restore with `GET /api/foods?deleted=only`. **Administrators only.** Idempotent: restoring a food that isn't deleted returns it unchanged, so a double-click is harmless.",
        "x-keywords": "undelete recover undo untrash deleted trash",
        "responses": {
          "200": { "$ref": "#/components/responses/SingleFood" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/AdminOnly" },
          "404": { "$ref": "#/components/responses/FoodNotFound" },
          "429": { "$ref": "#/components/responses/TooManyRequests" }
        }
      }
    },
    "/api/foods/{idOrSlug}/force": {
      "parameters": [
        { "$ref": "#/components/parameters/IdOrSlug" }
      ],
      "delete": {
        "tags": ["Foods"],
        "summary": "Permanently delete a food",
        "operationId": "forceDeleteFood",
        "description": "The irreversible delete, from the trash bin. The row is removed for good — along with its barcode and image rows (FK cascade) and its stored image files — and **cannot be restored**.\n\nOnly a food that is **already soft-deleted** can be purged: a live food answers `409`, so permanent deletion is always a deliberate two-step (`DELETE /api/foods/{idOrSlug}` first, then this). **Administrators only.**",
        "x-keywords": "purge permanent forever hard delete destroy irreversible trash empty",
        "responses": {
          "200": {
            "description": "Permanently deleted.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Error" },
                "example": { "message": "Permanently deleted." }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/AdminOnly" },
          "404": { "$ref": "#/components/responses/FoodNotFound" },
          "409": {
            "description": "The food is still live — soft-delete it first. Only a trashed food can be permanently deleted.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Error" },
                "example": { "message": "Delete the food first — permanent deletion only applies to an already-deleted food." }
              }
            }
          },
          "429": { "$ref": "#/components/responses/TooManyRequests" }
        }
      }
    },
    "/api/foods/{idOrSlug}/barcodes": {
      "parameters": [
        { "$ref": "#/components/parameters/IdOrSlug" }
      ],
      "post": {
        "tags": ["Barcodes"],
        "summary": "Attach a barcode",
        "operationId": "addBarcode",
        "description": "Attach a scanned barcode to a food.\n\n| Status | Means |\n|---|---|\n| `201` | Attached |\n| `200` | Already this food's — a no-op, so a double scan is safe |\n| `409` | Owned by **another** food; the body's `food_id` says which |\n\n**Prefer this over the `barcodes` array** when attaching a single code. Create and update skip a conflicting barcode silently; this one tells you where it went.",
        "x-keywords": "scan link ean upc attach 409 conflict",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["barcode"],
                "properties": {
                  "barcode": { "type": "string", "maxLength": 32 }
                }
              },
              "example": { "barcode": "5201234567890" }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The barcode was already attached to this food — no-op.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/FoodEnvelope" }
              }
            }
          },
          "201": {
            "description": "Attached. Returns the food with its updated barcode set.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/FoodEnvelope" }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/AdminOnly" },
          "404": { "$ref": "#/components/responses/FoodNotFound" },
          "409": {
            "description": "That barcode belongs to a different food.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "message": { "type": "string" },
                    "food_id": { "type": "integer", "description": "The food that already owns this barcode." }
                  }
                },
                "example": { "message": "Barcode is already attached to another food.", "food_id": 44 }
              }
            }
          },
          "422": { "$ref": "#/components/responses/ValidationError" },
          "429": { "$ref": "#/components/responses/TooManyRequests" }
        }
      }
    },
    "/api/foods/{idOrSlug}/barcodes/{barcode}": {
      "parameters": [
        { "$ref": "#/components/parameters/IdOrSlug" },
        {
          "name": "barcode",
          "in": "path",
          "required": true,
          "description": "The code to detach.",
          "schema": { "type": "string", "maxLength": 32 },
          "example": "5201234567890"
        }
      ],
      "delete": {
        "tags": ["Barcodes"],
        "summary": "Detach a barcode",
        "operationId": "removeBarcode",
        "description": "Detach a barcode, freeing it to be attached elsewhere. Idempotent — detaching one that was never attached still 200s.",
        "x-keywords": "unlink remove ean upc",
        "responses": {
          "200": {
            "description": "Detached.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Error" },
                "example": { "message": "Detached." }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/AdminOnly" },
          "404": { "$ref": "#/components/responses/FoodNotFound" },
          "429": { "$ref": "#/components/responses/TooManyRequests" }
        }
      }
    },
    "/api/foods/{idOrSlug}/images": {
      "parameters": [
        { "$ref": "#/components/parameters/IdOrSlug" }
      ],
      "post": {
        "tags": ["Images"],
        "summary": "Upload an image",
        "operationId": "uploadImage",
        "description": "`multipart/form-data` with an `image` field. jpeg, png or webp, **2 MB max**.\n\n```bash\ncurl -sS -X POST https://api.food-lib.gr/api/foods/812/images \\\n  -H \"Authorization: Bearer $TOKEN\" -H 'Accept: application/json' \\\n  -F 'image=@product.jpg'\n```\n\nAppends at the next free position; the first image is the primary one that `image_url` points at. Over 2 MB is a **422**, not a 413. Don't set `Content-Type` by hand — curl sets the multipart boundary for you.",
        "x-keywords": "photo picture upload multipart file jpeg png webp",
        "requestBody": {
          "required": true,
          "content": {
            "multipart/form-data": {
              "schema": {
                "type": "object",
                "required": ["image"],
                "properties": {
                  "image": {
                    "type": "string",
                    "format": "binary",
                    "description": "jpeg, png or webp. Max 2 MB (2048 KB)."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Uploaded. Returns the food with its updated image list.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/FoodEnvelope" }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/AdminOnly" },
          "404": { "$ref": "#/components/responses/FoodNotFound" },
          "422": { "$ref": "#/components/responses/ValidationError" },
          "429": { "$ref": "#/components/responses/TooManyRequests" }
        }
      }
    },
    "/api/foods/{idOrSlug}/images/{imageId}": {
      "parameters": [
        { "$ref": "#/components/parameters/IdOrSlug" },
        {
          "name": "imageId",
          "in": "path",
          "required": true,
          "description": "A numeric image id — the only path segment here that isn't a food.",
          "schema": { "type": "integer" },
          "example": 3
        }
      ],
      "delete": {
        "tags": ["Images"],
        "summary": "Delete an image",
        "operationId": "deleteImage",
        "description": "Removes the row **and** the file on disk.\n\nDeleting position 1 promotes nothing; `image_url` then points at whatever remains lowest.",
        "x-keywords": "photo remove picture destroy",
        "responses": {
          "200": {
            "description": "Deleted.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Error" },
                "example": { "message": "Deleted." }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/AdminOnly" },
          "404": {
            "description": "No such food, or no such image on that food.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Error" },
                "example": { "message": "Image not found." }
              }
            }
          },
          "429": { "$ref": "#/components/responses/TooManyRequests" }
        }
      }
    },
    "/api/food-contributions": {
      "post": {
        "tags": ["Contributions"],
        "summary": "Submit a food for review",
        "operationId": "createFoodContribution",
        "description": "Propose a food for the catalog. **The only write in this API an ordinary token may make** — everything under `/api/foods` is administrators-only.\n\nThe submission lands in a moderation queue with `approved: false` and changes nothing in the catalog. An operator reviews it, may correct it, and either approves it — which is what creates the food — or rejects it.\n\nUnlike `POST /api/foods`, a name that already exists is **accepted**, not rejected: you have no way to know the catalog already holds it, and the duplicate is the reviewer's to resolve. For the same reason `barcodes` here are recorded as claims and are not linked to anything until approval — a code that turns out to belong to another food is simply skipped then.\n\nSet `source` to something that identifies **your client**, not just the data's origin: it is what tells a reviewer an app submission from a partner integration, and it survives approval onto the catalog food as its provenance.\n\nPhotos may be sent as `image_urls` when you have links rather than files. They are fetched during this request, so read `images` in the response to see which ones actually landed.\n\n`approved` is not a writable field. It is set only by the approve endpoint, which creates the food in the same transaction.",
        "x-keywords": "contribute submit propose suggest user review queue moderation image photo url source",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/FoodContributionCreate" },
              "example": {
                "name": "Γιαούρτι Στραγγιστό 2%",
                "brand": "Test Dairy",
                "category": "dairy",
                "calories": 60,
                "protein_g": 10,
                "carbs_g": 4,
                "fat_g": 2,
                "barcodes": ["5201054001234"],
                "source": "my-app:user",
                "image_urls": ["https://example.com/yogurt-front.jpg"]
              }
            }
          }
        },
        "responses": {
          "201": { "$ref": "#/components/responses/SingleFoodContribution" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "422": { "$ref": "#/components/responses/ValidationError" },
          "429": { "$ref": "#/components/responses/TooManyRequests" }
        }
      },
      "get": {
        "tags": ["Contributions"],
        "summary": "The review queue",
        "operationId": "listFoodContributions",
        "description": "Administrators only. Pending submissions, oldest first — the fair order to review in, and it keeps a long-waiting submission visible instead of buried under today's.\n\nNot cached: a submission that arrived a second ago must be in the list.\n\nEvery pending row carries `duplicate_of`, naming the catalog food it would clash with, so a queue full of already-known foods can be spotted without opening them one at a time. `duplicates=1` narrows the list to exactly those.",
        "x-keywords": "queue pending review moderation contributions list duplicates",
        "parameters": [
          {
            "name": "status",
            "in": "query",
            "required": false,
            "description": "Which submissions to list. Defaults to the pending queue.",
            "schema": { "type": "string", "enum": ["pending", "approved", "all"], "default": "pending" }
          },
          {
            "name": "q",
            "in": "query",
            "required": false,
            "description": "Substring match on name or brand. A plain `LIKE` — the queue is small and is deliberately not in the search index.",
            "schema": { "type": "string", "maxLength": 255 }
          },
          {
            "name": "duplicates",
            "in": "query",
            "required": false,
            "description": "Only proposals the catalog already holds — the ones approving would 409 on. **Implies `status=pending`** and overrides it: an approved contribution matches the food it itself created, so including approved rows would flag every one of them.\n\nMatching is the catalog's own `(name_key, brand_key)` normalization, so this means duplicate in exactly the sense the unique index does. Soft-deleted foods count — they still own their name.",
            "schema": { "type": "boolean", "default": false }
          },
          { "$ref": "#/components/parameters/Page" },
          {
            "name": "per_page",
            "in": "query",
            "required": false,
            "schema": { "type": "integer", "minimum": 1, "maximum": 100, "default": 50 }
          }
        ],
        "responses": {
          "200": {
            "description": "A page of contributions.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/FoodContributionPage" }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/AdminOnly" },
          "422": { "$ref": "#/components/responses/ValidationError" },
          "429": { "$ref": "#/components/responses/TooManyRequests" }
        }
      }
    },
    "/api/food-contributions/{contribution}": {
      "parameters": [{ "$ref": "#/components/parameters/ContributionId" }],
      "get": {
        "tags": ["Contributions"],
        "summary": "Read one contribution",
        "operationId": "getFoodContribution",
        "description": "Administrators only.",
        "x-keywords": "contribution show detail review",
        "responses": {
          "200": { "$ref": "#/components/responses/SingleFoodContribution" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/AdminOnly" },
          "404": { "$ref": "#/components/responses/ContributionNotFound" },
          "429": { "$ref": "#/components/responses/TooManyRequests" }
        }
      },
      "patch": {
        "tags": ["Contributions"],
        "summary": "Correct a contribution",
        "operationId": "updateFoodContribution",
        "description": "Administrators only. Fix a submission before accepting it — the usual case is correcting macros or assigning a category, then approving.\n\nOmitted fields keep their stored value. An **already approved** contribution is frozen and answers `409`: the catalog food is its own row from that moment on, so an edit here would look like it changed something and wouldn't. Edit the food instead.",
        "x-keywords": "edit fix correct contribution before approving",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/FoodContributionUpdate" },
              "example": { "calories": 61, "category": "dairy" }
            }
          }
        },
        "responses": {
          "200": { "$ref": "#/components/responses/SingleFoodContribution" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/AdminOnly" },
          "404": { "$ref": "#/components/responses/ContributionNotFound" },
          "409": {
            "description": "Already approved, so no longer editable.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/ContributionConflict" },
                "example": {
                  "message": "This contribution was already approved; edit the food instead.",
                  "food_id": 5231
                }
              }
            }
          },
          "422": { "$ref": "#/components/responses/ValidationError" },
          "429": { "$ref": "#/components/responses/TooManyRequests" }
        }
      },
      "delete": {
        "tags": ["Contributions"],
        "summary": "Reject a contribution",
        "operationId": "rejectFoodContribution",
        "description": "Administrators only. A real delete — there is no trash for the queue, and an unreviewed proposal an operator threw away is not a record worth keeping.\n\nAn **approved** contribution is refused with `409`: it is the audit trail linking a catalog food back to whoever proposed it. Delete the food itself if that is what you mean.",
        "x-keywords": "reject discard decline delete contribution",
        "responses": {
          "200": {
            "description": "Rejected.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Error" },
                "example": { "message": "Rejected." }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/AdminOnly" },
          "404": { "$ref": "#/components/responses/ContributionNotFound" },
          "409": {
            "description": "Already approved, so kept as its food's record.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/ContributionConflict" },
                "example": {
                  "message": "This contribution was already approved and is kept as its food's record.",
                  "food_id": 5231
                }
              }
            }
          },
          "429": { "$ref": "#/components/responses/TooManyRequests" }
        }
      }
    },
    "/api/food-contributions/{contribution}/approve": {
      "parameters": [{ "$ref": "#/components/parameters/ContributionId" }],
      "post": {
        "tags": ["Contributions"],
        "summary": "Accept a contribution into the catalog",
        "operationId": "approveFoodContribution",
        "description": "Administrators only. Creates the food, links the contribution to it and flips `approved` — **all in one transaction**, so the flag can never be true without a food behind it. This is the only thing that sets `approved`.\n\nThe new food is created **unverified**: accepting a proposal is not the same as having checked its nutrition against a label. Mark it verified separately if you did.\n\nBarcodes claimed by the contribution are linked to the new food, except any already owned by another food, which are skipped — one taken code must not sink an otherwise good approval.\n\nA `(name, brand)` the catalog already holds answers **409** rather than the `500` the unique index would produce, and names the food it clashed with. Soft-deleted foods still hold their name, and say so via `deleted: true`. Your move is to reject the duplicate, or rename it if it really is a different product.",
        "x-keywords": "approve accept publish promote contribution catalog",
        "responses": {
          "201": {
            "description": "Approved; the food now exists.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "message": { "type": "string" },
                    "food_id": { "type": "integer", "description": "The catalog food this created." },
                    "contribution": { "$ref": "#/components/schemas/FoodContribution" }
                  },
                  "required": ["message", "food_id", "contribution"]
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/AdminOnly" },
          "404": { "$ref": "#/components/responses/ContributionNotFound" },
          "409": {
            "description": "Already approved, or the catalog already holds this `(name, brand)`.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/ContributionConflict" },
                "examples": {
                  "duplicate": {
                    "summary": "The food already exists",
                    "value": {
                      "message": "This food already exists (id 44, slug greek-yogurt-2). Reject the contribution, or rename it.",
                      "food_id": 44,
                      "deleted": false
                    }
                  },
                  "alreadyApproved": {
                    "summary": "Approved by someone else already",
                    "value": {
                      "message": "This contribution was already approved.",
                      "food_id": 5231
                    }
                  }
                }
              }
            }
          },
          "429": { "$ref": "#/components/responses/TooManyRequests" }
        }
      }
    },
    "/api/food-contributions/bulk-approve": {
      "post": {
        "tags": ["Contributions"],
        "summary": "Accept many contributions",
        "operationId": "bulkApproveFoodContributions",
        "description": "Administrators only. Runs the same approval as `POST /api/food-contributions/{contribution}/approve` over a list — same transaction, same barcode handling, same rules — and reports each outcome.\n\nThe batch is **not a transaction**. A row that can't be approved (already approved, or a `(name, brand)` the catalog already holds) lands in `failed` and the rest still go through. On this queue that is the ordinary case rather than an edge, since holding proposals that may already exist is what the queue is for.\n\nRows are processed in id order, which is what makes a batch holding two proposals for the same new food deterministic: the first creates it, the second is reported as a clash against what the first just created.\n\nEvery food created here is **unverified**, exactly as in the single-row route.",
        "x-keywords": "bulk mass approve accept batch multiple contributions queue selection",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["ids"],
                "properties": {
                  "ids": {
                    "type": "array",
                    "minItems": 1,
                    "items": { "type": "integer" },
                    "description": "Contribution ids."
                  }
                }
              },
              "example": { "ids": [4120, 4121, 4130] }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Per-item summary. `approved: 0` is a normal outcome, not an error.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["approved", "foods", "failed", "missing"],
                  "properties": {
                    "approved": { "type": "integer", "description": "How many became catalog foods." },
                    "foods": {
                      "type": "array",
                      "description": "The approved contributions, each paired with the food it created.",
                      "items": {
                        "type": "object",
                        "required": ["id", "food_id"],
                        "properties": {
                          "id": { "type": "integer" },
                          "food_id": { "type": "integer" }
                        }
                      }
                    },
                    "failed": { "$ref": "#/components/schemas/ContributionBulkFailures" },
                    "missing": { "$ref": "#/components/schemas/ContributionBulkMissing" }
                  }
                },
                "example": {
                  "approved": 1,
                  "foods": [{ "id": 4120, "food_id": 9210 }],
                  "failed": [
                    {
                      "id": 4121,
                      "message": "This food already exists (id 44, slug greek-yogurt-2). Reject the contribution, or rename it.",
                      "food_id": 44,
                      "deleted": false
                    }
                  ],
                  "missing": [4130]
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/AdminOnly" },
          "422": { "$ref": "#/components/responses/ValidationError" },
          "429": { "$ref": "#/components/responses/TooManyRequests" }
        }
      }
    },
    "/api/food-contributions/bulk-reject": {
      "post": {
        "tags": ["Contributions"],
        "summary": "Reject many contributions",
        "operationId": "bulkRejectFoodContributions",
        "description": "Administrators only. Deletes a list of pending proposals — **permanently, with no undo**, exactly as `DELETE /api/food-contributions/{contribution}` does. There is no soft delete on this table.\n\nApproved rows are skipped, never deleted, and come back in `failed`: an approved contribution is the record of who proposed a catalog food, and a bulk selection that happens to include one must not be what destroys that.\n\nPair it with `duplicates=1` on the queue to clear out proposals the catalog already holds.",
        "x-keywords": "bulk mass reject delete discard batch multiple contributions queue selection",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["ids"],
                "properties": {
                  "ids": {
                    "type": "array",
                    "minItems": 1,
                    "items": { "type": "integer" },
                    "description": "Contribution ids."
                  }
                }
              },
              "example": { "ids": [4120, 4121, 4130] }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Per-item summary. `rejected` counts the rows actually deleted.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["rejected", "failed", "missing"],
                  "properties": {
                    "rejected": { "type": "integer" },
                    "failed": { "$ref": "#/components/schemas/ContributionBulkFailures" },
                    "missing": { "$ref": "#/components/schemas/ContributionBulkMissing" }
                  }
                },
                "example": {
                  "rejected": 2,
                  "failed": [
                    {
                      "id": 4130,
                      "message": "This contribution was already approved and is kept as its food's record.",
                      "food_id": 5231
                    }
                  ],
                  "missing": []
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/AdminOnly" },
          "422": { "$ref": "#/components/responses/ValidationError" },
          "429": { "$ref": "#/components/responses/TooManyRequests" }
        }
      }
    },
    "/api/food-contributions/{contribution}/images": {
      "parameters": [{ "$ref": "#/components/parameters/ContributionId" }],
      "post": {
        "tags": ["Contributions"],
        "summary": "Attach a photo to a proposal",
        "operationId": "uploadFoodContributionImage",
        "description": "Administrators only — **unlike the POST that creates a contribution**. Proposing a food's numbers is open to any token; uploading arbitrary binaries through the same door is a different risk, and nothing in the app asks for it. This exists so an operator can give a submission that arrived without one a product shot before it reaches the catalog.\n\n`multipart/form-data` with an `image` field. jpeg, png or webp, **2 MB max** — the same rules as `POST /api/foods/{idOrSlug}/images`, and over 2 MB is a **422**, not a 413.\n\nFiles are stored under `contributions/{id}/`, kept apart from catalog media so an unreviewed upload can never be mistaken for it. Approving the contribution **copies** them into the food's own directory, keeping position — so position 1 becomes the food's primary image. Copy rather than move: the contribution is frozen afterwards as the record of what was proposed, and that record includes the photo.\n\nRefused with **409** once approved; add the image to the food instead.",
        "x-keywords": "photo picture upload multipart contribution proposal review queue",
        "requestBody": {
          "required": true,
          "content": {
            "multipart/form-data": {
              "schema": {
                "type": "object",
                "required": ["image"],
                "properties": {
                  "image": {
                    "type": "string",
                    "format": "binary",
                    "description": "jpeg, png or webp. Max 2 MB (2048 KB)."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Uploaded. Returns the contribution with its updated image list.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/FoodContributionEnvelope" }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/AdminOnly" },
          "404": { "$ref": "#/components/responses/ContributionNotFound" },
          "409": {
            "description": "Already approved — the food owns its images now.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/ContributionConflict" },
                "example": {
                  "message": "This contribution was already approved; add the image to the food instead.",
                  "food_id": 5231
                }
              }
            }
          },
          "422": { "$ref": "#/components/responses/ValidationError" },
          "429": { "$ref": "#/components/responses/TooManyRequests" }
        }
      }
    },
    "/api/food-contributions/{contribution}/images/{image}": {
      "parameters": [
        { "$ref": "#/components/parameters/ContributionId" },
        {
          "name": "image",
          "in": "path",
          "required": true,
          "description": "A numeric image id — the only path segment here that isn't a contribution.",
          "schema": { "type": "integer" },
          "example": 3
        }
      ],
      "delete": {
        "tags": ["Contributions"],
        "summary": "Remove a photo from a proposal",
        "operationId": "deleteFoodContributionImage",
        "description": "Administrators only. Removes the row **and** the file on disk.\n\nRefused with **409** once approved: by then the file has been copied onto the food, and this row is the audit trail of what was proposed. Delete the food's copy on the food.",
        "x-keywords": "photo remove picture destroy contribution proposal",
        "responses": {
          "200": {
            "description": "Deleted.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Error" },
                "example": { "message": "Deleted." }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/AdminOnly" },
          "404": {
            "description": "No such contribution, or no such image on it.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Error" },
                "example": { "message": "Image not found." }
              }
            }
          },
          "409": {
            "description": "Already approved — the submission is kept as its food's record.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/ContributionConflict" }
              }
            }
          },
          "429": { "$ref": "#/components/responses/TooManyRequests" }
        }
      }
    },
    "/api/categories": {
      "get": {
        "tags": ["Reference"],
        "summary": "List categories",
        "operationId": "listCategories",
        "description": "The nine fixed categories. The set does not change.\n\nThese slugs are what `category` accepts everywhere else in the API.",
        "x-keywords": "taxonomy slugs dairy meat grain vegetable fruit snack beverage legume other",
        "responses": {
          "200": {
            "description": "Every category, ordered by name.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": { "$ref": "#/components/schemas/Category" }
                    }
                  }
                },
                "example": {
                  "data": [
                    { "id": 1, "name": "Beverage", "slug": "beverage" },
                    { "id": 2, "name": "Dairy", "slug": "dairy" }
                  ]
                }
              }
            },
            "headers": {
              "ETag": { "$ref": "#/components/headers/ETag" }
            }
          },
          "304": { "$ref": "#/components/responses/NotModified" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "429": { "$ref": "#/components/responses/TooManyRequests" }
        },
        "parameters": [
          { "$ref": "#/components/parameters/IfNoneMatch" }
        ]
      }
    },
    "/api/brands": {
      "get": {
        "tags": ["Reference"],
        "summary": "List brands",
        "operationId": "listBrands",
        "description": "Every distinct non-empty `brand` in the catalog, alphabetically.\n\nBrands are a DISTINCT over `foods.brand`, not their own table — `brand` is free text and is NULL for most bulk-imported rows today. A flat array of strings, not objects.",
        "x-keywords": "manufacturer distinct names",
        "responses": {
          "200": {
            "description": "Distinct brand names.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": { "type": "string" }
                    }
                  }
                },
                "example": {
                  "data": ["3ALPHA", "Total", "Φάγε"]
                }
              }
            },
            "headers": {
              "ETag": { "$ref": "#/components/headers/ETag" }
            }
          },
          "304": { "$ref": "#/components/responses/NotModified" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "429": { "$ref": "#/components/responses/TooManyRequests" }
        },
        "parameters": [
          { "$ref": "#/components/parameters/IfNoneMatch" }
        ]
      }
    },
    "/api/sources": {
      "get": {
        "tags": ["Reference"],
        "summary": "List sources",
        "operationId": "listSources",
        "description": "Every distinct non-empty `source` in the catalog, alphabetically. **Administrators only** — `source` is withheld from non-admin food payloads, so the list of them is withheld the same way.\n\nSources are a DISTINCT over `foods.source`, not their own table: free text minted by whatever created the row — usually the client that submitted it, or the name of the batch it arrived in. Soft-deleted foods are counted too, so a source is still offered while filtering the trash. A flat array of strings, not objects.",
        "x-keywords": "provenance distinct origin admin",
        "responses": {
          "200": {
            "description": "Distinct source names.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": { "type": "string" }
                    }
                  }
                },
                "example": {
                  "data": ["my-app:user", "user"]
                }
              }
            },
            "headers": {
              "ETag": { "$ref": "#/components/headers/ETag" }
            }
          },
          "304": { "$ref": "#/components/responses/NotModified" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/AdminOnly" },
          "429": { "$ref": "#/components/responses/TooManyRequests" }
        },
        "parameters": [
          { "$ref": "#/components/parameters/IfNoneMatch" }
        ]
      }
    },
    "/api/usage": {
      "get": {
        "tags": ["Usage"],
        "summary": "Your monthly quota meter",
        "operationId": "getUsage",
        "description": "How much of your plan's monthly request quota you have spent, how much is left, and when it rolls over. Reports the meter for the account behind the token — reading it does NOT count against the quota, and is never rate-limited by it, so you can call it even while over.\n\nEvery other endpoint returns the same figures live in the `X-Quota-Limit` / `X-Quota-Remaining` / `X-Quota-Reset` response headers; this is the one place to read them without making a billable call.\n\nAdministrator accounts are exempt from the quota: for them `unlimited` is `true` and the numeric fields are `null`.",
        "x-keywords": "quota usage meter monthly limit remaining plan billing",
        "responses": {
          "200": {
            "description": "The caller's current monthly meter.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/UsageEnvelope" },
                "examples": {
                  "metered": {
                    "summary": "A Free-plan account mid-month",
                    "value": { "data": { "plan": "free", "unlimited": false, "used": 153, "limit": 10000, "remaining": 9847, "resets_at": "2026-08-01T00:00:00+00:00" } }
                  },
                  "admin": {
                    "summary": "An administrator (exempt)",
                    "value": { "data": { "plan": "pro", "unlimited": true, "used": 0, "limit": null, "remaining": null, "resets_at": "2026-08-01T00:00:00+00:00" } }
                  }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "429": { "$ref": "#/components/responses/TooManyRequests" }
        }
      }
    },
    "/api/users": {
      "get": {
        "tags": ["Users"],
        "summary": "List users",
        "operationId": "listUsers",
        "description": "**Administrators only** — an ordinary token gets a `403`.\n\nThe whole operator roster, ordered by name. There is no pagination: this is a handful of staff accounts, not the catalog. Not cached — a just-granted admin bit shows on the very next read.\n\nPass `deleted=only` for the trash bin (soft-deleted accounts) or `deleted=with` for both; each deleted row carries a non-null `deleted_at`. Restore one with `POST /api/users/{user}/restore`.",
        "x-keywords": "operators roster staff admins list users deleted trash",
        "parameters": [
          {
            "name": "deleted",
            "in": "query",
            "required": false,
            "description": "`only` returns just soft-deleted accounts, `with` returns live and deleted together; omit for the default (live only).",
            "schema": { "type": "string", "enum": ["with", "only"] }
          }
        ],
        "responses": {
          "200": {
            "description": "Every user, ordered by name.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": { "$ref": "#/components/schemas/User" }
                    }
                  }
                },
                "example": {
                  "data": [
                    { "id": 1, "name": "Ada", "email": "ada@example.com", "is_admin": true, "created_at": "2026-07-14T21:00:00+00:00" },
                    { "id": 2, "name": "Grace", "email": "grace@example.com", "is_admin": false, "created_at": "2026-07-15T09:30:00+00:00" }
                  ]
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": {
            "description": "Your token is valid, but the account behind it is not an administrator.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Error" },
                "example": { "message": "Only an administrator can list users." }
              }
            }
          },
          "429": { "$ref": "#/components/responses/TooManyRequests" }
        }
      }
    },
    "/api/users/{user}": {
      "parameters": [
        {
          "name": "user",
          "in": "path",
          "required": true,
          "schema": { "type": "integer" },
          "description": "The user's numeric id, from `GET /api/users`."
        }
      ],
      "patch": {
        "tags": ["Users"],
        "summary": "Grant or revoke admin",
        "operationId": "updateUser",
        "description": "**Administrators only** — an ordinary token gets a `403`. This is the only way to make another account an administrator, besides the `foods:make-admin` CLI on the server.\n\n`is_admin` is the only field. The privilege is never reachable from any other write path — it is not mass-assignable, so a food or a profile update cannot set it; only this endpoint can.\n\nThe **last** administrator cannot be demoted — demoting yourself included. Promote someone else first, or the panel locks everyone out and only the CLI can recover it.",
        "x-keywords": "promote demote admin privilege grant revoke role make-admin",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["is_admin"],
                "properties": {
                  "is_admin": { "type": "boolean", "description": "true grants the admin bit, false revokes it." }
                }
              },
              "example": { "is_admin": true }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The updated user.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": { "data": { "$ref": "#/components/schemas/User" } }
                },
                "example": {
                  "data": { "id": 2, "name": "Grace", "email": "grace@example.com", "is_admin": true, "created_at": "2026-07-15T09:30:00+00:00" }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": {
            "description": "Your token is valid, but the account behind it is not an administrator.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Error" },
                "example": { "message": "Only an administrator can change a user's privileges." }
              }
            }
          },
          "404": {
            "description": "No user with that id.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Error" },
                "example": { "message": "No query results for model [App\\Models\\User]." }
              }
            }
          },
          "422": {
            "description": "The body failed validation (missing or non-boolean `is_admin`), or you tried to demote the last administrator. The last-admin case is a plain `message`; a validation failure also carries `errors`.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Error" },
                "example": { "message": "This is the last administrator — promote someone else before revoking this one." }
              }
            }
          },
          "429": { "$ref": "#/components/responses/TooManyRequests" }
        }
      },
      "delete": {
        "tags": ["Users"],
        "summary": "Delete a user",
        "operationId": "deleteUser",
        "description": "**Administrators only** — an ordinary token gets a `403`.\n\nSoft-deletes the operator account: the row is marked deleted, not removed. The account disappears from `GET /api/users`, and its token — still cryptographically valid — stops resolving, so the deleted user's next request gets a `401`. Deleting an account revokes its access on the very next request without touching the token.\n\nThe account keeps holding its email, so re-registering that address is rejected as a `422` until it is restored.\n\nTwo accounts cannot be deleted: the **last** administrator (promote someone else first, or the panel locks everyone out and only the `foods:make-admin` CLI can recover it), and your **own** account (it would kill the token you are calling with).",
        "x-keywords": "remove destroy deactivate revoke account",
        "responses": {
          "200": {
            "description": "The account was soft-deleted.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Error" },
                "example": { "message": "Deleted." }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": {
            "description": "Your token is valid, but the account behind it is not an administrator.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Error" },
                "example": { "message": "Only an administrator can delete users." }
              }
            }
          },
          "404": {
            "description": "No user with that id (an already-deleted user 404s too).",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Error" },
                "example": { "message": "No query results for model [App\\Models\\User]." }
              }
            }
          },
          "422": {
            "description": "You tried to delete the last administrator, or your own account.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Error" },
                "example": { "message": "This is the last administrator — promote someone else before deleting this one." }
              }
            }
          },
          "429": { "$ref": "#/components/responses/TooManyRequests" }
        }
      }
    },
    "/api/users/{user}/plan": {
      "parameters": [
        {
          "name": "user",
          "in": "path",
          "required": true,
          "schema": { "type": "integer" },
          "description": "The user's numeric id, from `GET /api/users`."
        }
      ],
      "post": {
        "tags": ["Users"],
        "summary": "Set an account's plan",
        "operationId": "setUserPlan",
        "description": "**Administrators only** — an ordinary token gets a `403`.\n\nSets the tier the account's rate limit (and, later, its monthly quota) is read from. The control plane calls this after payment decides the tier — billing stays on that side; this only records which tier to enforce.\n\n`plan` must be one of the configured tiers (currently `free`, `pro`); an unknown value is a `422`. The privilege is never reachable from any other write path — `plan` is not mass-assignable, exactly like `is_admin`.",
        "x-keywords": "plan tier upgrade downgrade subscription rate limit quota billing",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["plan"],
                "properties": {
                  "plan": { "type": "string", "enum": ["free", "pro"], "description": "The tier to move the account to." }
                }
              },
              "example": { "plan": "pro" }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The updated account.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": { "data": { "$ref": "#/components/schemas/User" } }
                },
                "example": {
                  "data": { "id": 2, "name": "Grace", "email": "grace@example.com", "is_admin": false, "plan": "pro", "created_at": "2026-07-15T09:30:00+00:00" }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": {
            "description": "Your token is valid, but the account behind it is not an administrator.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Error" },
                "example": { "message": "Only an administrator can change an account's plan." }
              }
            }
          },
          "404": {
            "description": "No user with that id.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Error" },
                "example": { "message": "No query results for model [App\\Models\\User]." }
              }
            }
          },
          "422": { "$ref": "#/components/responses/ValidationError" },
          "429": { "$ref": "#/components/responses/TooManyRequests" }
        }
      }
    },
    "/api/users/{user}/restore": {
      "parameters": [
        {
          "name": "user",
          "in": "path",
          "required": true,
          "schema": { "type": "integer" },
          "description": "The user's numeric id, from `GET /api/users?deleted=only`."
        }
      ],
      "post": {
        "tags": ["Users"],
        "summary": "Restore a deleted user",
        "operationId": "restoreUser",
        "description": "**Administrators only** — an ordinary token gets a `403`.\n\nUndo a soft delete: the account returns to `GET /api/users` and its existing token starts resolving again, so its access is re-granted on the next request. Find deleted accounts with `GET /api/users?deleted=only`. Idempotent: restoring an account that isn't deleted returns it unchanged.",
        "x-keywords": "undelete recover undo untrash reinstate deleted trash",
        "responses": {
          "200": {
            "description": "The restored user.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": { "data": { "$ref": "#/components/schemas/User" } }
                },
                "example": {
                  "data": { "id": 2, "name": "Grace", "email": "grace@example.com", "is_admin": false, "created_at": "2026-07-15T09:30:00+00:00", "deleted_at": null }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": {
            "description": "Your token is valid, but the account behind it is not an administrator.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Error" },
                "example": { "message": "Only an administrator can restore users." }
              }
            }
          },
          "404": {
            "description": "No user with that id.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Error" },
                "example": { "message": "No query results for model [App\\Models\\User]." }
              }
            }
          },
          "429": { "$ref": "#/components/responses/TooManyRequests" }
        }
      }
    },
    "/api/users/{user}/force": {
      "parameters": [
        {
          "name": "user",
          "in": "path",
          "required": true,
          "schema": { "type": "integer" },
          "description": "The user's numeric id, from `GET /api/users?deleted=only`."
        }
      ],
      "delete": {
        "tags": ["Users"],
        "summary": "Permanently delete a user",
        "operationId": "forceDeleteUser",
        "description": "**Administrators only** — an ordinary token gets a `403`.\n\nThe irreversible delete, from the trash bin: the account row is removed for good and **cannot be restored**. Only an **already soft-deleted** account can be purged — a live one answers `409`, so this is always a deliberate two-step (`DELETE /api/users/{user}` first, then this). Because the account is already trashed, the last-admin and self guards have already been satisfied.",
        "x-keywords": "purge permanent forever hard delete destroy irreversible trash",
        "responses": {
          "200": {
            "description": "Permanently deleted.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Error" },
                "example": { "message": "Permanently deleted." }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": {
            "description": "Your token is valid, but the account behind it is not an administrator.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Error" },
                "example": { "message": "Only an administrator can delete users." }
              }
            }
          },
          "404": {
            "description": "No user with that id.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Error" },
                "example": { "message": "No query results for model [App\\Models\\User]." }
              }
            }
          },
          "409": {
            "description": "The account is still live — soft-delete it first. Only a trashed account can be permanently deleted.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Error" },
                "example": { "message": "Delete the account first — permanent deletion only applies to an already-deleted account." }
              }
            }
          },
          "429": { "$ref": "#/components/responses/TooManyRequests" }
        }
      }
    },
    "/api/demo/search": {
      "get": {
        "tags": ["Demo"],
        "summary": "Search the catalog without a token",
        "operationId": "demoSearch",
        "security": [],
        "description": "The same Meilisearch query as `GET /api/foods/search`, rendering the same `Food` objects, with no `Authorization` header — this is what the landing page's search box calls.\n\nWhat it does not do is page. The page number is fixed at `1` **server-side**, so `?page=` is not merely undocumented, it is unreachable: you can see the top `12` hits for any query and you can never walk past them. Together with an IP-keyed rate limit tighter than any plan's, that is what makes a keyless search endpoint safe to leave open — the catalog cannot be enumerated through it.\n\n`meta` answers the two questions a demo gets asked:\n\n- **`total`** is the real match count, usually larger than `shown`. It is how the response says \"there is more here\" without handing it over.\n- **`took_ms`** is server-side wall-clock for the index round trip and hydrating the hits — not JSON encoding, not the network. Compare it to what your own client measures; the gap is your latency to Helsinki, not ours.\n\n`semantic=1` opts into hybrid (keyword + vector) matching where the deployment allows it, and degrades to keyword-only where it doesn't — read `meta.semantic` for what actually ran, exactly as on the paid endpoint.\n\n`404` means this deployment has the demo switched off.",
        "x-keywords": "demo public unauthenticated no token try playground sandbox search",
        "parameters": [
          {
            "name": "q",
            "in": "query",
            "required": true,
            "description": "The search text. Greek, greeklish, English and typos all work — see the table on `GET /api/foods/search`.",
            "schema": { "type": "string", "maxLength": 255 },
            "example": "φαβα"
          },
          {
            "name": "semantic",
            "in": "query",
            "required": false,
            "description": "Opt into meaning-based matching. Safe to send anywhere: it is ignored rather than rejected where hybrid search is unavailable, and `meta.semantic` reports which one ran.",
            "schema": { "type": "boolean" },
            "example": true
          }
        ],
        "responses": {
          "200": {
            "description": "The top hits, and what the search cost.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/DemoSearchPage" },
                "example": {
                  "data": [{ "id": 812, "name": "Φάβα Σαντορίνης", "brand": null, "slug": "fava-santorinis", "category": "grain", "barcodes": ["5201234567890"], "image_url": "https://api.food-lib.gr/storage/foods/1223921/1.jpg", "calories": 341, "protein_g": 21.3, "carbs_g": 58.2, "fat_g": 1.9, "verified": false }],
                  "meta": { "total": 4, "shown": 4, "limit": 12, "semantic": false, "took_ms": 3.4 }
                }
              }
            }
          },
          "404": {
            "description": "The demo is switched off on this deployment.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Error" }
              }
            }
          },
          "422": { "$ref": "#/components/responses/ValidationError" },
          "429": { "$ref": "#/components/responses/TooManyRequests" }
        }
      }
    },
    "/api/demo/stats": {
      "get": {
        "tags": ["Demo"],
        "summary": "How big the catalog is, and how fast it is growing",
        "operationId": "demoStats",
        "security": [],
        "description": "Live counts, so a page that advertises the catalog cannot overstate it — or understate it after an import. Cached under the same `foods` tag as the reference endpoints, so a catalog write is reflected on the next read.\n\n`foods` counts what a caller can actually reach: soft-deleted rows are excluded.\n\n`coverage` answers the question a size cannot: which fields you can rely on. It counts foods carrying each **optional** field, against the `foods` total in the same object. Energy, protein, carbs and fat are absent from it deliberately — those columns are NOT NULL, so every food has them and there is no percentage to publish. Expect the rest to be uneven: photos cover most of the catalog, barcodes very little of it so far.\n\n`growth` is the same idea applied to time: the question that usually decides a purchase is whether the catalog will still cover your users next year, and that is a question about slope rather than size. Both blocks are deliberately source-agnostic — `source` is operator-only on every read path, so a provenance breakdown here would hand back exactly what that redaction keeps.",
        "x-keywords": "demo stats counts size catalog how many foods images growth added over time monthly new coverage completeness which fields are filled",
        "responses": {
          "200": {
            "description": "Catalog counts, and when the catalog grew.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/DemoStatsEnvelope" },
                "example": {
                  "data": {
                    "foods": 4535,
                    "images": 9977,
                    "barcodes": 77,
                    "categories": 9,
                    "brands": 101,
                    "coverage": {
                      "foods": 4535,
                      "sugars": 4473,
                      "saturated_fat": 4485,
                      "sodium": 4448,
                      "fibre": 2243,
                      "serving_size": 123,
                      "brand": 328,
                      "photo": 4261,
                      "barcode": 73
                    },
                    "growth": {
                      "monthly": [
                        { "month": "2026-06", "added": 39, "total": 4473 },
                        { "month": "2026-07", "added": 22, "total": 4495 },
                        { "month": "2026-08", "added": 40, "total": 4535 }
                      ],
                      "added_last_30_days": 62,
                      "first_added_on": "2026-03-18",
                      "latest": { "name": "Μήλο", "added_on": "2026-08-06" }
                    }
                  }
                }
              }
            }
          },
          "404": {
            "description": "The demo is switched off on this deployment.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Error" }
              }
            }
          },
          "429": { "$ref": "#/components/responses/TooManyRequests" }
        }
      }
    },
    "/": {
      "get": {
        "tags": ["Health"],
        "summary": "Liveness",
        "operationId": "root",
        "description": "What a human or an uptime monitor hits to confirm the service is alive without holding a token. Touches no database and is not rate limited.",
        "x-keywords": "ping status uptime monitor health root",
        "security": [],
        "responses": {
          "200": {
            "description": "The service is up.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "name": { "type": "string" },
                    "status": { "type": "string" },
                    "docs": { "type": "string", "format": "uri", "description": "The human-readable reference." },
                    "openapi": {
                      "type": "string",
                      "format": "uri",
                      "description": "This description, so a client can find the contract in one hop."
                    }
                  }
                },
                "example": {
                  "name": "Foods API",
                  "status": "ok",
                  "docs": "https://api.food-lib.gr/docs",
                  "openapi": "https://api.food-lib.gr/docs/openapi.json"
                }
              }
            }
          }
        }
      }
    },
    "/up": {
      "get": {
        "tags": ["Health"],
        "summary": "Health check",
        "operationId": "up",
        "description": "Laravel's own health check, wired in `bootstrap/app.php`. This is what the deploy workflow smoke-tests after a rollout. Not rate limited.",
        "x-keywords": "uptime monitor deploy smoke status",
        "security": [],
        "responses": {
          "200": {
            "description": "The application booted.",
            "content": {
              "text/html": {
                "schema": { "type": "string" }
              }
            }
          }
        }
      }
    },
    "/api/keys": {
      "get": {
        "tags": ["Keys"],
        "summary": "List API keys",
        "operationId": "listApiKeys",
        "description": "**Administrators only** — an ordinary token gets a `403`.\n\nEvery key, newest first, optionally scoped to one account with `user_id`. Revoked and expired keys are included: this is a key's whole life, with `revoked_at` and `expires_at` telling its state. The secret is never here — a key is only ever recognisable by its `prefix`.",
        "x-keywords": "api keys tokens credentials list integrations",
        "parameters": [
          {
            "name": "user_id",
            "in": "query",
            "required": false,
            "description": "Scope the list to one account's keys.",
            "schema": { "type": "integer" }
          }
        ],
        "responses": {
          "200": {
            "description": "The keys, newest first.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": { "type": "array", "items": { "$ref": "#/components/schemas/ApiKey" } }
                  }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/AdminOnly" },
          "429": { "$ref": "#/components/responses/TooManyRequests" }
        }
      },
      "post": {
        "tags": ["Keys"],
        "summary": "Create an API key",
        "operationId": "createApiKey",
        "description": "**Administrators only** — an ordinary token gets a `403`.\n\nMints a key for the named account. The plaintext secret is returned **once**, as a `secret` field beside `data`; only its hash is stored, so it can never be shown again — a lost key is reissued, not recovered. The key acts as `user_id`'s account and inherits exactly its rights.",
        "x-keywords": "api key token credential create mint issue provision",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["user_id", "name"],
                "properties": {
                  "user_id": { "type": "integer", "description": "The account the key authenticates as." },
                  "name": { "type": "string", "maxLength": 255, "description": "A human label, e.g. \"Production\"." },
                  "expires_at": { "type": ["string", "null"], "format": "date-time", "description": "Optional expiry; must be in the future. Omit for a key that never expires." }
                }
              },
              "example": { "user_id": 2, "name": "Production" }
            }
          }
        },
        "responses": {
          "201": {
            "description": "The key was created. `secret` is the plaintext, shown here and nowhere else again.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data", "secret"],
                  "properties": {
                    "data": { "$ref": "#/components/schemas/ApiKey" },
                    "secret": { "type": "string", "description": "The full plaintext key (`flk_…`). Store it now — it is unrecoverable." }
                  }
                },
                "example": {
                  "data": {
                    "id": 10,
                    "user_id": 2,
                    "name": "Production",
                    "prefix": "flk_a1b2c3d4e5f6",
                    "last_used_at": null,
                    "expires_at": null,
                    "revoked_at": null,
                    "created_at": "2026-07-24T12:00:00+00:00"
                  },
                  "secret": "flk_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0"
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/AdminOnly" },
          "422": { "$ref": "#/components/responses/ValidationError" },
          "429": { "$ref": "#/components/responses/TooManyRequests" }
        }
      }
    },
    "/api/keys/{apiKey}": {
      "parameters": [
        {
          "name": "apiKey",
          "in": "path",
          "required": true,
          "schema": { "type": "integer" },
          "description": "The key's numeric id, from `GET /api/keys`. Not the secret."
        }
      ],
      "delete": {
        "tags": ["Keys"],
        "summary": "Revoke an API key",
        "operationId": "revokeApiKey",
        "description": "**Administrators only** — an ordinary token gets a `403`.\n\nRevokes the key: it stops authenticating on its next use. The row is kept, not deleted, so its audit trail and usage history outlive it. Idempotent — revoking an already-revoked key still returns `200`.",
        "x-keywords": "revoke disable delete api key token credential",
        "responses": {
          "200": {
            "description": "The key was revoked (or already was).",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Error" },
                "example": { "message": "Revoked." }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/AdminOnly" },
          "404": {
            "description": "No key with that id.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Error" },
                "example": { "message": "No query results for model [App\\Models\\ApiKey]." }
              }
            }
          },
          "429": { "$ref": "#/components/responses/TooManyRequests" }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "bearerFormat": "JWT",
        "description": "A JWT from `POST /api/auth/login`. Send it as `Authorization: Bearer <token>`.\n\nValid for 60 minutes; refreshable for 14 days. Opaque — do not parse it."
      }
    },
    "headers": {
      "ETag": {
        "description": "An opaque fingerprint of this body. Send it back as `If-None-Match` on the next read of the same URL and you get a `304` with no body if nothing changed.",
        "schema": { "type": "string" },
        "example": "\"2e5997c7e5110fc8e63636c6c2ece65e\""
      },
      "XQuotaLimit": {
        "description": "The account plan's total requests for the current calendar month.",
        "schema": { "type": "integer" },
        "example": 10000
      },
      "XQuotaRemaining": {
        "description": "Requests left in the current month before the quota `429`s. Falls to `0` at the cap; read `GET /api/usage` for the same figures without spending one.",
        "schema": { "type": "integer" },
        "example": 9847
      },
      "XQuotaReset": {
        "description": "Unix timestamp (seconds) at which the month's counter rolls over — 00:00 UTC on the 1st of next month.",
        "schema": { "type": "integer" },
        "example": 1722470400
      }
    },
    "examples": {
      "Food": {
        "summary": "A food with images, barcodes and full macros",
        "value": {
          "data": {
            "id": 812,
            "name": "Φάβα Σαντορίνης",
            "brand": null,
            "slug": "fava-santorinis",
            "category": "legume",
            "barcodes": ["5201234567890"],
            "image_url": "https://api.food-lib.gr/storage/foods/12345/1.jpg",
            "image_urls": [
              "https://api.food-lib.gr/storage/foods/12345/1.jpg",
              "https://api.food-lib.gr/storage/foods/12345/2.jpg"
            ],
            "images": [
              {"id": 4501, "url": "https://api.food-lib.gr/storage/foods/12345/1.jpg", "position": 1},
              {"id": 4502, "url": "https://api.food-lib.gr/storage/foods/12345/2.jpg", "position": 2}
            ],
            "serving_size_g": 60,
            "serving_description": "1 μερίδα",
            "calories": 333,
            "protein_g": 20.5,
            "carbs_g": 58.2,
            "fat_g": 1.6,
            "fiber_g": 10.4,
            "sugars_g": 2.1,
            "added_sugars_g": null,
            "saturated_fat_g": 0.3,
            "monounsat_fat_g": null,
            "polyunsat_fat_g": null,
            "trans_fat_g": null,
            "sodium_mg": 12,
            "cholesterol_mg": null,
            "source": "user",
            "external_id": "12345",
            "verified": false,
            "created_at": "2026-07-14T09:12:44+00:00",
            "updated_at": "2026-07-14T09:12:44+00:00"
          }
        }
      }
    },
    "parameters": {
      "IfNoneMatch": {
        "name": "If-None-Match",
        "in": "header",
        "required": false,
        "description": "The `ETag` from your last read of this URL. If the body still hashes to it you get a `304` and no payload; otherwise the current body. Cheap to always send.",
        "schema": { "type": "string" },
        "example": "\"2e5997c7e5110fc8e63636c6c2ece65e\""
      },
      "IdOrSlug": {
        "name": "idOrSlug",
        "in": "path",
        "required": true,
        "description": "A numeric food id or its slug — `812` and `fava-santorinis` both resolve to the same food.",
        "schema": { "type": "string" },
        "example": "fava-santorinis"
      },
      "Page": {
        "name": "page",
        "in": "query",
        "required": false,
        "description": "1-based page number.",
        "schema": { "type": "integer", "minimum": 1, "default": 1 }
      },
      "ContributionId": {
        "name": "contribution",
        "in": "path",
        "required": true,
        "description": "The numeric id of a queued contribution. Unlike a food, there is no slug — a proposal has no stable public identity.",
        "schema": { "type": "integer" },
        "example": 17
      }
    },
    "responses": {
      "Token": {
        "description": "A bearer token.",
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/TokenResponse" },
            "example": {
              "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…",
              "token_type": "bearer",
              "expires_in": 3600
            }
          }
        }
      },
      "SingleFood": {
        "description": "One food, wrapped in `data`.",
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/FoodEnvelope" },
            "examples": {
              "food": { "$ref": "#/components/examples/Food" }
            }
          }
        }
      },
      "SingleFoodRevalidated": {
        "description": "One food, wrapped in `data`. Carries an `ETag`.",
        "headers": {
          "ETag": { "$ref": "#/components/headers/ETag" }
        },
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/FoodEnvelope" },
            "examples": {
              "food": { "$ref": "#/components/examples/Food" }
            }
          }
        }
      },
      "FoodList": {
        "description": "A page of foods.",
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/FoodPage" }
          }
        }
      },
      "FoodSearchList": {
        "description": "A page of search hits.",
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/FoodSearchPage" }
          }
        }
      },
      "FoodListRevalidated": {
        "description": "A page of foods. Carries an `ETag`.",
        "headers": {
          "ETag": { "$ref": "#/components/headers/ETag" }
        },
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/FoodPage" }
          }
        }
      },
      "NotModified": {
        "description": "Your `If-None-Match` still matches — the body is unchanged and is not resent. Use the copy you already hold.",
        "headers": {
          "ETag": { "$ref": "#/components/headers/ETag" }
        }
      },
      "Unauthorized": {
        "description": "No token, an expired token, or a blacklisted one. Refresh — don't re-login.",
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/Error" },
            "example": { "message": "Unauthenticated." }
          }
        }
      },
      "SingleFoodContribution": {
        "description": "One queued contribution, wrapped in `data`.",
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/FoodContributionEnvelope" }
          }
        }
      },
      "ContributionNotFound": {
        "description": "No contribution with that id — it may have been rejected.",
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/Error" },
            "example": { "message": "No query results for model [App\\Models\\FoodContribution] 17" }
          }
        }
      },
      "FoodNotFound": {
        "description": "No food with that id or slug.",
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/Error" },
            "example": { "message": "Food not found." }
          }
        }
      },
      "AdminOnly": {
        "description": "Your token is valid, but the account behind it is not an administrator. The catalog is operator-managed: every write is administrators-only, while reads and search are open to any token. This is not something a refresh can fix.",
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/Error" },
            "example": { "message": "Only an administrator can modify the catalog." }
          }
        }
      },
      "ValidationError": {
        "description": "Validation failed. The body names the field and why.",
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/ValidationError" },
            "example": {
              "message": "The name field is required.",
              "errors": {
                "name": ["The name field is required."]
              }
            }
          }
        }
      },
      "TooManyRequests": {
        "description": "Rate limited. Two ceilings can produce this, both set by the account's plan:\n\n- **Per-minute burst** — a short-window cap (`free` is 120/min), keyed per account; login is limited separately to 10/min per IP. The body reads `Too Many Attempts.` and `Retry-After` says when to try again.\n- **Monthly quota** — the plan's total requests for the calendar month (`free` is 10,000). The body reads `Monthly request quota exceeded for your plan.`, `X-Quota-Remaining` is `0`, and `X-Quota-Reset` says when the month rolls over. A refresh cannot help; wait for the reset or upgrade the plan.",
        "headers": {
          "Retry-After": {
            "description": "Seconds to wait before retrying (per-minute limit only).",
            "schema": { "type": "integer" }
          },
          "X-Quota-Remaining": { "$ref": "#/components/headers/XQuotaRemaining" },
          "X-Quota-Reset": { "$ref": "#/components/headers/XQuotaReset" }
        },
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/Error" },
            "example": { "message": "Too Many Attempts." }
          }
        }
      }
    },
    "schemas": {
      "CategorySlug": {
        "type": "string",
        "description": "One of the nine fixed category slugs.",
        "enum": ["dairy", "meat", "grain", "vegetable", "fruit", "snack", "beverage", "legume", "other"]
      },
      "Category": {
        "type": "object",
        "properties": {
          "id": { "type": "integer" },
          "name": { "type": "string", "description": "Display name." },
          "slug": { "$ref": "#/components/schemas/CategorySlug" }
        },
        "required": ["id", "name", "slug"]
      },
      "Nutrition": {
        "type": "object",
        "description": "Per 100 g. Always. Required macros are never null; the rest are null when nobody recorded them — which is not the same as zero.",
        "properties": {
          "calories": { "type": "number", "description": "kcal per 100 g." },
          "protein_g": { "type": "number" },
          "carbs_g": { "type": "number" },
          "fat_g": { "type": "number" },
          "fiber_g": {
            "type": ["number", "null"]
          },
          "sugars_g": {
            "type": ["number", "null"]
          },
          "added_sugars_g": {
            "type": ["number", "null"]
          },
          "saturated_fat_g": {
            "type": ["number", "null"]
          },
          "monounsat_fat_g": {
            "type": ["number", "null"]
          },
          "polyunsat_fat_g": {
            "type": ["number", "null"]
          },
          "trans_fat_g": {
            "type": ["number", "null"]
          },
          "sodium_mg": {
            "type": ["number", "null"]
          },
          "cholesterol_mg": {
            "type": ["number", "null"]
          }
        }
      },
      "Food": {
        "type": "object",
        "description": "A catalog product. Nutrition is per 100 g.",
        "allOf": [
          { "$ref": "#/components/schemas/Nutrition" }
        ],
        "properties": {
          "id": { "type": "integer" },
          "name": { "type": "string" },
          "brand": {
            "type": ["string", "null"],
            "description": "Free text, and NULL for most bulk-imported rows."
          },
          "slug": {
            "type": "string",
            "description": "Generated from the name; Greek is transliterated (Φάβα → fava). Stable unless the food is renamed."
          },
          "category": {
            "oneOf": [
              { "$ref": "#/components/schemas/CategorySlug" },
              { "type": "null" }
            ]
          },
          "barcodes": {
            "type": "array",
            "description": "Every code attached to this food. A code belongs to at most one food.",
            "items": { "type": "string" }
          },
          "image_url": {
            "type": ["string", "null"],
            "format": "uri",
            "description": "The primary image (position 1), absolute. `null` when the food has no image yet — the catalog is imported before its images are, so this says nothing about the food being valid."
          },
          "image_urls": {
            "type": "array",
            "description": "Every image, primary first. All absolute and directly fetchable.",
            "items": { "type": "string", "format": "uri" }
          },
          "images": {
            "type": "array",
            "description": "The same images as `image_urls`, carrying the `id` and `position` an editor needs to delete a specific one (`DELETE /foods/{idOrSlug}/images/{imageId}`). Additive to `image_url`/`image_urls`, which keep the shape the mobile client decodes.",
            "items": {
              "type": "object",
              "properties": {
                "id": { "type": "integer", "description": "Image id, for the delete route." },
                "url": { "type": "string", "format": "uri" },
                "position": { "type": "integer", "description": "1 is primary; used for ordering." }
              },
              "required": ["id", "url", "position"]
            }
          },
          "serving_size_g": {
            "type": ["number", "null"],
            "description": "Display metadata only — it is **not** the basis of the nutrition figures."
          },
          "serving_description": {
            "type": ["string", "null"],
            "example": "1 μερίδα"
          },
          "source": {
            "type": ["string", "null"],
            "description": "**Administrators only.** Which client or batch created the row (`user`, or a name the client supplied). The key is present only when the requesting token is an admin, and omitted entirely for every other token — on every read, including `GET /api/foods/{idOrSlug}`, the list and search.",
            "example": "user"
          },
          "external_id": {
            "type": ["string", "null"],
            "description": "**Administrators only.** The id this row carried in its source system. Present only for an admin token; omitted entirely for every other token, the same way as `source`."
          },
          "verified": { "type": "boolean", "description": "Whether a human has checked the nutrition." },
          "created_at": {
            "type": ["string", "null"],
            "format": "date-time"
          },
          "updated_at": {
            "type": ["string", "null"],
            "format": "date-time"
          },
          "deleted_at": {
            "type": ["string", "null"],
            "format": "date-time",
            "description": "`null` for a live food; the moment it was soft-deleted otherwise. Non-null only in the admin trash view (`GET /api/foods?deleted=only|with`)."
          }
        },
        "required": ["id", "name", "brand", "slug", "calories", "protein_g", "carbs_g", "fat_g", "verified"]
      },
      "FoodEnvelope": {
        "type": "object",
        "description": "Single-food responses are wrapped in `data`.",
        "properties": {
          "data": { "$ref": "#/components/schemas/Food" }
        },
        "required": ["data"]
      },
      "FoodPage": {
        "type": "object",
        "description": "A page of foods: `data` plus the pagination envelope.",
        "properties": {
          "data": {
            "type": "array",
            "items": { "$ref": "#/components/schemas/Food" }
          },
          "links": { "$ref": "#/components/schemas/PaginationLinks" },
          "meta": { "$ref": "#/components/schemas/PaginationMeta" }
        },
        "required": ["data", "links", "meta"]
      },
      "FoodSearchPage": {
        "type": "object",
        "description": "A page of search hits. Same shape as `FoodPage`, with one extra field in `meta` reporting whether the search was semantic.",
        "properties": {
          "data": {
            "type": "array",
            "items": { "$ref": "#/components/schemas/Food" }
          },
          "links": { "$ref": "#/components/schemas/PaginationLinks" },
          "meta": { "$ref": "#/components/schemas/SearchMeta" }
        },
        "required": ["data", "links", "meta"]
      },
      "FoodWritable": {
        "type": "object",
        "description": "The fields a client may write. Nutrition is per 100 g.",
        "allOf": [
          { "$ref": "#/components/schemas/Nutrition" }
        ],
        "properties": {
          "name": { "type": "string", "maxLength": 255 },
          "brand": {
            "type": ["string", "null"],
            "maxLength": 255
          },
          "category": {
            "oneOf": [
              { "$ref": "#/components/schemas/CategorySlug" },
              { "type": "null" }
            ],
            "description": "A category slug that must already exist."
          },
          "serving_size_g": {
            "type": ["number", "null"],
            "minimum": 0
          },
          "serving_description": {
            "type": ["string", "null"],
            "maxLength": 255
          },
          "source": { "type": "string", "maxLength": 255 },
          "external_id": {
            "type": ["string", "null"],
            "maxLength": 255
          },
          "verified": { "type": "boolean" },
          "barcodes": {
            "type": "array",
            "description": "**Replaces** the whole set. Codes owned by another food are skipped silently — use `POST /api/foods/{idOrSlug}/barcodes` if you need to know.",
            "items": { "type": "string", "maxLength": 32 }
          }
        }
      },
      "FoodCreate": {
        "allOf": [
          { "$ref": "#/components/schemas/FoodWritable" }
        ],
        "required": ["name", "calories", "protein_g", "carbs_g", "fat_g"]
      },
      "FoodUpdate": {
        "allOf": [
          { "$ref": "#/components/schemas/FoodWritable" }
        ],
        "description": "Every field is optional — omitted fields keep their stored value."
      },
      "FoodContributionWritable": {
        "type": "object",
        "description": "The fields a contribution carries. The same set as a food, minus `verified` — nothing in the queue is verified by definition, and the flag belongs on the food approval creates.",
        "allOf": [
          { "$ref": "#/components/schemas/Nutrition" }
        ],
        "properties": {
          "name": { "type": "string", "maxLength": 255 },
          "brand": {
            "type": ["string", "null"],
            "maxLength": 255
          },
          "category": {
            "oneOf": [
              { "$ref": "#/components/schemas/CategorySlug" },
              { "type": "null" }
            ],
            "description": "A category slug that must already exist."
          },
          "serving_size_g": {
            "type": ["number", "null"],
            "minimum": 0
          },
          "serving_description": {
            "type": ["string", "null"],
            "maxLength": 255
          },
          "source": {
            "type": "string",
            "maxLength": 255,
            "default": "user",
            "description": "Who sent this and where its numbers came from — free text, but name your client in it rather than only the data's origin (for example `my-app:user` for something a person typed, `my-app:scan` for a barcode scan). It is what an operator sorts the queue by, and it is copied onto the catalog food at approval as its provenance, so `user` — the default — tells a later reader nothing. Distinct from `contributed_by`/`client_user`, which are the API account and the end user, not the software."
          },
          "external_id": {
            "type": ["string", "null"],
            "maxLength": 255
          },
          "barcodes": {
            "type": "array",
            "description": "Claims, not links. Nothing is written to the barcode table until approval, because that table's unique index is what guarantees one barcode means one food — unreviewed claims must not get to break that promise.",
            "items": { "type": "string", "maxLength": 32 }
          }
        }
      },
      "FoodContributionCreate": {
        "allOf": [
          { "$ref": "#/components/schemas/FoodContributionWritable" }
        ],
        "required": ["name", "calories", "protein_g", "carbs_g", "fat_g"],
        "properties": {
          "image_urls": {
            "type": "array",
            "maxItems": 2,
            "description": "Product photos you have a link to rather than a file — what an AI-assisted client produces, since it finds images on the web. Each is downloaded during this request and stored as an ordinary contribution image, so the response's `images` is the authoritative result: a URL that is unreachable, too large, or not actually a JPEG/PNG/WebP is **skipped silently** and the contribution still succeeds, because losing the nutrition data over a picture would be the worse outcome. Submission only — on `PATCH` this field is rejected (422); attach photos there with `POST /food-contributions/{contribution}/images`, which takes a real file.",
            "items": {
              "type": "string",
              "format": "uri",
              "maxLength": 2048,
              "description": "Must be `https`. A URL that resolves into a private or reserved network is refused."
            }
          }
        }
      },
      "FoodContributionUpdate": {
        "allOf": [
          { "$ref": "#/components/schemas/FoodContributionWritable" }
        ],
        "description": "Every field is optional — omitted fields keep their stored value. `approved` is absent on purpose: approval is an endpoint, not an attribute."
      },
      "FoodContribution": {
        "type": "object",
        "description": "A food someone proposed, and its review state.",
        "allOf": [
          { "$ref": "#/components/schemas/FoodContributionWritable" }
        ],
        "properties": {
          "id": { "type": "integer" },
          "approved": {
            "type": "boolean",
            "description": "False until an operator accepts it. Set only by the approve endpoint, alongside creating the food."
          },
          "approved_food_id": {
            "type": ["integer", "null"],
            "description": "The catalog food this became, once approved."
          },
          "contributed_by": {
            "type": ["integer", "null"],
            "description": "The API account that posted it. For everything from the iOS app that is one shared proxy service account — see `client_user`."
          },
          "contributor_email": {
            "type": ["string", "null"],
            "description": "That account's email, when the relation was loaded."
          },
          "client_user": {
            "type": ["string", "null"],
            "description": "The end user the caller was acting for, from `X-Client-User`. Populated only for a configured proxy account; anyone else's header is ignored, so this is never self-asserted."
          },
          "images": {
            "type": "array",
            "description": "Photos an operator attached while reviewing, absolute URLs, ordered. Position 1 becomes the food's primary image at approval. Present only where the relation was loaded (the list, the single read, and the image endpoints).",
            "items": {
              "type": "object",
              "properties": {
                "id": { "type": "integer" },
                "url": { "type": "string", "format": "uri" },
                "position": { "type": "integer" }
              },
              "required": ["id", "url", "position"]
            }
          },
          "duplicate_of": {
            "type": ["object", "null"],
            "description": "The catalog food this proposal collides with on `(name, brand)` — the one approving it would 409 on.\n\nThree-valued: an object means the catalog already holds this food, `null` means it was checked and does not, and the property being **absent** means nobody looked. It is attached by the list and single-read endpoints and only to pending rows, since an approved contribution always matches the food it itself created.",
            "properties": {
              "id": { "type": "integer" },
              "name": { "type": "string" },
              "slug": { "type": "string" },
              "deleted": {
                "type": "boolean",
                "description": "The clashing food is soft-deleted. It still owns the name — restore it, or rename the contribution."
              }
            },
            "required": ["id", "name", "slug", "deleted"]
          },
          "created_at": { "type": "string", "format": "date-time" },
          "updated_at": { "type": "string", "format": "date-time" }
        },
        "required": ["id", "approved", "approved_food_id", "name", "calories", "protein_g", "carbs_g", "fat_g"]
      },
      "FoodContributionEnvelope": {
        "type": "object",
        "description": "Single-contribution responses are wrapped in `data`.",
        "properties": {
          "data": { "$ref": "#/components/schemas/FoodContribution" }
        },
        "required": ["data"]
      },
      "FoodContributionPage": {
        "type": "object",
        "description": "A page of contributions: `data` plus the pagination envelope.",
        "properties": {
          "data": {
            "type": "array",
            "items": { "$ref": "#/components/schemas/FoodContribution" }
          },
          "links": { "$ref": "#/components/schemas/PaginationLinks" },
          "meta": { "$ref": "#/components/schemas/PaginationMeta" }
        },
        "required": ["data", "links", "meta"]
      },
      "ContributionConflict": {
        "type": "object",
        "description": "A 409 from the queue. `food_id` always names the food involved — the one this became, or the one it clashed with.",
        "properties": {
          "message": { "type": "string" },
          "food_id": { "type": ["integer", "null"] },
          "deleted": {
            "type": "boolean",
            "description": "Only on an approve clash: true when the name is held by a soft-deleted food, which still owns it. Restore that food or rename this contribution."
          }
        },
        "required": ["message"]
      },
      "ContributionBulkFailures": {
        "type": "array",
        "description": "Rows a bulk action left alone, each with the reason. Shared by bulk-approve and bulk-reject, which refuse for different reasons but report them identically.",
        "items": {
          "type": "object",
          "properties": {
            "id": { "type": "integer" },
            "message": { "type": "string" },
            "food_id": {
              "type": ["integer", "null"],
              "description": "The food behind the refusal — the one already holding the name, or the one this contribution already created."
            },
            "deleted": {
              "type": "boolean",
              "description": "Only on an approve clash: the food holding the name is soft-deleted."
            }
          },
          "required": ["id", "message"]
        }
      },
      "ContributionBulkMissing": {
        "type": "array",
        "description": "Ids that matched no contribution — reported rather than silently dropped. On this queue it usually means another operator got there first.",
        "items": { "type": "integer" }
      },
      "User": {
        "type": "object",
        "description": "`password` is never returned. Bare (not wrapped in `data`) from `/api/auth/me`; wrapped in `data` by the user-management endpoints.",
        "properties": {
          "id": { "type": "integer" },
          "name": { "type": "string" },
          "email": { "type": "string", "format": "email" },
          "is_admin": {
            "type": "boolean",
            "description": "Whether this account may create other users and manage the roster. False for every account this API hands out; it is granted only by an admin via `PATCH /api/users/{user}` (or the `foods:make-admin` CLI), never through an ordinary request body."
          },
          "plan": {
            "type": "string",
            "description": "The tier the account's per-minute rate limit and monthly quota are read from — currently `free` or `pro`. Defaults to `free`; changed only by an admin via `POST /api/users/{user}/plan`, never through an ordinary request body.",
            "example": "free"
          },
          "email_verified_at": {
            "type": ["string", "null"],
            "format": "date-time"
          },
          "created_at": {
            "type": ["string", "null"],
            "format": "date-time"
          },
          "updated_at": {
            "type": ["string", "null"],
            "format": "date-time"
          },
          "deleted_at": {
            "type": ["string", "null"],
            "format": "date-time",
            "description": "`null` for a live account; the moment it was soft-deleted otherwise. Non-null only in the admin trash view (`GET /api/users?deleted=only|with`)."
          }
        },
        "required": ["id", "name", "email", "is_admin"]
      },
      "ApiKey": {
        "type": "object",
        "description": "An API key as an operator sees it. It never carries the secret — that is returned once, at creation, in the `secret` field of the create response. `prefix` is the human handle you recognise a key by.",
        "properties": {
          "id": { "type": "integer" },
          "user_id": { "type": "integer", "description": "The account the key authenticates as. It can do exactly what that account's own token can — no more." },
          "name": { "type": "string", "description": "A human label, e.g. \"Production\"." },
          "prefix": { "type": "string", "description": "The first characters of the secret (`flk_a1b2c3d4…`), stored so a key is recognisable. Not the secret, and not enough to authenticate." },
          "last_used_at": { "type": ["string", "null"], "format": "date-time", "description": "When the key last authenticated a request; `null` until first use." },
          "expires_at": { "type": ["string", "null"], "format": "date-time", "description": "When the key stops authenticating, if an expiry was set." },
          "revoked_at": { "type": ["string", "null"], "format": "date-time", "description": "Non-null once revoked; a revoked key answers `401`." },
          "created_at": { "type": ["string", "null"], "format": "date-time" }
        },
        "required": ["id", "user_id", "name", "prefix"]
      },
      "UsageEnvelope": {
        "type": "object",
        "description": "The monthly quota meter for the calling account, wrapped in `data`.",
        "properties": {
          "data": {
            "type": "object",
            "properties": {
              "plan": { "type": "string", "description": "The account's current tier.", "example": "free" },
              "unlimited": { "type": "boolean", "description": "`true` for administrator accounts, which are exempt from the quota; the numeric fields are `null` then." },
              "used": { "type": ["integer", "null"], "description": "Requests spent in the current calendar month. `0` for an exempt admin." },
              "limit": { "type": ["integer", "null"], "description": "The plan's monthly ceiling, or `null` when `unlimited`." },
              "remaining": { "type": ["integer", "null"], "description": "`limit` minus `used`, floored at 0; `null` when `unlimited`." },
              "resets_at": { "type": "string", "format": "date-time", "description": "When the month's counter rolls over (00:00 UTC on the 1st of next month)." }
            },
            "required": ["plan", "unlimited", "resets_at"]
          }
        },
        "required": ["data"]
      },
      "TokenResponse": {
        "type": "object",
        "description": "Not wrapped in `data`.",
        "properties": {
          "access_token": { "type": "string", "description": "Send as `Authorization: Bearer <token>`." },
          "token_type": { "type": "string", "const": "bearer" },
          "expires_in": { "type": "integer", "description": "Seconds until the token expires.", "example": 3600 }
        },
        "required": ["access_token", "token_type", "expires_in"]
      },
      "PaginationLinks": {
        "type": "object",
        "properties": {
          "first": {
            "type": ["string", "null"],
            "format": "uri"
          },
          "last": {
            "type": ["string", "null"],
            "format": "uri"
          },
          "prev": {
            "type": ["string", "null"],
            "format": "uri"
          },
          "next": {
            "type": ["string", "null"],
            "format": "uri"
          }
        }
      },
      "PaginationMeta": {
        "type": "object",
        "description": "`total` and `last_page` are the two worth reading.",
        "properties": {
          "current_page": { "type": "integer" },
          "from": {
            "type": ["integer", "null"]
          },
          "last_page": { "type": "integer" },
          "path": { "type": "string" },
          "per_page": { "type": "integer" },
          "to": {
            "type": ["integer", "null"]
          },
          "total": { "type": "integer" }
        }
      },
      "SearchMeta": {
        "description": "Pagination, plus what the search engine actually did.",
        "allOf": [
          { "$ref": "#/components/schemas/PaginationMeta" },
          {
            "type": "object",
            "properties": {
              "semantic": {
                "type": "boolean",
                "description": "Whether meaning-based matching ran for this query.\n\nAlways present, so read the boolean rather than inferring from an absent key. `false` on a request that sent `semantic=1` means you got keyword hits — because this deployment has no embedder configured, or because your plan does not include hybrid search. The two are not distinguished, and the results are still valid keyword results either way."
              }
            },
            "required": ["semantic"]
          }
        ]
      },
      "DemoSearchPage": {
        "type": "object",
        "description": "The demo's answer. `data` holds the same `Food` objects the paid search returns; `meta` is deliberately NOT the paginated `SearchMeta` — there are no pages here to describe.",
        "properties": {
          "data": {
            "type": "array",
            "items": { "$ref": "#/components/schemas/Food" }
          },
          "meta": { "$ref": "#/components/schemas/DemoSearchMeta" }
        },
        "required": ["data", "meta"]
      },
      "DemoSearchMeta": {
        "type": "object",
        "properties": {
          "total": { "type": "integer", "description": "Every match in the catalog, not just the ones returned. Larger than `shown` whenever the query is broad — which is most of the time." },
          "shown": { "type": "integer", "description": "How many hits are in `data`. At most `limit`." },
          "limit": { "type": "integer", "description": "The ceiling this deployment puts on a demo query. There is no way to raise it and no page two." },
          "semantic": { "type": "boolean", "description": "Whether hybrid (meaning-based) matching actually ran. `false` on a request that asked for it means keyword hits — the deployment has no embedder, or the demo's own semantic switch is off." },
          "took_ms": { "type": "number", "description": "Server-side milliseconds for the index round trip plus hydrating the hits. Excludes JSON encoding and the network, so it is the engine's time and not a flattering total." }
        },
        "required": ["total", "shown", "limit", "semantic", "took_ms"]
      },
      "DemoStatsEnvelope": {
        "type": "object",
        "properties": {
          "data": {
            "type": "object",
            "properties": {
              "foods": { "type": "integer", "description": "Live foods; soft-deleted rows are excluded." },
              "images": { "type": "integer" },
              "barcodes": { "type": "integer" },
              "categories": { "type": "integer" },
              "brands": { "type": "integer", "description": "Distinct non-empty brand strings. `brand` is a column, not a table — see the note in the repo." },
              "coverage": { "$ref": "#/components/schemas/DemoCoverage" },
              "growth": { "$ref": "#/components/schemas/DemoGrowth" }
            },
            "required": ["foods", "images", "barcodes", "categories", "brands", "coverage", "growth"]
          }
        },
        "required": ["data"]
      },
      "DemoCoverage": {
        "type": "object",
        "description": "How many foods carry each **optional** field, against the `foods` total in the same object. Only nullable columns appear here: `calories`, `protein_g`, `carbs_g` and `fat_g` are NOT NULL in the schema, so every food has energy and the three macros by construction and there is no percentage to report. Counts are of foods, never of sources — a breakdown by origin would route around the redaction applied to `source` and `external_id`.",
        "properties": {
          "foods": { "type": "integer", "description": "The denominator, repeated here so a rendered `N of M` cannot disagree with itself." },
          "sugars": { "type": "integer" },
          "saturated_fat": { "type": "integer" },
          "sodium": { "type": "integer" },
          "fibre": { "type": "integer", "description": "Counts recorded values. A food declaring 0 g counts — zero is a measurement, not a gap." },
          "serving_size": { "type": "integer", "description": "Foods with a `serving_size_g`. Thin: nutrition is per 100 g regardless, so a serving is convenience rather than a dependency." },
          "brand": { "type": "integer", "description": "Foods with a non-empty `brand`. Lower than you would expect — many rows carry the brand inside `name` instead." },
          "photo": { "type": "integer", "description": "Foods with **at least one** image, not the image count in `images`. A food with four photos counts once, because the question a client asks is whether a row will render with a picture." },
          "barcode": { "type": "integer", "description": "Foods with at least one barcode. Currently a small fraction of the catalog and growing through the contribution queue — see `POST /api/food-contributions`." }
        },
        "required": ["foods", "sugars", "saturated_fat", "sodium", "fibre", "serving_size", "brand", "photo", "barcode"]
      },
      "DemoGrowth": {
        "type": "object",
        "description": "When the catalog grew, month by month. Source-agnostic by design: a month, a count and a running total is enough to show the slope, and anything more would describe how the catalog is assembled.",
        "properties": {
          "monthly": {
            "type": "array",
            "description": "Oldest first, capped at the last 12 months. Months with no additions are emitted as explicit zeros — a gap would render as a narrower chart rather than a flat month, which would quietly overstate consistency. Empty only when the catalog is.",
            "items": {
              "type": "object",
              "properties": {
                "month": { "type": "string", "example": "2026-08", "description": "`YYYY-MM`, UTC." },
                "added": { "type": "integer", "description": "Foods first seen in this month." },
                "total": { "type": "integer", "description": "Catalog size at the end of this month. Anything older than the 12-month window is folded into the first bucket's total, so this is the real catalog size and not a subtotal of the window." }
              },
              "required": ["month", "added", "total"]
            }
          },
          "added_last_30_days": { "type": "integer", "description": "A rolling 30-day window, deliberately not the calendar month — on the 2nd of a month the latter reports a near-zero through pure accident of the date." },
          "first_added_on": { "type": ["string", "null"], "format": "date", "description": "The oldest food's date, or `null` on an empty catalog." },
          "latest": {
            "type": ["object", "null"],
            "description": "The most recently added food. `null` on an empty catalog.",
            "properties": {
              "name": { "type": "string" },
              "added_on": { "type": "string", "format": "date" }
            },
            "required": ["name", "added_on"]
          }
        },
        "required": ["monthly", "added_last_30_days", "first_added_on", "latest"]
      },
      "Error": {
        "type": "object",
        "properties": {
          "message": { "type": "string" }
        },
        "required": ["message"]
      },
      "ValidationError": {
        "type": "object",
        "description": "A 422 always looks like this.",
        "properties": {
          "message": { "type": "string", "description": "The first error, as a sentence." },
          "errors": {
            "type": "object",
            "description": "Field name to the list of things wrong with it.",
            "additionalProperties": {
              "type": "array",
              "items": { "type": "string" }
            }
          }
        },
        "required": ["message", "errors"]
      }
    }
  }
}
