# grapeminds API (v2) - Machine-Readable Spec (Plain Text) # Purpose: Provide a concise, design-free reference for LLMs/agents building the mobile app. BASE_URL: /api/v2 AUTH: Authorization: Bearer CONTENT_TYPE: application/json; charset=utf-8 DATE_FORMAT: ISO 8601 UTC PAGINATION: query params: page (int, default 1), per_page (int, default 25); response meta: { page, per_page, total, total_pages } ERROR_FORMAT: { error: { code: string, message: string, details?: object } } # General rules - All times in UTC. - Language: Use Accept-Language header (e.g., de, en, it). The API will localize language-dependent relations (descriptions, pairings, tastingnotes) to the preferred language and fall back to English if not available. Authenticated user settings.lang also influences locale. - Idempotency: For POST/PUT endpoints that can be retried, send header: Idempotency-Key: . - Locale: Accept-Language header may influence localized labels; data fields remain language-agnostic unless stated. - Sorting: ?sort=field or ?sort=-field (DESC). Only whitelisted fields per endpoint. - Filtering: Explicitly documented per endpoint. # Endpoints (incrementally updated) [AUTH] POST /auth/register { name: string, email: string, password: string, password_confirmation: string, lang: string, emailNewsletter?: boolean, device_name?: string } 201 { message: string, user: { id, email, name }, token: string, token_type: "Bearer" } 401|422|500 { error | validation errors } POST /auth/login { email: string, password: string, device_name?: string } 200 { token: string, token_type: "Bearer" } 401|422 { error | validation errors } POST /auth/password-reset (v2) { email: string } 200 { message: string, status: string, role?: string } 422 { error: { code, message }, role?: string } POST /auth/password-reset/confirm (v2) { token: string, email: string, password: string, password_confirmation: string } 200 { message: string, role?: string } 422 { error: { code, message }, role?: string } POST /auth/logout (auth) 204 401 POST /auth/email/resend (auth) 204 200 { message: "Email already verified." } 429 { error } 500 { error } [USERS] (auth) GET /users/me 200 { id, email, name, role, settings: { lang, timezone, newsletter } } PATCH /users/me { name?: string, settings?: { lang?: string, timezone?: string, newsletter?: boolean } } 200 { id, email, name, role, settings } [CELLARS] GET /cellar (auth) 200 { cellar: { id, name }, stock: { sparkling?: [Item], white?: [Item], rose?: [Item], red?: [Item] } } Item = { id, display_name, residual_sugar?, type, sub_type?, color, bottle_size, vintage?, quantity, price, grapes: string[] } Query (optional): q: string (search in wine.display_name and grape names by locale) color: red|white|rose|sparkling type: string sub_type: string bottle_size: string vintage_min, vintage_max: int (1900..2100) quantity_min, quantity_max: int (>=0) in_stock: boolean price_min, price_max: number (>=0) POST /analyze-photo (auth) Body: multipart/form-data { photo: file } OR JSON { photo: base64|string|data-url } 200 { message: "ok", photo_id: int, photo_path: string, photo_url: string, photo_url_small?: string, photo_url_medium?: string, candidates: [ { id, display_name } ] } 200 { message: "NORESULT", photo_id: int, candidates: [] } 502 { error: "gemini_failed", message } Note: The Gemini vision model is selected server-side via config key grapeminds.gemini_model. Clients cannot override it. Rate limit: 15 requests per minute per user. # v2-specific endpoints GET /v2/cellars (auth) 200 { cellars: [ { id: int, name: string } ] } Notes: If the authenticated user's role is `appuser`, the API returns a single cellar in the array (created on demand if missing). Other roles receive all cellars belonging to the user. GET /v2/cellars/{cellar}/wines (auth) 200 { cellar: { id, name }, stock: { sparkling?: [Item], white?: [Item], rose?: [Item], red?: [Item] } } Query params: same as GET /cellar (q, color, type, bottle_size, vintage_min, vintage_max, quantity_min, quantity_max, in_stock, price_min, price_max) 403 { error } POST /confirm-wine (auth) { photo_id: int, wine_id: int, merchant_id: int, quantity: int>=1, bottle_size: string, vintage?: int, price?: number>=0 } 201 { message: "added", photo_id: int } 422|403|404 { error } GET /photos/{id} (auth) 200 { id: int, path: string, url: string } 404 { error } [WINES] GET /wines/search?q=...&limit=10 (auth) 200 { data: [ { id, display_name, producer_id, color, vintage } ] } 422 { error } Note: Trigger search only when user input length ≥ 4 characters. POST /add-wine (auth) { wine_id: int, merchant_id: int, quantity: int>=1, bottle_size: string, vintage?: int, price?: number>=0 } 201 { message: "added"|"updated", stock_id: int } 422|403|404 { error } GET /wines/{id} (auth) 200 { id, display_name, producer: { id, name, title, display_name }, grapes: [ { id, name } ], region: { id, name }, descriptions: [ ... ], pairings: [ ... ], tastingnotes: [ ... ], ... } # v2: per-resource wine endpoints (partial fetches) GET /v2/wines/{wine}/core (auth) 200 { id, display_name, producer_id, region_id?, producer?, color, type, sub_type, residual_sugar, vintage } Notes: `region_id` may be null. The `producer` object (when present) contains { id, name, title, display_name } for quick lookups. `title` is the producer prefix (e.g. "Weingut", "Château", "Domaine"), `display_name` is the computed full name (title + name). `residual_sugar` is a localized label (e.g. "dry", "off-dry", "medium sweet", "sweet", "very sweet" in English; localized in Accept-Language). Clients should treat these fields as optional. GET /v2/wines/{wine}/descriptions?lang=xx (auth) 200 { descriptions: [ { id, wine_id, lang, text, text_long? } ] } Notes: Will attempt to return requested language; if missing the server may fall back to English. The server will trigger enrichment via WineDataEnrichmentService when data is missing. GET /v2/wines/{wine}/drinking-periods?lang=xx (auth) 200 { drinking_period: { id, wine_id, start_year?, end_year?, note?, lang? } } GET /v2/wines/{wine}/flavor-profiles (auth) 200 { flavor_profile: { id, wine_id, sweetness?, acidity?, body?, tannin?, aroma: { primary: [], secondary: [] } } } GET /v2/wines/{wine}/grapes?lang=xx (auth) 200 { grapes: [ { id, name } ] } GET /v2/wines/{wine}/pairings?lang=xx (auth) 200 { pairings: [ { id, wine_id, lang, text, text_long? } ] } GET /v2/wines/{wine}/ratings (auth) 200 { ratings: [ { id, wine_id, user_id?, score, source?, comment? } ], aggregated: { average, count } } GET /v2/regions/{region} (auth) 200 { id, name, country, name_en?, name_de?, name_it?, name_fr? } Notes: Regions have localized name columns. Clients should request Accept-Language and prefer `name_{lang}` when present, otherwise fall back to `name` or `name_en`. GET /v2/producers/{producer} (auth) 200 { id, name, title, display_name, country?, established_year? } Notes: Producer objects include `title` (prefix like "Weingut", "Château") and `display_name` (computed: title + name). Use `id`/`name`/`display_name` to display producer information or to match by `producer_id` returned by wine endpoints. [FAVORITES] GET /favorites (auth) Query: page, per_page (1..100) 200 { data: [ { cellar_stock_item_id, wine_id, display_name, color, bottle_size, vintage, grapes: [string] } ], meta: { page, per_page, total, total_pages } } POST /favorites (auth) { cellar_stock_item_id: int } 201 { message: "added", favorite_id } 403|422 { error } DELETE /favorites (auth) { cellar_stock_item_id: int } 200 { message: "removed" } 404 { error } [WATCHLIST] GET /watchlist (auth) Query: page, per_page (1..100) 200 { data: [ { id, wine_id, display_name, color, region, grapes: [string], comment, created_at } ], meta: { page, per_page, total, total_pages } } POST /watchlist (auth) { wine_id: int, comment?: string } 201 { message: "added", watchlist_id } 409 { message: "already_exists" } 422 { error } DELETE /watchlist (auth) { watchlist_id?: int, wine_id?: int } 200 { message: "removed" } 404 { error: { code: "not_found", message } } POST /watchlist/{id}/convert-to-cellar (auth) { merchant_id: int, quantity: int>=1, bottle_size: string, vintage?: int, price?: number, remove_watchlist_item?: boolean=true } 201 { message: "added"|"updated", stock_id: int } 422|403|404 { error } [ORDERS] GET /orders 200 { data: [ { id, merchant_id, status, sent_at?, fulfilled_at? } ], meta } POST /orders { merchant_id: int, items: [ { cellar_stock_item_id: int, quantity: int } ], notes?: string } 201 { id, status: "sent" | "draft", sent_at?, notes? } PATCH /orders/{id}/status { status: "draft" | "sent" | "delivered" | "cancelled" } 200 { id, status, sent_at?, fulfilled_at? } POST /orders/{id}/import-delivery-note multipart/form-data: delivery_note (pdf|jpg|jpeg|png, max 20MB) 202 { id, analysis_id } POST /orders/{id}/confirm-delivery 200 { id, status: "delivered", fulfilled_at } # Errors 400 { error: { code: "validation_error", message, details: { field: [errors...] } } } 401 { error: { code: "unauthorized", message } } 403 { error: { code: "forbidden", message } } 404 { error: { code: "not_found", message } } 409 { error: { code: "conflict", message } } 429 { error: { code: "rate_limited", message, retry_after_seconds } } 500 { error: { code: "server_error", message } } # Notes - This spec is a living document and will be kept in sync with the implemented API. - Fields and objects may include additional keys if they are additive and backward-compatible. - Tokens currently include the ability: ["mobile"]. Check abilities with user.tokenCan('mobile').