openapi: 3.0.3
info:
  title: Talerka AI API
  version: 1.0.0
  description: |
    HTTP API for photo- and text-based meal analysis. Images are uploaded
    directly by clients to Firebase Storage, while text descriptions are sent
    as meal context. Analysis is asynchronous; clients poll the meal status
    endpoint while a PostgreSQL-backed worker processes the job.
servers:
  - url: http://localhost:8080
    description: Local development
tags:
  - name: Health
  - name: Onboarding
  - name: Meals
  - name: Progress
  - name: Subscriptions
paths:
  /api/v1/subscription:
    get:
      tags: [Subscriptions]
      summary: Get verified access for the authenticated user
      operationId: getSubscription
      description: |
        Available without a subscription or completed onboarding. Uses the verified
        Firebase UID. Active verification is cached for five minutes, inactive for
        30 seconds. Responses are not cacheable by clients or shared HTTP caches.
      security:
        - bearerAuth: []
      responses:
        '200':
          $ref: '#/components/responses/SubscriptionStatus'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '503':
          $ref: '#/components/responses/SubscriptionUnavailable'

  /api/v1/subscription/refresh:
    post:
      tags: [Subscriptions]
      summary: Refresh access after purchase or restoration
      operationId: refreshSubscription
      description: |
        Accepts no request body or client subscription evidence. Bypasses normal
        freshness with a shared ten-second per-user cooldown. During cooldown,
        returns a usable saved status or 503 if verification is unavailable.
        Paddle synchronization may still be pending; retry with backoff.
      security:
        - bearerAuth: []
      responses:
        '200':
          $ref: '#/components/responses/SubscriptionStatus'
        '400':
          description: A request body was supplied
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '503':
          $ref: '#/components/responses/SubscriptionUnavailable'

  /webhooks/revenuecat:
    post:
      tags: [Subscriptions]
      summary: Invalidate saved subscription verification
      operationId: revenueCatWebhook
      description: |
        Server-to-server endpoint; use the exact configured RevenueCat Authorization
        header value, not a Firebase token. Bodies are limited to 1 MiB. Events
        are deduplicated atomically with invalidation; unknown extra fields are
        accepted. Transfers invalidate both sides and aliases. Events never grant
        access directly. Retry non-200 responses.
      security:
        - revenueCatWebhookAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [event]
              properties:
                event:
                  type: object
                  required: [id, type]
                  properties:
                    id:
                      type: string
                    type:
                      type: string
                    environment:
                      type: string
                      enum: [PRODUCTION, SANDBOX]
                      description: Required except for TEST and TRANSFER events.
                    app_user_id:
                      type: string
                    original_app_user_id:
                      type: string
                    aliases:
                      type: array
                      items:
                        type: string
                    transferred_from:
                      type: array
                      items:
                        type: string
                    transferred_to:
                      type: array
                      items:
                        type: string
      responses:
        '200':
          description: Persisted, duplicate, or irrelevant event
        '400':
          description: Invalid or oversized payload
        '401':
          $ref: '#/components/responses/Unauthorized'
        '503':
          $ref: '#/components/responses/SubscriptionUnavailable'

  /healthz:
    get:
      tags: [Health]
      summary: Check service health
      operationId: getHealth
      security: []
      responses:
        '200':
          description: Service is healthy
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HealthResponse'
              example:
                status: ok

  /api/v1/onboarding/preview:
    post:
      tags: [Onboarding]
      summary: Preview the nutrition target for onboarding answers
      description: |
        Validates the completed onboarding quiz and returns a stateless
        nutrition estimate. This endpoint does not require authentication,
        create a user, or write to the database. It is limited to adult
        wellness estimates and is not medical nutrition advice; do not use it
        for pregnancy, breastfeeding, or medical nutrition management. Request
        bodies are limited to 256 KiB. Production traffic must place this
        endpoint behind a distributed, endpoint-specific edge rate limiter;
        the application deliberately does not use an in-process limiter.
      operationId: previewOnboarding
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/OnboardingPreviewRequest'
            example:
              questionnaire_version: 1
              age: 28
              sex: male
              height_cm: 178
              current_weight_kg: 82
              daily_activity: mostly_sitting
              workout_frequency: three_to_four_per_week
              goal:
                type: lose_weight
                target_weight_kg: 74
                pace: balanced
      responses:
        '200':
          description: Canonical nutrition target recommendation
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OnboardingPreviewResponse'
              example:
                nutrition_target:
                  calories_kcal: 2061
                  proteins_g: 131
                  carbs_g: 256
                  fats_g: 57
                  source: calculated
                  calculation_version: nutrition-targets-v1
        '400':
          description: Malformed JSON or unsupported onboarding input
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              examples:
                invalidProfile:
                  value:
                    error:
                      code: invalid_profile
                      message: profile answers are invalid
                invalidGoal:
                  value:
                    error:
                      code: invalid_goal
                      message: goal selection is invalid
                unsupportedInput:
                  value:
                    error:
                      code: unsupported_calculation_input
                      message: the requested inputs are outside the supported range
        '500':
          $ref: '#/components/responses/InternalError'
        '429':
          $ref: '#/components/responses/RateLimited'

  /api/v1/onboarding:
    put:
      tags: [Onboarding]
      summary: Complete authenticated onboarding
      description: |
        Atomically stores the authenticated user's profile, goal, initial
        weight measurement, and effective nutrition target. The server
        recalculates the recommendation and canonicalizes submitted macros.
        Repeating a request with the same client_request_id and content is
        safe and returns the same complete aggregate. Request bodies are
        limited to 256 KiB.
      operationId: completeOnboarding
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CompleteOnboardingRequest'
            example:
              client_request_id: 28b5973c-0b13-47e8-9b9d-4bd7c4cae1c1
              timezone: Europe/Warsaw
              questionnaire_version: 1
              age: 28
              sex: male
              height_cm: 178
              current_weight:
                weight_kg: 82
                measured_at: '2026-08-29T10:00:00Z'
              daily_activity: mostly_sitting
              workout_frequency: three_to_four_per_week
              goal:
                type: lose_weight
                target_weight_kg: 74
                pace: balanced
              nutrition_target:
                calories_kcal: 2101
                proteins_g: 135
                carbs_g: 235
                fats_g: 69
      responses:
        '200':
          description: Onboarding completed or idempotent replay returned
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProfileResponse'
        '400':
          description: Invalid onboarding answers or nutrition target
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              examples:
                invalidProfile:
                  value:
                    error:
                      code: invalid_profile
                      message: profile answers are invalid
                invalidTarget:
                  value:
                    error:
                      code: invalid_nutrition_target
                      message: nutrition target is invalid
        '401':
          $ref: '#/components/responses/Unauthorized'
        '409':
          description: Onboarding completion conflict
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              examples:
                alreadyCompleted:
                  value:
                    error:
                      code: onboarding_already_completed
                      message: onboarding is already completed
                idempotencyConflict:
                  value:
                    error:
                      code: idempotency_conflict
                      message: client request ID was already used with different content
        '500':
          $ref: '#/components/responses/InternalError'

  /api/v1/profile:
    put:
      tags: [Onboarding]
      summary: Replace the authenticated user's profile and goal
      description: |
        Requires an active subscription.
        Replaces the complete mutable profile and goal using optimistic
        concurrency. The server uses the latest persisted weight measurement
        to recalculate and store a new target with source `calculated`.
        Weight and nutrition-target fields are not accepted in this request.
        Request bodies are limited to 256 KiB.
      operationId: updateProfile
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateProfileRequest'
            example:
              expected_profile_version: 1
              expected_goal_version: 1
              questionnaire_version: 1
              age: 28
              sex: male
              height_cm: 178
              daily_activity: mostly_sitting
              workout_frequency: three_to_four_per_week
              goal:
                type: lose_weight
                target_weight_kg: 74
                pace: balanced
      responses:
        '200':
          description: Updated profile aggregate with a recalculated target
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProfileResponse'
        '400':
          description: Invalid profile or goal answers
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              examples:
                invalidProfile:
                  value:
                    error:
                      code: invalid_profile
                      message: profile answers are invalid
                invalidGoal:
                  value:
                    error:
                      code: invalid_goal
                      message: goal selection is invalid
        '401':
          $ref: '#/components/responses/Unauthorized'
        '503':
          $ref: '#/components/responses/SubscriptionUnavailable'
        '403':
          $ref: '#/components/responses/SubscriptionRequired'
        '409':
          description: The profile or goal version is stale, or onboarding is incomplete
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              examples:
                versionConflict:
                  value:
                    error:
                      code: profile_version_conflict
                      message: profile version is stale
                onboardingRequired:
                  value:
                    error:
                      code: onboarding_required
                      message: onboarding must be completed first
        '500':
          $ref: '#/components/responses/InternalError'
    get:
      tags: [Onboarding]
      summary: Get the authenticated user's complete profile
      description: Returns the profile, goal, current weight, and effective nutrition target owned by the authenticated user.
      operationId: getProfile
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Complete profile aggregate
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProfileResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '409':
          description: The authenticated user must complete onboarding first
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                error:
                  code: onboarding_required
                  message: onboarding must be completed first
        '500':
          $ref: '#/components/responses/InternalError'

  /api/v1/nutrition-target:
    put:
      tags: [Onboarding]
      summary: Replace the authenticated user's nutrition target
      description: |
        Requires an active subscription.
        Replaces all editable nutrition-target values after verifying the
        optimistic-concurrency version. Calories are recalculated from the
        submitted whole-gram macros; the submitted calorie value must be no
        more than 5 kcal away from that canonical value. The stored source is
        `user_adjusted`. Request bodies are limited to 256 KiB.
      operationId: replaceNutritionTarget
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ReplaceNutritionTargetRequest'
            example:
              expected_version: 3
              calories_kcal: 2192
              proteins_g: 138
              carbs_g: 248
              fats_g: 72
      responses:
        '200':
          description: Canonical user-adjusted nutrition target
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProfileNutritionTarget'
        '400':
          description: Invalid nutrition target or calorie mismatch
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                error:
                  code: invalid_nutrition_target
                  message: nutrition target is invalid
        '401':
          $ref: '#/components/responses/Unauthorized'
        '503':
          $ref: '#/components/responses/SubscriptionUnavailable'
        '403':
          $ref: '#/components/responses/SubscriptionRequired'
        '409':
          description: The target version is stale, or onboarding is incomplete
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              examples:
                versionConflict:
                  value:
                    error:
                      code: nutrition_target_version_conflict
                      message: nutrition target version is stale
                onboardingRequired:
                  value:
                    error:
                      code: onboarding_required
                      message: onboarding must be completed first
        '500':
          $ref: '#/components/responses/InternalError'

  /api/v1/weight-measurements:
    post:
      tags: [Onboarding]
      summary: Record a current weight measurement
      description: |
        Requires an active subscription.
        Appends a user-scoped manual weight measurement and atomically
        recalculates the effective nutrition target from the persisted profile
        and goal. A measurement earlier than the current measurement is
        rejected for the MVP. Reusing the same client_request_id with the same
        content is idempotent; reusing it with different content is a conflict.
        Request bodies are limited to 256 KiB.
      operationId: recordWeightMeasurement
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RecordWeightMeasurementRequest'
            example:
              client_request_id: d8ffba48-b7c3-4622-97c0-aa2dab7d5843
              weight_kg: 80.5
              measured_at: '2026-09-05T08:00:00Z'
      responses:
        '200':
          description: Updated profile aggregate, or the original idempotent result
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProfileResponse'
        '400':
          description: Invalid weight measurement or unsupported calculation input
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              examples:
                invalidWeight:
                  value:
                    error:
                      code: invalid_weight_measurement
                      message: current weight is invalid
                unsupportedInput:
                  value:
                    error:
                      code: unsupported_calculation_input
                      message: the requested inputs are outside the supported range
        '401':
          $ref: '#/components/responses/Unauthorized'
        '503':
          $ref: '#/components/responses/SubscriptionUnavailable'
        '403':
          $ref: '#/components/responses/SubscriptionRequired'
        '409':
          description: Onboarding is incomplete or the client request ID conflicts
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              examples:
                onboardingRequired:
                  value:
                    error:
                      code: onboarding_required
                      message: onboarding must be completed first
                idempotencyConflict:
                  value:
                    error:
                      code: idempotency_conflict
                      message: client request ID was already used with different content
        '500':
          $ref: '#/components/responses/InternalError'

  /api/v1/progress/calories:
    get:
      tags: [Progress]
      summary: Get daily calorie totals and goal status
      description: |
        Returns one row for every date in the inclusive range, including days
        without eligible meals. Dates are interpreted in the saved profile
        timezone. The range may contain at most 90 days and cannot extend
        beyond local today. This read-only endpoint remains available after a
        subscription expires. A target is applied from the historical target
        effective on each date; dates before target history began have a null
        target and `target_unknown` status. A day is `within_target` when
        logged calories are between 90% and 110% of its target, inclusive.
      operationId: getCalorieProgress
      security:
        - bearerAuth: []
      parameters:
        - name: from
          in: query
          required: true
          description: Inclusive start date in YYYY-MM-DD format.
          schema:
            type: string
            format: date
        - name: to
          in: query
          required: true
          description: Inclusive end date in YYYY-MM-DD format; must be local today or earlier.
          schema:
            type: string
            format: date
      responses:
        '200':
          description: Daily calorie progress with gap rows included
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CalorieProgressResponse'
              example:
                timezone: Europe/Warsaw
                today: '2026-09-11'
                tolerance_percent: 10
                days:
                  - date: '2026-09-09'
                    calories_kcal: 1980.5
                    meal_count: 3
                    target_kcal: 2000
                    status: within_target
                  - date: '2026-09-10'
                    calories_kcal: 0
                    meal_count: 0
                    target_kcal: 2000
                    status: no_data
        '400':
          $ref: '#/components/responses/InvalidRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '409':
          $ref: '#/components/responses/OnboardingRequired'
        '500':
          $ref: '#/components/responses/InternalError'

  /api/v1/meals:
    get:
      tags: [Meals]
      summary: List analyzed meals for a calendar date
      description: |
        Returns the authenticated user's previously analyzed meals for the
        requested `meal_date`. This read-only history endpoint remains
        available after a subscription expires. Only meals in `review_pending`
        or `completed` with a persisted analysis result are returned. Draft,
        in-progress, and failed meals are omitted. Results are ordered newest
        first by creation time.
      operationId: listMealsByDate
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/MealDate'
      responses:
        '200':
          description: Analyzed meals for the requested date; the list may be empty
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MealListResponse'
        '400':
          $ref: '#/components/responses/InvalidRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '409':
          $ref: '#/components/responses/OnboardingRequired'
        '500':
          $ref: '#/components/responses/InternalError'
    post:
      tags: [Meals]
      summary: Create or retrieve an idempotent meal draft
      description: |
        Requires an active subscription.
        Creates either a photo meal in `awaiting_upload` or a text meal in
        `ready_for_analysis`. Photo clients upload to the returned Firebase
        Storage key; text meals return a null upload. Both flows then start
        analysis explicitly. Repeating the same request with the same
        `Idempotency-Key` for the authenticated user returns the original meal.
      operationId: createMealDraft
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateMealDraftRequest'
            example:
              input_type: photo
              content_type: image/jpeg
              user_context: Chicken bowl, lunch
              meal_date: '2026-08-20'
              captured_at: '2026-08-20T12:30:00Z'
      responses:
        '201':
          description: Meal draft created or the original idempotent draft returned
          headers:
            Location:
              description: URL of the meal status resource
              schema:
                type: string
                format: uri-reference
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MealDraftResponse'
        '400':
          $ref: '#/components/responses/InvalidRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '503':
          $ref: '#/components/responses/SubscriptionUnavailable'
        '403':
          $ref: '#/components/responses/SubscriptionRequired'
        '409':
          $ref: '#/components/responses/OnboardingRequired'
        '500':
          $ref: '#/components/responses/InternalError'

  /api/v1/meals/{id}:
    get:
      tags: [Meals]
      summary: Get the current meal status and composition
      description: The authenticated user owns the meal resource returned by this endpoint. Read-only access remains available after a subscription expires.
      operationId: getMeal
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/MealID'
      responses:
        '200':
          description: Current meal aggregate
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MealResponse'
        '400':
          $ref: '#/components/responses/InvalidMealID'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '409':
          $ref: '#/components/responses/OnboardingRequired'
        '404':
          $ref: '#/components/responses/MealNotFound'
        '500':
          $ref: '#/components/responses/InternalError'
    delete:
      tags: [Meals]
      summary: Delete a meal and all retained data
      description: |
        Permanently deletes the authenticated user's meal photo when present,
        analysis job, provider attempts, immutable analysis snapshot, editable
        composition, and meal row. The operation applies to meals in any
        lifecycle state and remains available after a subscription expires so
        users can remove their stored data.
        The request body must be empty.
      operationId: deleteMeal
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/MealID'
      responses:
        '204':
          description: Meal and retained data deleted
        '400':
          $ref: '#/components/responses/InvalidMealID'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/MealNotFound'
        '409':
          $ref: '#/components/responses/OnboardingRequired'
        '503':
          $ref: '#/components/responses/StorageUnavailable'
        '500':
          $ref: '#/components/responses/InternalError'

  /api/v1/meals/{id}/analysis:
    post:
      tags: [Meals]
      summary: Verify the upload and enqueue asynchronous analysis
      description: |
        Requires an active subscription.
        The request body is empty. For photo meals, the API inspects the
        canonical Firebase Storage object and validates its MIME type, size,
        and generation. Text meals require no storage object. The API then
        atomically transitions the meal to `queued` and creates one durable
        analysis job. Repeating the request after it has been queued is safe.
      operationId: startMealAnalysis
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/MealID'
      requestBody:
        required: false
        description: Must be empty when provided.
        content:
          application/json:
            schema:
              type: object
              maxProperties: 0
      responses:
        '202':
          description: Analysis accepted or an existing analysis lifecycle returned
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MealResponse'
        '400':
          description: Malformed request, unsupported image, or image too large
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              examples:
                unsupportedImage:
                  value:
                    error:
                      code: unsupported_image
                      message: uploaded object is not a supported image
                imageTooLarge:
                  value:
                    error:
                      code: image_too_large
                      message: uploaded image exceeds the maximum size
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/SubscriptionRequired'
        '404':
          $ref: '#/components/responses/MealNotFound'
        '409':
          description: Upload is incomplete or the meal cannot be started
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              examples:
                uploadNotComplete:
                  value:
                    error:
                      code: upload_not_complete
                      message: meal image upload is not complete
                stateConflict:
                  value:
                    error:
                      code: meal_state_conflict
                      message: failed meals cannot be retried yet
                onboardingRequired:
                  value:
                    error:
                      code: onboarding_required
                      message: onboarding must be completed first
        '503':
          $ref: '#/components/responses/StorageOrSubscriptionUnavailable'
        '500':
          $ref: '#/components/responses/InternalError'

  /api/v1/meals/{id}/composition:
    put:
      tags: [Meals]
      summary: Replace the editable meal composition
      description: |
        Requires an active subscription.
        Replaces all current items in one transaction. The supplied version
        must equal the current composition version. Positions, provenance, and
        totals are assigned or calculated by the server. Edits are allowed in
        `review_pending` and `completed`; a completed meal remains completed.
      operationId: replaceMealComposition
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/MealID'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CompositionReplacementRequest'
            example:
              version: 1
              items:
                - id: f12f4fbf-2a7e-4e93-89a2-6df18cfa0d6e
                  name: Chicken breast
                  weight_g: 150
                  calories: 248
                  proteins_g: 46.5
                  fats_g: 5.4
                  carbs_g: 0
      responses:
        '200':
          description: Updated meal aggregate
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MealResponse'
        '400':
          $ref: '#/components/responses/InvalidRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '503':
          $ref: '#/components/responses/SubscriptionUnavailable'
        '403':
          $ref: '#/components/responses/SubscriptionRequired'
        '404':
          $ref: '#/components/responses/MealNotFound'
        '409':
          description: Composition version or meal lifecycle conflict
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              examples:
                staleVersion:
                  value:
                    error:
                      code: composition_version_conflict
                      message: composition version is stale
                stateConflict:
                  value:
                    error:
                      code: meal_state_conflict
                      message: composition can only be edited after analysis review
                onboardingRequired:
                  value:
                    error:
                      code: onboarding_required
                      message: onboarding must be completed first
        '500':
          $ref: '#/components/responses/InternalError'

  /api/v1/meals/{id}/confirm:
    post:
      tags: [Meals]
      summary: Confirm the reviewed meal composition
      description: Transitions `review_pending` to `completed`. Repeating confirmation for a completed meal is idempotent.
      operationId: confirmMeal
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/MealID'
      requestBody:
        required: false
        description: Must be empty when provided.
        content:
          application/json:
            schema:
              type: object
              maxProperties: 0
      responses:
        '200':
          description: Confirmed or already-completed meal aggregate
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MealResponse'
        '400':
          $ref: '#/components/responses/InvalidMealID'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '503':
          $ref: '#/components/responses/SubscriptionUnavailable'
        '403':
          $ref: '#/components/responses/SubscriptionRequired'
        '404':
          $ref: '#/components/responses/MealNotFound'
        '409':
          description: The meal is not confirmable or onboarding is incomplete
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              examples:
                stateConflict:
                  value:
                    error:
                      code: meal_state_conflict
                      message: meal is not awaiting confirmation
                onboardingRequired:
                  value:
                    error:
                      code: onboarding_required
                      message: onboarding must be completed first
        '500':
          $ref: '#/components/responses/InternalError'

