{"openapi":"3.1.0","info":{"title":"VoltPlan API","version":"0.3.0","description":"VoltPlan's HTTP API. The wiring-diagram MCP server (Model Context Protocol over Streamable HTTP, tag `MCP`, at `/.well-known/mcp`) generates 12V/24V/48V wiring diagrams and performs wire gauge, fuse, battery bank, inverter, solar, and charging-time calculations; it needs no API key or account. The REST endpoints under `/api` serve the VoltPlan web app. Authentication: public endpoints need no credentials; user endpoints expect a PocketBase `users` auth token as `Authorization: Bearer <token>` (`userBearer`); admin endpoints additionally require `is_admin=true` and answer 403 otherwise (`adminBearer`); the IndexNow trigger uses a shared secret (`indexNowBearer`). Each operation's `security` field names its requirement.","contact":{"name":"VoltPlan","url":"https://voltplan.app/developers","email":"support@voltplan.app"},"license":{"name":"MIT","url":"https://github.com/YUZU-Hub/wiring-diagram-mcp/blob/main/LICENSE"}},"servers":[{"url":"https://voltplan.app"}],"externalDocs":{"description":"MCP server source, tool parameter schemas, and changelog","url":"https://github.com/YUZU-Hub/wiring-diagram-mcp"},"tags":[{"name":"MCP","description":"Wiring-diagram MCP server (Model Context Protocol over Streamable HTTP): diagram generation and electrical calculations. No authentication."},{"name":"redirects","description":"Tracked outbound redirects to Amazon or merchant pages and affiliate click tracking. No authentication; clicks are not recorded in development or for bot user agents."},{"name":"catalog","description":"Public component catalog reads (calculator component lists, published-component search), curated BOM shopping-list products, and component reviews. Reads are public; review writes need a PocketBase user token (userBearer)."},{"name":"account","description":"Public account and list sign-up endpoints used by the web app: stepped-login email lookup, email verification, newsletter subscribe/unsubscribe, app tester sign-up and app waitlist. None of them requires authentication; they call PocketBase server-side (unauthenticated client, except email-status which uses the superuser client)."},{"name":"sharing","description":"Diagram sharing through share tokens stored on PocketBase `wireframe_states` records, wireframe preview images, and conversion of PocketBase component images to data URLs. No authentication."},{"name":"generators","description":"Server-side rendering of wiring diagrams and multi-diagram projects (SVG/PNG), plus AI-assisted spec estimators for loads and chargers backed by OpenRouter. No authentication; all four endpoints are rate-limited per client IP."},{"name":"ops","description":"Operational, feed and meta endpoints: liveness and health probes, region detection diagnostics, IndexNow submission, the RSS feed, the LLM full-text export and this OpenAPI document."},{"name":"admin-components","description":"Admin-only management of the `components` catalogue behind /admin/components and /admin/publishing: read, edit, publish and delete a component, click statistics, product gallery, AI research and batch enhancement, list/search, and a one-shot utilization backfill. All operations use the PocketBase superuser client server-side and require an admin token (adminBearer); anything else answers 403."},{"name":"admin-catalog-tools","description":"Admin-only catalog tooling behind /admin: review and delete user-submitted custom components, extract component data from product pages with an LLM, save extracted components to the catalog, and look up amazon.de product links. All operations require an admin PocketBase token (adminBearer) and answer 403 otherwise."},{"name":"admin-affiliate","description":"Admin-only affiliate operations behind /admin/affiliate-ops and /admin/experiments: affiliate click statistics and heatmap, per-country affiliate_links maintenance, LLM link suggestions, curated BOM product slots (bom_products), and the experiments dashboard (Umami + PocketBase). All operations require an admin PocketBase token (adminBearer) and answer 403 otherwise."}],"paths":{"/.well-known/mcp":{"summary":"MCP Streamable HTTP endpoint","description":"VoltPlan-hosted proxy to the live MCP endpoint (Model Context Protocol over Streamable HTTP). The request body and the `Content-Type`, `Accept`, `Mcp-Session-Id` and `Mcp-Protocol-Version` headers are forwarded unchanged. The same interface is directly reachable at https://mcp.voltplan.app/mcp (canonical endpoint).","x-canonical-endpoint":"https://mcp.voltplan.app/mcp","post":{"operationId":"callMcp","tags":["MCP"],"summary":"Send an MCP JSON-RPC message (initialize, tools/list, tools/call, ...)","description":"Streamable HTTP transport: send one JSON-RPC 2.0 request per call. Start with `initialize`, then `tools/list` to enumerate tools, then `tools/call` with `{ name, arguments }` to run one. The response is `application/json` or `text/event-stream` depending on the `Accept` header sent.","parameters":[{"name":"Mcp-Session-Id","in":"header","required":false,"description":"Session id returned by `initialize`; required on every call after that.","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/JsonRpcRequest"},"examples":{"initialize":{"value":{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"example-client","version":"0.0.1"}}}},"callTool":{"value":{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"calculate_wire_gauge","arguments":{}}}}}}}},"responses":{"200":{"description":"JSON-RPC result or SSE stream of JSON-RPC messages","headers":{"Mcp-Session-Id":{"description":"Session id assigned by the server on `initialize`; send it back as the `Mcp-Session-Id` request header on every later call. Forwarded unchanged from the upstream MCP server.","schema":{"type":"string"}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/JsonRpcResponse"}},"text/event-stream":{"schema":{"type":"string"}}}},"502":{"description":"MCP upstream unreachable; the proxy answers with a JSON-RPC error envelope.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/JsonRpcResponse"},"example":{"jsonrpc":"2.0","error":{"code":-32000,"message":"MCP upstream unreachable"}}}}}}},"get":{"operationId":"openMcpStream","tags":["MCP"],"summary":"Open the server-initiated SSE stream for an existing session","parameters":[{"name":"Mcp-Session-Id","in":"header","required":true,"description":"Session id returned by `initialize`; required on every call after that.","schema":{"type":"string"}}],"responses":{"200":{"description":"text/event-stream of server-initiated JSON-RPC messages","headers":{"Mcp-Session-Id":{"description":"Session id assigned by the server on `initialize`; send it back as the `Mcp-Session-Id` request header on every later call. Forwarded unchanged from the upstream MCP server.","schema":{"type":"string"}}},"content":{"text/event-stream":{"schema":{"type":"string"}}}}}},"delete":{"operationId":"closeMcpSession","tags":["MCP"],"summary":"Terminate an MCP session","parameters":[{"name":"Mcp-Session-Id","in":"header","required":true,"description":"Session id returned by `initialize`; required on every call after that.","schema":{"type":"string"}}],"responses":{"204":{"description":"Session closed"}}},"options":{"operationId":"preflightMcp","tags":["MCP"],"summary":"CORS preflight for browser-based MCP clients","description":"Answered by VoltPlan without contacting the upstream server.","responses":{"204":{"description":"Preflight accepted; no body.","headers":{"Access-Control-Allow-Origin":{"schema":{"type":"string","enum":["*"]}},"Access-Control-Allow-Methods":{"schema":{"type":"string","enum":["GET, POST, DELETE, OPTIONS"]}},"Access-Control-Allow-Headers":{"schema":{"type":"string","enum":["Content-Type, Accept, Mcp-Session-Id"]}},"Access-Control-Expose-Headers":{"schema":{"type":"string","enum":["Mcp-Session-Id"]}}}}}}},"/api/go":{"get":{"operationId":"goRedirect","tags":["redirects"],"summary":"Redirect a tracked outbound product link","description":"Resolves an outbound product link and answers 302 to the target, or 307 to the VoltPlan homepage when nothing resolves. Branches by priority: (1) `bom` — curated `bom_products` row: the row's URL (Amazon ASIN or manufacturer-direct) when its country matches the visitor's region (`universal` rows count as `de`), else the same slot's row curated for the visitor's country, else a localized Amazon search; unknown or inactive rows → 307. (2) `q` — stored affiliate link of `component_id`/`c` for the visitor's region when one exists, otherwise a localized Amazon search for `q`; never 307. (3) `component_id`, `c` or a known legacy `p` slug (`sterling-pro-combi-s-1600`, `renogy-rover-40a`) — stored affiliate link for the region, otherwise a localized Amazon search for the component name; unknown component → 307. (4) No usable parameter, including an unknown `p` slug → 307. `component_id` takes precedence over `c`, both over `p`. Region comes from the region parameters in their listed precedence, falling back to `de`. The click is recorded in `component_click_events` (with `User-Agent` and `Referer`), except in development or for bot user agents.","parameters":[{"name":"bom","in":"query","required":false,"description":"Curated `bom_products` row id; highest-priority branch. Unknown or inactive rows redirect home (307).","schema":{"type":"string"}},{"name":"q","in":"query","required":false,"description":"Free-text product query; used when `bom` is absent or blank. Redirects to the stored affiliate link of `component_id`/`c` when one exists for the region, else to a localized Amazon search for `q`; never 307.","schema":{"type":"string"}},{"name":"component_id","in":"query","required":false,"description":"PocketBase `components` id. Without `q`, an unknown id redirects home (307).","schema":{"type":"string"}},{"name":"c","in":"query","required":false,"description":"Alias of `component_id`, used when `component_id` is absent.","schema":{"type":"string"}},{"name":"p","in":"query","required":false,"description":"Legacy product slug (`sterling-pro-combi-s-1600`, `renogy-rover-40a`), used when neither `component_id` nor `c` is set; unknown slugs redirect home (307).","schema":{"type":"string"}},{"name":"source","in":"query","required":false,"description":"Click source prefix; the recorded source is `<source>_affiliate` or `<source>_amazon_search` (`bom` and `q` branches) or `source` verbatim (component branch).","schema":{"type":"string"}},{"name":"component_name","in":"query","required":false,"description":"Name recorded for the click in the `q` branch; defaults to `q`.","schema":{"type":"string"}},{"name":"component_type","in":"query","required":false,"description":"Type recorded for the click in the `q` branch.","schema":{"type":"string"}},{"name":"region","in":"query","required":false,"description":"Marketplace override, highest precedence. Accepts a marketplace code (`de`, `us`, `uk`, `fr`, `es`, `it`, `au`), a country alias (`at`, `ch`, `lu`, `nl`, `se`, `dk`, `no` → `de`; `be` → `fr`; `ca`, `mx` → `us`; `nz` → `au`; `ie`, `gb` → `uk`; `pt` → `es`), a language code (`en` → `us`, `de`, `fr`, `es`, `it`) or a `lang-COUNTRY` tag (country part tried first, then language). Case-insensitive; any other value is ignored and the next region signal applies.","schema":{"type":"string"}},{"name":"country","in":"query","required":false,"description":"Same as `region`; read only when `region` is absent or empty.","schema":{"type":"string"}},{"name":"x-voltplan-region","in":"header","required":false,"description":"Region override header, used when no query override applies.","schema":{"type":"string"}},{"name":"x-voltplan-country","in":"header","required":false,"description":"Read only when `x-voltplan-region` is absent or empty.","schema":{"type":"string"}},{"name":"x-vercel-ip-country","in":"header","required":false,"description":"CDN country header; checked after the VoltPlan headers.","schema":{"type":"string"}},{"name":"cf-ipcountry","in":"header","required":false,"description":"CDN country header; checked after `x-vercel-ip-country`.","schema":{"type":"string"}},{"name":"x-country-code","in":"header","required":false,"description":"CDN country header; checked after `cf-ipcountry`.","schema":{"type":"string"}},{"name":"x-forwarded-for","in":"header","required":false,"description":"First address is GeoIP-resolved to a country when no header above yields a region.","schema":{"type":"string"}},{"name":"x-real-ip","in":"header","required":false,"description":"GeoIP-resolved when `x-forwarded-for` is absent.","schema":{"type":"string"}},{"name":"accept-language","in":"header","required":false,"description":"Last signal: the first language range that normalizes to a region wins.","schema":{"type":"string"}}],"responses":{"302":{"description":"Redirect to the resolved target: a stored regional affiliate link (Amazon product page carrying the region's affiliate tag, or an untagged manufacturer-direct URL from a curated BOM row) or a localized Amazon search on the visitor's marketplace.","headers":{"Location":{"description":"Redirect target URL.","schema":{"type":"string","format":"uri"}},"Cache-Control":{"description":"Always `no-store`.","schema":{"type":"string","enum":["no-store"]}}}},"307":{"description":"Homepage fallback (`NEXT_PUBLIC_BASE_URL`, default `https://voltplan.app/`): unknown or inactive `bom` row, unknown component in the component branch, unknown `p` slug, or no usable parameter. No `Cache-Control` header, no body.","headers":{"Location":{"description":"VoltPlan homepage URL.","schema":{"type":"string","format":"uri"}}}}}}},"/{componentId}":{"parameters":[{"name":"componentId","in":"path","required":true,"description":"PocketBase `components` record id (15 alphanumeric characters, case-insensitive). Other values answer 404 without a database lookup.","schema":{"type":"string","pattern":"^[a-zA-Z0-9]{15}$"}}],"get":{"operationId":"getComponentRedirect","tags":["redirects"],"summary":"Redirect a component short link to its affiliate target","description":"Looks up the component (plus its `originalId` variant) and redirects to the stored affiliate link for the visitor's region, otherwise to a localized Amazon search for the component on that marketplace. Region comes from the region parameters in their listed precedence, falling back to `us`. The click is recorded in `component_click_events` with source `<src>_affiliate` or `<src>_amazon_search`, except in development or for bot `User-Agent`s. A malformed or unknown id answers 404.","parameters":[{"name":"src","in":"query","required":false,"description":"Click attribution prefix stored in the recorded click source; defaults to `voltplan`.","schema":{"type":"string"}},{"name":"region","in":"query","required":false,"description":"Marketplace override, highest precedence. Accepts a marketplace code (`de`, `us`, `uk`, `fr`, `es`, `it`, `au`), a country alias (`at`, `ch`, `lu`, `nl`, `se`, `dk`, `no` → `de`; `be` → `fr`; `ca`, `mx` → `us`; `nz` → `au`; `ie`, `gb` → `uk`; `pt` → `es`), a language code (`en` → `us`, `de`, `fr`, `es`, `it`) or a `lang-COUNTRY` tag (country part tried first, then language). Case-insensitive; any other value is ignored and the next region signal applies.","schema":{"type":"string"}},{"name":"country","in":"query","required":false,"description":"Same as `region`; read only when `region` is absent or empty.","schema":{"type":"string"}},{"name":"x-voltplan-region","in":"header","required":false,"description":"Region override header, used when no query override applies.","schema":{"type":"string"}},{"name":"x-voltplan-country","in":"header","required":false,"description":"Read only when `x-voltplan-region` is absent or empty.","schema":{"type":"string"}},{"name":"x-vercel-ip-country","in":"header","required":false,"description":"CDN country header; checked after the VoltPlan headers.","schema":{"type":"string"}},{"name":"cf-ipcountry","in":"header","required":false,"description":"CDN country header; checked after `x-vercel-ip-country`.","schema":{"type":"string"}},{"name":"x-country-code","in":"header","required":false,"description":"CDN country header; checked after `cf-ipcountry`.","schema":{"type":"string"}},{"name":"x-forwarded-for","in":"header","required":false,"description":"First address is GeoIP-resolved to a country when no header above yields a region.","schema":{"type":"string"}},{"name":"x-real-ip","in":"header","required":false,"description":"GeoIP-resolved when `x-forwarded-for` is absent.","schema":{"type":"string"}},{"name":"accept-language","in":"header","required":false,"description":"Last signal: the first language range that normalizes to a region wins.","schema":{"type":"string"}}],"responses":{"302":{"description":"Redirect to the stored affiliate link for the visitor's region or a localized Amazon search for the component.","headers":{"Location":{"description":"Redirect target URL.","schema":{"type":"string","format":"uri"}},"Cache-Control":{"description":"Always `no-store`.","schema":{"type":"string","enum":["no-store"]}}}},"404":{"$ref":"#/components/responses/RedirectNotFound"}}},"head":{"operationId":"headComponentRedirect","tags":["redirects"],"summary":"Same as GET without a body","description":"Runs the GET handler, including click recording; same status codes and headers, no body.","parameters":[{"name":"src","in":"query","required":false,"description":"Click attribution prefix stored in the recorded click source; defaults to `voltplan`.","schema":{"type":"string"}},{"name":"region","in":"query","required":false,"description":"Marketplace override, highest precedence. Accepts a marketplace code (`de`, `us`, `uk`, `fr`, `es`, `it`, `au`), a country alias (`at`, `ch`, `lu`, `nl`, `se`, `dk`, `no` → `de`; `be` → `fr`; `ca`, `mx` → `us`; `nz` → `au`; `ie`, `gb` → `uk`; `pt` → `es`), a language code (`en` → `us`, `de`, `fr`, `es`, `it`) or a `lang-COUNTRY` tag (country part tried first, then language). Case-insensitive; any other value is ignored and the next region signal applies.","schema":{"type":"string"}},{"name":"country","in":"query","required":false,"description":"Same as `region`; read only when `region` is absent or empty.","schema":{"type":"string"}},{"name":"x-voltplan-region","in":"header","required":false,"description":"Region override header, used when no query override applies.","schema":{"type":"string"}},{"name":"x-voltplan-country","in":"header","required":false,"description":"Read only when `x-voltplan-region` is absent or empty.","schema":{"type":"string"}},{"name":"x-vercel-ip-country","in":"header","required":false,"description":"CDN country header; checked after the VoltPlan headers.","schema":{"type":"string"}},{"name":"cf-ipcountry","in":"header","required":false,"description":"CDN country header; checked after `x-vercel-ip-country`.","schema":{"type":"string"}},{"name":"x-country-code","in":"header","required":false,"description":"CDN country header; checked after `cf-ipcountry`.","schema":{"type":"string"}},{"name":"x-forwarded-for","in":"header","required":false,"description":"First address is GeoIP-resolved to a country when no header above yields a region.","schema":{"type":"string"}},{"name":"x-real-ip","in":"header","required":false,"description":"GeoIP-resolved when `x-forwarded-for` is absent.","schema":{"type":"string"}},{"name":"accept-language","in":"header","required":false,"description":"Last signal: the first language range that normalizes to a region wins.","schema":{"type":"string"}}],"responses":{"302":{"description":"Redirect to the stored affiliate link for the visitor's region or a localized Amazon search for the component.","headers":{"Location":{"description":"Redirect target URL.","schema":{"type":"string","format":"uri"}},"Cache-Control":{"description":"Always `no-store`.","schema":{"type":"string","enum":["no-store"]}}}},"404":{"$ref":"#/components/responses/RedirectNotFound"}}}},"/de/{componentId}":{"parameters":[{"name":"componentId","in":"path","required":true,"description":"PocketBase `components` record id (15 alphanumeric characters, case-insensitive). Other values answer 404 without a database lookup.","schema":{"type":"string","pattern":"^[a-zA-Z0-9]{15}$"}}],"get":{"operationId":"getComponentRedirectDe","tags":["redirects"],"summary":"Redirect a component short link to its affiliate target (German locale)","description":"Looks up the component (plus its `originalId` variant) and redirects to the stored affiliate link for the visitor's region, otherwise to a localized Amazon search for the component on that marketplace. Region comes from the region parameters in their listed precedence, falling back to `de`. The click is recorded in `component_click_events` with source `<src>_affiliate` or `<src>_amazon_search`, except in development or for bot `User-Agent`s. A malformed or unknown id answers 404.","parameters":[{"name":"src","in":"query","required":false,"description":"Click attribution prefix stored in the recorded click source; defaults to `voltplan`.","schema":{"type":"string"}},{"name":"region","in":"query","required":false,"description":"Marketplace override, highest precedence. Accepts a marketplace code (`de`, `us`, `uk`, `fr`, `es`, `it`, `au`), a country alias (`at`, `ch`, `lu`, `nl`, `se`, `dk`, `no` → `de`; `be` → `fr`; `ca`, `mx` → `us`; `nz` → `au`; `ie`, `gb` → `uk`; `pt` → `es`), a language code (`en` → `us`, `de`, `fr`, `es`, `it`) or a `lang-COUNTRY` tag (country part tried first, then language). Case-insensitive; any other value is ignored and the next region signal applies.","schema":{"type":"string"}},{"name":"country","in":"query","required":false,"description":"Same as `region`; read only when `region` is absent or empty.","schema":{"type":"string"}},{"name":"x-voltplan-region","in":"header","required":false,"description":"Region override header, used when no query override applies.","schema":{"type":"string"}},{"name":"x-voltplan-country","in":"header","required":false,"description":"Read only when `x-voltplan-region` is absent or empty.","schema":{"type":"string"}},{"name":"x-vercel-ip-country","in":"header","required":false,"description":"CDN country header; checked after the VoltPlan headers.","schema":{"type":"string"}},{"name":"cf-ipcountry","in":"header","required":false,"description":"CDN country header; checked after `x-vercel-ip-country`.","schema":{"type":"string"}},{"name":"x-country-code","in":"header","required":false,"description":"CDN country header; checked after `cf-ipcountry`.","schema":{"type":"string"}},{"name":"x-forwarded-for","in":"header","required":false,"description":"First address is GeoIP-resolved to a country when no header above yields a region.","schema":{"type":"string"}},{"name":"x-real-ip","in":"header","required":false,"description":"GeoIP-resolved when `x-forwarded-for` is absent.","schema":{"type":"string"}},{"name":"accept-language","in":"header","required":false,"description":"Last signal: the first language range that normalizes to a region wins.","schema":{"type":"string"}}],"responses":{"302":{"description":"Redirect to the stored affiliate link for the visitor's region or a localized Amazon search for the component.","headers":{"Location":{"description":"Redirect target URL.","schema":{"type":"string","format":"uri"}},"Cache-Control":{"description":"Always `no-store`.","schema":{"type":"string","enum":["no-store"]}}}},"404":{"$ref":"#/components/responses/RedirectNotFound"}}},"head":{"operationId":"headComponentRedirectDe","tags":["redirects"],"summary":"Same as GET without a body (German locale)","description":"Runs the GET handler, including click recording; same status codes and headers, no body.","parameters":[{"name":"src","in":"query","required":false,"description":"Click attribution prefix stored in the recorded click source; defaults to `voltplan`.","schema":{"type":"string"}},{"name":"region","in":"query","required":false,"description":"Marketplace override, highest precedence. Accepts a marketplace code (`de`, `us`, `uk`, `fr`, `es`, `it`, `au`), a country alias (`at`, `ch`, `lu`, `nl`, `se`, `dk`, `no` → `de`; `be` → `fr`; `ca`, `mx` → `us`; `nz` → `au`; `ie`, `gb` → `uk`; `pt` → `es`), a language code (`en` → `us`, `de`, `fr`, `es`, `it`) or a `lang-COUNTRY` tag (country part tried first, then language). Case-insensitive; any other value is ignored and the next region signal applies.","schema":{"type":"string"}},{"name":"country","in":"query","required":false,"description":"Same as `region`; read only when `region` is absent or empty.","schema":{"type":"string"}},{"name":"x-voltplan-region","in":"header","required":false,"description":"Region override header, used when no query override applies.","schema":{"type":"string"}},{"name":"x-voltplan-country","in":"header","required":false,"description":"Read only when `x-voltplan-region` is absent or empty.","schema":{"type":"string"}},{"name":"x-vercel-ip-country","in":"header","required":false,"description":"CDN country header; checked after the VoltPlan headers.","schema":{"type":"string"}},{"name":"cf-ipcountry","in":"header","required":false,"description":"CDN country header; checked after `x-vercel-ip-country`.","schema":{"type":"string"}},{"name":"x-country-code","in":"header","required":false,"description":"CDN country header; checked after `cf-ipcountry`.","schema":{"type":"string"}},{"name":"x-forwarded-for","in":"header","required":false,"description":"First address is GeoIP-resolved to a country when no header above yields a region.","schema":{"type":"string"}},{"name":"x-real-ip","in":"header","required":false,"description":"GeoIP-resolved when `x-forwarded-for` is absent.","schema":{"type":"string"}},{"name":"accept-language","in":"header","required":false,"description":"Last signal: the first language range that normalizes to a region wins.","schema":{"type":"string"}}],"responses":{"302":{"description":"Redirect to the stored affiliate link for the visitor's region or a localized Amazon search for the component.","headers":{"Location":{"description":"Redirect target URL.","schema":{"type":"string","format":"uri"}},"Cache-Control":{"description":"Always `no-store`.","schema":{"type":"string","enum":["no-store"]}}}},"404":{"$ref":"#/components/responses/RedirectNotFound"}}}},"/search":{"get":{"operationId":"searchRedirect","tags":["redirects"],"summary":"Redirect a free-text query to an amazon.de search","description":"Redirects to the amazon.de search results for `q`, carrying the DE affiliate tag; there is no region detection. The click is recorded in `component_click_events` with source `<src>_amazon_search`, except for bot user agents. A missing or blank `q` answers 404.","parameters":[{"name":"q","in":"query","required":true,"description":"Search query, trimmed. Missing or blank answers 404.","schema":{"type":"string"}},{"name":"src","in":"query","required":false,"description":"Click attribution prefix stored in the recorded click source; defaults to `voltplan`.","schema":{"type":"string"}}],"responses":{"302":{"description":"Redirect to the amazon.de search results for `q`.","headers":{"Location":{"description":"Redirect target URL.","schema":{"type":"string","format":"uri"}},"Cache-Control":{"description":"Always `no-store`.","schema":{"type":"string","enum":["no-store"]}}}},"404":{"$ref":"#/components/responses/RedirectNotFound"}}}},"/api/affiliate-click":{"post":{"operationId":"recordAffiliateClick","tags":["redirects"],"summary":"Record a client-side affiliate link click","description":"Writes one `component_click_events` row (`resolved_country: universal`; `User-Agent` and `Referer` truncated to 255 characters). Development short-circuit: with `NODE_ENV=development` the handler answers `{ ok: true }` before reading the body, so nothing is validated or recorded. Bot user agents, a failed PocketBase admin login, a malformed JSON body and any server error also answer 200 `{ ok: true }` without recording.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AffiliateClickRequest"}}}},"responses":{"200":{"description":"Accepted (also returned whenever recording is skipped or fails).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AffiliateClickResponse"},"example":{"ok":true}}}},"400":{"description":"`component_id` missing or empty (not checked in development).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Missing component_id"}}}}}}},"/api/components/batteries":{"get":{"operationId":"listCatalogBatteries","tags":["catalog"],"summary":"List battery components","description":"Every `components` record with `type = \"battery\"`, sorted by name. A PocketBase failure also answers 200 with `[]`.","responses":{"200":{"description":"Matching components, sorted by name.","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/CatalogBattery"}}}}}}}},"/api/components/inverters":{"get":{"operationId":"listCatalogInverters","tags":["catalog"],"summary":"List inverter and transformer components","description":"Every `components` record with `type = \"inverter\"` or `type = \"transformer\"`, sorted by name. A PocketBase failure also answers 200 with `[]`.","responses":{"200":{"description":"Matching components, sorted by name.","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/CatalogInverter"}}}}}}}},"/api/components/loads":{"get":{"operationId":"listCatalogLoads","tags":["catalog"],"summary":"List load components","description":"Every `components` record with `type = \"load\"`, sorted by name. A PocketBase failure also answers 200 with `[]`.","responses":{"200":{"description":"Matching components, sorted by name.","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/CatalogLoad"}}}}}}}},"/api/components/solar-panels":{"get":{"operationId":"listCatalogSolarPanels","tags":["catalog"],"summary":"List power-source components (solar panels and other sources)","description":"Every `components` record with `type = \"power\"`, sorted by name. The collection cannot tell solar panels from generators or shore power, so this returns every power source; clients filter solar panels by name and watt. A PocketBase failure also answers 200 with `[]`.","responses":{"200":{"description":"Matching components, sorted by name.","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/CatalogSolarPanel"}}}}}}}},"/api/components/search":{"get":{"operationId":"searchCatalogComponents","tags":["catalog"],"summary":"Search published components","description":"Paginated search over published components that have a slug (the public /components pages). A PocketBase failure also answers 200 with empty `items`.","parameters":[{"name":"search","in":"query","required":false,"description":"Case-insensitive contains match on name, summary, description and brand name.","schema":{"type":"string"}},{"name":"type","in":"query","required":false,"description":"Component type filter, one of `battery`, `charger`, `inverter`, `transformer`, `power`, `load`, `bridge`, `tool`; any other value is ignored (no type filter).","schema":{"type":"string"}},{"name":"sort","in":"query","required":false,"description":"`name` (A–Z), `-rating` (average rating, then review count, descending) or `-updated` (newest published first); unknown values fall back to `-rating`.","schema":{"type":"string","default":"-rating"}},{"name":"page","in":"query","required":false,"description":"1-based page number; non-numeric values or values below 1 fall back to 1.","schema":{"type":"integer","default":1}},{"name":"perPage","in":"query","required":false,"description":"Page size; non-numeric values or values below 1 fall back to 24, values above 100 are clamped to 100.","schema":{"type":"integer","default":24}}],"responses":{"200":{"description":"One page of matching published components.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CatalogSearchPage"}}}}}}},"/api/bom-products":{"get":{"operationId":"listBomProducts","tags":["catalog"],"summary":"List curated BOM shopping-list products","description":"Every active `bom_products` record (curated purchase links for the wire-gauge calculator shopping list), sorted by `kind`. Cached for 300 seconds. A PocketBase failure also answers 200 with `[]`.","responses":{"200":{"description":"Active BOM products.","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/BomProduct"}}}}}}}},"/api/components/{id}/reviews":{"parameters":[{"name":"id","in":"path","required":true,"description":"Id of the `components` record.","schema":{"type":"string"}}],"get":{"operationId":"listComponentReviews","tags":["catalog"],"summary":"Get the review thread and rating aggregate of a component","description":"Read-only. The bearer token is optional and only personalises `mine` and `helpfulByMe`; a missing, invalid or expired token is treated as anonymous and never answered with 401. An unknown or unpublished component id yields empty `nodes` and a zero aggregate.","security":[{},{"userBearer":[]}],"responses":{"200":{"description":"Threaded reviews and aggregate, relative to the caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ComponentReviewsResponse"}}}},"500":{"description":"Unexpected failure while loading the reviews.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Could not load reviews."}}}}}},"post":{"operationId":"saveComponentReview","tags":["catalog"],"summary":"Write a review or reply on a published component","description":"The token is checked first, then the body. With `parent` the request creates a reply: `parent` must be a top-level review of the same component, the text is required and the rating is ignored (stored as 0).\n\nWithout `parent` it writes the caller's top-level review: `rating` 1–5 is required, the text is optional, and each user has at most one top-level review per component (a second POST updates its rating and text).\n\nThe author name is derived from the token's user (name, else the local part of the e-mail address). On success the component's `review_count`, `review_avg` and `review_dist` are recomputed and the fresh thread is returned.","security":[{"userBearer":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ComponentReviewCreateRequest"}}}},"responses":{"200":{"description":"Review or reply saved; the updated thread and aggregate.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ComponentReviewsResponse"}}}},"400":{"description":"Malformed body, text too long, reply without text, or missing/invalid rating.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"invalidBody":{"value":{"error":"Invalid request body"}},"tooLong":{"value":{"error":"Please keep your review under 4000 characters."}},"emptyReply":{"value":{"error":"A reply needs some text."}},"badRating":{"value":{"error":"Pick a rating between 1 and 5 stars."}}}}}},"401":{"$ref":"#/components/responses/ComponentReviewUnauthorized"},"404":{"$ref":"#/components/responses/ComponentReviewNotFound"},"500":{"description":"Unexpected failure while saving.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Could not save your review."}}}}}},"patch":{"operationId":"toggleComponentReviewHelpful","tags":["catalog"],"summary":"Toggle the caller's \"helpful\" vote on a review","description":"Adds the caller to the review's `helpful_by` voters, or removes them if they already voted, and returns the fresh thread.","security":[{"userBearer":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ComponentReviewTargetRequest"}}}},"responses":{"200":{"description":"Vote toggled; the updated thread and aggregate.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ComponentReviewsResponse"}}}},"400":{"description":"Body is not a JSON object or has no non-empty `reviewId`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Invalid request body"}}}},"401":{"$ref":"#/components/responses/ComponentReviewUnauthorized"},"404":{"$ref":"#/components/responses/ComponentReviewNotFound"},"500":{"description":"Unexpected failure while recording the vote.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Could not record your vote."}}}}}},"delete":{"operationId":"deleteComponentReview","tags":["catalog"],"summary":"Delete one of the caller's own reviews or replies","description":"Deletes the review or reply `reviewId` if the caller wrote it; replies of a deleted review are deleted with it. The aggregate is recomputed and the fresh thread returned.","security":[{"userBearer":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ComponentReviewTargetRequest"}}}},"responses":{"200":{"description":"Review deleted; the updated thread and aggregate.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ComponentReviewsResponse"}}}},"400":{"description":"Body is not a JSON object or has no non-empty `reviewId`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Invalid request body"}}}},"401":{"$ref":"#/components/responses/ComponentReviewUnauthorized"},"403":{"description":"The review belongs to another user.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"You can only delete your own review."}}}},"404":{"$ref":"#/components/responses/ComponentReviewNotFound"},"500":{"description":"Unexpected failure while deleting.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Could not delete your review."}}}}}}},"/api/auth/email-status":{"post":{"operationId":"lookupEmailStatus","tags":["account"],"summary":"Look up which sign-in path an email address needs","description":"No authentication. Drives the second step of the stepped login.\n\n- Rate limit, checked before the body is parsed: more than 15 requests within 60 s from one client IP answer 429. The IP is the first `x-forwarded-for` entry, else `x-real-ip`, else the shared bucket `unknown`. The limiter is in-memory per server instance, and rejected requests count toward the window.\n- `email` is normalized with `String(email ?? '').trim().toLowerCase()`; a non-JSON body answers 400 `Invalid request`, an empty or malformed email (`^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$`) 400 `Invalid email`.\n- The `users` lookup and the external-auth listing run with the PocketBase superuser client (`getAdminPb`, `POCKETBASE_ADMIN_EMAIL` / `POCKETBASE_ADMIN_PASSWORD`).\n- `new`: no account with this email. `oauth`: the account is linked to Google and/or Apple. `password`: any other account. Every backend failure (including the superuser login) also answers `password`, so this operation never returns 500.","security":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthEmailStatusRequest"}}}},"responses":{"200":{"description":"Sign-in path for the email address.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthEmailStatusResponse"}}}},"400":{"description":"Body is not JSON, or the email is empty or malformed after normalization.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthEmailStatusBadRequest"},"examples":{"invalidRequest":{"value":{"error":"Invalid request"}},"invalidEmail":{"value":{"error":"Invalid email"}}}}}},"429":{"description":"More than 15 requests from this client IP within 60 seconds.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthEmailStatusRateLimited"},"examples":{"tooManyRequests":{"value":{"error":"Too many requests"}}}}}}}}},"/api/auth/verify-email":{"post":{"operationId":"verifyEmail","tags":["account"],"summary":"Confirm an email address with a PocketBase verification token","description":"No authentication. Calls `users.confirmVerification(token)` with an unauthenticated PocketBase client (`POCKETBASE_URL`); no superuser client is involved. A PocketBase 404 or a token field error answers 400; any other PocketBase failure answers 500 with the PocketBase field errors in `details`. A truthy non-string `token` (e.g. a number) is not rejected up front and ends in 500 `Failed to verify email`. A non-JSON or `null` body answers 500 `Internal server error`.","security":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthVerifyEmailRequest"}}}},"responses":{"200":{"description":"Email address verified.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthVerifyEmailResponse"}}}},"400":{"description":"Token missing, unknown, expired or rejected by PocketBase.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthVerifyEmailBadRequest"},"examples":{"verificationTokenIsRequired":{"value":{"error":"Verification token is required"}},"invalidOrExpiredVerificationToken":{"value":{"error":"Invalid or expired verification token"}},"invalidVerificationToken":{"value":{"error":"Invalid verification token"}}}}}},"500":{"description":"PocketBase failed for another reason, or the body could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthVerifyEmailServerError"},"examples":{"failedToVerifyEmail":{"value":{"error":"Failed to verify email"}},"internalServerError":{"value":{"error":"Internal server error"}}}}}}}}},"/api/newsletter/subscribe":{"post":{"operationId":"subscribeNewsletter","tags":["account"],"summary":"Subscribe an email address to the newsletter","description":"No authentication. Uses an unauthenticated PocketBase client (`POCKETBASE_URL`); no superuser client is involved.\n\n- `email` is only checked for presence: it is not trimmed, lowercased or format-validated and is used verbatim.\n- An existing `users` record with this email (or a PocketBase `validation_not_unique` error on `email`) answers 200 with `isExisting: true`.\n- Otherwise a `users` record is created via `POST <POCKETBASE_URL>/api/collections/users/records` with a random server-side password, `name` derived from the email local part, `emailVisibility: false`, `newsletter_source` (= `source`, default `landing_page`) and `newsletter_subscribed_at`. The PocketBase verification email is then requested; a failure there is ignored. The answer is 200 with `needsVerification: true`.\n- Any other PocketBase failure answers 500 `Failed to subscribe`; a non-JSON or `null` body answers 500 `Internal server error`.","security":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/NewsletterSubscribeRequest"}}}},"responses":{"200":{"description":"Account created (verification pending) or already subscribed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/NewsletterSubscribeResponse"}}}},"400":{"description":"`email` missing or empty.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/NewsletterSubscribeBadRequest"},"examples":{"emailIsRequired":{"value":{"error":"Email is required"}}}}}},"500":{"description":"PocketBase failed, or the body could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/NewsletterSubscribeServerError"},"examples":{"failedToSubscribe":{"value":{"error":"Failed to subscribe"}},"internalServerError":{"value":{"error":"Internal server error"}}}}}}}}},"/api/newsletter/unsubscribe":{"post":{"operationId":"unsubscribeNewsletter","tags":["account"],"summary":"Unsubscribe an email address from the newsletter","description":"No authentication. Creates a `users_unsubscribed` record `{ email, unsubscribed_at }` with an unauthenticated PocketBase client (`POCKETBASE_URL`); no superuser client is involved. `email` is only checked for presence and stored verbatim. Every PocketBase failure (duplicate, unknown address, outage) is swallowed and answers the same 200, so the response never reveals whether the address was known. A non-JSON or `null` body answers 500.","security":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/NewsletterUnsubscribeRequest"}}}},"responses":{"200":{"description":"Unsubscribe accepted.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/NewsletterUnsubscribeResponse"}}}},"400":{"description":"`email` missing or empty.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/NewsletterUnsubscribeBadRequest"},"examples":{"emailIsRequired":{"value":{"error":"Email is required"}}}}}},"500":{"description":"The body could not be read (non-JSON or `null`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/NewsletterUnsubscribeServerError"},"examples":{"internalServerError":{"value":{"error":"Internal server error"}}}}}}}}},"/api/testers":{"post":{"operationId":"signUpTester","tags":["account"],"summary":"Sign up as an app tester","description":"No authentication. Creates an `app_testers` record `{ email, name, platform, status: \"pending\", locale, source }` with an unauthenticated PocketBase client (`POCKETBASE_URL`); no superuser client is involved.\n\n- The email is validated first (400 `A valid email is required`), then the name (400 `Name is required`).\n- A PocketBase `validation_not_unique` error on any field (unique index on email + platform) answers 200 with `isExisting: true`.\n- Any other PocketBase failure answers 500 `Failed to sign up`; a non-JSON or `null` body answers 500 `Internal server error`.","security":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestersRequest"}}}},"responses":{"200":{"description":"On the tester list (new or existing).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestersResponse"}}}},"400":{"description":"Email missing or malformed, or name missing.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestersBadRequest"},"examples":{"aValidEmailIsRequired":{"value":{"error":"A valid email is required"}},"nameIsRequired":{"value":{"error":"Name is required"}}}}}},"500":{"description":"PocketBase failed, or the body could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestersServerError"},"examples":{"failedToSignUp":{"value":{"error":"Failed to sign up"}},"internalServerError":{"value":{"error":"Internal server error"}}}}}}}}},"/api/waitlist":{"post":{"operationId":"joinWaitlist","tags":["account"],"summary":"Join the app waitlist","description":"No authentication. Creates an `app_waitlist` record `{ email, platform, locale, source }` with an unauthenticated PocketBase client (`POCKETBASE_URL`); no superuser client is involved.\n\n- An empty or malformed email answers 400 `A valid email is required`.\n- A PocketBase `validation_not_unique` error on any field (unique index on email + platform) answers 200 with `isExisting: true`.\n- Any other PocketBase failure answers 500 `Failed to sign up`; a non-JSON or `null` body answers 500 `Internal server error`.","security":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WaitlistRequest"}}}},"responses":{"200":{"description":"On the waitlist (new or existing).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WaitlistResponse"}}}},"400":{"description":"Email missing or malformed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WaitlistBadRequest"},"examples":{"aValidEmailIsRequired":{"value":{"error":"A valid email is required"}}}}}},"500":{"description":"PocketBase failed, or the body could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WaitlistServerError"},"examples":{"failedToSignUp":{"value":{"error":"Failed to sign up"}},"internalServerError":{"value":{"error":"Internal server error"}}}}}}}}},"/api/share/diagram":{"post":{"operationId":"createSharedDiagram","tags":["sharing"],"deprecated":true,"summary":"Retired: create a shared diagram","description":"Retired endpoint; always answers 410 Gone. Sharing now uses the `share_token` on the PocketBase `wireframe_states` record — call `POST /api/share/generate-token`. The request body is ignored.","responses":{"410":{"description":"Endpoint retired.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShareDeprecatedError"},"example":{"error":"This API endpoint is deprecated. Sharing now uses wireframe_states directly.","deprecated":true}}}}}}},"/api/share/diagram/{token}/png":{"parameters":[{"name":"token","in":"path","required":true,"description":"Share token stored in the `share_token` field of a PocketBase `wireframe_states` record (issued by `POST /api/share/generate-token`, 16 alphanumeric characters).","schema":{"type":"string"}}],"get":{"operationId":"getSharedDiagramPng","tags":["sharing"],"summary":"Redirect a share token to the diagram preview PNG","description":"Looks up the PocketBase `wireframe_states` record whose `share_token` equals `token` and redirects to `/api/wireframe/{id}/preview/` of that record. Depends on the wireframe having a share token. Lookup errors are answered like an unknown token.","responses":{"302":{"description":"Redirect to the preview image of the shared wireframe.","headers":{"Location":{"description":"Absolute URL of `/api/wireframe/{id}/preview/` on the requesting origin.","schema":{"type":"string","format":"uri"}}}},"404":{"description":"No wireframe has this share token, or the lookup failed.","content":{"text/plain":{"schema":{"type":"string"},"example":"Shared diagram not found"}}}}}},"/api/share/generate-token":{"post":{"operationId":"generateShareToken","tags":["sharing"],"summary":"Get or create the share token of a wireframe","description":"Authenticates server-side as PocketBase superuser, returns the `share_token` of the `wireframe_states` record `wireframeId`, and generates and stores a new 16-character token (crypto-random, alphanumeric) when the record has none. No caller authentication. PocketBase errors are answered with their HTTP status and message (status 500 when none).","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShareTokenRequest"}}}},"responses":{"200":{"description":"Share token of the wireframe.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShareTokenResponse"},"example":{"shareToken":"aB3dE5fG7hJ9kL1m"}}}},"400":{"description":"`wireframeId` missing or empty, or PocketBase rejected the superuser login or the update.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"missingWireframeId":{"value":{"error":"wireframeId is required"}}}}}},"404":{"description":"No `wireframe_states` record with this id (PocketBase message).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"notFound":{"value":{"error":"The requested resource wasn't found."}}}}}},"500":{"description":"Superuser credentials (`POCKETBASE_ADMIN_EMAIL`, `POCKETBASE_ADMIN_PASSWORD`) not configured, malformed JSON body, or PocketBase unreachable.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"credentials":{"value":{"error":"Admin credentials not configured"}},"fallback":{"value":{"error":"Failed to generate share token"}}}}}}}}},"/api/share/save-png":{"post":{"operationId":"saveSharePng","tags":["sharing"],"summary":"Store the preview PNG of a shared wireframe","description":"Authenticates server-side as PocketBase superuser and uploads `preview_image` into the `preview_image` field of the `wireframe_states` record `wireframeId`; `GET /api/share/{token}/{filename}` later serves it. No caller authentication. PocketBase errors are answered with their HTTP status and message (status 500 when none).","requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/SharePngUpload"}}}},"responses":{"200":{"description":"PNG stored.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SharePngSaved"},"example":{"success":true}}}},"400":{"description":"`wireframeId` or `preview_image` missing, or PocketBase rejected the superuser login or the file.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"missingFields":{"value":{"error":"wireframeId and preview_image are required"}}}}}},"404":{"description":"No `wireframe_states` record with this id (PocketBase message).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"notFound":{"value":{"error":"The requested resource wasn't found."}}}}}},"500":{"description":"Superuser credentials not configured, body is not multipart/form-data, or PocketBase unreachable.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"credentials":{"value":{"error":"Admin credentials not configured"}},"fallback":{"value":{"error":"Failed to save PNG"}}}}}}}}},"/api/share/{token}/{filename}":{"parameters":[{"name":"token","in":"path","required":true,"description":"Share token stored in the `share_token` field of a PocketBase `wireframe_states` record (issued by `POST /api/share/generate-token`, 16 alphanumeric characters).","schema":{"type":"string"}},{"name":"filename","in":"path","required":true,"description":"Download name; must start with `voltplan-` and end with `.png`, otherwise 400. Echoed in `Content-Disposition`.","schema":{"type":"string","pattern":"^voltplan-.*\\.png$"}}],"get":{"operationId":"getSharedPng","tags":["sharing"],"summary":"Serve the stored PNG of a shared wireframe","description":"Finds the PocketBase `wireframe_states` record whose `share_token` equals `token` and proxies its stored `preview_image` (saved by `POST /api/share/save-png`) without exposing the PocketBase URL. Depends on the wireframe having a share token and a stored preview image.","responses":{"200":{"description":"The stored PNG.","headers":{"Content-Length":{"description":"Byte length of the PNG.","schema":{"type":"integer"}},"Content-Disposition":{"description":"`inline; filename=\"<filename>\"`","schema":{"type":"string"}},"Cache-Control":{"schema":{"type":"string","enum":["public, max-age=31536000, immutable"]}}},"content":{"image/png":{"schema":{"type":"string","format":"binary"}}}},"400":{"description":"`filename` does not match `voltplan-*.png`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"invalidFilename":{"value":{"error":"Invalid filename format"}}}}}},"404":{"description":"Unknown share token, no stored PNG, or the stored file could not be fetched.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"unknownToken":{"value":{"error":"Wireframe not found"}},"noPng":{"value":{"error":"PNG not available. Generate by sharing the wireframe first."}},"unreachable":{"value":{"error":"PNG file not accessible"}}}}}},"500":{"description":"PocketBase lookup or file fetch threw.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"failed":{"value":{"error":"Failed to serve PNG"}}}}}}}}},"/api/wireframe/{id}/preview":{"parameters":[{"name":"id","in":"path","required":true,"description":"PocketBase `wireframe_states` record id.","schema":{"type":"string"}}],"get":{"operationId":"getWireframePreview","tags":["sharing"],"summary":"Render a wireframe preview image (PNG)","description":"Renders the PocketBase `wireframe_states` record `id` (with its components) to PNG. Read with the unauthenticated server PocketBase client; does not use the share token. Falls back to SVG when PNG conversion yields nothing, and to a generic VoltPlan SVG (shorter cache) when rendering throws. Any lookup failure answers 404.","responses":{"200":{"description":"Preview image: PNG, or SVG when PNG conversion is unavailable or rendering failed.","headers":{"Cache-Control":{"description":"`public, max-age=3600, s-maxage=3600` for a rendered preview; `public, max-age=300, s-maxage=300` for the fallback image served when rendering throws.","schema":{"type":"string","enum":["public, max-age=3600, s-maxage=3600","public, max-age=300, s-maxage=300"]}}},"content":{"image/png":{"schema":{"type":"string","format":"binary"}},"image/svg+xml":{"schema":{"type":"string"}}}},"404":{"description":"Record missing or not readable.","content":{"text/plain":{"schema":{"type":"string"},"example":"Wireframe not found"}}}}}},"/api/wireframe/{id}/preview-svg":{"parameters":[{"name":"id","in":"path","required":true,"description":"PocketBase `wireframe_states` record id.","schema":{"type":"string"}}],"get":{"operationId":"getWireframePreviewSvg","tags":["sharing"],"summary":"Render a wireframe preview image (SVG)","description":"Renders the PocketBase `wireframe_states` record `id` (with its components) to SVG; image references point at `NEXT_PUBLIC_BASE_URL` or the request origin. Read with the unauthenticated server PocketBase client; does not use the share token. Rendering errors answer a generic VoltPlan SVG with a shorter cache. Any lookup failure answers 404.","responses":{"200":{"description":"Preview SVG, or the generic fallback SVG when rendering failed.","headers":{"Cache-Control":{"description":"`public, max-age=3600, s-maxage=3600` for a rendered preview; `public, max-age=300, s-maxage=300` for the fallback image served when rendering throws.","schema":{"type":"string","enum":["public, max-age=3600, s-maxage=3600","public, max-age=300, s-maxage=300"]}}},"content":{"image/svg+xml":{"schema":{"type":"string"}}}},"404":{"description":"Record missing or not readable.","content":{"text/plain":{"schema":{"type":"string"},"example":"Wireframe not found"}}}}}},"/api/convert-image":{"post":{"operationId":"convertImageToDataUrl","tags":["sharing"],"summary":"Convert a PocketBase component image to a data URL","description":"Fetches `imageUrl` server-side (`User-Agent: VoltPlan/1.0`) and returns it base64-encoded as a data URL. Only URLs containing `/api/files/components/` are accepted. Independent of share tokens. No authentication.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConvertImageRequest"}}}},"responses":{"200":{"description":"Image as data URL.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConvertImageResponse"}}}},"400":{"description":"`imageUrl` missing/empty or not a PocketBase component file URL.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"missing":{"value":{"error":"Image URL is required"}},"notAllowed":{"value":{"error":"Only PocketBase component images are allowed"}}}}}},"404":{"description":"Upstream answered a non-2xx status.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"fetchFailed":{"value":{"error":"Failed to fetch image"}}}}}},"500":{"description":"Malformed JSON body, non-string `imageUrl`, or network error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"internal":{"value":{"error":"Internal server error"}}}}}}}}},"/api/diagram/generate":{"post":{"operationId":"generateDiagram","tags":["generators"],"summary":"Render a wiring diagram as SVG or PNG","description":"Public, CORS-open endpoint; 30 requests per minute per client IP. The client IP is the first `X-Forwarded-For` entry, else `X-Real-IP`, else one shared `unknown` bucket; counters are kept in memory per server instance and per route. Flat mode renders `loads`, `batteries` and `chargers`. When `circuits` is non-empty the flat lists are ignored and one labelled panel per circuit is rendered, with `links` drawn as connectors; links whose `from`/`to` resolve to no circuit or to the same circuit are dropped. A power source is added automatically for each charger based on its `sourceType`, and fuses/protection components are added when batteries and loads exist. `Accept: image/png` returns PNG, anything else SVG; when PNG conversion fails the SVG is returned instead. Component icons from `iconUrl` are fetched server-side and embedded as data URIs. No external API is called.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DiagramGenerateRequest"},"examples":{"flat":{"value":{"systemName":"Camper Van","batteries":[{"name":"LiFePO4 100Ah","voltage":12,"capacityAh":100,"energyWh":1200}],"loads":[{"name":"Fridge","power":60,"voltage":12,"current":5}],"chargers":[{"name":"MPPT 75/15","inputVoltage":48,"outputVoltage":12,"power":200,"sourceType":"solar"}]}},"multiCircuit":{"value":{"systemName":"Sailboat","circuits":[{"name":"House","batteries":[{"name":"House Bank","voltage":12,"capacityAh":200,"energyWh":2560}],"loads":[{"name":"Fridge","power":60,"voltage":12,"current":5}]},{"name":"Engine","batteries":[{"name":"Starter","voltage":12,"capacityAh":70,"energyWh":840}]}],"links":[{"from":"Engine","to":"House","kind":"dcdc","spec":{"inputVoltage":12,"outputVoltage":12,"power":360}}]}}}}}},"responses":{"200":{"description":"Rendered diagram. SVG by default; PNG when the Accept header contains image/png and conversion succeeds.","headers":{"Access-Control-Allow-Origin":{"schema":{"type":"string","enum":["*"]}},"Access-Control-Allow-Methods":{"schema":{"type":"string","enum":["POST, OPTIONS"]}},"Access-Control-Allow-Headers":{"schema":{"type":"string","enum":["Content-Type, Accept"]}},"Cache-Control":{"schema":{"type":"string","enum":["no-cache"]}}},"content":{"image/svg+xml":{"schema":{"type":"string","description":"SVG document"}},"image/png":{"schema":{"type":"string","format":"binary"}}}},"400":{"description":"Body is not an object, systemName is not a string, loads/batteries/chargers/links is not an array, or circuits is not an array of objects with a string name. Nested items are not validated.","headers":{"Access-Control-Allow-Origin":{"schema":{"type":"string","enum":["*"]}},"Access-Control-Allow-Methods":{"schema":{"type":"string","enum":["POST, OPTIONS"]}},"Access-Control-Allow-Headers":{"schema":{"type":"string","enum":["Content-Type, Accept"]}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"invalidRequest":{"value":{"error":"Invalid request. Expected { systemName: string, loads?: [], batteries?: [], chargers?: [] }"}}}}}},"429":{"description":"More than 30 requests within 60 seconds from this client IP.","headers":{"Retry-After":{"description":"Seconds until the client should retry; always 60.","schema":{"type":"integer","enum":[60]}},"Access-Control-Allow-Origin":{"schema":{"type":"string","enum":["*"]}},"Access-Control-Allow-Methods":{"schema":{"type":"string","enum":["POST, OPTIONS"]}},"Access-Control-Allow-Headers":{"schema":{"type":"string","enum":["Content-Type, Accept"]}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"rateLimited":{"value":{"error":"Too many requests. Try again later."}}}}}},"500":{"description":"Rendering failed, or the request body was not valid JSON: a generic fallback SVG (\"Diagram generation failed\").","headers":{"Access-Control-Allow-Origin":{"schema":{"type":"string","enum":["*"]}},"Access-Control-Allow-Methods":{"schema":{"type":"string","enum":["POST, OPTIONS"]}},"Access-Control-Allow-Headers":{"schema":{"type":"string","enum":["Content-Type, Accept"]}}},"content":{"image/svg+xml":{"schema":{"type":"string"}}}}}},"options":{"operationId":"preflightDiagramGenerate","tags":["generators"],"summary":"CORS preflight for browser clients","responses":{"204":{"description":"Preflight accepted; no body.","headers":{"Access-Control-Allow-Origin":{"schema":{"type":"string","enum":["*"]}},"Access-Control-Allow-Methods":{"schema":{"type":"string","enum":["POST, OPTIONS"]}},"Access-Control-Allow-Headers":{"schema":{"type":"string","enum":["Content-Type, Accept"]}}}}}}},"/api/project/generate":{"post":{"operationId":"generateProject","tags":["generators"],"summary":"Render every diagram of a multi-diagram project","description":"Public, CORS-open endpoint; 30 requests per minute per client IP (counted separately from `/api/diagram/generate`). The client IP is the first `X-Forwarded-For` entry, else `X-Real-IP`, else one shared `unknown` bucket; counters are kept in memory per server instance and per route. Each entry of `diagrams` is rendered as its own SVG. Each bridge adds a virtual load to the diagram whose `id` equals `fromDiagramId` and a virtual charger plus power source to the diagram whose `id` equals `toDiagramId`. The response is always JSON; with `Accept: image/png` each diagram additionally carries a base64 PNG (`null` when not requested or when conversion failed). Component icons are embedded as in `/api/diagram/generate`. No external API is called.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectGenerateRequest"},"example":{"projectName":"Sailboat","diagrams":[{"id":"house","name":"House","batteries":[{"name":"House Bank","voltage":12,"capacityAh":200,"energyWh":2560}],"loads":[{"name":"Fridge","power":60,"voltage":12,"current":5}]},{"id":"engine","name":"Engine","batteries":[{"name":"Starter","voltage":12,"capacityAh":70,"energyWh":840}]}],"bridges":[{"id":"b1","name":"DC-DC 30A","kind":"dcdc","fromDiagramId":"engine","toDiagramId":"house","spec":{"inputVoltage":12,"outputVoltage":12,"power":360}}]}}}},"responses":{"200":{"description":"Rendered diagrams in request order.","headers":{"Access-Control-Allow-Origin":{"schema":{"type":"string","enum":["*"]}},"Access-Control-Allow-Methods":{"schema":{"type":"string","enum":["POST, OPTIONS"]}},"Access-Control-Allow-Headers":{"schema":{"type":"string","enum":["Content-Type, Accept"]}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectGenerateResponse"}}}},"400":{"description":"Body is not an object, diagrams is not an array of objects with string id and name, or bridges is not an array.","headers":{"Access-Control-Allow-Origin":{"schema":{"type":"string","enum":["*"]}},"Access-Control-Allow-Methods":{"schema":{"type":"string","enum":["POST, OPTIONS"]}},"Access-Control-Allow-Headers":{"schema":{"type":"string","enum":["Content-Type, Accept"]}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"invalidRequest":{"value":{"error":"Invalid request. Expected { diagrams: [{id, name, ...}], bridges?: [...] }"}}}}}},"429":{"description":"More than 30 requests within 60 seconds from this client IP.","headers":{"Retry-After":{"description":"Seconds until the client should retry; always 60.","schema":{"type":"integer","enum":[60]}},"Access-Control-Allow-Origin":{"schema":{"type":"string","enum":["*"]}},"Access-Control-Allow-Methods":{"schema":{"type":"string","enum":["POST, OPTIONS"]}},"Access-Control-Allow-Headers":{"schema":{"type":"string","enum":["Content-Type, Accept"]}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"rateLimited":{"value":{"error":"Too many requests. Try again later."}}}}}},"500":{"description":"Rendering failed, or the request body was not valid JSON: a generic fallback SVG (\"Diagram generation failed\").","headers":{"Access-Control-Allow-Origin":{"schema":{"type":"string","enum":["*"]}},"Access-Control-Allow-Methods":{"schema":{"type":"string","enum":["POST, OPTIONS"]}},"Access-Control-Allow-Headers":{"schema":{"type":"string","enum":["Content-Type, Accept"]}}},"content":{"image/svg+xml":{"schema":{"type":"string"}}}}}},"options":{"operationId":"preflightProjectGenerate","tags":["generators"],"summary":"CORS preflight for browser clients","responses":{"204":{"description":"Preflight accepted; no body.","headers":{"Access-Control-Allow-Origin":{"schema":{"type":"string","enum":["*"]}},"Access-Control-Allow-Methods":{"schema":{"type":"string","enum":["POST, OPTIONS"]}},"Access-Control-Allow-Headers":{"schema":{"type":"string","enum":["Content-Type, Accept"]}}}}}}},"/api/loads/estimate":{"post":{"operationId":"estimateLoad","tags":["generators"],"summary":"Estimate a device's electrical specs from a free-text description (AI)","description":"Used by the wireframe editor's add-load dialog. Calls the external OpenRouter chat-completions API (https://openrouter.ai) with the model configured server-side in `OPENROUTER_LOAD_ASSISTANT_MODEL`; 20 s timeout. 20 requests per hour per client IP. The client IP is the first `X-Forwarded-For` entry, else `X-Real-IP`, else one shared `unknown` bucket; counters are kept in memory per server instance and per route. No CORS headers.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoadEstimateRequest"},"example":{"description":"Dometic CFX3 55 compressor cooler"}}}},"responses":{"200":{"description":"Parsed estimate.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoadEstimate"}}}},"400":{"description":"Body is not valid JSON, or description (trimmed) is missing, not a string, or outside 2–300 characters.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"invalidJson":{"value":{"error":"Invalid JSON body"}},"invalidDescription":{"value":{"error":"description must be between 2 and 300 characters"}}}}}},"422":{"description":"The model did not recognize the description as a device (empty name or watt ≤ 0).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"unrecognized":{"value":{"error":"Couldn't recognize that as a device. Try describing it differently, or enter the wattage manually."}}}}}},"429":{"description":"More than 20 requests within one hour from this client IP.","headers":{"Retry-After":{"description":"Seconds until the client should retry; always 60.","schema":{"type":"integer","enum":[60]}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"rateLimited":{"value":{"error":"Too many requests. Try again later."}}}}}},"502":{"description":"No model configured, OpenRouter failed or timed out, or its reply contained no parseable JSON object.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"unavailable":{"value":{"error":"Load assistant is unavailable right now. Try entering the wattage manually."}}}}}}}}},"/api/chargers/estimate":{"post":{"operationId":"estimateCharger","tags":["generators"],"summary":"Look up or estimate a battery charger's output specs from a free-text description (AI)","description":"Used by the wireframe editor's add-charger dialog. Calls the external OpenRouter chat-completions API (https://openrouter.ai) with the model configured server-side in `OPENROUTER_CHARGER_ASSISTANT_MODEL`, falling back to `OPENROUTER_LOAD_ASSISTANT_MODEL`; 20 s timeout. The optional power-source context sharpens the estimate. 20 requests per hour per client IP (counted separately from `/api/loads/estimate`). The client IP is the first `X-Forwarded-For` entry, else `X-Real-IP`, else one shared `unknown` bucket; counters are kept in memory per server instance and per route. No CORS headers.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChargerEstimateRequest"},"example":{"description":"Victron SmartSolar MPPT 100/30","powerSourceName":"Solar Panel 400W","powerSourceWatt":400}}}},"responses":{"200":{"description":"Parsed estimate.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChargerEstimate"}}}},"400":{"description":"Body is not valid JSON, or description (trimmed) is missing, not a string, or outside 2–300 characters.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"invalidJson":{"value":{"error":"Invalid JSON body"}},"invalidDescription":{"value":{"error":"description must be between 2 and 300 characters"}}}}}},"422":{"description":"The model did not recognize the description as a charger (empty name or watt ≤ 0).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"unrecognized":{"value":{"error":"Couldn't recognize that as a charger. Try describing it differently, or enter the specs manually."}}}}}},"429":{"description":"More than 20 requests within one hour from this client IP.","headers":{"Retry-After":{"description":"Seconds until the client should retry; always 60.","schema":{"type":"integer","enum":[60]}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"rateLimited":{"value":{"error":"Too many requests. Try again later."}}}}}},"502":{"description":"No model configured, OpenRouter failed or timed out, or its reply contained no parseable JSON object.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"unavailable":{"value":{"error":"Charger assistant is unavailable right now. Try entering the specs manually."}}}}}}}}},"/health":{"get":{"operationId":"getLiveness","tags":["ops"],"summary":"Liveness probe with the deployed build SHA","description":"Plain-text liveness check used by the post-deploy smoke test. The body is `ok <GIT_SHA>`; GIT_SHA is baked in at image build time and is `dev` locally. Does not contact PocketBase.","responses":{"200":{"description":"Service is up.","content":{"text/plain":{"schema":{"type":"string","pattern":"^ok \\S+$"},"example":"ok dev"}}}}}},"/api/health":{"get":{"operationId":"getHealthStatus","tags":["ops"],"summary":"Health status including PocketBase reachability","description":"Checks PocketBase with a health call. Answers 200 when PocketBase is reachable and 503 with `status: degraded` when it is not.","responses":{"200":{"description":"All dependencies healthy.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HealthStatus"},"example":{"status":"ok","pocketbase":"ok","timestamp":"2026-01-01T00:00:00.000Z"}}}},"503":{"description":"PocketBase unreachable; service degraded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HealthStatus"},"example":{"status":"degraded","pocketbase":"error","timestamp":"2026-01-01T00:00:00.000Z"}}}}}}},"/api/debug-region":{"get":{"operationId":"getDebugRegion","tags":["ops"],"summary":"Diagnose marketplace region detection for the calling client","description":"Returns the region the affiliate redirects would pick for this request together with the inputs used: client IP from `X-Forwarded-For`/`X-Real-IP`, the GeoIP lookup, `Accept-Language` and all `x-*`, `accept-language` and `host` request headers. Detection order: `region`/`country` query parameter, `X-VoltPlan-Region`/`X-VoltPlan-Country` header, CDN country headers, GeoIP, `Accept-Language`, default `us`.","parameters":[{"name":"region","in":"query","required":false,"description":"Force a region or country code (e.g. `de`, `at`); unknown codes are ignored.","schema":{"type":"string"}},{"name":"country","in":"query","required":false,"description":"Alias of `region`, used when `region` is absent.","schema":{"type":"string"}}],"responses":{"200":{"description":"Detected region and detection inputs.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DebugRegion"}}}}}}},"/api/indexnow":{"get":{"operationId":"submitSitemapToIndexNow","tags":["ops"],"summary":"Submit every sitemap URL to IndexNow","description":"Collects all URLs from the sitemap and submits them to IndexNow in one batch. `success`/`status` echo the IndexNow API response; an IndexNow rejection still answers 200 with `success: false`.","security":[{"indexNowBearer":[]}],"responses":{"200":{"description":"Sitemap URLs submitted.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/IndexNowSitemapSubmission"}}}},"401":{"description":"Missing or wrong `Authorization: Bearer <INDEXNOW_SECRET>` header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Unauthorized"}}}},"500":{"description":"Building the sitemap or calling IndexNow failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Failed to submit sitemap URLs","details":"Error: fetch failed"}}}}}},"post":{"operationId":"submitUrlsToIndexNow","tags":["ops"],"summary":"Submit specific site paths to IndexNow","description":"Each entry of `urls` is a site path (e.g. `/blog/my-post`); it is prefixed with `https://voltplan.app` and the result is submitted to IndexNow. `success`/`status` echo the IndexNow API response.","security":[{"indexNowBearer":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/IndexNowSubmitRequest"},"example":{"urls":["/blog/my-post","/de/blog/mein-post"]}}}},"responses":{"200":{"description":"URLs submitted.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/IndexNowUrlSubmission"}}}},"400":{"description":"`urls` is missing, empty or not an array, or the body is not valid JSON. The handler also maps any exception during submission to `Invalid request body`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"emptyUrls":{"value":{"error":"urls must be a non-empty array of paths"}},"invalidBody":{"value":{"error":"Invalid request body"}}}}}},"401":{"description":"Missing or wrong `Authorization: Bearer <INDEXNOW_SECRET>` header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Unauthorized"}}}}}}},"/rss.xml":{"get":{"operationId":"getRssFeed","tags":["ops"],"summary":"RSS 2.0 feed of the VoltPlan blog","description":"RSS 2.0 document with one item per blog post. Served as `application/xml; charset=utf-8` and cached for one hour.","responses":{"200":{"description":"RSS 2.0 XML document.","content":{"application/xml":{"schema":{"type":"string"}}}}}}},"/llms-full.txt":{"get":{"operationId":"getLlmsFullText","tags":["ops"],"summary":"Full English guide and blog text for LLMs","description":"The contents of /llms.txt followed by the complete English text of every guide and blog post in one plain-text file. Statically generated and revalidated hourly.","responses":{"200":{"description":"Plain-text knowledge base export.","content":{"text/plain":{"schema":{"type":"string"}}}}}}},"/openapi.json":{"get":{"operationId":"getOpenApiDocument","tags":["ops"],"summary":"This OpenAPI 3.1 document","description":"The OpenAPI 3.1 description of the VoltPlan API, assembled from all API modules. Statically generated and revalidated hourly.","responses":{"200":{"description":"OpenAPI 3.1 document.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OpenApiDocument"}}}}}}},"/api/admin/components/{id}":{"parameters":[{"name":"id","in":"path","required":true,"description":"Id of the `components` record (PocketBase record id).","schema":{"type":"string"}}],"get":{"operationId":"getAdminComponent","tags":["admin-components"],"summary":"Get one component with its affiliate links","description":"Loads the `components` record with `expand: brand` and every `affiliate_links` record whose `component` is `id`. An unknown `id` answers 500 (the PocketBase 404 is not mapped).","security":[{"adminBearer":[]}],"responses":{"200":{"description":"The component and its affiliate links.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminComponentDetail"}}}},"403":{"$ref":"#/components/responses/AdminForbidden"},"500":{"description":"PocketBase read failed, including an unknown `id`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"e1":{"value":{"error":"Failed to fetch component"}},"e2":{"value":{"error":"The requested resource wasn't found."}}}}}}}},"patch":{"operationId":"updateAdminComponent","tags":["admin-components"],"summary":"Update, publish or unpublish a component","description":"Partial update. Only the keys of `AdminComponentPatch` are taken from the body; any other key is ignored. Values are passed to PocketBase without further validation, so anything PocketBase rejects (and invalid JSON) answers 500.\n\n- `brand` is a brand **name**: it is matched or created in `component_brands`; an empty or non-string value clears the relation.\n- `published: true` enforces the publication gate: `summary` (sent, or else stored) must be non-empty, otherwise 400. A sent non-empty `slug` is used; without a sent or stored slug one is derived from name and brand and made unique with `-2` … `-50` (500 when none is free); a stored slug is kept when none is sent. `published_at` is set to now when neither sent nor stored.\n- `affiliate_links` (when an array) replaces the link set: stored links whose `id` is missing from the array are deleted, entries with `id` are updated (`url`, `country`), entries without `id` are created; entries lacking `url` or `country` are skipped.\n\nAfterwards the cached component reads and the public pages `/components` and `/components/<slug>` are revalidated.","security":[{"adminBearer":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminComponentPatch"},"example":{"published":true,"summary":"12 V compressor fridge, 45 W.","brand":"Victron"}}}},"responses":{"200":{"description":"Updated record (without `expand`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminComponentUpdateResult"}}}},"400":{"description":"Publishing without a summary.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"A summary is required before publishing: the public page needs a lead paragraph."}}}},"403":{"$ref":"#/components/responses/AdminForbidden"},"500":{"description":"Invalid JSON, unknown `id`, PocketBase validation error or no free slug.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"e1":{"value":{"error":"Failed to update component"}},"e2":{"value":{"error":"Could not derive a free slug from \"victron-fridge\" — set one manually"}}}}}}}},"delete":{"operationId":"deleteAdminComponent","tags":["admin-components"],"summary":"Delete a component and its affiliate links","description":"Deletes every `affiliate_links` record of the component first, then the `components` record. An unknown `id` answers 500.","security":[{"adminBearer":[]}],"responses":{"200":{"description":"Component deleted.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminComponentSuccess"}}}},"403":{"$ref":"#/components/responses/AdminForbidden"},"500":{"description":"PocketBase delete failed, including an unknown `id`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Failed to delete component"}}}}}}},"/api/admin/components/{id}/stats":{"parameters":[{"name":"id","in":"path","required":true,"description":"Id of the `components` record (PocketBase record id).","schema":{"type":"string"}}],"get":{"operationId":"getAdminComponentStats","tags":["admin-components"],"summary":"Affiliate click statistics of one component","description":"Aggregates the `component_click_events` of the component. Bot and crawler user agents are skipped (`isBotUserAgent`, which also treats a missing or empty `user_agent` as a bot). An unknown `id` answers 200 with zero counts.","security":[{"adminBearer":[]}],"responses":{"200":{"description":"Click statistics.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminComponentStats"}}}},"403":{"$ref":"#/components/responses/AdminForbidden"},"500":{"description":"PocketBase read failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Failed to load stats"}}}}}}},"/api/admin/components/{id}/gallery":{"parameters":[{"name":"id","in":"path","required":true,"description":"Id of the `components` record (PocketBase record id).","schema":{"type":"string"}}],"get":{"operationId":"getAdminComponentGallery","tags":["admin-components"],"summary":"List the product photos of a component","description":"Returns the stored filenames of the `gallery` file field and their public file URLs. An unknown `id` answers 500.","security":[{"adminBearer":[]}],"responses":{"200":{"description":"Current gallery.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminComponentGallery"}}}},"403":{"$ref":"#/components/responses/AdminForbidden"},"500":{"description":"PocketBase read failed, including an unknown `id`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Failed to load gallery"}}}}}},"post":{"operationId":"uploadAdminComponentGalleryImages","tags":["admin-components"],"summary":"Append product photos (upload or import by URL)","description":"Appends images to the `gallery` file field (`gallery+`); stored images stay in place.\n\n- `Content-Type: application/json` expects `{ urls }`: the server downloads every http(s) URL (15 s timeout each). Any failed download answers 400 with the message of the first failure.\n- Any other content type is read as `multipart/form-data`; every `files` part is one image.\n\nA component holds at most 8 images in total; each image must be at most 5 MB and of type image/png, image/jpeg, image/webp, image/avif, image/svg+xml. On success the cached component reads and the public pages `/components` and `/components/<slug>` are revalidated.","security":[{"adminBearer":[]}],"requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/AdminComponentGalleryUpload"},"encoding":{"files":{"contentType":"image/png, image/jpeg, image/webp, image/avif, image/svg+xml"}}},"application/json":{"schema":{"$ref":"#/components/schemas/AdminComponentGalleryImport"},"example":{"urls":["https://example.com/product.jpg"]}}}},"responses":{"200":{"description":"Gallery after the append.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminComponentGallery"}}}},"400":{"description":"Nothing usable received, limit exceeded, wrong type, too large or download failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"e1":{"value":{"error":"No image URLs received"}},"e2":{"value":{"error":"No files received"}},"e3":{"value":{"error":"A component can hold 8 images; 7 are already there and you sent 2."}},"e4":{"value":{"error":"photo.gif: image/gif is not an accepted image format."}},"e5":{"value":{"error":"photo.png is larger than 5 MB."}},"e6":{"value":{"error":"https://example.com/a.png: server answered 404."}}}}}},"403":{"$ref":"#/components/responses/AdminForbidden"},"500":{"description":"Unknown `id`, unreadable multipart body or PocketBase write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Upload failed"}}}}}},"delete":{"operationId":"deleteAdminComponentGalleryImage","tags":["admin-components"],"summary":"Remove one product photo","description":"Removes the named file from the `gallery` file field (`gallery-`). On success the cached component reads and the public pages `/components` and `/components/<slug>` are revalidated.","security":[{"adminBearer":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminComponentGalleryDelete"}}}},"responses":{"200":{"description":"Gallery after the removal.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminComponentGallery"}}}},"400":{"description":"`filename` missing or not a string.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"filename is required"}}}},"403":{"$ref":"#/components/responses/AdminForbidden"},"404":{"description":"The file is not in this gallery.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"That image is not on this component"}}}},"500":{"description":"Invalid JSON, unknown `id` or PocketBase write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Delete failed"}}}}}},"patch":{"operationId":"reorderAdminComponentGallery","tags":["admin-components"],"summary":"Reorder the product photos","description":"Restates the `gallery` filenames in a new order. `order` must be a permutation of the stored filenames, so nothing can be dropped or added; the first image is the one the detail page and the catalogue card show. On success the cached component reads and the public pages `/components` and `/components/<slug>` are revalidated.","security":[{"adminBearer":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminComponentGalleryReorder"}}}},"responses":{"200":{"description":"Gallery in the new order.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminComponentGallery"}}}},"400":{"description":"`order` is not a string array or not a permutation of the stored files.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"e1":{"value":{"error":"order must be an array of filenames"}},"e2":{"value":{"error":"order must contain exactly the images already stored"}}}}}},"403":{"$ref":"#/components/responses/AdminForbidden"},"500":{"description":"Invalid JSON, unknown `id` or PocketBase write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Reorder failed"}}}}}}},"/api/admin/components/{id}/research":{"parameters":[{"name":"id","in":"path","required":true,"description":"Id of the `components` record (PocketBase record id).","schema":{"type":"string"}}],"post":{"operationId":"researchAdminComponent","tags":["admin-components"],"summary":"Research the manufacturer's published data with AI","description":"Looks up the component's datasheet figures with a web-search-backed model (OpenRouter `web` plugin, up to 120 s) and returns them for the editor to review. **Writes nothing**: the publishing drawer fills its form, and the editor still has to save with `PATCH /api/admin/components/{id}`.\n\n- Rate limit: 60 calls per hour per client IP (`x-forwarded-for`, else `x-real-ip`), checked before anything else.\n- Needs `OPENROUTER_RESEARCH_MODEL` (fallback `OPENROUTER_LOAD_ASSISTANT_MODEL`) and `OPENROUTER_API_KEY`; otherwise 502.\n- 422 when the model found no summary, specs or safety notes.\n- `images` are verified product-photo candidates: what the model saw plus the `og:image` of every cited page.\n- Any other failure (PocketBase, unknown `id`, model call, unparseable answer) answers 502.","security":[{"adminBearer":[]}],"responses":{"200":{"description":"Research result.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminComponentResearchResult"}}}},"400":{"description":"The component has no name.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"This component has no name to research."}}}},"403":{"$ref":"#/components/responses/AdminForbidden"},"422":{"description":"Nothing found; the message is the model's `note` when it gave one.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"e1":{"value":{"error":"No datasheet found for \"Fridge\". Check the exact model name, or fill the fields in manually."}}}}}},"429":{"description":"Rate limit exceeded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Too many research requests. Try again later."}}},"headers":{"Retry-After":{"description":"Seconds to wait.","schema":{"type":"string","enum":["60"]}}}},"502":{"description":"Research not configured or failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"e1":{"value":{"error":"Research is not configured on this environment."}},"e2":{"value":{"error":"Research failed. Try again, or fill the fields in manually."}}}}}}}}},"/api/admin/components/backfill-utilization":{"get":{"operationId":"backfillAdminComponentUtilization","tags":["admin-components"],"summary":"Report or fix fractional utilization values","description":"One-shot correction for components whose `default_utilization_factor` or `duty_cycle` was stored as a fraction (`0 < v ≤ 1`) instead of a percentage; the corrected value is `Math.round(v * 100)`. Without `apply=1` this is a read-only dry run listing at most 200 candidates. **With `apply=1` this GET writes** (not idempotent): one PocketBase update per component; per-record failures are listed in `errors`.","security":[{"adminBearer":[]}],"parameters":[{"name":"apply","in":"query","required":false,"description":"`1` writes the changes; absent or any other value runs a dry run.","schema":{"type":"string","enum":["1"]}}],"responses":{"200":{"description":"`AdminComponentBackfillDryRun` without `apply=1`, `AdminComponentBackfillApplied` with it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminComponentBackfillResult"}}}},"403":{"$ref":"#/components/responses/AdminForbidden"},"500":{"description":"PocketBase read failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Backfill failed"}}}}}}},"/api/admin/components/enhance":{"get":{"operationId":"getAdminComponentEnhanceJob","tags":["admin-components"],"summary":"Poll a batch enhancement job","description":"Returns the state of a job started with `POST /api/admin/components/enhance`. Jobs live in the memory of the server instance that started them; they are lost on restart and dropped one hour after creation (on the next POST), which answers 404.","security":[{"adminBearer":[]}],"parameters":[{"name":"jobId","in":"query","required":false,"description":"Id returned by the POST. Missing → 400.","schema":{"type":"string","pattern":"^job_[a-z0-9]+$"}}],"responses":{"200":{"description":"Job state.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminComponentEnhanceJob"}}}},"400":{"description":"`jobId` missing.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"jobId required"}}}},"403":{"$ref":"#/components/responses/AdminForbidden"},"404":{"description":"Unknown, expired or other-instance job.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"job not found"}}}}}},"post":{"operationId":"startAdminComponentEnhanceJob","tags":["admin-components"],"summary":"Start an AI data-cleanup job for up to 100 components","description":"Creates an in-memory job and processes it asynchronously: per component one Anthropic Messages call (`ANTHROPIC_API_KEY`), then a 1 s pause. Ids that cannot be loaded become rows with `status: error` and `name: (not found)`. Nothing is written to PocketBase; apply proposals with `PATCH /api/admin/components/{id}`. Poll with `GET /api/admin/components/enhance?jobId=…`.","security":[{"adminBearer":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminComponentEnhanceRequest"}}}},"responses":{"200":{"description":"Job started.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminComponentEnhanceStarted"}}}},"400":{"description":"`ids` missing, empty or longer than 100.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"e1":{"value":{"error":"ids[] required"}},"e2":{"value":{"error":"max 100 ids per job"}}}}}},"403":{"$ref":"#/components/responses/AdminForbidden"},"500":{"description":"Invalid JSON or PocketBase unavailable.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Failed to start job"}}}}}}},"/api/admin/components/list":{"get":{"operationId":"listAdminComponents","tags":["admin-components"],"summary":"Search and page through the component catalogue","description":"One page of `components` (with `expand: brand`), enriched with brand name and colour, 30-day human click counts and affiliate link counts.\n\n- `totalItems` and `totalPages` count the PocketBase filter result **before** the `hasAffiliate` post-filter; `hasAffiliate` and the `clicks30d` sorts act on the loaded page only.\n- `affiliateLinkCount` is only filled when `hasAffiliate` or a `clicks30d` sort is set; otherwise it is 0.\n- `clicks30d` counts `component_click_events` of the last 30 days, skipping bot user agents.","security":[{"adminBearer":[]}],"parameters":[{"name":"page","in":"query","required":false,"schema":{"type":"integer","minimum":1,"default":1}},{"name":"perPage","in":"query","required":false,"description":"Values above 200 are capped to 200.","schema":{"type":"integer","minimum":1,"maximum":200,"default":50}},{"name":"search","in":"query","required":false,"description":"Substring of `name` (PocketBase `~`).","schema":{"type":"string"}},{"name":"type","in":"query","required":false,"description":"Exact component type.","schema":{"type":"string"}},{"name":"brand","in":"query","required":false,"description":"Id of the `component_brands` record (not the brand name).","schema":{"type":"string"}},{"name":"missing","in":"query","required":false,"description":"Only components without watt, price or image (`null`/`0`/`\"\"`). Other values are ignored.","schema":{"type":"string","enum":["watt","price","image"]}},{"name":"hasAffiliate","in":"query","required":false,"description":"Only components with (`yes`) or without (`no`) affiliate links, applied to the loaded page.","schema":{"type":"string","enum":["yes","no"]}},{"name":"published","in":"query","required":false,"description":"`yes` = published; `no` = `false` or never set.","schema":{"type":"string","enum":["yes","no"]}},{"name":"sort","in":"query","required":false,"description":"PocketBase sort expression, passed through unchanged. `clicks30d` / `-clicks30d` sort the loaded page by 30-day clicks (PocketBase then sorts by `-updated`).","schema":{"type":"string","default":"-updated","examples":["name","-name","type","-updated","clicks30d","-clicks30d"]}}],"responses":{"200":{"description":"One page of components.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminComponentList"}}}},"403":{"$ref":"#/components/responses/AdminForbidden"},"500":{"description":"PocketBase read failed, e.g. an invalid `sort`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Failed to list components"}}}}}}},"/api/admin/custom-components":{"get":{"operationId":"listAdminCustomComponents","tags":["admin-catalog-tools"],"summary":"List all user-submitted custom components","description":"Returns every `custom_components` record, fetched with the PocketBase superuser client (`getFullList`, batch size 500), newest first (`sort: -created`). There is no pagination; `totalItems` equals `items.length`.","security":[{"adminBearer":[]}],"responses":{"200":{"description":"All custom components.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminCatalogCustomComponentList"}}}},"403":{"$ref":"#/components/responses/AdminForbidden"},"500":{"$ref":"#/components/responses/AdminCatalogCustomComponentsFetchFailed"}}}},"/api/admin/custom-components/delete":{"post":{"operationId":"deleteAdminCustomComponent","tags":["admin-catalog-tools"],"summary":"Delete a user-submitted custom component","description":"Deletes the `custom_components` record with the given id using the PocketBase superuser client. An unknown id is reported as 500 (the PocketBase 404 is not mapped).","security":[{"adminBearer":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminCatalogDeleteCustomComponentRequest"}}}},"responses":{"200":{"description":"Record deleted.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminCatalogDeleteCustomComponentResponse"}}}},"400":{"$ref":"#/components/responses/AdminCatalogCustomComponentIdRequired"},"403":{"$ref":"#/components/responses/AdminForbidden"},"500":{"$ref":"#/components/responses/AdminCatalogCustomComponentDeleteFailed"}}}},"/api/admin/extract-component":{"post":{"operationId":"extractAdminComponent","tags":["admin-catalog-tools"],"summary":"Extract component data from a product page with an LLM","description":"Turns a product page into a component draft. Nothing is persisted — save the result with `POST /api/admin/save-component`. Depends on external AI and fetch services, so a request can take tens of seconds; any upstream failure ends in 500.\n\n1. Input precedence: `productInfo.url` > `url` > `searchTerm`. A `searchTerm` without `url` first calls `POST /api/admin/search-amazon` on `NEXT_PUBLIC_BASE_URL`, forwarding the caller's `Authorization` header; if no product is found, extraction continues from the search term alone.\n2. The page is fetched as markdown through Jina Reader (`https://r.jina.ai/<url>`, optional `JINA_API_KEY`, 30 s timeout) and cached in-process for 1 h (at most 50 entries).\n3. Blocked or captcha Amazon pages fall back to a SearxNG search (`search.yuzuhub.com`) for the ASIN.\n4. Existing brand names are read from the PocketBase `component_brands` collection.\n5. The Anthropic Messages API extracts the specification (`ANTHROPIC_API_KEY` required; model `EXTRACT_MODEL`, default `claude-haiku-4-5-20251001`; up to 5 turns in which the model may call a `searxng_search` tool).\n6. A second `search-amazon` call supplies one amazon.de affiliate link; without a hit the link is an amazon.de search-results URL carrying the affiliate tag.\n7. http(s) icon URLs go through self-hosted background removal (BRIA RMBG-1.4 via `@huggingface/transformers`); on failure the original URL is kept.","security":[{"adminBearer":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminCatalogExtractComponentRequest"}}}},"responses":{"200":{"description":"Extracted component draft.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminCatalogExtractedComponent"}}}},"400":{"$ref":"#/components/responses/AdminCatalogExtractBadRequest"},"403":{"$ref":"#/components/responses/AdminForbidden"},"500":{"$ref":"#/components/responses/AdminCatalogExtractFailed"}}}},"/api/admin/save-component":{"post":{"operationId":"saveAdminComponent","tags":["admin-catalog-tools"],"summary":"Save an extracted component to the catalog","description":"Creates a `components` record (always `preferred_suggestion: false`; null and empty fields are dropped). `brand` is a brand name that is matched or created in `component_brands`. An `icon` data URI is uploaded as a `<slugified-name>.png` file; any other `icon` string is stored as-is. Each `affiliate_links` entry becomes an `affiliate_links` record. Brand and per-link failures do not fail the request; they are reported in `warnings`. The route performs no input validation of its own, so invalid input surfaces as 500.","security":[{"adminBearer":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminCatalogSaveComponentRequest"}}}},"responses":{"200":{"description":"Component created.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminCatalogSaveComponentResponse"}}}},"403":{"$ref":"#/components/responses/AdminForbidden"},"500":{"$ref":"#/components/responses/AdminCatalogSaveFailed"}}}},"/api/admin/search-amazon":{"post":{"operationId":"searchAdminAmazon","tags":["admin-catalog-tools"],"summary":"Find an amazon.de product link for a component","description":"Exact matches (`isExactMatch: true`, one link):\n- `originalUrl` on amazon.de containing an ASIN (trusted without a request);\n- `originalUrl` on another Amazon marketplace whose ASIN `HEAD https://www.amazon.de/dp/<ASIN>` confirms;\n- `searchTerms` that is a bare 10-character ASIN.\n\nOtherwise a full-text search: SearxNG (`search.yuzuhub.com`, engines brave, google, duckduckgo, bing), then Jina Reader on amazon.de's own search page (20 s timeout, optional `JINA_API_KEY`). Returns up to 5 candidates with `isExactMatch: false`. Upstream failures never produce an error; they yield `links: []` and `link: null`.","security":[{"adminBearer":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminCatalogAmazonSearchRequest"}}}},"responses":{"200":{"description":"Search result (possibly empty).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminCatalogAmazonSearchResponse"}}}},"400":{"$ref":"#/components/responses/AdminCatalogAmazonSearchBadRequest"},"403":{"$ref":"#/components/responses/AdminForbidden"},"500":{"$ref":"#/components/responses/AdminCatalogAmazonSearchFailed"}}}},"/api/admin/affiliate-apply":{"post":{"operationId":"applyAdminAffiliateRecommendation","tags":["admin-affiliate"],"summary":"Apply an affiliate-link recommendation to one component and country","description":"Upserts the single `affiliate_links` row for (`componentId`, `country`) with the PocketBase superuser client: the first matching row is updated, otherwise a row is created. `country` defaults to `universal`.\n\nPer `actionType`:\n- `use_existing_url` (needs `preferredUrl`) and `search_and_apply` (needs `researchedUrl`) store the expanded URL with `unavailable: false`. A bare 10-character ASIN becomes a product URL on the country's Amazon marketplace (`buildProductUrl`); any other URL gets that marketplace's affiliate tag (`applyRegionalTag`). `universal` uses the `de` marketplace.\n- `mark_unavailable` stores `url: null, unavailable: true`.\n- `delete_link` deletes the `affiliate_links` record `linkId` (its country is not checked).\n- `keep_search_fallback` writes nothing.\n- `manual_review` and `keep_external` are rejected with 400.\n\n`componentId` is required for every action, including `delete_link`. `componentName`, `unavailable` and `searchQuery` are accepted and ignored.","security":[{"adminBearer":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminAffiliateApplyRequest"}}}},"responses":{"200":{"description":"Recommendation applied.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminAffiliateApplyResponse"}}}},"400":{"$ref":"#/components/responses/AdminAffiliateApplyBadRequest"},"403":{"$ref":"#/components/responses/AdminForbidden"},"500":{"$ref":"#/components/responses/AdminAffiliateApplyFailed"}}}},"/api/admin/affiliate-heatmap":{"get":{"operationId":"getAdminAffiliateHeatmap","tags":["admin-affiliate"],"summary":"Daily affiliate click counts for the click heatmap","description":"Counts `component_click_events` created since the start of the day `months` months ago (server local time). Bot and crawler user agents are skipped (`isBotUserAgent`, which also treats a missing or empty `user_agent` as a bot). Keys are UTC dates `YYYY-MM-DD`; days without clicks are absent.","security":[{"adminBearer":[]}],"parameters":[{"name":"months","in":"query","required":false,"description":"Number of months back. A non-numeric value yields an invalid start date and a 500.","schema":{"type":"integer","default":12}},{"name":"app","in":"query","required":false,"description":"`all`, `voltplan`, `cable` or `blog`. An event's app is the first `_`-separated part of its `source` when that is `voltplan`, `cable` or `blog`, otherwise `voltplan`. Any other value matches nothing and returns empty `dailyCounts`.","schema":{"type":"string","default":"all"}}],"responses":{"200":{"description":"Daily click counts.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminAffiliateHeatmap"}}}},"403":{"$ref":"#/components/responses/AdminForbidden"},"500":{"$ref":"#/components/responses/AdminAffiliateHeatmapFailed"}}}},"/api/admin/affiliate-links":{"get":{"operationId":"listAdminAffiliateLinks","tags":["admin-affiliate"],"summary":"List all affiliate links","description":"Returns every `affiliate_links` record (`getFullList` with `fields: id,component,country,url,unavailable,isExact,title`, sorted by `component,country`). There is no pagination.","security":[{"adminBearer":[]}],"responses":{"200":{"description":"All affiliate links.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminAffiliateLinkList"}}}},"403":{"$ref":"#/components/responses/AdminForbidden"},"500":{"$ref":"#/components/responses/AdminAffiliateLinksFetchFailed"}}}},"/api/admin/affiliate-links/{id}":{"parameters":[{"name":"id","in":"path","required":true,"description":"Id of the `affiliate_links` record.","schema":{"type":"string"}}],"delete":{"operationId":"deleteAdminAffiliateLink","tags":["admin-affiliate"],"summary":"Delete an affiliate link","description":"Deletes the `affiliate_links` record with the PocketBase superuser client. An unknown id is reported as 500 (the PocketBase 404 is not mapped).","security":[{"adminBearer":[]}],"responses":{"200":{"description":"Record deleted.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminAffiliateSuccessResponse"}}}},"403":{"$ref":"#/components/responses/AdminForbidden"},"500":{"$ref":"#/components/responses/AdminAffiliateLinkDeleteFailed"}}}},"/api/admin/affiliate-stats":{"get":{"operationId":"getAdminAffiliateStats","tags":["admin-affiliate"],"summary":"Aggregated affiliate click statistics","description":"Aggregates every `component_click_events` record. Bot and crawler user agents (`isBotUserAgent`; a missing or empty `user_agent` counts as a bot) are removed first; their number is returned as `botEventsFiltered`.\n\n- `today` counts since 00:00 UTC; `last7Days` and `last30Days` are rolling windows ending now.\n- Country is `resolved_country` or `unknown`; source is `source` or `unknown`; app is the first `_`-separated part of the source when it is `voltplan`, `cable`, `blog` or `calculator`, otherwise `voltplan`.\n- Components are grouped by the event's `component` id, or by `search:<name>` when the event has none. Name and type come from the event, else from the `components` record, else `Unknown` / `unknown`.\n- `components` is sorted by `total` descending, with `Unknown` names last. The top-level totals and `byCountry`/`bySource`/`byApp` sum over all components.","security":[{"adminBearer":[]}],"responses":{"200":{"description":"Click statistics.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminAffiliateStats"}}}},"403":{"$ref":"#/components/responses/AdminForbidden"},"500":{"$ref":"#/components/responses/AdminAffiliateStatsFailed"}}}},"/api/admin/affiliate-suggest":{"post":{"operationId":"suggestAdminAffiliateAction","tags":["admin-affiliate"],"summary":"Ask an LLM for the next affiliate-link improvement of one component","description":"Sends `component` and `stats` to the Anthropic Messages API (`ANTHROPIC_API_KEY` required and checked before the body is read; model `EXTRACT_MODEL`, default `claude-haiku-4-5-20251001`; `max_tokens` 1200) and normalises the returned JSON. Nothing is persisted; apply the result with `POST /api/admin/affiliate-apply`.\n\n- An unknown `actionType` becomes `manual_review`; `reasoning` keeps at most 4 strings.\n- `use_existing_url` without `preferredUrl`, and `search_and_apply` or `keep_search_fallback` without `searchQuery`, are downgraded to `manual_review` with `canAutoApply: false`.\n- For `search_and_apply` the server calls `POST /api/admin/search-amazon` on its own origin (forwarding the `Authorization` header) and puts the top hit into `researchedUrl`; without a hit it downgrades to `manual_review` and appends a reasoning line.","security":[{"adminBearer":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminAffiliateSuggestRequest"}}}},"responses":{"200":{"description":"Normalised suggestion.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminAffiliateSuggestion"}}}},"400":{"$ref":"#/components/responses/AdminAffiliateSuggestBadRequest"},"403":{"$ref":"#/components/responses/AdminForbidden"},"500":{"$ref":"#/components/responses/AdminAffiliateSuggestFailed"}}}},"/api/admin/bom-coverage":{"get":{"operationId":"getAdminBomCoverage","tags":["admin-affiliate"],"summary":"Country coverage of the curated BOM product slots","description":"Reads all `bom_products` records (sorted by `kind,gauge_mm2,amp`) and groups them into slots keyed by `kind` plus gauge, amp, fuse type and color. Per country the active row (`active !== false`) is listed; a slot whose rows are all inactive has `countries: {}`. A later row for the same country overwrites an earlier one; a row without `country` counts as `universal`.","security":[{"adminBearer":[]}],"responses":{"200":{"description":"BOM slots with their covered countries.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminBomCoverage"}}}},"403":{"$ref":"#/components/responses/AdminForbidden"},"500":{"$ref":"#/components/responses/AdminBomCoverageFailed"}}}},"/api/admin/bom-products":{"post":{"operationId":"createAdminBomProduct","tags":["admin-affiliate"],"summary":"Create a curated BOM product","description":"Creates a `bom_products` record with `active: true` and `url` derived from `rawUrl`. A bare 10-character ASIN becomes a product URL on the country's Amazon marketplace (`buildProductUrl`); any other URL gets that marketplace's affiliate tag (`applyRegionalTag`). `universal` uses the `de` marketplace. Enum and URL validation is left to PocketBase, so violations surface as 500.","security":[{"adminBearer":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminBomProductCreateRequest"}}}},"responses":{"200":{"description":"Record created.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminBomProductCreateResponse"}}}},"400":{"$ref":"#/components/responses/AdminBomProductCreateBadRequest"},"403":{"$ref":"#/components/responses/AdminForbidden"},"500":{"$ref":"#/components/responses/AdminBomProductCreateFailed"}}}},"/api/admin/bom-products/{id}":{"parameters":[{"name":"id","in":"path","required":true,"description":"Id of the `bom_products` record.","schema":{"type":"string"}}],"patch":{"operationId":"updateAdminBomProduct","tags":["admin-affiliate"],"summary":"Update the URL or brand of a curated BOM product","description":"Writes only `rawUrl` (expanded and stored as `url`) and `brand`. A bare 10-character ASIN becomes a product URL on the country's Amazon marketplace (`buildProductUrl`); any other URL gets that marketplace's affiliate tag (`applyRegionalTag`). `universal` uses the `de` marketplace. `country` is read only to pick the marketplace for `rawUrl` and is not stored, so send it together with `rawUrl`. Other fields are ignored; `{}` succeeds without changes. An unknown id is reported as 500.","security":[{"adminBearer":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminBomProductPatchRequest"}}}},"responses":{"200":{"description":"Record updated.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminAffiliateSuccessResponse"}}}},"403":{"$ref":"#/components/responses/AdminForbidden"},"500":{"$ref":"#/components/responses/AdminBomProductUpdateFailed"}}},"delete":{"operationId":"deleteAdminBomProduct","tags":["admin-affiliate"],"summary":"Delete a curated BOM product","description":"Deletes the `bom_products` record with the PocketBase superuser client. An unknown id is reported as 500 (the PocketBase 404 is not mapped).","security":[{"adminBearer":[]}],"responses":{"200":{"description":"Record deleted.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminAffiliateSuccessResponse"}}}},"403":{"$ref":"#/components/responses/AdminForbidden"},"500":{"$ref":"#/components/responses/AdminBomProductDeleteFailed"}}}},"/api/admin/experiments":{"get":{"operationId":"getAdminExperiments","tags":["admin-affiliate"],"summary":"Live numbers for the experiments dashboard","description":"Depends on the self-hosted **Umami** analytics API: the runtime env `UMAMI_URL`, `UMAMI_USERNAME` and `UMAMI_PASSWORD` must be set. The server logs in at `<UMAMI_URL>/api/auth/login`, caches the token in-process and logs in again once on a 401.\n\nFor every experiment in `EXPERIMENTS` (`src/lib/experiments.ts`) it queries `/api/websites/0e838cbf-d608-4400-b2c7-4557f2613cec/metrics?type=event` and `/event-data/values` per event and property, for the window from `shippedAt` 00:00 UTC to now. Experiments with a `pb` check additionally get PocketBase cohort stats from `users` and `wireframe_states`; the recent cohort covers at most the last 13 days because unverified accounts are pruned after 14.\n\nAny failure (Umami not configured, login or query failed, PocketBase error) answers 502.","security":[{"adminBearer":[]}],"responses":{"200":{"description":"Experiment numbers.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminExperimentsReport"}}}},"403":{"$ref":"#/components/responses/AdminForbidden"},"502":{"$ref":"#/components/responses/AdminExperimentsUpstreamFailed"}}}}},"components":{"schemas":{"ErrorResponse":{"type":"object","description":"Error body returned by VoltPlan REST endpoints.","required":["error"],"properties":{"error":{"type":"string","description":"Human-readable error message."},"details":{"type":"string","description":"Optional extra context about the failure."}}},"JsonRpcRequest":{"type":"object","required":["jsonrpc","method"],"properties":{"jsonrpc":{"const":"2.0"},"id":{"oneOf":[{"type":"string"},{"type":"integer"}]},"method":{"type":"string","enum":["initialize","tools/list","tools/call"]},"params":{"type":"object","additionalProperties":true}}},"JsonRpcResponse":{"type":"object","properties":{"jsonrpc":{"const":"2.0"},"id":{"oneOf":[{"type":"string"},{"type":"integer"}]},"result":{"type":"object","additionalProperties":true},"error":{"$ref":"#/components/schemas/JsonRpcError"}}},"JsonRpcError":{"type":"object","properties":{"code":{"type":"integer"},"message":{"type":"string"}}},"AffiliateClickRequest":{"type":"object","description":"Click tracked by POST /api/affiliate-click.","required":["component_id"],"properties":{"component_id":{"type":"string","minLength":1,"description":"Clicked PocketBase `components` id; missing or empty → 400."},"component_name":{"type":"string","description":"Stored as given; defaults to empty."},"component_type":{"type":"string","description":"Stored as given; defaults to empty."},"link_type":{"type":"string","description":"`search` records source `amazon_search`, any other value or absence `affiliate`; ignored when `source` is set."},"target_url":{"type":"string","description":"Destination URL; defaults to empty."},"source":{"type":"string","description":"Explicit click source; overrides the `link_type` mapping."}}},"AffiliateClickResponse":{"type":"object","description":"Acknowledgement of POST /api/affiliate-click.","required":["ok"],"properties":{"ok":{"type":"boolean","const":true}}},"CatalogBattery":{"type":"object","description":"Battery in the calculator component list.","required":["id","name","watt","voltage_in","voltage_in_type","voltage_out","voltage_out_type","icon","type"],"additionalProperties":false,"properties":{"id":{"type":"string","description":"Id of the `components` record."},"name":{"type":"string","description":"Product name."},"watt":{"type":"number","description":"Rated power in W; 0 when unset."},"voltage_in":{"type":"number","description":"Input voltage in V; 0 when unset."},"voltage_in_type":{"type":"string","enum":["ac","dc",""],"description":"Input current type; '' when unset."},"voltage_out":{"type":"number","description":"Output voltage in V; 0 when unset."},"voltage_out_type":{"type":"string","enum":["ac","dc",""],"description":"Output current type; '' when unset."},"icon":{"type":"string","description":"Icon file name in the PocketBase `components` collection; '' when none."},"type":{"const":"battery"},"brand_name":{"type":"string","description":"Name of the expanded `brand` relation; omitted when the component has no brand."}}},"CatalogInverter":{"type":"object","description":"Inverter or transformer in the calculator component list.","required":["id","name","watt","voltage_in","voltage_in_type","voltage_out","voltage_out_type","icon","type"],"additionalProperties":false,"properties":{"id":{"type":"string","description":"Id of the `components` record."},"name":{"type":"string","description":"Product name."},"watt":{"type":"number","description":"Rated power in W; 0 when unset."},"voltage_in":{"type":"number","description":"Input voltage in V; 0 when unset."},"voltage_in_type":{"type":"string","enum":["ac","dc",""],"description":"Input current type; '' when unset."},"voltage_out":{"type":"number","description":"Output voltage in V; 0 when unset."},"voltage_out_type":{"type":"string","enum":["ac","dc",""],"description":"Output current type; '' when unset."},"icon":{"type":"string","description":"Icon file name in the PocketBase `components` collection; '' when none."},"type":{"type":"string","enum":["inverter","transformer"]},"brand_name":{"type":"string","description":"Name of the expanded `brand` relation; omitted when the component has no brand."}}},"CatalogSolarPanel":{"type":"object","description":"Power source (solar panel, generator, shore power, ...) in the calculator component list.","required":["id","name","watt","voltage_in","voltage_in_type","voltage_out","voltage_out_type","icon","type"],"additionalProperties":false,"properties":{"id":{"type":"string","description":"Id of the `components` record."},"name":{"type":"string","description":"Product name."},"watt":{"type":"number","description":"Rated power in W; 0 when unset."},"voltage_in":{"type":"number","description":"Input voltage in V; 0 when unset."},"voltage_in_type":{"type":"string","enum":["ac","dc",""],"description":"Input current type; '' when unset."},"voltage_out":{"type":"number","description":"Output voltage in V; 0 when unset."},"voltage_out_type":{"type":"string","enum":["ac","dc",""],"description":"Output current type; '' when unset."},"icon":{"type":"string","description":"Icon file name in the PocketBase `components` collection; '' when none."},"type":{"const":"power"},"brand_name":{"type":"string","description":"Name of the expanded `brand` relation; omitted when the component has no brand."}}},"CatalogLoad":{"type":"object","description":"Load in the calculator component list.","required":["id","name","watt","voltage_in","voltage_in_type","icon","type"],"additionalProperties":false,"properties":{"id":{"type":"string","description":"Id of the `components` record."},"name":{"type":"string","description":"Product name."},"watt":{"type":"number","description":"Rated power in W; 0 when unset."},"voltage_in":{"type":"number","description":"Input voltage in V; 0 when unset."},"voltage_in_type":{"type":"string","enum":["ac","dc",""],"description":"Input current type; '' when unset."},"icon":{"type":"string","description":"Icon file name in the PocketBase `components` collection; '' when none."},"type":{"const":"load"},"brand_name":{"type":"string","description":"Name of the expanded `brand` relation; omitted when the component has no brand."}}},"CatalogSearchHit":{"type":"object","description":"Card data of one published component.","required":["id","slug","name","type","summary","reviewCount","reviewAvg"],"additionalProperties":false,"properties":{"id":{"type":"string","description":"Id of the `components` record."},"slug":{"type":"string","description":"Non-empty URL segment of the public page `/components/{slug}`."},"name":{"type":"string","description":"Product name."},"type":{"type":"string","enum":["battery","charger","inverter","transformer","power","load","bridge","tool",""],"description":"Component type; '' when unset."},"brandName":{"type":"string","description":"Brand name; omitted when the component has no brand."},"summary":{"type":"string","description":"`summary`, else `description`, else ''."},"imageUrl":{"type":"string","description":"The `image` URL, else the URL of the first gallery file; omitted when neither exists."},"reviewCount":{"type":"integer","minimum":0,"description":"Number of top-level ratings."},"reviewAvg":{"type":"number","minimum":0,"maximum":5,"description":"Average rating; 0 without ratings."}}},"CatalogSearchPage":{"type":"object","description":"One page of published-component search results.","required":["items","page","perPage","totalItems","totalPages"],"additionalProperties":false,"properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/CatalogSearchHit"}},"page":{"type":"integer","minimum":1,"description":"Returned page number."},"perPage":{"type":"integer","minimum":1,"maximum":100,"description":"Applied page size."},"totalItems":{"type":"integer","minimum":0,"description":"Matches across all pages."},"totalPages":{"type":"integer","minimum":0,"description":"Number of pages."}}},"BomProduct":{"type":"object","description":"Active `bom_products` record, projected to the fields this endpoint selects (`url`, `active`, `created` and `updated` are never returned). Unset numbers are 0, unset texts and selects ''.","required":["id","kind","tier","gauge_mm2","gauge_min_mm2","gauge_max_mm2","amp","fuse_type","color","brand","title","country"],"additionalProperties":false,"properties":{"id":{"type":"string","description":"Id of the `bom_products` record."},"kind":{"type":"string","enum":["cable","ring_terminal","heat_shrink","wire_stripper","cutter","crimp_tool","heat_gun","fuse","fuse_holder"],"description":"Shopping-list slot the product fills."},"tier":{"type":"string","enum":["budget","standard","pro",""],"description":"Price tier; '' when unset."},"gauge_mm2":{"type":"number","description":"Exact cross-section in mm² (cable, ring terminal, heat shrink); 0 when unset."},"gauge_min_mm2":{"type":"number","description":"Smallest cross-section in mm² a tool covers (stripper, cutter, crimp tool); 0 when unset."},"gauge_max_mm2":{"type":"number","description":"Largest cross-section in mm² a tool covers (stripper, cutter, crimp tool); 0 when unset."},"amp":{"type":"number","description":"Fuse rating in A; 0 when unset."},"fuse_type":{"type":"string","enum":["Blade (ATC/ATO)","MAXI Blade","ANL","Class T","NH / Industrial",""],"description":"Fuse type; '' when unset."},"color":{"type":"string","enum":["red","black","any","set",""],"description":"Conductor colour, `any`, or `set` (both colours); '' when unset."},"brand":{"type":"string","maxLength":128,"description":"Brand; '' when unset."},"title":{"type":"string","maxLength":200,"description":"Product title; '' when unset."},"country":{"type":"string","enum":["universal","de","us","uk","fr","es","it","au",""],"description":"Marketplace country the link targets (`universal` for all); '' when unset."}}},"ComponentReviewCreateRequest":{"type":"object","description":"Top-level review (no `parent`; `rating` required) or reply (`parent` set; non-empty `body` required). Other keys are ignored.","properties":{"rating":{"type":"integer","minimum":1,"maximum":5,"description":"Star rating; required for a top-level review, ignored for a reply."},"body":{"type":"string","maxLength":4000,"description":"Review or reply text, trimmed; optional for a review, required for a reply."},"parent":{"type":"string","description":"Id of a top-level review of the same component; turns the request into a reply."}}},"ComponentReviewTargetRequest":{"type":"object","description":"Names the review a vote toggle or deletion applies to. Other keys are ignored.","required":["reviewId"],"properties":{"reviewId":{"type":"string","minLength":1,"description":"Id of the `component_reviews` record (trimmed)."}}},"ComponentReviewNode":{"type":"object","description":"A review or reply as shown to the caller.","required":["id","author_name","rating","body","created","helpful","helpfulByMe","mine","replies"],"additionalProperties":false,"properties":{"id":{"type":"string","description":"Id of the `component_reviews` record."},"author_name":{"type":"string","maxLength":100,"description":"Display name captured when it was written."},"rating":{"type":"integer","minimum":0,"maximum":5,"description":"Stars 1–5; 0 for a reply."},"body":{"type":"string","description":"Text; may be ''."},"created":{"type":"string","description":"PocketBase creation timestamp, e.g. `2026-08-20 14:56:29.009Z`."},"helpful":{"type":"integer","minimum":0,"description":"Number of \"helpful\" votes."},"helpfulByMe":{"type":"boolean","description":"The caller voted; false without a valid token."},"mine":{"type":"boolean","description":"The caller wrote it; false without a valid token."},"replies":{"type":"array","description":"Replies, oldest first.","items":{"$ref":"#/components/schemas/ComponentReviewNode"}}}},"ComponentReviewDistribution":{"type":"object","description":"Number of top-level ratings per star value.","required":["1","2","3","4","5"],"additionalProperties":false,"properties":{"1":{"type":"integer","minimum":0},"2":{"type":"integer","minimum":0},"3":{"type":"integer","minimum":0},"4":{"type":"integer","minimum":0},"5":{"type":"integer","minimum":0}}},"ComponentReviewAggregate":{"type":"object","description":"Rating summary over top-level reviews; replies never count.","required":["count","avg","dist"],"additionalProperties":false,"properties":{"count":{"type":"integer","minimum":0,"description":"Number of ratings."},"avg":{"type":"number","minimum":0,"maximum":5,"description":"Average rating rounded to one decimal; 0 without ratings."},"dist":{"$ref":"#/components/schemas/ComponentReviewDistribution"}}},"ComponentReviewsResponse":{"type":"object","description":"Review thread of a component with its rating aggregate.","required":["success","nodes","aggregate"],"additionalProperties":false,"properties":{"success":{"const":true},"nodes":{"type":"array","description":"Top-level reviews, newest first.","items":{"$ref":"#/components/schemas/ComponentReviewNode"}},"aggregate":{"$ref":"#/components/schemas/ComponentReviewAggregate"}}},"AccountPocketBaseFieldErrors":{"type":"object","description":"PocketBase validation errors keyed by field name (`response.data` of the failed PocketBase call).","additionalProperties":{"$ref":"#/components/schemas/AccountPocketBaseFieldError"}},"AccountPocketBaseFieldError":{"type":"object","description":"One PocketBase field validation error.","properties":{"code":{"type":"string","description":"PocketBase error code, e.g. `validation_not_unique`."},"message":{"type":"string"}}},"AuthEmailStatusRequest":{"type":"object","required":["email"],"properties":{"email":{"type":"string","description":"Trimmed and lowercased server-side (`trim().toLowerCase()`); must then match `^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$`."}}},"AuthEmailStatusResponse":{"description":"`new`: no account with this email. `password`: password account, or the lookup failed. `oauth`: the account is linked to the listed social providers.","oneOf":[{"$ref":"#/components/schemas/AuthEmailStatusBasicResponse"},{"$ref":"#/components/schemas/AuthEmailStatusOAuthResponse"}]},"AuthEmailStatusBasicResponse":{"type":"object","description":"No account (`new`) or a password account / failed lookup (`password`).","required":["status"],"additionalProperties":false,"properties":{"status":{"type":"string","enum":["new","password"]}}},"AuthEmailStatusOAuthResponse":{"type":"object","description":"Account linked to at least one social provider; steer the user to the provider button.","required":["status","providers"],"additionalProperties":false,"properties":{"status":{"const":"oauth"},"providers":{"type":"array","minItems":1,"items":{"type":"string","enum":["google","apple"]}}}},"AuthEmailStatusBadRequest":{"type":"object","description":"Body is not JSON, or the email is invalid.","required":["error"],"additionalProperties":false,"properties":{"error":{"type":"string","enum":["Invalid request","Invalid email"]}}},"AuthEmailStatusRateLimited":{"type":"object","description":"Per-IP rate limit exceeded.","required":["error"],"additionalProperties":false,"properties":{"error":{"const":"Too many requests"}}},"AuthVerifyEmailRequest":{"type":"object","required":["token"],"properties":{"token":{"type":"string","description":"Verification token from the PocketBase verification email link; used as sent."}}},"AuthVerifyEmailResponse":{"type":"object","description":"Email address verified.","required":["success","message"],"additionalProperties":false,"properties":{"success":{"const":true},"message":{"const":"Email verified successfully"}}},"AuthVerifyEmailBadRequest":{"type":"object","description":"Token missing or rejected by PocketBase.","required":["error"],"additionalProperties":false,"properties":{"error":{"type":"string","enum":["Verification token is required","Invalid or expired verification token","Invalid verification token"]}}},"AuthVerifyEmailServerError":{"type":"object","description":"PocketBase failure or unreadable body.","required":["error"],"additionalProperties":false,"properties":{"error":{"type":"string","enum":["Failed to verify email","Internal server error"]},"details":{"$ref":"#/components/schemas/AccountPocketBaseFieldErrors","description":"Only with `Failed to verify email`: PocketBase `response.data` of the failed call; omitted when PocketBase returned no data."}}},"NewsletterSubscribeRequest":{"type":"object","required":["email"],"properties":{"email":{"type":"string","description":"Only checked for presence; not trimmed, lowercased or format-validated."},"source":{"type":"string","default":"landing_page","description":"Sign-up origin stored as `newsletter_source`."}}},"NewsletterSubscribeResponse":{"description":"New account awaiting verification, or an already subscribed address.","oneOf":[{"$ref":"#/components/schemas/NewsletterSubscribeCreatedResponse"},{"$ref":"#/components/schemas/NewsletterSubscribeExistingResponse"}]},"NewsletterSubscribeCreatedResponse":{"type":"object","description":"Account created; the PocketBase verification email was requested.","required":["success","message","needsVerification"],"additionalProperties":false,"properties":{"success":{"const":true},"message":{"const":"Please check your email to verify your subscription"},"needsVerification":{"const":true}}},"NewsletterSubscribeExistingResponse":{"type":"object","description":"An account with this email already exists.","required":["success","message","isExisting"],"additionalProperties":false,"properties":{"success":{"const":true},"message":{"const":"Already subscribed"},"isExisting":{"const":true}}},"NewsletterSubscribeBadRequest":{"type":"object","description":"`email` missing or empty.","required":["error"],"additionalProperties":false,"properties":{"error":{"const":"Email is required"}}},"NewsletterSubscribeServerError":{"type":"object","description":"PocketBase failure or unreadable body.","required":["error"],"additionalProperties":false,"properties":{"error":{"type":"string","enum":["Failed to subscribe","Internal server error"]},"details":{"$ref":"#/components/schemas/AccountPocketBaseFieldErrors","description":"Only with `Failed to subscribe`: PocketBase `response.data` of the failed call; omitted when PocketBase returned no data."}}},"NewsletterUnsubscribeRequest":{"type":"object","required":["email"],"properties":{"email":{"type":"string","description":"Only checked for presence; stored verbatim."}}},"NewsletterUnsubscribeResponse":{"type":"object","description":"Returned for every readable request with an email, whatever PocketBase answered.","required":["success","message","email"],"additionalProperties":false,"properties":{"success":{"const":true},"message":{"const":"Successfully unsubscribed from newsletter"},"email":{"type":"string","description":"Echo of the request email."}}},"NewsletterUnsubscribeBadRequest":{"type":"object","description":"`email` missing or empty.","required":["error"],"additionalProperties":false,"properties":{"error":{"const":"Email is required"}}},"NewsletterUnsubscribeServerError":{"type":"object","description":"The body could not be read.","required":["error"],"additionalProperties":false,"properties":{"error":{"const":"Internal server error"}}},"TestersRequest":{"type":"object","required":["email","name"],"properties":{"email":{"type":"string","description":"Trimmed and lowercased server-side (`trim().toLowerCase()`); must then match `^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$`. A non-string value is treated as missing."},"name":{"type":"string","description":"Trimmed; must be non-empty. A non-string value is treated as missing."},"platform":{"type":"string","enum":["android","ios"],"default":"android","description":"Any other value is stored as android."},"locale":{"type":"string","description":"Truncated to 8 characters; default empty string."},"source":{"type":"string","default":"homepage_card","description":"Truncated to 64 characters."}}},"TestersResponse":{"description":"Newly added to, or already on, the tester list.","oneOf":[{"$ref":"#/components/schemas/TestersCreatedResponse"},{"$ref":"#/components/schemas/TestersExistingResponse"}]},"TestersCreatedResponse":{"type":"object","description":"Added to the tester list.","required":["success","message"],"additionalProperties":false,"properties":{"success":{"const":true},"message":{"const":"You are on the tester list"}}},"TestersExistingResponse":{"type":"object","description":"Already on the tester list for this platform.","required":["success","message","isExisting"],"additionalProperties":false,"properties":{"success":{"const":true},"message":{"const":"Already on the tester list"},"isExisting":{"const":true}}},"TestersBadRequest":{"type":"object","description":"Email or name invalid.","required":["error"],"additionalProperties":false,"properties":{"error":{"type":"string","enum":["A valid email is required","Name is required"]}}},"TestersServerError":{"type":"object","description":"PocketBase failure or unreadable body.","required":["error"],"additionalProperties":false,"properties":{"error":{"type":"string","enum":["Failed to sign up","Internal server error"]},"details":{"$ref":"#/components/schemas/AccountPocketBaseFieldErrors","description":"Only with `Failed to sign up`: PocketBase `response.data` of the failed call; omitted when PocketBase returned no data."}}},"WaitlistRequest":{"type":"object","required":["email"],"properties":{"email":{"type":"string","description":"Trimmed and lowercased server-side (`trim().toLowerCase()`); must then match `^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$`. A non-string value is treated as missing."},"platform":{"type":"string","enum":["android","ios"],"default":"android","description":"Any other value is stored as android."},"locale":{"type":"string","description":"Truncated to 8 characters; default empty string."},"source":{"type":"string","default":"homepage_card","description":"Truncated to 64 characters."}}},"WaitlistResponse":{"description":"Newly added to, or already on, the waitlist.","oneOf":[{"$ref":"#/components/schemas/WaitlistCreatedResponse"},{"$ref":"#/components/schemas/WaitlistExistingResponse"}]},"WaitlistCreatedResponse":{"type":"object","description":"Added to the waitlist.","required":["success","message"],"additionalProperties":false,"properties":{"success":{"const":true},"message":{"const":"You are on the waitlist"}}},"WaitlistExistingResponse":{"type":"object","description":"Already on the waitlist for this platform.","required":["success","message","isExisting"],"additionalProperties":false,"properties":{"success":{"const":true},"message":{"const":"Already on the waitlist"},"isExisting":{"const":true}}},"WaitlistBadRequest":{"type":"object","description":"Email invalid.","required":["error"],"additionalProperties":false,"properties":{"error":{"const":"A valid email is required"}}},"WaitlistServerError":{"type":"object","description":"PocketBase failure or unreadable body.","required":["error"],"additionalProperties":false,"properties":{"error":{"type":"string","enum":["Failed to sign up","Internal server error"]},"details":{"$ref":"#/components/schemas/AccountPocketBaseFieldErrors","description":"Only with `Failed to sign up`: PocketBase `response.data` of the failed call; omitted when PocketBase returned no data."}}},"ShareDeprecatedError":{"type":"object","description":"Body of the retired POST /api/share/diagram.","required":["error","deprecated"],"additionalProperties":false,"properties":{"error":{"type":"string"},"deprecated":{"type":"boolean","const":true}}},"ShareTokenRequest":{"type":"object","description":"Body of POST /api/share/generate-token.","required":["wireframeId"],"additionalProperties":false,"properties":{"wireframeId":{"type":"string","minLength":1,"description":"PocketBase `wireframe_states` record id; missing or empty → 400."}}},"ShareTokenResponse":{"type":"object","description":"Share token returned by POST /api/share/generate-token.","required":["shareToken"],"additionalProperties":false,"properties":{"shareToken":{"type":"string","description":"Existing `share_token` of the record, or a newly generated 16-character alphanumeric token."}}},"SharePngUpload":{"type":"object","description":"multipart/form-data fields read by POST /api/share/save-png.","required":["wireframeId","preview_image"],"additionalProperties":false,"properties":{"wireframeId":{"type":"string","minLength":1,"description":"PocketBase `wireframe_states` record id."},"preview_image":{"type":"string","format":"binary","description":"PNG file stored as the record's `preview_image`."}}},"SharePngSaved":{"type":"object","description":"Acknowledgement of POST /api/share/save-png.","required":["success"],"additionalProperties":false,"properties":{"success":{"type":"boolean","const":true}}},"ConvertImageRequest":{"type":"object","description":"Body of POST /api/convert-image.","required":["imageUrl"],"additionalProperties":false,"properties":{"imageUrl":{"type":"string","description":"Absolute URL that must contain `/api/files/components/` (PocketBase component file)."}}},"ConvertImageResponse":{"type":"object","description":"Result of POST /api/convert-image.","required":["dataUrl"],"additionalProperties":false,"properties":{"dataUrl":{"type":"string","pattern":"^data:","description":"`data:<upstream Content-Type, default image/png>;base64,<bytes>`."}}},"DiagramGenerateRequest":{"type":"object","description":"Wiring diagram to render: either flat component lists or `circuits` with `links`.","required":["systemName"],"properties":{"systemName":{"type":"string","description":"System name; required by validation, not drawn."},"source":{"type":"string","enum":["cable","voltplan"],"default":"voltplan","description":"Branding variant of the footer bar."},"loads":{"type":"array","items":{"$ref":"#/components/schemas/DiagramLoad"}},"batteries":{"type":"array","items":{"$ref":"#/components/schemas/DiagramBattery"}},"chargers":{"type":"array","items":{"$ref":"#/components/schemas/DiagramCharger"}},"circuits":{"type":"array","items":{"$ref":"#/components/schemas/DiagramCircuit"},"description":"Multi-circuit mode: when non-empty, the top-level loads/batteries/chargers are ignored."},"links":{"type":"array","items":{"$ref":"#/components/schemas/DiagramCircuitLink"},"description":"Only used with `circuits`."}}},"DiagramLoad":{"type":"object","description":"DC load.","required":["name","power","voltage","current"],"properties":{"name":{"type":"string"},"power":{"type":"number","description":"Rated power in W."},"voltage":{"type":"number","description":"Supply voltage in V (DC)."},"current":{"type":"number","description":"Current in A; accepted, the diagram derives currents from power and voltage."},"iconUrl":{"type":"string","description":"URL of an icon image drawn on the component; fetched server-side and embedded as a data URI."}}},"DiagramBattery":{"type":"object","description":"Battery (bank).","required":["name","voltage","capacityAh","energyWh"],"properties":{"name":{"type":"string"},"voltage":{"type":"number","description":"Nominal voltage in V."},"capacityAh":{"type":"number","description":"Capacity in Ah; accepted, sizing uses energyWh."},"energyWh":{"type":"number","description":"Stored energy in Wh."},"iconUrl":{"type":"string","description":"URL of an icon image drawn on the component; fetched server-side and embedded as a data URI."}}},"DiagramCharger":{"type":"object","description":"Battery charger; a matching power source is added upstream automatically.","required":["name","inputVoltage","outputVoltage","power"],"properties":{"name":{"type":"string"},"inputVoltage":{"type":"number","description":"Input voltage in V."},"outputVoltage":{"type":"number","description":"Battery-side output voltage in V (DC)."},"power":{"type":"number","description":"Output power in W."},"sourceType":{"type":"string","enum":["shore","solar","wind","generator","alternator"],"default":"shore","description":"Power source added upstream of the charger; `shore` and `generator` make the charger input AC, the others DC."},"iconUrl":{"type":"string","description":"URL of an icon image drawn on the component; fetched server-side and embedded as a data URI."}}},"DiagramCircuit":{"type":"object","description":"One labelled circuit panel in multi-circuit mode.","required":["name"],"properties":{"name":{"type":"string","description":"Circuit name; referenced by link `from`/`to`."},"loads":{"type":"array","items":{"$ref":"#/components/schemas/DiagramLoad"}},"batteries":{"type":"array","items":{"$ref":"#/components/schemas/DiagramBattery"}},"chargers":{"type":"array","items":{"$ref":"#/components/schemas/DiagramCharger"}}}},"DiagramCircuitLink":{"type":"object","description":"Connection that charges circuit `to` from circuit `from`.","required":["from","to","kind","spec"],"properties":{"from":{"oneOf":[{"type":"string"},{"type":"integer","minimum":0}],"description":"Circuit name or zero-based index into `circuits`."},"to":{"oneOf":[{"type":"string"},{"type":"integer","minimum":0}],"description":"Circuit name or zero-based index into `circuits`."},"kind":{"$ref":"#/components/schemas/BridgeKind"},"spec":{"$ref":"#/components/schemas/BridgeSpec"}}},"BridgeKind":{"type":"string","enum":["dcdc","vsr","isolator"],"description":"DC-DC charger, voltage-sensitive relay, or battery isolator."},"BridgeSpec":{"type":"object","description":"Electrical rating of a link or bridge device.","required":["inputVoltage","outputVoltage","power"],"properties":{"inputVoltage":{"type":"number","description":"Input voltage in V."},"outputVoltage":{"type":"number","description":"Output voltage in V."},"power":{"type":"number","description":"Power in W."}}},"ProjectGenerateRequest":{"type":"object","description":"Project with several diagrams and the bridges between them.","required":["diagrams"],"properties":{"projectName":{"type":"string","description":"Echoed back in the response."},"diagrams":{"type":"array","items":{"$ref":"#/components/schemas/ProjectDiagram"}},"bridges":{"type":"array","items":{"$ref":"#/components/schemas/ProjectBridge"}}}},"ProjectDiagram":{"type":"object","description":"One diagram of a project.","required":["id","name"],"properties":{"id":{"type":"string","description":"Client-chosen id; referenced by bridges."},"name":{"type":"string"},"source":{"type":"string","enum":["cable","voltplan"],"default":"voltplan","description":"Branding variant of the footer bar."},"loads":{"type":"array","items":{"$ref":"#/components/schemas/DiagramLoad"}},"batteries":{"type":"array","items":{"$ref":"#/components/schemas/DiagramBattery"}},"chargers":{"type":"array","items":{"$ref":"#/components/schemas/DiagramCharger"}}}},"ProjectBridge":{"type":"object","description":"Device that charges one diagram from another.","required":["id","kind","fromDiagramId","toDiagramId","spec"],"properties":{"id":{"type":"string"},"name":{"type":"string","description":"Label of the bridge device."},"kind":{"$ref":"#/components/schemas/BridgeKind"},"fromDiagramId":{"type":"string","description":"Diagram that supplies power."},"toDiagramId":{"type":"string","description":"Diagram being charged."},"spec":{"$ref":"#/components/schemas/BridgeSpec"}}},"ProjectGenerateResponse":{"type":"object","additionalProperties":false,"required":["diagrams"],"properties":{"projectName":{"type":"string","description":"Omitted when the request had none."},"diagrams":{"type":"array","items":{"$ref":"#/components/schemas/ProjectRenderedDiagram"}}}},"ProjectRenderedDiagram":{"type":"object","additionalProperties":false,"required":["id","name","svg","png"],"properties":{"id":{"type":"string"},"name":{"type":"string"},"svg":{"type":"string","description":"SVG document."},"png":{"oneOf":[{"type":"string"},{"type":"null"}],"description":"Base64-encoded PNG; null unless Accept contains image/png and conversion succeeded."}}},"LoadEstimateRequest":{"type":"object","required":["description"],"properties":{"description":{"type":"string","minLength":2,"maxLength":300,"description":"Free-text description; the length is checked after trimming."}}},"LoadEstimate":{"type":"object","description":"AI estimate of a load (mirrors `LoadEstimate` in src/lib/loadAssistantPrompt.ts).","additionalProperties":false,"required":["name","watt"],"properties":{"name":{"type":"string","minLength":1,"maxLength":40},"watt":{"type":"number","exclusiveMinimum":0,"description":"Nominal running power in W."},"wattMin":{"type":"number","description":"Lower bound of the plausible running power in W."},"wattMax":{"type":"number","description":"Upper bound of the plausible running power in W."},"category":{"type":"string","enum":["resistive","electronics","fridge","motor","pump"]},"note":{"type":"string","maxLength":200},"dutyCycle":{"type":"number","minimum":0,"maximum":100,"description":"Percent of switched-on time the compressor/motor actually draws power."},"utilizationFactor":{"type":"number","minimum":0,"maximum":100,"description":"Percent of a day the device is switched on."}}},"ChargerEstimateRequest":{"type":"object","required":["description"],"properties":{"description":{"type":"string","minLength":2,"maxLength":300,"description":"Free-text description; the length is checked after trimming."},"powerSourceName":{"type":"string","description":"Connected power source; trimmed, cut to 80 characters, used as context."},"powerSourceWatt":{"type":"number","exclusiveMinimum":0,"description":"Rated power of the connected source in W; ignored unless a positive finite number."}}},"ChargerEstimate":{"type":"object","description":"AI estimate of a charger (mirrors `ChargerEstimate` in src/lib/chargerAssistantPrompt.ts).","additionalProperties":false,"required":["name","watt"],"properties":{"name":{"type":"string","minLength":1,"maxLength":40},"watt":{"type":"number","exclusiveMinimum":0,"description":"Maximum continuous output power in W."},"amps":{"type":"number","exclusiveMinimum":0,"description":"Rated output current in A."},"voltageOut":{"type":"integer","enum":[12,24,48],"description":"Battery-side nominal voltage in V."},"note":{"type":"string","maxLength":200}}},"HealthStatus":{"type":"object","description":"Health of the web app and its PocketBase backend.","required":["status","pocketbase","timestamp"],"properties":{"status":{"type":"string","enum":["ok","degraded"]},"pocketbase":{"type":"string","enum":["ok","error"]},"timestamp":{"type":"string","format":"date-time"}}},"DebugRegion":{"type":"object","description":"Region detection result and the request data it was derived from.","required":["detectedRegion","clientIp","xForwardedFor","xRealIp","geoResult","acceptLanguage","allHeaders"],"properties":{"detectedRegion":{"type":"string","enum":["de","us","uk","fr","es","it","au"],"description":"Amazon marketplace region chosen for this request."},"clientIp":{"type":["string","null"],"description":"First `X-Forwarded-For` entry, else `X-Real-IP`."},"xForwardedFor":{"type":["string","null"]},"xRealIp":{"type":["string","null"]},"geoResult":{"oneOf":[{"$ref":"#/components/schemas/GeoIpLookup"},{"$ref":"#/components/schemas/ErrorResponse"},{"type":"null"}],"description":"GeoIP lookup for `clientIp`; null without a client IP or without a match; `{ error }` when geoip-lite cannot be loaded."},"acceptLanguage":{"type":["string","null"]},"allHeaders":{"type":"object","additionalProperties":{"type":"string"},"description":"Request headers starting with `x-` plus `accept-language` and `host`."}}},"GeoIpLookup":{"type":"object","description":"geoip-lite lookup result.","required":["range","country","region","eu","timezone","city","ll","metro","area"],"properties":{"range":{"type":"array","items":{"type":"integer"},"minItems":2,"maxItems":2},"country":{"type":"string","description":"ISO 3166-1 alpha-2 code."},"region":{"type":"string"},"eu":{"type":"string","enum":["0","1"]},"timezone":{"type":"string"},"city":{"type":"string"},"ll":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":2,"description":"Latitude, longitude."},"metro":{"type":"integer"},"area":{"type":"integer","description":"Accuracy radius in km."}}},"IndexNowSubmitRequest":{"type":"object","required":["urls"],"properties":{"urls":{"type":"array","minItems":1,"items":{"type":"string"},"description":"Site paths such as `/blog/my-post`; a missing leading slash is added."}}},"IndexNowSitemapSubmission":{"type":"object","description":"Result of submitting all sitemap URLs.","required":["submitted","success","status"],"properties":{"submitted":{"type":"integer","description":"Number of URLs sent."},"success":{"type":"boolean","description":"IndexNow answered 2xx."},"status":{"type":"integer","description":"HTTP status returned by the IndexNow API."}}},"IndexNowUrlSubmission":{"type":"object","description":"Result of submitting the given paths.","required":["submitted","urls","success","status"],"properties":{"submitted":{"type":"integer","description":"Number of URLs sent."},"urls":{"type":"array","items":{"type":"string","format":"uri"},"description":"Absolute URLs that were submitted."},"success":{"type":"boolean","description":"IndexNow answered 2xx."},"status":{"type":"integer","description":"HTTP status returned by the IndexNow API."}}},"OpenApiDocument":{"type":"object","description":"OpenAPI 3.1 document"},"AdminComponentBrand":{"type":"object","description":"A `component_brands` record (expanded relation).","required":["id","name"],"properties":{"id":{"type":"string"},"name":{"type":"string"},"description":{"type":"string"},"price_range":{"type":"number"},"color":{"type":"string","description":"Hex colour code."},"created":{"type":"string","description":"Creation time. PocketBase datetime `YYYY-MM-DD HH:MM:SS.sssZ`; `\"\"` when unset."},"updated":{"type":"string","description":"Last update. PocketBase datetime `YYYY-MM-DD HH:MM:SS.sssZ`; `\"\"` when unset."}}},"AdminComponentExpand":{"type":"object","description":"Expanded relations; `brand` is present only when the component has a brand and it was loaded with `expand`.","properties":{"brand":{"$ref":"#/components/schemas/AdminComponentBrand"}}},"AdminComponentSpecRow":{"type":"object","description":"One row of the technical-data table.","additionalProperties":false,"required":["k","v"],"properties":{"k":{"type":"string","maxLength":80},"v":{"type":"string","maxLength":160}}},"AdminComponentDownload":{"type":"object","description":"A datasheet or manual link.","additionalProperties":false,"required":["ext","name","url"],"properties":{"ext":{"type":"string","description":"Badge text, e.g. `PDF`."},"name":{"type":"string"},"meta":{"type":"string","description":"E.g. `1.2 MB · rev. 2026-03`."},"url":{"type":"string","format":"uri"}}},"AdminComponent":{"type":"object","description":"A `components` record. It carries every column of the live collection; the properties below are the ones the app reads. Empty text fields are `\"\"`, empty numbers `0`, empty JSON fields `null`.","required":["id","collectionId","collectionName"],"additionalProperties":true,"properties":{"id":{"type":"string"},"collectionId":{"type":"string"},"collectionName":{"type":"string"},"created":{"type":"string","description":"Creation time. PocketBase datetime `YYYY-MM-DD HH:MM:SS.sssZ`; `\"\"` when unset."},"updated":{"type":"string","description":"Last update. PocketBase datetime `YYYY-MM-DD HH:MM:SS.sssZ`; `\"\"` when unset."},"name":{"type":"string"},"type":{"type":"string","description":"Component type: battery, charger, inverter, transformer, load, power, fuse, shunt, switch, disconnect, bridge or tool; `\"\"` when unset."},"description":{"type":"string"},"image":{"type":"string","description":"Product image URL."},"icon":{"type":"string","description":"Filename in the `icon` file field."},"watt":{"type":["number","null"]},"voltage_in":{"type":["number","null"]},"voltage_in_type":{"type":"string","description":"`ac`, `dc`, or `\"\"` when unset."},"voltage_out":{"type":["number","null"]},"voltage_out_type":{"type":"string","description":"`ac`, `dc`, or `\"\"` when unset."},"estd_price_euro":{"type":["number","null"]},"duty_cycle":{"type":["number","null"],"description":"Share of time a load cycles on. Percentage 0–100."},"default_utilization_factor":{"type":["number","null"],"description":"Default share of time a load is used. Percentage 0–100."},"usage_hours_per_day":{"type":["number","null"],"description":"Hours per day, 0–24."},"amperage":{"type":["number","null"]},"cutoff_voltage":{"type":["number","null"]},"surge_watt":{"type":["number","null"]},"pure_sine":{"type":["boolean","null"]},"load_category":{"type":"string"},"fuse_type":{"type":"string"},"wire_gauge":{"type":"string"},"source_url":{"type":"string"},"preferred_suggestion":{"type":"boolean"},"published":{"type":"boolean","description":"Manual release gate of the public page."},"published_at":{"type":"string","description":"First publication. PocketBase datetime `YYYY-MM-DD HH:MM:SS.sssZ`; `\"\"` when unset."},"slug":{"type":"string","maxLength":120,"pattern":"^$|^[a-z0-9]+(?:-[a-z0-9]+)*$","description":"Public URL segment of /components/<slug>; unique among non-empty values."},"summary":{"type":"string","maxLength":400,"description":"Lead paragraph of the public page."},"safety_notes":{"type":["array","null"],"items":{"type":"string"},"description":"Wiring hazards shown in the amber callout of the public page."},"specs":{"type":["array","null"],"items":{"$ref":"#/components/schemas/AdminComponentSpecRow"},"description":"Technical-data table of the public page."},"downloads":{"type":["array","null"],"items":{"$ref":"#/components/schemas/AdminComponentDownload"},"description":"Datasheets and manuals."},"gallery":{"type":"array","maxItems":8,"items":{"type":"string"},"description":"Filenames in the `gallery` file field."},"works_with":{"type":"array","maxItems":12,"items":{"type":"string"},"description":"Ids of curated companion components."},"works_with_notes":{"type":["object","null"],"additionalProperties":{"type":"string"},"description":"Component id → short pairing note."},"requires_any":{"type":"array","items":{"type":"string"}},"brand":{"type":"string","description":"Id of the `component_brands` record; `\"\"` when none."},"component_category":{"type":"string"},"review_count":{"type":"number"},"review_avg":{"type":"number"},"review_dist":{"type":["object","null"],"additionalProperties":{"type":"integer"}},"expand":{"$ref":"#/components/schemas/AdminComponentExpand"}}},"AdminComponentAffiliateLink":{"type":"object","description":"An `affiliate_links` record: one purchase link of the component for one country.","required":["id","component","country"],"properties":{"id":{"type":"string"},"collectionId":{"type":"string"},"collectionName":{"type":"string"},"component":{"type":"string","description":"Id of the `components` record."},"url":{"type":"string","description":"Product URL. Optional since migration `1782759321`; `\"\"` when the row only marks the country unavailable."},"country":{"type":"string","enum":["universal","de","us","uk","fr","es","it","au"]},"unavailable":{"type":"boolean"},"title":{"type":"string"},"isExact":{"type":"boolean"},"created":{"type":"string","description":"Creation time. PocketBase datetime `YYYY-MM-DD HH:MM:SS.sssZ`; `\"\"` when unset."},"updated":{"type":"string","description":"Last update. PocketBase datetime `YYYY-MM-DD HH:MM:SS.sssZ`; `\"\"` when unset."}}},"AdminComponentDetail":{"type":"object","additionalProperties":false,"required":["component","affiliate_links"],"properties":{"component":{"$ref":"#/components/schemas/AdminComponent"},"affiliate_links":{"type":"array","items":{"$ref":"#/components/schemas/AdminComponentAffiliateLink"}}}},"AdminComponentAffiliateLinkInput":{"type":"object","description":"Desired affiliate link. Without `id` it is created; entries without `url` or `country` are skipped.","properties":{"id":{"type":"string","description":"Id of an existing `affiliate_links` record to update."},"url":{"type":"string"},"country":{"type":"string","enum":["universal","de","us","uk","fr","es","it","au"]}}},"AdminComponentPatch":{"type":"object","description":"Partial update; only keys present are written. Any other key is ignored.","properties":{"name":{"type":"string"},"type":{"type":"string","description":"Component type: battery, charger, inverter, transformer, load, power, fuse, shunt, switch, disconnect, bridge or tool; `\"\"` when unset."},"description":{"type":"string"},"image":{"type":"string"},"icon":{"type":"string"},"watt":{"type":["number","null"]},"voltage_in":{"type":["number","null"]},"voltage_in_type":{"type":"string","description":"`ac`, `dc`, or `\"\"` when unset."},"voltage_out":{"type":["number","null"]},"voltage_out_type":{"type":"string","description":"`ac`, `dc`, or `\"\"` when unset."},"estd_price_euro":{"type":["number","null"]},"duty_cycle":{"type":["number","null"],"description":"Share of time a load cycles on. Percentage 0–100."},"default_utilization_factor":{"type":["number","null"],"description":"Default share of time a load is used. Percentage 0–100."},"usage_hours_per_day":{"type":["number","null"]},"amperage":{"type":["number","null"]},"cutoff_voltage":{"type":["number","null"]},"fuse_type":{"type":"string"},"wire_gauge":{"type":"string"},"preferred_suggestion":{"type":"boolean"},"published":{"type":"boolean","description":"`true` runs the publication gate described on the operation."},"published_at":{"type":"string","description":"PocketBase datetime; set to now on first publication when empty."},"slug":{"type":"string","maxLength":120,"pattern":"^$|^[a-z0-9]+(?:-[a-z0-9]+)*$","description":"Public URL segment of /components/<slug>; unique among non-empty values."},"summary":{"type":"string","maxLength":400,"description":"Lead paragraph of the public page."},"safety_notes":{"type":["array","null"],"items":{"type":"string"},"description":"Wiring hazards shown in the amber callout of the public page."},"specs":{"type":["array","null"],"items":{"$ref":"#/components/schemas/AdminComponentSpecRow"},"description":"Technical-data table of the public page."},"downloads":{"type":["array","null"],"items":{"$ref":"#/components/schemas/AdminComponentDownload"},"description":"Datasheets and manuals."},"works_with":{"type":"array","maxItems":12,"items":{"type":"string"},"description":"Ids of curated companion components."},"works_with_notes":{"type":["object","null"],"additionalProperties":{"type":"string"},"description":"Component id → short pairing note."},"brand":{"type":["string","null"],"description":"Brand name, matched or created in `component_brands`; empty or non-string clears the brand."},"affiliate_links":{"type":"array","items":{"$ref":"#/components/schemas/AdminComponentAffiliateLinkInput"},"description":"Complete desired link set; stored links missing here are deleted."}}},"AdminComponentUpdateResult":{"type":"object","additionalProperties":false,"required":["success","component"],"properties":{"success":{"const":true},"component":{"$ref":"#/components/schemas/AdminComponent"}}},"AdminComponentSuccess":{"type":"object","additionalProperties":false,"required":["success"],"properties":{"success":{"const":true}}},"AdminComponentStatsDay":{"type":"object","additionalProperties":false,"required":["date","count"],"properties":{"date":{"type":"string","pattern":"^\\d{4}-\\d{2}-\\d{2}$","description":"UTC day."},"count":{"type":"integer","minimum":0}}},"AdminComponentStats":{"type":"object","description":"Human affiliate clicks of one component (bot user agents excluded).","additionalProperties":false,"required":["total","today","last7Days","last30Days","byCountry","bySource","byApp","dailyBuckets"],"properties":{"total":{"type":"integer","minimum":0,"description":"All clicks, all time."},"today":{"type":"integer","minimum":0,"description":"Clicks since 00:00 UTC."},"last7Days":{"type":"integer","minimum":0,"description":"Clicks in the last 7 × 24 h."},"last30Days":{"type":"integer","minimum":0,"description":"Clicks in the last 30 × 24 h."},"byCountry":{"type":"object","additionalProperties":{"type":"integer","minimum":0},"description":"Clicks by `resolved_country`; `unknown` when empty."},"bySource":{"type":"object","additionalProperties":{"type":"integer","minimum":0},"description":"Clicks by `source`; `unknown` when empty."},"byApp":{"type":"object","propertyNames":{"enum":["voltplan","cable","blog","calculator"]},"additionalProperties":{"type":"integer","minimum":0},"description":"Clicks by app: the `source` prefix before `_` when it is a known app, else `voltplan`."},"dailyBuckets":{"type":"array","minItems":30,"maxItems":30,"items":{"$ref":"#/components/schemas/AdminComponentStatsDay"},"description":"Last 30 UTC days, oldest first; the last entry is today."}}},"AdminComponentGallery":{"type":"object","additionalProperties":false,"required":["success","gallery","urls"],"properties":{"success":{"const":true},"gallery":{"type":"array","maxItems":8,"items":{"type":"string"},"description":"Stored filenames in display order; the first one is the cover image."},"urls":{"type":"array","maxItems":8,"items":{"type":"string","format":"uri"},"description":"Public file URLs `<PocketBase>/api/files/<collectionId>/<id>/<filename>`, same order as `gallery`."}}},"AdminComponentGalleryUpload":{"type":"object","description":"Multipart form; every `files` part is one image.","required":["files"],"properties":{"files":{"type":"array","minItems":1,"items":{"type":"string","contentMediaType":"application/octet-stream"}}}},"AdminComponentGalleryImport":{"type":"object","description":"Images for the server to download (research suggestions).","required":["urls"],"properties":{"urls":{"type":"array","minItems":1,"items":{"type":"string","format":"uri"}}}},"AdminComponentGalleryDelete":{"type":"object","required":["filename"],"properties":{"filename":{"type":"string","minLength":1,"description":"Stored filename to remove."}}},"AdminComponentGalleryReorder":{"type":"object","required":["order"],"properties":{"order":{"type":"array","items":{"type":"string"},"description":"All stored filenames in the new order (a permutation of `gallery`)."}}},"AdminComponentResearchImage":{"type":"object","additionalProperties":false,"required":["url","caption","source"],"properties":{"url":{"type":"string","format":"uri"},"caption":{"type":"string","maxLength":80},"source":{"type":"string","description":"Page the image was found on; `\"\"` for a URL the model supplied directly."}}},"AdminComponentResearchResult":{"type":"object","description":"Unsaved research result for the editor to review.","additionalProperties":false,"required":["success","model","summary","specs","safetyNotes","downloads","images","sources","confidence","note"],"properties":{"success":{"const":true},"model":{"type":"string","description":"OpenRouter model that answered."},"summary":{"type":"string","maxLength":380},"specs":{"type":"array","maxItems":24,"items":{"$ref":"#/components/schemas/AdminComponentSpecRow"}},"safetyNotes":{"type":"array","maxItems":6,"items":{"type":"string","maxLength":400}},"downloads":{"type":"array","maxItems":6,"items":{"$ref":"#/components/schemas/AdminComponentDownload"}},"images":{"type":"array","maxItems":8,"items":{"$ref":"#/components/schemas/AdminComponentResearchImage"}},"sources":{"type":"array","maxItems":8,"items":{"type":"string","format":"uri"}},"confidence":{"type":"string","enum":["high","medium","low"]},"note":{"type":"string","maxLength":400,"description":"The model's remark, e.g. why data is missing."}}},"AdminComponentBackfillCandidate":{"type":"object","additionalProperties":false,"required":["id","name","type","field","before","after"],"properties":{"id":{"type":"string"},"name":{"type":"string"},"type":{"type":"string","description":"Component type: battery, charger, inverter, transformer, load, power, fuse, shunt, switch, disconnect, bridge or tool; `\"\"` when unset."},"field":{"type":"string","enum":["default_utilization_factor","duty_cycle"]},"before":{"type":"number","exclusiveMinimum":0,"maximum":1},"after":{"type":"integer","description":"`Math.round(before * 100)`."}}},"AdminComponentBackfillError":{"type":"object","additionalProperties":false,"required":["id","error"],"properties":{"id":{"type":"string"},"error":{"type":"string"}}},"AdminComponentBackfillDryRun":{"type":"object","description":"Report without writes.","additionalProperties":false,"required":["dryRun","total","candidates","truncated","note"],"properties":{"dryRun":{"const":true},"total":{"type":"integer","minimum":0,"description":"Number of field changes found."},"candidates":{"type":"array","maxItems":200,"items":{"$ref":"#/components/schemas/AdminComponentBackfillCandidate"}},"truncated":{"type":"boolean","description":"`true` when more than 200 changes were found."},"note":{"type":"string"}}},"AdminComponentBackfillApplied":{"type":"object","description":"Result of `apply=1`.","additionalProperties":false,"required":["dryRun","updated","totalChanges","errors"],"properties":{"dryRun":{"const":false},"updated":{"type":"integer","minimum":0,"description":"Components updated."},"totalChanges":{"type":"integer","minimum":0,"description":"Field changes attempted."},"errors":{"type":"array","items":{"$ref":"#/components/schemas/AdminComponentBackfillError"}}}},"AdminComponentBackfillResult":{"description":"`AdminComponentBackfillApplied` with `apply=1`, `AdminComponentBackfillDryRun` otherwise.","oneOf":[{"$ref":"#/components/schemas/AdminComponentBackfillDryRun"},{"$ref":"#/components/schemas/AdminComponentBackfillApplied"}]},"AdminComponentEnhanceRequest":{"type":"object","required":["ids"],"properties":{"ids":{"type":"array","minItems":1,"maxItems":100,"items":{"type":"string"},"description":"Component ids."}}},"AdminComponentEnhanceStarted":{"type":"object","additionalProperties":false,"required":["jobId"],"properties":{"jobId":{"type":"string","pattern":"^job_[a-z0-9]+$"}}},"AdminComponentEnhanceSnapshot":{"type":"object","description":"Values the model saw; `{}` until the row is processed.","properties":{"id":{"type":"string"},"name":{"type":"string"},"type":{"type":"string","description":"Component type: battery, charger, inverter, transformer, load, power, fuse, shunt, switch, disconnect, bridge or tool; `\"\"` when unset."},"description":{"type":"string"},"watt":{"type":["number","null"]},"voltage_in":{"type":["number","null"]},"voltage_in_type":{"type":["string","null"]},"voltage_out":{"type":["number","null"]},"voltage_out_type":{"type":["string","null"]},"estd_price_euro":{"type":["number","null"]},"duty_cycle":{"type":["number","null"]},"default_utilization_factor":{"type":["number","null"]},"usage_hours_per_day":{"type":["number","null"]},"amperage":{"type":["number","null"]},"cutoff_voltage":{"type":["number","null"]},"fuse_type":{"type":["string","null"]},"wire_gauge":{"type":["string","null"]},"brand_name":{"type":["string","null"]}}},"AdminComponentEnhanceProposal":{"type":"object","description":"Values proposed by the model, unvalidated. Only whitelisted fields; fields irrelevant for the component type, empty values and values equal to the current ones are removed.","propertyNames":{"enum":["description","watt","voltage_in","voltage_in_type","voltage_out","voltage_out_type","estd_price_euro","duty_cycle","default_utilization_factor","usage_hours_per_day","amperage","cutoff_voltage","fuse_type","wire_gauge"]},"additionalProperties":true},"AdminComponentEnhanceRow":{"type":"object","additionalProperties":false,"required":["id","name","status","current"],"properties":{"id":{"type":"string"},"name":{"type":"string","description":"`(not found)` when the id could not be loaded."},"status":{"type":"string","enum":["pending","running","done","error"]},"error":{"type":"string"},"current":{"$ref":"#/components/schemas/AdminComponentEnhanceSnapshot"},"proposed":{"$ref":"#/components/schemas/AdminComponentEnhanceProposal"}}},"AdminComponentEnhanceJob":{"type":"object","additionalProperties":false,"required":["id","createdAt","status","rows","done","total"],"properties":{"id":{"type":"string","pattern":"^job_[a-z0-9]+$"},"createdAt":{"type":"integer","description":"Unix time in milliseconds."},"status":{"type":"string","enum":["running","done","error"]},"error":{"type":"string"},"rows":{"type":"array","items":{"$ref":"#/components/schemas/AdminComponentEnhanceRow"}},"done":{"type":"integer","minimum":0,"description":"Rows processed so far."},"total":{"type":"integer","minimum":0,"description":"Rows in the job."}}},"AdminComponentListItem":{"type":"object","description":"A component as listed in the admin browser. Columns missing from the collection are omitted.","additionalProperties":false,"required":["id","brand_name","brand_color","clicks30d","affiliateLinkCount"],"properties":{"id":{"type":"string"},"name":{"type":"string"},"type":{"type":"string","description":"Component type: battery, charger, inverter, transformer, load, power, fuse, shunt, switch, disconnect, bridge or tool; `\"\"` when unset."},"description":{"type":"string"},"image":{"type":"string"},"icon":{"type":"string"},"watt":{"type":["number","null"]},"voltage_in":{"type":["number","null"]},"voltage_in_type":{"type":"string","description":"`ac`, `dc`, or `\"\"` when unset."},"voltage_out":{"type":["number","null"]},"voltage_out_type":{"type":"string","description":"`ac`, `dc`, or `\"\"` when unset."},"estd_price_euro":{"type":["number","null"]},"duty_cycle":{"type":["number","null"]},"default_utilization_factor":{"type":["number","null"]},"usage_hours_per_day":{"type":["number","null"]},"amperage":{"type":["number","null"]},"cutoff_voltage":{"type":["number","null"]},"fuse_type":{"type":"string"},"wire_gauge":{"type":"string"},"preferred_suggestion":{"type":"boolean"},"published":{"type":"boolean"},"published_at":{"type":"string","description":"First publication. PocketBase datetime `YYYY-MM-DD HH:MM:SS.sssZ`; `\"\"` when unset."},"slug":{"type":"string","maxLength":120,"pattern":"^$|^[a-z0-9]+(?:-[a-z0-9]+)*$","description":"Public URL segment of /components/<slug>; unique among non-empty values."},"summary":{"type":"string","maxLength":400,"description":"Lead paragraph of the public page."},"review_count":{"type":"number"},"review_avg":{"type":"number"},"brand":{"type":"string","description":"Id of the `component_brands` record; `\"\"` when none."},"brand_name":{"type":["string","null"]},"brand_color":{"type":["string","null"]},"updated":{"type":"string","description":"Last update. PocketBase datetime `YYYY-MM-DD HH:MM:SS.sssZ`; `\"\"` when unset."},"created":{"type":"string","description":"Creation time. PocketBase datetime `YYYY-MM-DD HH:MM:SS.sssZ`; `\"\"` when unset."},"clicks30d":{"type":"integer","minimum":0,"description":"Human clicks in the last 30 days."},"affiliateLinkCount":{"type":"integer","minimum":0,"description":"Affiliate links; only counted when `hasAffiliate` or a `clicks30d` sort is set, else 0."}}},"AdminComponentList":{"type":"object","additionalProperties":false,"required":["items","page","perPage","totalItems","totalPages"],"properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/AdminComponentListItem"}},"page":{"type":"integer"},"perPage":{"type":"integer"},"totalItems":{"type":"integer","minimum":0,"description":"Matches of the PocketBase filter, before `hasAffiliate`."},"totalPages":{"type":"integer","minimum":0}}},"AdminCatalogCustomComponent":{"type":"object","description":"A `custom_components` record as returned by PocketBase (mirrors `CustomComponent` in `src/lib/types.ts`). Unset text fields are `\"\"`, numbers `0`, booleans `false`.","required":["id","collectionId","collectionName","name","created","updated"],"properties":{"id":{"type":"string"},"collectionId":{"type":"string"},"collectionName":{"const":"custom_components"},"name":{"type":"string"},"wattage":{"type":"number"},"type":{"type":"string","description":"`load`, `battery`, `power`, `charger`, `inverter` or `transformer`; `\"\"` when unset."},"voltage_in":{"type":"number"},"voltage_out":{"type":"number"},"voltage_in_type":{"type":"string","description":"`dc`, `ac`, or `\"\"` when unset."},"voltage_out_type":{"type":"string","description":"`dc`, `ac`, or `\"\"` when unset."},"description":{"type":"string"},"product_url":{"type":"string"},"user_email":{"type":"string"},"approved":{"type":"boolean"},"approved_at":{"type":"string","description":"Approval time; `\"\"` when unset. PocketBase datetime `YYYY-MM-DD HH:MM:SS.sssZ` (space, not `T`)."},"created":{"type":"string","description":"Creation time. PocketBase datetime `YYYY-MM-DD HH:MM:SS.sssZ` (space, not `T`)."},"updated":{"type":"string","description":"Last update time. PocketBase datetime `YYYY-MM-DD HH:MM:SS.sssZ` (space, not `T`)."}}},"AdminCatalogCustomComponentList":{"type":"object","additionalProperties":false,"required":["items","totalItems"],"properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/AdminCatalogCustomComponent"}},"totalItems":{"type":"integer","minimum":0,"description":"Equals `items.length`."}}},"AdminCatalogDeleteCustomComponentRequest":{"type":"object","required":["id"],"properties":{"id":{"type":"string","minLength":1,"description":"Id of the `custom_components` record."}}},"AdminCatalogDeleteCustomComponentResponse":{"type":"object","additionalProperties":false,"required":["success"],"properties":{"success":{"const":true}}},"AdminCatalogExtractProductInfo":{"type":"object","description":"A candidate picked from `search-amazon` results. Only `url` is read; the other fields are accepted and ignored.","properties":{"url":{"type":"string","description":"Page to crawl."},"name":{"type":"string"},"description":{"type":"string"},"title":{"type":"string"}}},"AdminCatalogExtractComponentRequest":{"type":"object","description":"At least one of `url`, `searchTerm`, `productInfo` must be set (otherwise 400). Precedence: `productInfo.url` > `url` > `searchTerm`.","properties":{"url":{"type":"string","description":"Product page to crawl."},"searchTerm":{"type":"string","description":"Used only without `url`: resolved to a product page via `search-amazon`."},"productInfo":{"$ref":"#/components/schemas/AdminCatalogExtractProductInfo"}}},"AdminCatalogExtractedAffiliateLink":{"type":"object","additionalProperties":false,"required":["url","country","isExact","title"],"properties":{"url":{"type":"string","description":"amazon.de product URL with the affiliate `tag`, or an amazon.de search-results URL as fallback."},"country":{"const":"universal"},"isExact":{"type":"boolean"},"title":{"type":"string","description":"`Search results page` for the fallback link."}}},"AdminCatalogExtractedComponent":{"type":"object","description":"The LLM's JSON output, not validated server-side (fields may be missing, `null`, or additional), enriched by the server with `icon` and `affiliate_links`. Send it, optionally edited, to `save-component`.","required":["affiliate_links"],"properties":{"name":{"type":"string"},"type":{"type":"string","description":"Expected one of battery, charger, inverter, transformer, power, load, fuse, shunt, switch, disconnect, tool; not enforced."},"brand":{"type":["string","null"]},"voltage_in":{"type":["number","null"]},"voltage_in_type":{"type":["string","null"],"description":"`ac` or `dc`."},"voltage_out":{"type":["number","null"]},"voltage_out_type":{"type":["string","null"],"description":"`ac` or `dc`."},"watt":{"type":["number","null"]},"estd_price_euro":{"type":["number","null"]},"description":{"type":["string","null"]},"image":{"type":["string","null"]},"icon":{"type":["string","null"],"description":"`data:image/png;base64,…` after background removal, else an http(s) image URL, else a generic `symbols/*.svg` path for battery, charger, inverter, transformer, power and load; absent or null otherwise."},"duty_cycle":{"type":["number","null"],"minimum":0,"maximum":100},"default_utilization_factor":{"type":["number","null"],"minimum":0,"maximum":100},"usage_hours_per_day":{"type":["number","null"],"minimum":0,"maximum":24},"amperage":{"type":["number","null"]},"cutoff_voltage":{"type":["number","null"]},"fuse_type":{"type":["string","null"]},"wire_gauge":{"type":["string","null"]},"affiliate_links":{"type":"array","minItems":1,"maxItems":1,"items":{"$ref":"#/components/schemas/AdminCatalogExtractedAffiliateLink"}}}},"AdminCatalogSaveAffiliateLink":{"type":"object","description":"Other keys (e.g. `isExact`, `title`) are ignored.","required":["url","country"],"properties":{"url":{"type":"string"},"country":{"type":"string"}}},"AdminCatalogSaveComponentRequest":{"type":"object","description":"Mirrors `ComponentData` in `src/app/api/admin/save-component/route.ts`. Optional fields accept `null` because the extract response is sent verbatim. Unknown keys are ignored.","required":["name","type"],"properties":{"name":{"type":"string"},"type":{"type":"string","enum":["battery","charger","inverter","transformer","power","load","fuse","shunt","switch","disconnect","tool"]},"voltage_in":{"type":["number","null"]},"voltage_in_type":{"type":["string","null"],"enum":["ac","dc",null]},"voltage_out":{"type":["number","null"]},"voltage_out_type":{"type":["string","null"],"enum":["ac","dc",null]},"watt":{"type":["number","null"]},"estd_price_euro":{"type":["number","null"]},"description":{"type":["string","null"]},"image":{"type":["string","null"]},"icon":{"type":["string","null"],"description":"A data URI is uploaded as a PNG file; any other string is stored as-is."},"duty_cycle":{"type":["number","null"]},"default_utilization_factor":{"type":["number","null"]},"usage_hours_per_day":{"type":["number","null"]},"amperage":{"type":["number","null"]},"cutoff_voltage":{"type":["number","null"]},"fuse_type":{"type":["string","null"]},"wire_gauge":{"type":["string","null"]},"brand":{"type":["string","null"],"description":"Brand name, matched or created in `component_brands`."},"affiliate_links":{"type":["array","null"],"items":{"$ref":"#/components/schemas/AdminCatalogSaveAffiliateLink"}}}},"AdminCatalogSaveComponentResponse":{"type":"object","additionalProperties":false,"required":["success","componentId","message"],"properties":{"success":{"const":true},"componentId":{"type":"string","description":"Id of the new `components` record."},"message":{"const":"Component saved successfully"},"warnings":{"type":"array","minItems":1,"items":{"type":"string"},"description":"Non-fatal problems (brand or affiliate-link creation failed). Omitted when empty."}}},"AdminCatalogSaveComponentError":{"type":"object","additionalProperties":false,"required":["success","error"],"properties":{"success":{"const":false},"error":{"type":"string"},"warnings":{"type":"array","minItems":1,"items":{"type":"string"},"description":"Non-fatal problems (brand or affiliate-link creation failed). Omitted when empty."}}},"AdminCatalogAmazonSearchRequest":{"type":"object","description":"At least one of `searchTerms` and `originalUrl` must be non-empty (otherwise 400).","properties":{"searchTerms":{"type":["string","null"],"description":"Free-text query or a bare ASIN."},"originalUrl":{"type":["string","null"],"description":"Product URL on any Amazon marketplace."}}},"AdminCatalogAmazonSearchResult":{"type":"object","additionalProperties":false,"required":["url","country","title","isExact","name","image"],"properties":{"url":{"type":"string","description":"`https://www.amazon.de/dp/<ASIN>`."},"country":{"const":"de"},"title":{"type":"string"},"isExact":{"type":"boolean"},"name":{"type":"string"},"image":{"type":["string","null"]}}},"AdminCatalogAmazonSearchResponse":{"type":"object","additionalProperties":false,"required":["links","link","isExactMatch"],"properties":{"searchTerms":{"type":["string","null"],"description":"Echo of the request value; omitted when not sent."},"links":{"type":"array","maxItems":5,"items":{"$ref":"#/components/schemas/AdminCatalogAmazonSearchResult"}},"link":{"oneOf":[{"$ref":"#/components/schemas/AdminCatalogAmazonSearchResult"},{"type":"null"}],"description":"First entry of `links`, or null."},"isExactMatch":{"type":"boolean"}}},"AffiliateLink":{"type":"object","description":"An `affiliate_links` record: one purchase link of a component for one country. There is no priority field; the newest row per country wins.","required":["id","component","country","title","isExact","unavailable"],"properties":{"id":{"type":"string"},"component":{"type":"string","description":"Id of the `components` record."},"url":{"type":"string","description":"Amazon or external product URL. Optional since migration `1782759321`; `\"\"` when the row only marks the country unavailable."},"country":{"type":"string","enum":["universal","de","us","uk","fr","es","it","au"]},"title":{"type":"string"},"isExact":{"type":"boolean","description":"`true` for an exact product page, `false` for a search or fallback URL."},"unavailable":{"type":"boolean","description":"`true` when the product is not sold in this country; the redirect skips the row."}}},"AdminAffiliateLinkList":{"type":"object","additionalProperties":false,"required":["links"],"properties":{"links":{"type":"array","items":{"$ref":"#/components/schemas/AffiliateLink"}}}},"AdminAffiliateSuccessResponse":{"type":"object","additionalProperties":false,"required":["success"],"properties":{"success":{"const":true}}},"AdminAffiliateApplyRequest":{"type":"object","required":["componentId","actionType"],"properties":{"componentId":{"type":"string","minLength":1,"description":"Id of the `components` record."},"componentName":{"type":"string","description":"Accepted, not read."},"country":{"type":"string","enum":["universal","de","us","uk","fr","es","it","au"],"default":"universal"},"unavailable":{"type":"boolean","description":"Accepted, not read; use `actionType: mark_unavailable`."},"actionType":{"type":"string","enum":["use_existing_url","search_and_apply","keep_search_fallback","keep_external","manual_review","mark_unavailable","delete_link"]},"preferredUrl":{"type":"string","description":"ASIN or URL; required for `use_existing_url`."},"researchedUrl":{"type":"string","description":"ASIN or URL; required for `search_and_apply`."},"searchQuery":{"type":"string","description":"Accepted, not read."},"linkId":{"type":"string","description":"Id of the `affiliate_links` record; required for `delete_link`."}}},"AdminAffiliateApplyResponse":{"type":"object","additionalProperties":false,"required":["success","mode"],"properties":{"success":{"const":true},"mode":{"type":"string","enum":["existing","search_result","unavailable","search_fallback","deleted"],"description":"`use_existing_url` → `existing`, `search_and_apply` → `search_result`, `mark_unavailable` → `unavailable`, `keep_search_fallback` → `search_fallback`, `delete_link` → `deleted`."},"url":{"type":"string","description":"Stored URL; only for `existing` and `search_result`."},"country":{"type":"string","enum":["universal","de","us","uk","fr","es","it","au"],"description":"Absent for `deleted`."}}},"AdminAffiliateHeatmap":{"type":"object","additionalProperties":false,"required":["dailyCounts"],"properties":{"dailyCounts":{"type":"object","description":"UTC date (`YYYY-MM-DD`) → human clicks that day; days without clicks are absent.","additionalProperties":{"type":"integer","minimum":1}}}},"AdminAffiliateRangeCounts":{"type":"object","additionalProperties":false,"required":["total","today","last7Days","last30Days"],"properties":{"total":{"type":"integer","minimum":0,"description":"All clicks."},"today":{"type":"integer","minimum":0,"description":"Clicks since 00:00 UTC today."},"last7Days":{"type":"integer","minimum":0,"description":"Clicks in the rolling 7 days ending now."},"last30Days":{"type":"integer","minimum":0,"description":"Clicks in the rolling 30 days ending now."}}},"AdminAffiliateRangeCountsByKey":{"type":"object","description":"Key (country, source or app) → click counts.","additionalProperties":{"$ref":"#/components/schemas/AdminAffiliateRangeCounts"}},"AdminAffiliateTargetUrlStats":{"type":"object","additionalProperties":false,"required":["total","today","last7Days","last30Days","lastClicked","countries","sources","apps"],"properties":{"total":{"type":"integer","minimum":0,"description":"All clicks."},"today":{"type":"integer","minimum":0,"description":"Clicks since 00:00 UTC today."},"last7Days":{"type":"integer","minimum":0,"description":"Clicks in the rolling 7 days ending now."},"last30Days":{"type":"integer","minimum":0,"description":"Clicks in the rolling 30 days ending now."},"lastClicked":{"type":"string","description":"`created` timestamp of the newest click, as stored by PocketBase."},"countries":{"$ref":"#/components/schemas/AdminAffiliateRangeCountsByKey"},"sources":{"$ref":"#/components/schemas/AdminAffiliateRangeCountsByKey"},"apps":{"$ref":"#/components/schemas/AdminAffiliateRangeCountsByKey"}}},"AdminAffiliateTargetUrlStatsByUrl":{"type":"object","description":"Clicked target URL → stats.","additionalProperties":{"$ref":"#/components/schemas/AdminAffiliateTargetUrlStats"}},"AdminAffiliateComponentStats":{"type":"object","additionalProperties":false,"required":["id","name","type","total","today","last7Days","last30Days","byCountry","bySource","byApp","targetUrls"],"properties":{"id":{"type":"string","description":"Id of the `components` record, or `search:<name>` for clicks without a component."},"name":{"type":"string","description":"`Unknown` when neither the event nor the component has a name."},"type":{"type":"string","description":"`unknown` when neither the event nor the component has a type."},"total":{"type":"integer","minimum":0,"description":"All clicks."},"today":{"type":"integer","minimum":0,"description":"Clicks since 00:00 UTC today."},"last7Days":{"type":"integer","minimum":0,"description":"Clicks in the rolling 7 days ending now."},"last30Days":{"type":"integer","minimum":0,"description":"Clicks in the rolling 30 days ending now."},"byCountry":{"$ref":"#/components/schemas/AdminAffiliateRangeCountsByKey"},"bySource":{"$ref":"#/components/schemas/AdminAffiliateRangeCountsByKey"},"byApp":{"$ref":"#/components/schemas/AdminAffiliateRangeCountsByKey"},"targetUrls":{"$ref":"#/components/schemas/AdminAffiliateTargetUrlStatsByUrl"}}},"AdminAffiliateStats":{"type":"object","additionalProperties":false,"required":["total","today","last7Days","last30Days","botEventsFiltered","byCountry","bySource","byApp","components"],"properties":{"total":{"type":"integer","minimum":0,"description":"All clicks."},"today":{"type":"integer","minimum":0,"description":"Clicks since 00:00 UTC today."},"last7Days":{"type":"integer","minimum":0,"description":"Clicks in the rolling 7 days ending now."},"last30Days":{"type":"integer","minimum":0,"description":"Clicks in the rolling 30 days ending now."},"botEventsFiltered":{"type":"integer","minimum":0,"description":"Click events dropped as bot or crawler traffic, including events without a `user_agent`."},"byCountry":{"$ref":"#/components/schemas/AdminAffiliateRangeCountsByKey","description":"Keys: `resolved_country`, or `unknown`."},"bySource":{"$ref":"#/components/schemas/AdminAffiliateRangeCountsByKey","description":"Keys: `source`, or `unknown`."},"byApp":{"$ref":"#/components/schemas/AdminAffiliateRangeCountsByKey","description":"Keys: `voltplan`, `cable`, `blog`, `calculator`."},"components":{"type":"array","items":{"$ref":"#/components/schemas/AdminAffiliateComponentStats"}}}},"AdminAffiliateSuggestComponent":{"type":"object","required":["id","name"],"properties":{"id":{"type":"string","description":"Id of the `components` record."},"name":{"type":"string"},"type":{"type":"string"}}},"AdminAffiliateSuggestCountryClicks":{"type":"object","required":["country","clicks"],"properties":{"country":{"type":"string"},"clicks":{"type":"integer","minimum":0}}},"AdminAffiliateSuggestUrlClicks":{"type":"object","required":["url","kind","clicks","asin"],"properties":{"url":{"type":"string"},"kind":{"type":"string","enum":["direct","search","external"]},"clicks":{"type":"integer","minimum":0},"asin":{"type":["string","null"]}}},"AdminAffiliateSuggestStats":{"type":"object","description":"Click statistics computed by the admin UI, forwarded verbatim into the prompt; the server only checks that `stats` is present.","properties":{"clicks":{"type":"integer","minimum":0},"directClicks":{"type":"integer","minimum":0},"searchClicks":{"type":"integer","minimum":0},"externalClicks":{"type":"integer","minimum":0},"hasDirect":{"type":"boolean"},"onlySearch":{"type":"boolean"},"duplicateUrlCount":{"type":"integer","minimum":0},"uniqueAsinCount":{"type":"integer","minimum":0},"urlCount":{"type":"integer","minimum":0},"topCountries":{"type":"array","items":{"$ref":"#/components/schemas/AdminAffiliateSuggestCountryClicks"}},"topUrls":{"type":"array","items":{"$ref":"#/components/schemas/AdminAffiliateSuggestUrlClicks"}}}},"AdminAffiliateSuggestRequest":{"type":"object","required":["component","stats"],"properties":{"component":{"$ref":"#/components/schemas/AdminAffiliateSuggestComponent"},"stats":{"$ref":"#/components/schemas/AdminAffiliateSuggestStats"}}},"AdminAffiliateSuggestion":{"type":"object","additionalProperties":false,"description":"Optional fields are omitted when unset.","required":["actionType","canAutoApply","summary","primaryAction","reasoning"],"properties":{"actionType":{"type":"string","enum":["use_existing_url","search_and_apply","keep_search_fallback","keep_external","manual_review"]},"canAutoApply":{"type":"boolean"},"summary":{"type":"string","description":"Server fallback text when the model omits it."},"primaryAction":{"type":"string","description":"Server fallback text when the model omits it."},"searchQuery":{"type":"string","description":"amazon.de search query."},"preferredUrl":{"type":"string","description":"Clicked URL to promote; never set for `search_and_apply`."},"researchedUrl":{"type":"string","description":"Top `search-amazon` hit; set only for `search_and_apply`."},"reasoning":{"type":"array","maxItems":4,"items":{"type":"string"}}}},"AdminBomCoverageEntry":{"type":"object","additionalProperties":false,"required":["id","url"],"properties":{"id":{"type":"string","description":"Id of the `bom_products` record."},"url":{"type":"string","description":"`\"\"` when the row has no URL."}}},"AdminBomCoverageCountries":{"type":"object","description":"Country (universal, de, us, uk, fr, es, it, au) → the active row covering it; inactive rows are not listed.","additionalProperties":{"$ref":"#/components/schemas/AdminBomCoverageEntry"}},"AdminBomCoverageSlot":{"type":"object","additionalProperties":false,"required":["key","label","kind","countries"],"properties":{"key":{"type":"string","description":"`kind|g<mm2>|gmin<mm2>|gmax<mm2>|a<amp>|<fuse_type>|<color>`, only the parts the row sets."},"label":{"type":"string","description":"Human-readable slot label, e.g. `cable · 16 mm² · red`."},"kind":{"type":"string","enum":["cable","ring_terminal","heat_shrink","wire_stripper","cutter","crimp_tool","heat_gun","fuse","fuse_holder"]},"countries":{"$ref":"#/components/schemas/AdminBomCoverageCountries"}}},"AdminBomCoverage":{"type":"object","additionalProperties":false,"required":["slots"],"properties":{"slots":{"type":"array","items":{"$ref":"#/components/schemas/AdminBomCoverageSlot"}}}},"AdminBomProductCreateRequest":{"type":"object","required":["kind","country","rawUrl"],"properties":{"kind":{"type":"string","enum":["cable","ring_terminal","heat_shrink","wire_stripper","cutter","crimp_tool","heat_gun","fuse","fuse_holder"]},"country":{"type":"string","enum":["universal","de","us","uk","fr","es","it","au"]},"rawUrl":{"type":"string","minLength":1,"description":"10-character ASIN or product URL; stored as `url` after expansion."},"brand":{"type":"string"},"title":{"type":"string"},"gauge_mm2":{"type":"number","description":"Cross-section in mm²; exact match for `cable`, `ring_terminal`, `heat_shrink`."},"gauge_min_mm2":{"type":"number","description":"Lower bound in mm² for range kinds (`wire_stripper`, `cutter`, `crimp_tool`)."},"gauge_max_mm2":{"type":"number","description":"Upper bound in mm² for range kinds (`wire_stripper`, `cutter`, `crimp_tool`)."},"amp":{"type":"number","description":"Fuse rating in amps; unset for an assortment."},"fuse_type":{"type":"string","enum":["Blade (ATC/ATO)","MAXI Blade","ANL","Class T","NH / Industrial",""]},"color":{"type":"string","enum":["red","black","any","set",""]},"tier":{"type":"string","enum":["budget","standard","pro",""]}}},"AdminBomProductCreateResponse":{"type":"object","additionalProperties":false,"required":["success","id"],"properties":{"success":{"const":true},"id":{"type":"string","description":"Id of the created `bom_products` record."}}},"AdminBomProductPatchRequest":{"type":"object","properties":{"rawUrl":{"type":"string","description":"10-character ASIN or product URL; stored as `url` after expansion."},"country":{"type":"string","enum":["universal","de","us","uk","fr","es","it","au"],"description":"Marketplace for `rawUrl`; not stored."},"brand":{"type":"string"}}},"AdminExperimentEventStats":{"type":"object","additionalProperties":false,"required":["total","byProperty"],"properties":{"total":{"type":"integer","minimum":0,"description":"Umami count of the event in the window."},"byProperty":{"type":"object","description":"Event-data property → value → count.","additionalProperties":{"type":"object","additionalProperties":{"type":"integer","minimum":0}}}}},"AdminExperimentPbCohort":{"type":"object","additionalProperties":false,"description":"Prune-free tail cohort: the only honest denominator for percentages.","required":["since","newUsers","verifiedNewUsers","activatedNewUsers"],"properties":{"since":{"type":"string","format":"date","description":"Day the cohort starts."},"newUsers":{"type":"integer","minimum":0},"verifiedNewUsers":{"type":"integer","minimum":0},"activatedNewUsers":{"type":"integer","minimum":0,"description":"Cohort users owning at least one `wireframe_states` record."}}},"AdminExperimentPbStats":{"type":"object","additionalProperties":false,"required":["verifiedNewUsers","recent"],"properties":{"verifiedNewUsers":{"type":"integer","minimum":0,"description":"Verified accounts created since `shippedAt`."},"recent":{"$ref":"#/components/schemas/AdminExperimentPbCohort"}}},"AdminExperimentResult":{"type":"object","additionalProperties":false,"required":["id","sinceDays","events"],"properties":{"id":{"type":"string","description":"`EXPERIMENTS[].id`."},"sinceDays":{"type":"number","minimum":1,"description":"Days since `shippedAt`, at least 1."},"events":{"type":"object","description":"Umami event name → stats.","additionalProperties":{"$ref":"#/components/schemas/AdminExperimentEventStats"}},"pb":{"$ref":"#/components/schemas/AdminExperimentPbStats","description":"Present only for experiments with a `pb` check."}}},"AdminExperimentsReport":{"type":"object","additionalProperties":false,"required":["generatedAt","experiments"],"properties":{"generatedAt":{"type":"integer","description":"Unix epoch milliseconds."},"experiments":{"type":"array","items":{"$ref":"#/components/schemas/AdminExperimentResult"}}}}},"responses":{"AdminForbidden":{"description":"Admin access required. Returned for a missing, invalid or expired bearer token as well as for a valid token whose user lacks `is_admin=true`; admin endpoints never answer 401.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Admin access required"}}}},"RedirectNotFound":{"description":"Agent-friendly 404 (buildNotFoundResponse): `text/markdown` when `Accept` lists `text/markdown` explicitly with a quality at least as high as `text/html` (wildcards never count), otherwise `text/html`. HEAD gets the same status and headers without a body.","headers":{"Vary":{"description":"Contains `Accept`; Next.js prepends its own router values (`RSC`, `Next-Router-State-Tree`, ...).","schema":{"type":"string"}},"Cache-Control":{"description":"Always `no-store`.","schema":{"type":"string","enum":["no-store"]}}},"content":{"text/html":{"schema":{"type":"string"}},"text/markdown":{"schema":{"type":"string"}}}},"ComponentReviewUnauthorized":{"description":"Missing or malformed `Authorization: Bearer` header, or PocketBase rejected the token on `authRefresh`; `error` carries the reason.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"noToken":{"value":{"error":"No authorization token provided"}},"invalidToken":{"value":{"error":"Invalid token"}},"validationFailed":{"value":{"error":"Token validation failed"}},"internal":{"value":{"error":"Internal server error"}}}}}},"ComponentReviewNotFound":{"description":"The component does not exist or is not published, or the referenced review does not exist, belongs to another component, or (as a reply `parent`) is itself a reply.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"component":{"value":{"error":"Component not found"}},"review":{"value":{"error":"Review not found"}}}}}},"AdminCatalogCustomComponentsFetchFailed":{"description":"The PocketBase query failed or the admin credentials (`POCKETBASE_ADMIN_EMAIL`, `POCKETBASE_ADMIN_PASSWORD`) are not configured.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Failed to fetch custom components"}}}},"AdminCatalogCustomComponentIdRequired":{"description":"`id` is missing or empty.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Component ID is required"}}}},"AdminCatalogCustomComponentDeleteFailed":{"description":"Malformed JSON body, unknown id, PocketBase error, or admin credentials not configured.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Failed to delete component"}}}},"AdminCatalogExtractBadRequest":{"description":"None of `url`, `searchTerm`, `productInfo` was given, or `productInfo` has no `url` and no `searchTerm` was given.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"missingInput":{"value":{"error":"URL, search term, or product info is required"}},"noUrl":{"value":{"error":"Could not determine URL to crawl"}}}}}},"AdminCatalogExtractFailed":{"description":"Extraction failed; `error` carries the thrown message. Besides the examples: `Claude API error: <status> …`, `Claude did not return a final response after <n> turns`, and the parser message for a malformed JSON body.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"missingKey":{"value":{"error":"ANTHROPIC_API_KEY environment variable is not configured"}},"noContent":{"value":{"error":"No meaningful content extracted from the page and ASIN fallback failed"}},"unparsable":{"value":{"error":"Failed to parse Claude response as JSON"}}}}}},"AdminCatalogSaveFailed":{"description":"Malformed JSON body, PocketBase rejected the `components` record (e.g. missing `name` or `type`), or admin credentials not configured. `warnings` collected before the failure are included.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminCatalogSaveComponentError"},"example":{"success":false,"error":"Failed to create record."}}}},"AdminCatalogAmazonSearchBadRequest":{"description":"Neither `searchTerms` nor `originalUrl` was given.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"searchTerms or originalUrl is required"}}}},"AdminCatalogAmazonSearchFailed":{"description":"Unexpected error, e.g. a malformed JSON body; `error` carries the message.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Unexpected end of JSON input"}}}},"AdminAffiliateApplyBadRequest":{"description":"Missing `componentId` or `actionType`, an action that must not be auto-applied, a missing action-specific field, or an unknown `actionType`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"missingFields":{"value":{"error":"componentId and actionType are required"}},"notAutoApplicable":{"value":{"error":"This recommendation should not be auto-applied."}},"missingLinkId":{"value":{"error":"linkId is required for delete_link"}},"missingPreferredUrl":{"value":{"error":"preferredUrl is required for use_existing_url"}},"missingResearchedUrl":{"value":{"error":"researchedUrl is required for search_and_apply"}},"unknownAction":{"value":{"error":"Could not determine URL to apply"}}}}}},"AdminAffiliateApplyFailed":{"description":"Malformed JSON body, PocketBase error, or admin credentials (`POCKETBASE_ADMIN_EMAIL`, `POCKETBASE_ADMIN_PASSWORD`) not configured; `error` carries the thrown message.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Failed to apply affiliate recommendation"}}}},"AdminAffiliateHeatmapFailed":{"description":"PocketBase error, an invalid `months` value, or admin credentials not configured.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Failed to fetch heatmap data"}}}},"AdminAffiliateLinksFetchFailed":{"description":"PocketBase error or admin credentials not configured; `error` carries the thrown message.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Failed to fetch affiliate links"}}}},"AdminAffiliateLinkDeleteFailed":{"description":"Unknown id, PocketBase error, or admin credentials not configured; `error` carries the thrown message.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Failed to delete affiliate link"}}}},"AdminAffiliateStatsFailed":{"description":"PocketBase error or admin credentials not configured.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Failed to fetch stats"}}}},"AdminAffiliateSuggestBadRequest":{"description":"`component.id`, `component.name` or `stats` is missing.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"component and stats are required"}}}},"AdminAffiliateSuggestFailed":{"description":"`ANTHROPIC_API_KEY` not configured, Anthropic API error (`Claude API error: <status> …`), unparsable model output, or a malformed JSON body; `error` carries the message.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"missingKey":{"value":{"error":"ANTHROPIC_API_KEY environment variable is not configured"}},"failed":{"value":{"error":"Failed to generate affiliate suggestion"}}}}}},"AdminBomCoverageFailed":{"description":"PocketBase error or admin credentials not configured; `error` carries the thrown message.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Failed to fetch BOM coverage"}}}},"AdminBomProductCreateBadRequest":{"description":"`kind`, `country` or `rawUrl` is missing or empty.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"kind, country, and rawUrl are required"}}}},"AdminBomProductCreateFailed":{"description":"Malformed JSON body, non-string `rawUrl`, PocketBase validation error (e.g. unknown `kind`, invalid URL), or admin credentials not configured; `error` carries the thrown message.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Failed to create BOM product"}}}},"AdminBomProductUpdateFailed":{"description":"Malformed JSON body, non-string `rawUrl`, unknown id, or PocketBase error; `error` carries the thrown message.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Failed to update BOM product"}}}},"AdminBomProductDeleteFailed":{"description":"Unknown id, PocketBase error, or admin credentials not configured; `error` carries the thrown message.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Failed to delete BOM product"}}}},"AdminExperimentsUpstreamFailed":{"description":"Umami is not configured, unreachable or failed, or a PocketBase query failed; `error` carries the message.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"notConfigured":{"value":{"error":"UMAMI_URL / UMAMI_USERNAME / UMAMI_PASSWORD not configured"}},"loginFailed":{"value":{"error":"Umami login failed (401)"}}}}}}},"securitySchemes":{"userBearer":{"type":"http","scheme":"bearer","bearerFormat":"JWT","description":"PocketBase `users` auth token sent as `Authorization: Bearer <token>`. The server validates it per request with a PocketBase `authRefresh` call."},"adminBearer":{"type":"http","scheme":"bearer","bearerFormat":"JWT","description":"Same PocketBase `users` auth token as `userBearer`, validated with `authRefresh`; the user record must additionally have `is_admin=true`. Missing, invalid and non-admin tokens all receive 403 (`AdminForbidden`)."},"indexNowBearer":{"type":"http","scheme":"bearer","description":"Shared secret configured server-side as `INDEXNOW_SECRET`, sent as `Authorization: Bearer <secret>`. A missing or wrong secret receives 401."}}},"x-mcp-tools":[{"name":"generate_wiring_diagram","description":"Generate a complete 12V/24V/48V wiring diagram from a list of components (batteries, loads, chargers, solar panels, inverters). Returns PNG or SVG."},{"name":"calculate_wire_gauge","description":"Calculate the correct wire gauge (AWG and mm²), voltage drop, and fuse size for a DC circuit given voltage, current, and cable length."},{"name":"calculate_battery_bank","description":"Size a battery bank for a given daily energy demand, days of autonomy, and depth of discharge."},{"name":"calculate_battery_config","description":"Work out a series/parallel configuration of individual cells or batteries for a target system voltage and capacity."},{"name":"calculate_charging_time","description":"Estimate battery charging time from a given charger or solar array."},{"name":"calculate_inverter_size","description":"Recommend an inverter size based on AC load profile, surge requirements, and system voltage."},{"name":"calculate_power_budget","description":"Build a 24h power budget from loads to size batteries, solar, and wiring."},{"name":"calculate_solar_size","description":"Size a solar array based on daily energy needs, location / sun hours, and system voltage."},{"name":"list_component_types","description":"List the component types VoltPlan knows about (batteries, chargers, loads, inverters, panels, etc.)."}]}