openapi: 3.1.0
info:
  title: LabNote ELN Lite — Public API
  version: "1.2.0"
  description: |
    Stable public HTTP surface of the LabNote backend. Designed for machine-to-machine
    integration (LIMS, ERP, instrument automation) and AI-agent use.

    ## Authentication

    Two stable token kinds are accepted as `Authorization: Bearer <token>`:

    - **API key** (org-scoped, recommended for server-to-server). The org id is
      embedded in the key; no extra header needed.
    - **Personal Access Token (PAT)** (user-scoped, multi-org). Must include
      either `?org_id=<uuid>` query parameter or `X-Org-Id: <uuid>` header.

    Token scopes: `read`, `write`, `audit:read`, `admin`, `*`.
    `write` implies `read`; `admin` and `*` imply everything.

    ## Rate limiting

    Per token: 120 requests/minute and 5000 requests/hour by default
    (configurable per org). 429 responses include `Retry-After` (seconds)
    and `X-RateLimit-*` headers.

    ## Idempotency

    All write endpoints accept `Idempotency-Key: <string>`. Identical
    key + payload within 24h replays the original response (with
    `Idempotent-Replay: true`). Same key with a different payload returns
    `409 idempotency_key_conflict`.

    ## Pagination

    - Main resources: `?limit=<n>&offset=<n>`; response includes
      `pagination.total`.
    - Audit log and jobs list: cursor pagination via `?cursor=<value>`;
      response includes `next_cursor`.

    ## Versioning

    Path-segment versioning (`/v1/...`). Breaking changes ship under `/v2/...`.
    Field additions are non-breaking. Removals get a 6-month deprecation
    window with `Sunset` and `Deprecation` response headers.

    ## Error envelope

    All `api-v1` endpoints return errors as:
    ```json
    { "error": { "code": "string_machine_code", "message": "human readable" } }
    ```
  contact:
    name: LabNote Security
    url: https://labnote-light.com/.well-known/security.txt
  license:
    name: Proprietary

servers:
  - url: https://vilasdqkwlszlulteqrb.supabase.co/functions/v1/api-v1
    description: Production — public REST API (api-v1)
  - url: https://vilasdqkwlszlulteqrb.supabase.co/functions/v1
    description: Production — other Edge Functions (exports, email, OAuth)
  - url: https://vilasdqkwlszlulteqrb.supabase.co/rest/v1
    description: Production — PostgREST RPCs

security:
  - bearerAuth: []

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: opaque
      description: API key (`lk_*`) or Personal Access Token (`pat_*`).
  parameters:
    OrgIdQuery:
      in: query
      name: org_id
      required: false
      description: Required for PAT tokens unless `X-Org-Id` is sent.
      schema: { type: string, format: uuid }
    Limit:
      in: query
      name: limit
      schema: { type: integer, default: 100, minimum: 1, maximum: 500 }
    Offset:
      in: query
      name: offset
      schema: { type: integer, default: 0, minimum: 0 }
    IdempotencyKey:
      in: header
      name: Idempotency-Key
      required: false
      schema: { type: string, minLength: 8, maxLength: 200 }
  schemas:
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code: { type: string }
            message: { type: string }
            details: { type: object, additionalProperties: true }
    Pagination:
      type: object
      properties:
        limit: { type: integer }
        offset: { type: integer }
        total: { type: integer, nullable: true }
    ListEnvelope:
      type: object
      properties:
        data: { type: array, items: { type: object, additionalProperties: true } }
        pagination: { $ref: "#/components/schemas/Pagination" }
    ItemEnvelope:
      type: object
      properties:
        data: { type: object, additionalProperties: true }
    Job:
      type: object
      properties:
        id: { type: string, format: uuid }
        job_type: { type: string }
        status: { type: string, enum: [queued, running, succeeded, failed, dead] }
        progress: { type: integer, minimum: 0, maximum: 100 }
        status_text: { type: string, nullable: true }
        result_path: { type: string, nullable: true, description: "Storage path of produced artifact, if any" }
        result_meta: { type: object, nullable: true, additionalProperties: true }
        error_message: { type: string, nullable: true }
        attempts: { type: integer }
        max_attempts: { type: integer }
        enqueued_at: { type: string, format: date-time }
        started_at: { type: string, format: date-time, nullable: true }
        finished_at: { type: string, format: date-time, nullable: true }
    JobCreate:
      type: object
      required: [job_type]
      properties:
        job_type:
          type: string
          enum:
            - audit_export
            - biosafety_export
            - gdpr_export
            - org_data_export
            - equipment_lifecycle_pdf
            - maintenance_report_pdf
            - equipment_book_pdf
            - experiment_entry_pdf
            - risk_assessment_pdf
            - box_layout_pdf
        input:
          type: object
          additionalProperties: true
          description: Job-type-specific input. See per-type docs.
    Notification:
      type: object
      properties:
        id: { type: string, format: uuid }
        notification_type: { type: string }
        title: { type: string }
        body: { type: string, nullable: true }
        link_url: { type: string, format: uri, nullable: true }
        entry_id: { type: string, format: uuid, nullable: true }
        read_at: { type: string, format: date-time, nullable: true }
        created_at: { type: string, format: date-time }
    NotificationCreate:
      type: object
      required: [title]
      properties:
        title:
          type: string
          minLength: 1
          maxLength: 200
        body:
          type: string
          maxLength: 2000
        notification_type:
          type: string
          minLength: 1
          maxLength: 64
          default: external
        link_url:
          type: string
          format: uri
          maxLength: 2000
        entry_id: { type: string, format: uuid }
        user_id: { type: string, format: uuid }
        user_ids:
          type: array
          minItems: 1
          maxItems: 500
          items: { type: string, format: uuid }
        role: { type: string, minLength: 1, maxLength: 64 }
      description: Exactly one of user_id, user_ids, or role must be provided.
    NotificationCreated:
      type: object
      properties:
        data:
          type: object
          required: [created, notification_ids]
          properties:
            created: { type: integer }
            notification_ids:
              type: array
              items: { type: string, format: uuid }
  responses:
    BadRequest:
      description: Validation failed or the request could not be processed.
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    Unauthorized:
      description: Missing or invalid Bearer token.
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    Forbidden:
      description: Token lacks required scope, or job_type not allowed.
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    NotFound:
      description: Resource not found in this org.
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    RateLimited:
      description: Rate limit exceeded.
      headers:
        Retry-After: { schema: { type: integer } }
        X-RateLimit-Limit: { schema: { type: integer } }
        X-RateLimit-Remaining: { schema: { type: integer } }
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    Conflict:
      description: Idempotency-Key reused with different payload.
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }

paths:
  # ===================== api-v1 service =====================
  /v1/health:
    get:
      summary: Liveness probe (unauthenticated)
      tags: [Meta]
      security: []
      responses:
        "200": { description: OK }

  /v1/ping:
    get:
      summary: Authenticated ping — returns identity and resource list
      tags: [Meta]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  authenticated_as:
                    type: object
                    properties:
                      kind: { type: string, enum: [api_key, pat] }
                      user_id: { type: string, format: uuid }
                      org_id: { type: string, format: uuid }
                  resources: { type: array, items: { type: string } }
                  docs: { type: string, format: uri }

  /v1/experiments:
    get:
      summary: List experiments
      tags: [Experiments]
      parameters:
        - $ref: "#/components/parameters/OrgIdQuery"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Offset"
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/ListEnvelope" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      summary: Create an experiment
      tags: [Experiments]
      parameters: [ { $ref: "#/components/parameters/IdempotencyKey" } ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                title: { type: string }
                description: { type: string }
                content: { type: object, additionalProperties: true }
                status: { type: string }
                tags: { type: array, items: { type: string } }
                project_id: { type: string, format: uuid }
                template_id: { type: string, format: uuid }
      responses:
        "201": { description: Created, content: { application/json: { schema: { $ref: "#/components/schemas/ItemEnvelope" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/experiments/{id}:
    parameters: [ { in: path, name: id, required: true, schema: { type: string, format: uuid } } ]
    get:
      tags: [Experiments]
      responses: { "200": { description: OK }, "404": { $ref: "#/components/responses/NotFound" } }
    patch:
      tags: [Experiments]
      parameters: [ { $ref: "#/components/parameters/IdempotencyKey" } ]
      requestBody: { required: true, content: { application/json: { schema: { type: object, additionalProperties: true } } } }
      responses: { "200": { description: Updated }, "404": { $ref: "#/components/responses/NotFound" } }
    delete:
      tags: [Experiments]
      responses: { "204": { description: Deleted } }

  /v1/projects:
    get:
      summary: List projects
      tags: [Projects]
      parameters: [ { $ref: "#/components/parameters/OrgIdQuery" }, { $ref: "#/components/parameters/Limit" }, { $ref: "#/components/parameters/Offset" } ]
      responses: { "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/ListEnvelope" } } } } }
    post:
      summary: Create a project
      tags: [Projects]
      parameters: [ { $ref: "#/components/parameters/IdempotencyKey" } ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string }
                description: { type: string }
                status: { type: string }
                tags: { type: array, items: { type: string } }
                department_id: { type: string, format: uuid }
                lead_researcher_id: { type: string, format: uuid }
                parent_project_id: { type: string, format: uuid }
                start_date: { type: string, format: date }
      responses: { "201": { description: Created, content: { application/json: { schema: { $ref: "#/components/schemas/ItemEnvelope" } } } } }
  /v1/projects/{id}:
    parameters: [ { in: path, name: id, required: true, schema: { type: string, format: uuid } } ]
    get:    { tags: [Projects], responses: { "200": { description: OK }, "404": { $ref: "#/components/responses/NotFound" } } }
    patch:
      tags: [Projects]
      parameters: [ { $ref: "#/components/parameters/IdempotencyKey" } ]
      requestBody: { required: true, content: { application/json: { schema: { type: object, additionalProperties: true } } } }
      responses: { "200": { description: Updated } }
    delete: { tags: [Projects], responses: { "204": { description: Deleted } } }

  /v1/samples:
    get:
      summary: List samples
      tags: [Samples]
      parameters: [ { $ref: "#/components/parameters/OrgIdQuery" }, { $ref: "#/components/parameters/Limit" }, { $ref: "#/components/parameters/Offset" } ]
      responses: { "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/ListEnvelope" } } } } }
    post:
      summary: Create a sample
      tags: [Samples]
      parameters: [ { $ref: "#/components/parameters/IdempotencyKey" } ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string }
                type: { type: string }
                status: { type: string }
                metadata: { type: object, additionalProperties: true }
                storage_location: { type: string }
                department_id: { type: string, format: uuid }
                owner_id: { type: string, format: uuid }
      responses: { "201": { description: Created, content: { application/json: { schema: { $ref: "#/components/schemas/ItemEnvelope" } } } } }
  /v1/samples/{id}:
    parameters: [ { in: path, name: id, required: true, schema: { type: string, format: uuid } } ]
    get:    { tags: [Samples], responses: { "200": { description: OK }, "404": { $ref: "#/components/responses/NotFound" } } }
    patch:
      tags: [Samples]
      parameters: [ { $ref: "#/components/parameters/IdempotencyKey" } ]
      requestBody: { required: true, content: { application/json: { schema: { type: object, additionalProperties: true } } } }
      responses: { "200": { description: Updated } }
    delete: { tags: [Samples], responses: { "204": { description: Deleted } } }

  /v1/equipment:
    get:
      summary: List equipment
      tags: [Equipment]
      parameters: [ { $ref: "#/components/parameters/OrgIdQuery" }, { $ref: "#/components/parameters/Limit" }, { $ref: "#/components/parameters/Offset" } ]
      responses: { "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/ListEnvelope" } } } } }
    post:
      summary: Register equipment
      tags: [Equipment]
      parameters: [ { $ref: "#/components/parameters/IdempotencyKey" } ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string }
                manufacturer: { type: string }
                model: { type: string }
                serial_number: { type: string }
                status: { type: string }
                location: { type: string }
                notes: { type: string }
                tags: { type: array, items: { type: string } }
                department_id: { type: string, format: uuid }
                room_id: { type: string, format: uuid }
                storage_location_id: { type: string, format: uuid }
                last_maintenance_date: { type: string, format: date }
                next_maintenance_date: { type: string, format: date }
      responses: { "201": { description: Created, content: { application/json: { schema: { $ref: "#/components/schemas/ItemEnvelope" } } } } }
  /v1/equipment/{id}:
    parameters: [ { in: path, name: id, required: true, schema: { type: string, format: uuid } } ]
    get:    { tags: [Equipment], responses: { "200": { description: OK }, "404": { $ref: "#/components/responses/NotFound" } } }
    patch:
      tags: [Equipment]
      parameters: [ { $ref: "#/components/parameters/IdempotencyKey" } ]
      requestBody: { required: true, content: { application/json: { schema: { type: object, additionalProperties: true } } } }
      responses: { "200": { description: Updated } }
    delete: { tags: [Equipment], responses: { "204": { description: Deleted } } }

  /v1/reagents:
    get:
      summary: List reagent catalog entries
      tags: [Reagents]
      parameters: [ { $ref: "#/components/parameters/OrgIdQuery" }, { $ref: "#/components/parameters/Limit" }, { $ref: "#/components/parameters/Offset" } ]
      responses: { "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/ListEnvelope" } } } } }
    post:
      summary: Create a reagent catalog entry
      tags: [Reagents]
      parameters: [ { $ref: "#/components/parameters/IdempotencyKey" } ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string }
                alias: { type: string }
                cas_number: { type: string }
                formula: { type: string }
                molecular_weight: { type: number }
                purity: { type: string }
                density: { type: number }
                aggregate_state: { type: string }
                signal_word: { type: string, enum: [danger, warning, none] }
                h_phrases: { type: array, items: { type: string } }
                p_phrases: { type: array, items: { type: string } }
                hazard_pictograms: { type: array, items: { type: string } }
                storage_temperature: { type: string }
                storage_class: { type: string }
                flash_point: { type: number }
                un_number: { type: string }
                external_link: { type: string, format: uri }
                description: { type: string }
                notes: { type: string }
                department_id: { type: string, format: uuid }
      responses: { "201": { description: Created, content: { application/json: { schema: { $ref: "#/components/schemas/ItemEnvelope" } } } } }
  /v1/reagents/{id}:
    parameters: [ { in: path, name: id, required: true, schema: { type: string, format: uuid } } ]
    get:    { tags: [Reagents], responses: { "200": { description: OK } } }
    patch:
      tags: [Reagents]
      parameters: [ { $ref: "#/components/parameters/IdempotencyKey" } ]
      requestBody: { required: true, content: { application/json: { schema: { type: object, additionalProperties: true } } } }
      responses: { "200": { description: Updated } }
    delete: { tags: [Reagents], responses: { "204": { description: Deleted } } }

  /v1/stock-items:
    get:
      summary: List reagent stock items
      tags: [Stock]
      parameters: [ { $ref: "#/components/parameters/OrgIdQuery" }, { $ref: "#/components/parameters/Limit" }, { $ref: "#/components/parameters/Offset" } ]
      responses: { "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/ListEnvelope" } } } } }
    post:
      summary: Create a stock item
      tags: [Stock]
      parameters: [ { $ref: "#/components/parameters/IdempotencyKey" } ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [catalog_id, quantity, unit]
              properties:
                catalog_id: { type: string, format: uuid }
                name_suffix: { type: string }
                manufacturer: { type: string }
                supplier: { type: string }
                article_number: { type: string }
                order_number: { type: string }
                lot_number: { type: string }
                lot_status: { type: string }
                quantity: { type: number }
                unit: { type: string }
                package_size_full: { type: number }
                package_size_current: { type: number }
                container_material: { type: string }
                storage_location_id: { type: string, format: uuid }
                department_id: { type: string, format: uuid }
                purchase_price: { type: number }
                cost_center: { type: string }
                project_number: { type: string }
                intended_use: { type: string }
                min_stock: { type: number }
                acquired_on: { type: string, format: date }
                opened_on: { type: string, format: date }
                expires_on: { type: string, format: date }
                inspected_on: { type: string, format: date }
                notes: { type: string }
      responses: { "201": { description: Created, content: { application/json: { schema: { $ref: "#/components/schemas/ItemEnvelope" } } } } }
  /v1/stock-items/{id}:
    parameters: [ { in: path, name: id, required: true, schema: { type: string, format: uuid } } ]
    get:    { tags: [Stock], responses: { "200": { description: OK } } }
    patch:
      tags: [Stock]
      parameters: [ { $ref: "#/components/parameters/IdempotencyKey" } ]
      requestBody: { required: true, content: { application/json: { schema: { type: object, additionalProperties: true } } } }
      responses: { "200": { description: Updated } }
    delete: { tags: [Stock], responses: { "204": { description: Deleted } } }

  /v1/stock-movements:
    get:
      summary: List stock movements
      tags: [Stock]
      parameters: [ { $ref: "#/components/parameters/OrgIdQuery" }, { $ref: "#/components/parameters/Limit" }, { $ref: "#/components/parameters/Offset" } ]
      responses: { "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/ListEnvelope" } } } } }
    post:
      summary: Record a stock movement (consume / restock / adjust)
      tags: [Stock]
      parameters: [ { $ref: "#/components/parameters/IdempotencyKey" } ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [stock_item_id, movement_type, quantity_delta]
              properties:
                stock_item_id: { type: string, format: uuid }
                movement_type:
                  type: string
                  enum: [consume, restock, adjustment, transfer, dispose]
                quantity_delta:
                  type: number
                  description: Negative for consumption, positive for restock.
                reason: { type: string }
                intended_use: { type: string }
                entry_id: { type: string, format: uuid, description: "Link to experiment entry that triggered the consumption." }
      responses: { "201": { description: Created, content: { application/json: { schema: { $ref: "#/components/schemas/ItemEnvelope" } } } } }

  /v1/inventories:
    get:
      summary: List physical inventory runs
      tags: [Inventories]
      parameters: [ { $ref: "#/components/parameters/OrgIdQuery" }, { $ref: "#/components/parameters/Limit" }, { $ref: "#/components/parameters/Offset" } ]
      responses: { "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/ListEnvelope" } } } } }
    post:
      summary: Start a new inventory run
      tags: [Inventories]
      parameters: [ { $ref: "#/components/parameters/IdempotencyKey" } ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string }
                notes: { type: string }
                scope_room_id: { type: string, format: uuid }
                scope_location_id: { type: string, format: uuid }
                status: { type: string, enum: [open, closed, archived] }
      responses: { "201": { description: Created, content: { application/json: { schema: { $ref: "#/components/schemas/ItemEnvelope" } } } } }
  /v1/inventories/{id}:
    parameters: [ { in: path, name: id, required: true, schema: { type: string, format: uuid } } ]
    get:    { tags: [Inventories], responses: { "200": { description: OK } } }
    patch:
      tags: [Inventories]
      parameters: [ { $ref: "#/components/parameters/IdempotencyKey" } ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                notes: { type: string }
                status: { type: string, enum: [open, closed, archived] }
                closed_at: { type: string, format: date-time }
                closed_by: { type: string, format: uuid }
      responses: { "200": { description: Updated } }
    delete: { tags: [Inventories], responses: { "204": { description: Deleted } } }


  /v1/equipment/{id}/usage:
    parameters: [ { in: path, name: id, required: true, schema: { type: string, format: uuid } } ]
    get:
      summary: List usage sessions for a piece of equipment
      tags: [Equipment]
      parameters: [ { $ref: "#/components/parameters/Limit" }, { $ref: "#/components/parameters/Offset" } ]
      responses: { "200": { description: OK } }
    post:
      summary: Start / record a usage session
      tags: [Equipment]
      parameters: [ { $ref: "#/components/parameters/IdempotencyKey" } ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                started_at: { type: string, format: date-time }
                ended_at: { type: string, format: date-time, nullable: true }
                purpose: { type: string }
                project_id: { type: string, format: uuid, nullable: true }
                entry_id: { type: string, format: uuid, nullable: true }
                settings: { type: object, additionalProperties: true }
                notes: { type: string }
      responses: { "201": { description: Created }, "409": { $ref: "#/components/responses/Conflict" } }

  /v1/equipment/{id}/maintenance:
    parameters: [ { in: path, name: id, required: true, schema: { type: string, format: uuid } } ]
    get: { tags: [Equipment], responses: { "200": { description: OK } } }
    post:
      tags: [Equipment]
      parameters: [ { $ref: "#/components/parameters/IdempotencyKey" } ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                maintenance_type: { type: string, enum: [scheduled, unscheduled, repair, inspection] }
                scheduled_date: { type: string, nullable: true }
                completed_date: { type: string, nullable: true }
                description: { type: string }
                result: { type: string }
                performed_by: { type: string, format: uuid, nullable: true }
                next_maintenance_date: { type: string, nullable: true }
                severity: { type: string, enum: [low, normal, high, critical] }
      responses: { "201": { description: Created } }

  /v1/equipment/{id}/calibrations:
    parameters: [ { in: path, name: id, required: true, schema: { type: string, format: uuid } } ]
    get: { tags: [Equipment], responses: { "200": { description: OK } } }
    post:
      tags: [Equipment]
      parameters: [ { $ref: "#/components/parameters/IdempotencyKey" } ]
      requestBody: { required: true, content: { application/json: { schema: { type: object, additionalProperties: true } } } }
      responses: { "201": { description: Created } }

  /v1/equipment/{id}/qualifications:
    parameters: [ { in: path, name: id, required: true, schema: { type: string, format: uuid } } ]
    get: { tags: [Equipment], responses: { "200": { description: OK } } }
    post:
      tags: [Equipment]
      parameters: [ { $ref: "#/components/parameters/IdempotencyKey" } ]
      requestBody: { required: true, content: { application/json: { schema: { type: object, additionalProperties: true } } } }
      responses: { "201": { description: Created } }

  /v1/equipment/{id}/{sub}/{subId}:
    parameters:
      - { in: path, name: id, required: true, schema: { type: string, format: uuid } }
      - { in: path, name: sub, required: true, schema: { type: string, enum: [usage, maintenance, calibrations, qualifications] } }
      - { in: path, name: subId, required: true, schema: { type: string, format: uuid } }
    patch:
      summary: Update a single equipment sub-resource record
      tags: [Equipment]
      parameters: [ { $ref: "#/components/parameters/IdempotencyKey" } ]
      requestBody: { required: true, content: { application/json: { schema: { type: object, additionalProperties: true } } } }
      responses:
        "200": { description: Updated }
        "404": { $ref: "#/components/responses/NotFound" }
    put:
      summary: Alias for PATCH
      tags: [Equipment]
      responses: { "200": { description: Updated } }

  /v1/equipment/{id}/remote:
    parameters: [ { in: path, name: id, required: true, schema: { type: string, format: uuid } } ]
    get:
      summary: Read the remote-access endpoint of a device
      tags: [Equipment]
      responses: { "200": { description: OK (data is null when not configured) } }
    post:
      summary: Register/refresh the remote-access endpoint (upsert)
      description: |
        Once this endpoint is fed, a "Remote access" entry appears in the app under
        Equipment detail -> Operation, linking operators to the device UI.
      tags: [Equipment]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url]
              properties:
                url: { type: string, format: uri, description: "http(s) URL of the device UI" }
                label: { type: string, nullable: true }
                protocol: { type: string, example: https }
                status: { type: string, enum: [online, offline, maintenance, unknown] }
                requires_vpn: { type: boolean }
                notes: { type: string, nullable: true }
                metadata: { type: object, additionalProperties: true }
      responses: { "200": { description: Upserted } }
    patch:
      summary: Partially update the remote-access endpoint
      tags: [Equipment]
      requestBody: { required: true, content: { application/json: { schema: { type: object, additionalProperties: true } } } }
      responses: { "200": { description: Updated }, "404": { description: Not configured } }
    delete:
      summary: Remove the remote-access endpoint (hides the button)
      tags: [Equipment]
      responses: { "204": { description: Deleted } }

  /v1/audit:
    get:
      summary: Read the org audit log (requires `audit:read` scope)
      tags: [Audit]
      parameters:
        - { in: query, name: limit, schema: { type: integer, default: 100, maximum: 1000 } }
        - { in: query, name: since_created_at, schema: { type: string, format: date-time }, description: "Pair with since_id for keyset pagination." }
        - { in: query, name: since_id, schema: { type: string, format: uuid } }
        - { in: query, name: from, schema: { type: string, format: date-time } }
        - { in: query, name: to, schema: { type: string, format: date-time } }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { type: object, additionalProperties: true } }
                  next_cursor:
                    type: object
                    nullable: true
                    properties:
                      since_created_at: { type: string, format: date-time }
                      since_id: { type: string, format: uuid }
                  count: { type: integer }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/jobs:
    get:
      summary: List background jobs in this org
      tags: [Jobs]
      parameters:
        - { in: query, name: limit, schema: { type: integer, default: 50, maximum: 200 } }
        - { in: query, name: status, schema: { type: string, enum: [queued, running, succeeded, failed, dead] } }
        - { in: query, name: job_type, schema: { type: string } }
        - { in: query, name: cursor, schema: { type: string, format: date-time }, description: "enqueued_at of last item from previous page" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: "#/components/schemas/Job" } }
                  next_cursor: { type: string, format: date-time, nullable: true }
                  count: { type: integer }
    post:
      summary: Enqueue an asynchronous job (allowlisted job_type)
      description: |
        Triggers a long-running background job. The response is `202 Accepted`
        with a job id; poll `GET /v1/jobs/{id}` or subscribe to the
        `background_jobs` realtime channel for status updates.
      tags: [Jobs]
      parameters: [ { $ref: "#/components/parameters/IdempotencyKey" } ]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/JobCreate" }
      responses:
        "202":
          description: Job enqueued
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Job" }
        "400": { description: Invalid body, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }

  /v1/jobs/{id}:
    parameters: [ { in: path, name: id, required: true, schema: { type: string, format: uuid } } ]
    get:
      summary: Poll a background job
      tags: [Jobs]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Job" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/devices:
    get:
      summary: List registered instrument devices
      tags: [Instruments]
      responses: { "200": { description: OK } }
    post:
      summary: Register or update a device (upsert on external_device_id)
      tags: [Instruments]
      parameters: [ { $ref: "#/components/parameters/IdempotencyKey" } ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [external_device_id, vendor]
              properties:
                external_device_id: { type: string, maxLength: 120 }
                vendor: { type: string, maxLength: 80 }
                model: { type: string, maxLength: 120 }
                device_type: { type: string, maxLength: 40 }
                default_unit_x: { type: string, maxLength: 20 }
                default_unit_y: { type: string, maxLength: 20 }
      responses:
        "201": { description: Created or updated }
        "400": { $ref: "#/components/responses/BadRequest" }
  /v1/devices/{id}:
    parameters: [ { in: path, name: id, required: true, schema: { type: string, format: uuid } } ]
    delete:
      summary: Delete a device
      tags: [Instruments]
      responses:
        "200": { description: Deleted }
        "400": { $ref: "#/components/responses/BadRequest" }

  /v1/results:
    get:
      summary: List instrument results
      tags: [Instruments]
      parameters:
        - { in: query, name: limit, schema: { type: integer, default: 50, maximum: 200 } }
        - { in: query, name: experiment_id, schema: { type: string, format: uuid } }
        - { in: query, name: sample_id, schema: { type: string, format: uuid } }
      responses: { "200": { description: OK } }
    post:
      summary: Ingest a measurement result (JSON, vendor adapters supported)
      description: >
        The vendor adapter is chosen from the `X-Instrument-Vendor` header or the payload shape.
        At least one of `experiment_id` or `sample_id` is required.
      tags: [Instruments]
      parameters: [ { $ref: "#/components/parameters/IdempotencyKey" } ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [device_id, data_points]
              properties:
                device_id: { type: string, maxLength: 120, description: external_device_id of the instrument }
                device_meta:
                  type: object
                  properties:
                    vendor: { type: string }
                    model: { type: string }
                    device_type: { type: string }
                experiment_id: { type: string, format: uuid }
                sample_id: { type: string, format: uuid }
                external_result_id: { type: string, maxLength: 200 }
                method: { type: string, maxLength: 120 }
                unit_x: { type: string, maxLength: 20 }
                unit_y: { type: string, maxLength: 20 }
                measured_at: { type: string, format: date-time }
                operator: { type: string, maxLength: 200 }
                data_points:
                  type: array
                  maxItems: 50000
                  items:
                    type: object
                    properties: { x: { type: number }, y: { type: number } }
                traces: { type: array, maxItems: 32, items: { type: object, additionalProperties: true } }
                peaks: { type: array, items: { type: object, additionalProperties: true } }
                metadata: { type: object, additionalProperties: true }
                callback_url: { type: string, format: uri, maxLength: 2000 }
                source_vendor: { type: string, maxLength: 40 }
                source_format: { type: string, maxLength: 40 }
      responses:
        "201": { description: Created }
        "400": { $ref: "#/components/responses/BadRequest" }
  /v1/results/upload:
    post:
      summary: Upload a raw measurement file (CSV, JCAMP-DX, mzML)
      description: >
        Multipart upload. Max 25 MB (50 MB for `.mzml`). Form fields override values parsed
        from the file. At least one of `experiment_id` or `sample_id` is required.
      tags: [Instruments]
      parameters: [ { $ref: "#/components/parameters/IdempotencyKey" } ]
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file, device_id]
              properties:
                file: { type: string, format: binary }
                device_id: { type: string }
                experiment_id: { type: string, format: uuid }
                sample_id: { type: string, format: uuid }
                external_result_id: { type: string }
                method: { type: string }
                unit_x: { type: string }
                unit_y: { type: string }
                measured_at: { type: string, format: date-time }
                operator: { type: string }
                vendor: { type: string }
                callback_url: { type: string, format: uri }
      responses:
        "201": { description: Created }
        "400": { $ref: "#/components/responses/BadRequest" }
        "413": { description: File too large }
  /v1/results/{id}:
    parameters: [ { in: path, name: id, required: true, schema: { type: string, format: uuid } } ]
    get:
      summary: Get a single result including device metadata
      tags: [Instruments]
      responses:
        "200": { description: OK }
        "404": { $ref: "#/components/responses/NotFound" }
  /v1/results/{id}/chart:
    parameters: [ { in: path, name: id, required: true, schema: { type: string, format: uuid } } ]
    get:
      summary: Get chart-ready data points and detected peaks
      tags: [Instruments]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  result_id: { type: string, format: uuid }
                  chart_type: { type: string }
                  unit_x: { type: string }
                  unit_y: { type: string }
                  data_points: { type: array, items: { type: object, additionalProperties: true } }
                  peaks: { type: array, items: { type: object, additionalProperties: true } }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/notifications:
    get:
      summary: List the caller's own notifications
      tags: [Notifications]
      parameters:
        - { $ref: "#/components/parameters/Limit" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: "#/components/schemas/Notification" } }
                  count: { type: integer }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      summary: Push notifications to users or roles
      tags: [Notifications]
      parameters: [ { $ref: "#/components/parameters/IdempotencyKey" } ]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/NotificationCreate" }
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/NotificationCreated" }
        "400": { $ref: "#/components/responses/Conflict" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/RateLimited" }

  # ===================== Other functions =====================
  /org-data-export:
    post:
      summary: Start EU Data Act org-wide data export (admin only)
      tags: [GDPR, "EU Data Act"]
      servers: [ { url: "https://vilasdqkwlszlulteqrb.supabase.co/functions/v1" } ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [org_id]
              properties:
                org_id: { type: string, format: uuid }
      responses:
        "202": { description: Job accepted, content: { application/json: { schema: { $ref: "#/components/schemas/Job" } } } }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /gdpr-delete-account:
    post:
      summary: GDPR Art. 17 — self-service account deletion
      tags: [GDPR]
      servers: [ { url: "https://vilasdqkwlszlulteqrb.supabase.co/functions/v1" } ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [confirmation]
              properties:
                confirmation: { type: string, description: "Must equal the user's primary email" }
      responses:
        "200": { description: Deletion scheduled }
        "400": { content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /send-transactional-email:
    post:
      summary: Send a templated transactional email (admin / service)
      tags: [Email]
      servers: [ { url: "https://vilasdqkwlszlulteqrb.supabase.co/functions/v1" } ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [templateName, recipientEmail, idempotencyKey]
              properties:
                templateName: { type: string }
                recipientEmail: { type: string, format: email }
                idempotencyKey: { type: string, minLength: 8 }
                templateData: { type: object, additionalProperties: true }
      responses:
        "200": { description: Email queued }
        "400": { content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "429": { $ref: "#/components/responses/RateLimited" }

  /oauth-authorize:
    post:
      summary: OAuth 2.0 authorization endpoint (third-party app consent)
      tags: [OAuth]
      servers: [ { url: "https://vilasdqkwlszlulteqrb.supabase.co/functions/v1" } ]
      responses: { "200": { description: Authorization code issued } }

  /oauth-token:
    post:
      summary: OAuth 2.0 token endpoint (authorization_code / refresh_token grants)
      tags: [OAuth]
      security: []
      servers: [ { url: "https://vilasdqkwlszlulteqrb.supabase.co/functions/v1" } ]
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [grant_type, client_id, client_secret]
              properties:
                grant_type: { type: string, enum: [authorization_code, refresh_token] }
                client_id: { type: string }
                client_secret: { type: string }
                code: { type: string }
                redirect_uri: { type: string, format: uri }
                refresh_token: { type: string }
      responses:
        "200": { description: Token issued }
        "400": { description: Invalid request (RFC 6749 error envelope) }

  /handle-email-unsubscribe:
    get:
      summary: One-click unsubscribe (RFC 8058)
      tags: [Email]
      security: []
      servers: [ { url: "https://vilasdqkwlszlulteqrb.supabase.co/functions/v1" } ]
      parameters: [ { in: query, name: token, required: true, schema: { type: string } } ]
      responses: { "200": { description: Unsubscribed } }

  /rpc/search_experiments:
    post:
      summary: Full-text search across experiments in caller's org
      tags: [Experiments]
      servers: [ { url: "https://vilasdqkwlszlulteqrb.supabase.co/rest/v1" } ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [_org_id, _query]
              properties:
                _org_id: { type: string, format: uuid }
                _query: { type: string }
                _status: { type: string, nullable: true }
                _project_id: { type: string, format: uuid, nullable: true }
                _limit: { type: integer, default: 50, maximum: 200 }
                _offset: { type: integer, default: 0 }
      responses:
        "200":
          description: List of matching entries
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    id: { type: string, format: uuid }
                    title: { type: string }
                    rank: { type: number }
                    status: { type: string }

  /rpc/create_signature:
    post:
      summary: Create a 21 CFR Part 11 electronic signature
      tags: [Signatures, GxP]
      servers: [ { url: "https://vilasdqkwlszlulteqrb.supabase.co/rest/v1" } ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [_target_table, _target_id, _meaning_code, _stepup_token_id]
              properties:
                _target_table: { type: string }
                _target_id: { type: string, format: uuid }
                _meaning_code: { type: string, enum: [author, witness, approval, review] }
                _reason_text: { type: string, nullable: true }
                _stepup_token_id: { type: string }
                _client_session_id: { type: string, nullable: true }
                _is_continuous_session: { type: boolean, default: false }
      responses:
        "200":
          description: Signature id
          content: { application/json: { schema: { type: string, format: uuid } } }
        "401": { description: Step-up token invalid or expired }

tags:
  - { name: Meta, description: Identity / health }
  - { name: Experiments }
  - { name: Equipment }
  - { name: Jobs, description: Asynchronous background jobs (exports, PDFs) }
  - { name: Notifications, description: In-app notifications pushed by external systems }
  - { name: Audit }
  - { name: Instruments, description: Instrument devices + measurement results }
  - { name: OAuth, description: OAuth 2.0 for third-party apps }
  - { name: GDPR }
  - { name: "EU Data Act" }
  - { name: Email }
  - { name: Signatures }
  - { name: GxP }