components:
  securitySchemes:
    revenueCatWebhookAuth:
      type: apiKey
      in: header
      name: Authorization
      description: Exact shared authorization value configured for RevenueCat webhooks.
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: Firebase ID token issued to the signed-in user.

  parameters:
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      description: User-scoped client request identifier persisted for draft idempotency.
      schema:
        type: string
        minLength: 1
        maxLength: 255
    MealID:
      name: id
      in: path
      required: true
      description: Meal UUID.
      schema:
        type: string
        format: uuid
    MealDate:
      name: meal_date
      in: query
      required: true
      description: Calendar date to query, in YYYY-MM-DD format.
      schema:
        type: string
        format: date

  responses:
    SubscriptionStatus:
      description: Verified entitlement status (active may be false)
      headers:
        Cache-Control:
          schema:
            type: string
            enum: [no-store]
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/SubscriptionStatus'
    SubscriptionRequired:
      description: Verified user has no active configured entitlement
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
          example:
            error:
              code: subscription_required
              message: an active subscription is required
    SubscriptionUnavailable:
      description: Verification failed and no usable verified access is available; retry with backoff
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
          example:
            error:
              code: subscription_unavailable
              message: subscription verification is temporarily unavailable
    StorageOrSubscriptionUnavailable:
      description: Storage is unavailable (storage_unavailable) or subscription verification is unavailable (subscription_unavailable)
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
    InvalidRequest:
      description: Request validation failed
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
          example:
            error:
              code: invalid_request
              message: request is invalid
    OnboardingRequired:
      description: The authenticated user must complete onboarding before using meal features
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
          example:
            error:
              code: onboarding_required
              message: onboarding must be completed first
    RateLimited:
      description: The public preview rate limit was exceeded at the deployment edge
      headers:
        Retry-After:
          description: Number of seconds to wait before retrying.
          schema:
            type: integer
            minimum: 1
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
          example:
            error:
              code: rate_limited
              message: too many preview requests; try again later
    InvalidMealID:
      description: The path does not contain a valid meal UUID
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
          example:
            error:
              code: invalid_meal_id
              message: meal ID must be a UUID
    Unauthorized:
      description: Missing, malformed, or invalid Firebase bearer token
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
          example:
            error:
              code: unauthorized
              message: unauthorized
    MealNotFound:
      description: Meal does not exist or is not owned by the authenticated user
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
          example:
            error:
              code: meal_not_found
              message: meal was not found
    MealStateConflict:
      description: Meal is not in a state that permits the operation
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
          example:
            error:
              code: meal_state_conflict
              message: meal is not awaiting confirmation
    StorageUnavailable:
      description: Storage metadata could not be inspected
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
          example:
            error:
              code: storage_unavailable
              message: storage metadata could not be inspected
    InternalError:
      description: Unexpected server failure
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
          example:
            error:
              code: internal_error
              message: the request could not be completed

  schemas:
    SubscriptionStatus:
      type: object
      required: [active, expires_at, verified_at, verification_stale]
      properties:
        active:
          type: boolean
        expires_at:
          type: string
          format: date-time
          nullable: true
          description: Effective access expiry including billing grace; null for a non-expiring grant or no entitlement.
        verified_at:
          type: string
          format: date-time
          nullable: true
        verification_stale:
          type: boolean
          description: True when using previously verified active access during a transient provider outage.
    HealthResponse:
      type: object
      required: [status]
      properties:
        status:
          type: string
          enum: [ok]

    OnboardingPreviewRequest:
      type: object
      additionalProperties: false
      required: [questionnaire_version, age, sex, height_cm, current_weight_kg, daily_activity, workout_frequency, goal]
      properties:
        questionnaire_version:
          type: integer
          minimum: 1
        age:
          type: integer
          minimum: 18
          maximum: 100
        sex:
          type: string
          enum: [male, female]
        height_cm:
          type: number
          format: double
          minimum: 100
          maximum: 250
        current_weight_kg:
          type: number
          format: double
          minimum: 30
          maximum: 350
        daily_activity:
          type: string
          enum: [mostly_sitting, mostly_standing_or_walking, physically_active]
        workout_frequency:
          type: string
          enum: [rarely_or_never, one_to_two_per_week, three_to_four_per_week, five_plus_per_week]
        goal:
          $ref: '#/components/schemas/OnboardingPreviewGoal'

    CompleteOnboardingRequest:
      type: object
      additionalProperties: false
      required: [client_request_id, questionnaire_version, age, sex, height_cm, current_weight, daily_activity, workout_frequency, goal, nutrition_target]
      properties:
        client_request_id:
          type: string
          minLength: 1
          description: Stable user-scoped identifier reused when retrying the same completion request.
        timezone:
          type: string
          description: Optional IANA timezone name; defaults to UTC.
          example: Europe/Warsaw
        questionnaire_version:
          type: integer
          minimum: 1
        age:
          type: integer
          minimum: 18
          maximum: 100
        sex:
          type: string
          enum: [male, female]
        height_cm:
          type: number
          format: double
          minimum: 100
          maximum: 250
        current_weight:
          $ref: '#/components/schemas/OnboardingCurrentWeight'
        daily_activity:
          type: string
          enum: [mostly_sitting, mostly_standing_or_walking, physically_active]
        workout_frequency:
          type: string
          enum: [rarely_or_never, one_to_two_per_week, three_to_four_per_week, five_plus_per_week]
        goal:
          $ref: '#/components/schemas/OnboardingPreviewGoal'
        nutrition_target:
          $ref: '#/components/schemas/OnboardingNutritionTarget'

    UpdateProfileRequest:
      type: object
      additionalProperties: false
      required: [expected_profile_version, expected_goal_version, questionnaire_version, age, sex, height_cm, daily_activity, workout_frequency, goal]
      properties:
        timezone:
          type: string
          description: Optional IANA timezone name. Omit to keep the saved timezone.
          example: Europe/Warsaw
        expected_profile_version:
          type: integer
          minimum: 1
          description: Profile version returned by the latest profile response.
        expected_goal_version:
          type: integer
          minimum: 1
          description: Goal version returned by the latest profile response.
        questionnaire_version:
          type: integer
          minimum: 1
        age:
          type: integer
          minimum: 18
          maximum: 100
        sex:
          type: string
          enum: [male, female]
        height_cm:
          type: number
          format: double
          minimum: 100
          maximum: 250
        daily_activity:
          type: string
          enum: [mostly_sitting, mostly_standing_or_walking, physically_active]
        workout_frequency:
          type: string
          enum: [rarely_or_never, one_to_two_per_week, three_to_four_per_week, five_plus_per_week]
        goal:
          $ref: '#/components/schemas/OnboardingPreviewGoal'

    ReplaceNutritionTargetRequest:
      type: object
      additionalProperties: false
      required: [expected_version, calories_kcal, proteins_g, carbs_g, fats_g]
      properties:
        expected_version:
          type: integer
          minimum: 1
          description: Target version returned by the latest profile response.
        calories_kcal:
          type: integer
          minimum: 1
          description: Submitted calories; the server recalculates calories from macros and accepts a difference of at most 5 kcal.
        proteins_g:
          type: integer
          minimum: 0
        carbs_g:
          type: integer
          minimum: 0
        fats_g:
          type: integer
          minimum: 0

    RecordWeightMeasurementRequest:
      type: object
      additionalProperties: false
      required: [client_request_id, weight_kg, measured_at]
      properties:
        client_request_id:
          type: string
          minLength: 1
          description: Stable user-scoped identifier reused when retrying the same weight request.
        weight_kg:
          type: number
          format: double
          minimum: 30
          maximum: 350
        measured_at:
          type: string
          format: date-time

    OnboardingCurrentWeight:
      type: object
      additionalProperties: false
      required: [weight_kg, measured_at]
      properties:
        weight_kg:
          type: number
          format: double
          minimum: 30
          maximum: 350
        measured_at:
          type: string
          format: date-time

    OnboardingNutritionTarget:
      type: object
      additionalProperties: false
      required: [calories_kcal, proteins_g, carbs_g, fats_g]
      properties:
        calories_kcal:
          type: integer
          minimum: 1
          description: Submitted calories; the server verifies them within 5 kcal and recalculates canonical calories from macros.
        proteins_g:
          type: integer
          minimum: 0
        carbs_g:
          type: integer
          minimum: 0
        fats_g:
          type: integer
          minimum: 0

    ProfileResponse:
      type: object
      required: [status, profile, goal, current_weight, nutrition_target]
      properties:
        status:
          type: string
          enum: [complete]
        profile:
          $ref: '#/components/schemas/ProfileDetails'
        goal:
          $ref: '#/components/schemas/ProfileGoal'
        current_weight:
          $ref: '#/components/schemas/ProfileCurrentWeight'
        nutrition_target:
          $ref: '#/components/schemas/ProfileNutritionTarget'

    ProfileDetails:
      type: object
      required: [timezone, age, sex, height_cm, daily_activity, workout_frequency, questionnaire_version, version, onboarding_completed_at]
      properties:
        timezone:
          type: string
          description: Saved IANA timezone used for calendar days.
        age:
          type: integer
          minimum: 18
          maximum: 100
        sex:
          type: string
          enum: [male, female]
        height_cm:
          type: number
          format: double
        daily_activity:
          type: string
          enum: [mostly_sitting, mostly_standing_or_walking, physically_active]
        workout_frequency:
          type: string
          enum: [rarely_or_never, one_to_two_per_week, three_to_four_per_week, five_plus_per_week]
        questionnaire_version:
          type: integer
          minimum: 1
        version:
          type: integer
          minimum: 1
        onboarding_completed_at:
          type: string
          format: date-time

    CalorieProgressResponse:
      type: object
      required: [timezone, today, tolerance_percent, days]
      properties:
        timezone:
          type: string
        today:
          type: string
          format: date
        tolerance_percent:
          type: integer
          enum: [10]
        days:
          type: array
          items:
            $ref: '#/components/schemas/CalorieProgressDay'

    CalorieProgressDay:
      type: object
      required: [date, calories_kcal, meal_count, target_kcal, status]
      properties:
        date:
          type: string
          format: date
        calories_kcal:
          type: number
          format: double
          minimum: 0
        meal_count:
          type: integer
          minimum: 0
        target_kcal:
          type: integer
          minimum: 1
          nullable: true
        status:
          type: string
          enum: [no_data, target_unknown, below_target, within_target, above_target]

    ProfileGoal:
      type: object
      required: [type, target_weight_kg, pace, version]
      properties:
        type:
          type: string
          enum: [lose_weight, maintain_weight, gain_weight, build_muscle]
        target_weight_kg:
          type: number
          format: double
          minimum: 30
          maximum: 350
          nullable: true
        pace:
          type: string
          enum: [comfortable, balanced, faster]
          nullable: true
        version:
          type: integer
          minimum: 1

    ProfileCurrentWeight:
      type: object
      required: [id, weight_kg, measured_at]
      properties:
        id:
          type: string
          format: uuid
        weight_kg:
          type: number
          format: double
        measured_at:
          type: string
          format: date-time

    ProfileNutritionTarget:
      type: object
      required: [calories_kcal, proteins_g, carbs_g, fats_g, source, calculation_version, version]
      properties:
        calories_kcal:
          type: integer
          minimum: 1
        proteins_g:
          type: integer
          minimum: 0
        carbs_g:
          type: integer
          minimum: 0
        fats_g:
          type: integer
          minimum: 0
        source:
          type: string
          enum: [calculated, user_adjusted]
        calculation_version:
          type: string
          enum: [nutrition-targets-v1]
        version:
          type: integer
          minimum: 1

    OnboardingPreviewGoal:
      oneOf:
        - $ref: '#/components/schemas/MaintainWeightGoal'
        - $ref: '#/components/schemas/ChangingWeightGoal'

    MaintainWeightGoal:
      type: object
      additionalProperties: false
      required: [type]
      properties:
        type:
          type: string
          enum: [maintain_weight]

    ChangingWeightGoal:
      type: object
      additionalProperties: false
      required: [type, target_weight_kg, pace]
      properties:
        type:
          type: string
          enum: [lose_weight, gain_weight, build_muscle]
        target_weight_kg:
          type: number
          format: double
          minimum: 30
          maximum: 350
        pace:
          type: string
          enum: [comfortable, balanced, faster]

    OnboardingPreviewResponse:
      type: object
      required: [nutrition_target]
      properties:
        nutrition_target:
          $ref: '#/components/schemas/OnboardingPreviewTarget'

    OnboardingPreviewTarget:
      type: object
      required: [calories_kcal, proteins_g, carbs_g, fats_g, source, calculation_version]
      properties:
        calories_kcal:
          type: integer
          minimum: 1
        proteins_g:
          type: integer
          minimum: 0
        carbs_g:
          type: integer
          minimum: 0
        fats_g:
          type: integer
          minimum: 0
        source:
          type: string
          enum: [calculated]
        calculation_version:
          type: string
          enum: [nutrition-targets-v1]

    CreateMealDraftRequest:
      type: object
      additionalProperties: false
      properties:
        input_type:
          $ref: '#/components/schemas/MealInputType'
          description: Optional discriminator. When omitted, content_type infers photo and a nonblank user_context without content_type infers text.
        content_type:
          type: string
          enum: [image/jpeg, image/png, image/webp, image/heic, image/heif]
          description: Required for photo input and forbidden for text input.
        user_context:
          type: string
          maxLength: 1000
          description: Optional photo context and the required nonblank description for text input.
        captured_at:
          type: string
          format: date-time
          description: Optional time associated with the meal input.
        meal_date:
          type: string
          format: date
          nullable: true
          description: Calendar date the meal belongs to; defaults to the current UTC date.

    MealDraftResponse:
      type: object
      required: [meal_id, meal_date, input_type, user_context, status, upload]
      properties:
        meal_id:
          type: string
          format: uuid
        meal_date:
          type: string
          format: date
        input_type:
          $ref: '#/components/schemas/MealInputType'
        user_context:
          type: string
        status:
          $ref: '#/components/schemas/MealStatus'
        upload:
          allOf:
            - $ref: '#/components/schemas/UploadInstruction'
          nullable: true

    UploadInstruction:
      type: object
      required: [provider, object_key, content_type, max_size_bytes]
      properties:
        provider:
          type: string
          enum: [firebase_storage]
        object_key:
          type: string
          description: Server-generated canonical Firebase Storage key.
          example: users/firebase-uid/meals/4c8a7f1d-7ab0-4e6c-8c66-71c2c16b7e4a/photo
        content_type:
          type: string
          enum: [image/jpeg, image/png, image/webp, image/heic, image/heif]
        max_size_bytes:
          type: integer
          format: int64
          enum: [10485760]

    MealResponse:
      type: object
      required: [meal_id, meal_date, input_type, user_context, image_object_key, status, progress, composition, review, created_at, updated_at]
      properties:
        meal_id:
          type: string
          format: uuid
        meal_date:
          type: string
          format: date
        input_type:
          $ref: '#/components/schemas/MealInputType'
        user_context:
          type: string
          maxLength: 1000
        image_object_key:
          type: string
          nullable: true
          description: Canonical Firebase Storage object key for the meal image. Use it with the Firebase client SDK; do not construct or modify it.
          example: users/firebase-uid/meals/4c8a7f1d-7ab0-4e6c-8c66-71c2c16b7e4a/photo
        status:
          $ref: '#/components/schemas/MealStatus'
        progress:
          $ref: '#/components/schemas/Progress'
        analysis:
          allOf:
            - $ref: '#/components/schemas/AnalysisSummary'
          nullable: true
        composition:
          $ref: '#/components/schemas/Composition'
        review:
          $ref: '#/components/schemas/ReviewState'
        error:
          allOf:
            - $ref: '#/components/schemas/SafeMealError'
          nullable: true
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    MealListResponse:
      type: object
      required: [meal_date, meals]
      properties:
        meal_date:
          type: string
          format: date
        meals:
          type: array
          items:
            $ref: '#/components/schemas/MealResponse'

    MealStatus:
      type: string
      enum: [awaiting_upload, ready_for_analysis, queued, processing, review_pending, completed, failed]

    MealInputType:
      type: string
      enum: [photo, text]

    Progress:
      type: object
      required: [stage, percent, updated_at]
      properties:
        stage:
          type: string
          enum:
            - awaiting_upload
            - ready_for_analysis
            - queued
            - validating_image
            - preparing_image
            - analyzing_primary
            - analyzing_fallback
            - validating_result
            - normalizing_nutrition
            - review_pending
            - completed
            - failed
        percent:
          type: integer
          minimum: 0
          maximum: 100
        updated_at:
          type: string
          format: date-time

    AnalysisSummary:
      type: object
      required: [run_id, dish_name, confidence_score, assumptions]
      properties:
        run_id:
          type: string
          format: uuid
        dish_name:
          type: string
        confidence_score:
          type: number
          format: double
          minimum: 0
          maximum: 1
        assumptions:
          type: array
          items:
            type: string

    Composition:
      type: object
      required: [version, items, totals]
      properties:
        version:
          type: integer
          minimum: 0
        items:
          type: array
          items:
            $ref: '#/components/schemas/MealItem'
        totals:
          $ref: '#/components/schemas/NutritionTotals'

    CompositionReplacementRequest:
      type: object
      additionalProperties: false
      required: [version, items]
      properties:
        version:
          type: integer
          minimum: 0
          description: Expected current composition version.
        items:
          type: array
          items:
            $ref: '#/components/schemas/MealItemInput'
        totals:
          allOf:
            - $ref: '#/components/schemas/NutritionTotals'
          deprecated: true
          description: Accepted for compatibility but ignored; totals are recalculated by the server.

    MealItemInput:
      type: object
      additionalProperties: false
      required: [id, name, weight_g, calories, proteins_g, fats_g, carbs_g]
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
          minLength: 1
          maxLength: 200
        weight_g:
          type: number
          format: double
          minimum: 0
        calories:
          type: number
          format: double
          minimum: 0
        proteins_g:
          type: number
          format: double
          minimum: 0
        fats_g:
          type: number
          format: double
          minimum: 0
        carbs_g:
          type: number
          format: double
          minimum: 0
        sugar_g:
          type: number
          format: double
          nullable: true
          minimum: 0
          description: Total sugar in grams, including naturally occurring and added sugar. Null means unknown.
        fibre_g:
          type: number
          format: double
          nullable: true
          minimum: 0
          description: Dietary fibre in grams. Null means unknown.
        position:
          type: integer
          minimum: 0
          deprecated: true
          description: Accepted for compatibility but ignored; position is assigned from request order.
        source:
          type: string
          deprecated: true
          description: Accepted for compatibility but ignored; edited items are stored as user-sourced.
        reference_food_id:
          type: string
          deprecated: true
          description: Accepted for compatibility but ignored in this phase.
        reference_match_confidence:
          type: number
          format: double
          minimum: 0
          maximum: 1
          deprecated: true
          description: Accepted for compatibility but ignored in this phase.
        nutrition_source:
          type: string
          deprecated: true
          description: Accepted for compatibility but ignored; edited items use manual nutrition.
        reference_version:
          type: string
          deprecated: true
          description: Accepted for compatibility but ignored in this phase.
        anomaly_flags:
          type: array
          items:
            type: string
          deprecated: true
          description: Accepted for compatibility but ignored in this phase.

    MealItem:
      allOf:
        - $ref: '#/components/schemas/MealItemInput'
        - type: object
          required: [source, nutrition_source, sugar_g, fibre_g]
          properties:
            source:
              type: string
              enum: [model, usda, user]
            nutrition_source:
              type: string
              enum: [model, usda, manual]
            reference_food_id:
              type: string
            reference_match_confidence:
              type: number
              format: double
              minimum: 0
              maximum: 1
            reference_version:
              type: string
            anomaly_flags:
              type: array
              items:
                type: string

    NutritionTotals:
      description: Server-calculated sums. Sugar and fibre totals are independently null if any item has an unknown value; empty compositions total zero.
      type: object
      required: [calories, proteins_g, fats_g, carbs_g, sugar_g, fibre_g]
      properties:
        calories:
          type: number
          format: double
          minimum: 0
        proteins_g:
          type: number
          format: double
          minimum: 0
        fats_g:
          type: number
          format: double
          minimum: 0
        carbs_g:
          type: number
          format: double
          minimum: 0
        sugar_g:
          type: number
          format: double
          nullable: true
          minimum: 0
          description: Total sugar in grams, including naturally occurring and added sugar. Null means unknown.
        fibre_g:
          type: number
          format: double
          nullable: true
          minimum: 0
          description: Dietary fibre in grams. Null means unknown.

    ReviewState:
      type: object
      required: [required, confirmed_at]
      properties:
        required:
          type: boolean
        confirmed_at:
          type: string
          format: date-time
          nullable: true

    SafeMealError:
      type: object
      required: [code, message]
      properties:
        code:
          type: string
          description: Stable safe failure code suitable for client branching.
        enum:
            - upload_not_found
            - upload_changed
            - unsupported_image
            - image_too_large
            - storage_unavailable
            - provider_unavailable
            - invalid_model_output
            - analysis_timeout
            - analysis_failed
            - internal_error
        message:
          type: string

    ErrorEnvelope:
      type: object
      required: [error]
      properties:
        error:
          $ref: '#/components/schemas/ErrorBody'

    ErrorBody:
      type: object
      required: [code, message]
      properties:
        code:
          type: string
          example: invalid_request
        message:
          type: string
          example: request is invalid
