openapi: "3.0.3"
info:
  title: "Vocea Integrations API"
  description: |
    External integrations API for the Vocea whistleblowing platform
    (Legislative Decree 24/2023 / GDPR compliant).

    Authentication is by per-tenant API key sent in the `X-API-Key` header
    (format `wbk_<prefix>_<secret>`). The API is intended for server-to-server use
    only; the key must never be exposed in a browser or client-side code.

    Zero-knowledge boundary: report content is encrypted client-side and stored as
    ciphertext. The API exposes metadata and ciphertext, NEVER decrypted content.
    Decryption happens only on the manager side with the private key.
  version: "1.0.0"
  contact:
    name: "Vocea — Agile Technology S.r.l."
    url: "https://vocea.cloud"

servers:
  - url: "https://api.vocea.cloud/v1"
    description: "Production — dedicated integrations domain"
  - url: "https://vocea.cloud/api/wb/v1"
    description: "Production — application gateway"

tags:
  - name: "Reports"
    description: "Submit, list, and check the status of whistleblowing reports."
  - name: "Channel administration"
    description: "Self-service management of your OWN channel (key holders, delegates). Per-tenant: an API key can only manage its own organization."

security:
  - ApiKeyAuth: []

paths:
  /integrations/reports/submit:
    post:
      tags: ["Reports"]
      operationId: "submitReport"
      summary: "Submit an encrypted whistleblowing report"
      description: |
        Submits a new, already-encrypted report. The `ciphertext` must be produced
        client-side using the channel public key; the server never decrypts it.
        Returns a tracking code.
      security:
        - ApiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SubmitReportRequest"
            examples:
              default:
                value:
                  content_ciphertext: "BASE64..."
                  content_iv: "BASE64_12B"
                  content_auth_tag: "BASE64_16B"
                  identity_ciphertext: null
                  identity_iv: null
                  identity_auth_tag: null
                  metadata_ciphertext: "BASE64..."
                  metadata_iv: "BASE64_12B"
                  metadata_auth_tag: "BASE64_16B"
                  key_wrappers:
                    - keyholder_id: "kh_1"
                      key_type: "K_content"
                      encrypted_aes_key: "BASE64_RSA_OAEP..."
                    - keyholder_id: "kh_3"
                      key_type: "K_metadata"
                      encrypted_aes_key: "BASE64_RSA_OAEP..."
                  category: "CORRUPTION_BRIBERY"
                  subcategory_d231: null
                  submission_channel: "API"
      responses:
        "201":
          description: "Report accepted and stored as ciphertext."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SubmitReportResponse"
              examples:
                default:
                  value:
                    success: true
                    anonymous_token: "WB-7F3K-9QA2"
                    tenant_slug: "acme"
                    message: "Segnalazione ricevuta e cifrata. Conserva il codice per verificare lo stato."
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"

  /integrations/reports/{code}/status:
    get:
      tags: ["Reports"]
      operationId: "getReportStatus"
      summary: "Get report status by tracking code"
      description: |
        Returns metadata-only status of a report identified by its tracking code.
        No content and no ciphertext are returned.
      security:
        - ApiKeyAuth: []
      parameters:
        - name: code
          in: path
          required: true
          description: "Tracking code returned at submission time."
          schema:
            type: string
            example: "WB-7F3K-9QA2"
      responses:
        "200":
          description: "Report status metadata."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ReportStatus"
              examples:
                default:
                  value:
                    code: "WB-7F3K-9QA2"
                    status: "under_review"
                    created_at: "2026-06-03T10:12:45Z"
                    last_update_at: "2026-06-05T08:30:00Z"
                    deadlines:
                      acknowledgment_deadline_at: "2026-06-10T10:12:45Z"
                      investigation_deadline_at: "2026-09-03T10:12:45Z"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"

  /integrations/reports/{code}/attachments:
    post:
      tags: ["Reports"]
      operationId: "uploadReportAttachment"
      summary: "Upload an encrypted attachment to a report"
      description: |
        Attaches an already-encrypted file (document, image, audio, video) to a report
        identified by its tracking code. Encrypt the file client-side with AES-256-GCM
        using the SAME content AES key already wrapped to the K_content key holders in
        `key_wrappers` at submit time — no extra wrapping is required. Filename and
        mime-type travel encrypted too. The server stores ciphertext only and never
        decrypts: the attachment is readable solely by the designated key holders.
        Per-attachment inline limit: 50 MB. For larger files (video) use the chunked
        upload endpoints (.../attachments/uploads...). Total per report: see limits.
      security:
        - ApiKeyAuth: []
      parameters:
        - name: code
          in: path
          required: true
          description: "Tracking code returned at submission time."
          schema:
            type: string
            example: "WB-7F3K-9QA2"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/AttachmentUpload"
            examples:
              default:
                value:
                  original_filename_encrypted: "BASE64..."
                  filename_iv: "BASE64_12B"
                  mime_type_encrypted: "BASE64..."
                  content_ciphertext: "BASE64..."
                  content_iv: "BASE64_12B"
                  content_auth_tag: "BASE64_16B"
      responses:
        "201":
          description: "Attachment accepted and stored as ciphertext."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AttachmentCreated"
              examples:
                default:
                  value:
                    success: true
                    attachment_id: 12
                    file_size_bytes: 348201
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "413":
          description: "Attachment exceeds the per-file size limit (50 MB)."
        "429":
          $ref: "#/components/responses/RateLimited"
    get:
      tags: ["Reports"]
      operationId: "listReportAttachments"
      summary: "List a report's attachments (metadata only)"
      description: |
        Returns metadata for the report's attachments (id, size, antivirus status, date).
        No ciphertext and no clear-text are returned.
      security:
        - ApiKeyAuth: []
      parameters:
        - name: code
          in: path
          required: true
          description: "Tracking code returned at submission time."
          schema:
            type: string
            example: "WB-7F3K-9QA2"
      responses:
        "200":
          description: "Attachment metadata list."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AttachmentList"
              examples:
                default:
                  value:
                    code: "WB-7F3K-9QA2"
                    attachments:
                      - id: 12
                        file_size_bytes: 348201
                        antivirus_scan_result: "PENDING"
                        antivirus_scanned_at: null
                        created_at: "2026-06-24T09:52:54Z"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"

  /integrations/reports/{code}/attachments/uploads:
    post:
      tags: ["Reports"]
      operationId: "initChunkedUpload"
      summary: "Start a chunked/resumable upload for a large attachment"
      description: |
        Opens an upload session for a LARGE encrypted attachment (video/audio) that exceeds
        the 50 MB inline limit. Encrypt the file client-side (AES-256-GCM, content key) and
        send the GCM metadata here; then upload the ciphertext as binary chunks via PUT and
        finalize with complete. Large attachments are stored on disk, not in the database.
      security:
        - ApiKeyAuth: []
      parameters:
        - { name: code, in: path, required: true, schema: { type: string }, description: "Tracking code." }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ChunkedUploadInit"
      responses:
        "201":
          description: "Upload session created."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ChunkedUploadInitResponse"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "413": { description: "Declared size exceeds the per-file maximum." }
        "429": { $ref: "#/components/responses/RateLimited" }

  /integrations/reports/{code}/attachments/uploads/{uploadId}/chunks/{index}:
    put:
      tags: ["Reports"]
      operationId: "uploadChunk"
      summary: "Upload one encrypted chunk"
      description: |
        Uploads chunk number `index` (0-based, in order). **Body** = `iv(12) ||
        ciphertext || tag(16)` of ONE independently AES-256-GCM-encrypted chunk.
        **Content-Type: application/octet-stream** (NOT base64). `index` MUST equal the
        current `received_chunks`; re-sending an already-received index is an idempotent
        ack (`duplicate: true`); a future index returns 409 with the expected
        `next_chunk_index` so the client can resume.
      security:
        - ApiKeyAuth: []
      parameters:
        - { name: code, in: path, required: true, schema: { type: string } }
        - { name: uploadId, in: path, required: true, schema: { type: string } }
        - { name: index, in: path, required: true, schema: { type: integer }, description: "0-based chunk index; must equal current received_chunks." }
      requestBody:
        required: true
        content:
          application/octet-stream:
            schema: { type: string, format: binary }
      responses:
        "200":
          description: "Chunk accepted (or idempotent ack)."
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  received_chunks: { type: integer }
                  next_chunk_index: { type: integer }
                  duplicate: { type: boolean, description: "true if this index was already received." }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { description: "Index out of sequence — response includes the expected next_chunk_index." }
        "413": { description: "Chunk exceeds chunk_max_bytes, or total exceeds the per-file maximum." }

  /integrations/reports/{code}/attachments/uploads/{uploadId}:
    get:
      tags: ["Reports"]
      operationId: "getChunkedUploadStatus"
      summary: "Get resumable upload status (chunk index)"
      security:
        - ApiKeyAuth: []
      parameters:
        - { name: code, in: path, required: true, schema: { type: string } }
        - { name: uploadId, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: "Upload status."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ChunkedUploadStatus"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /integrations/reports/{code}/attachments/uploads/{uploadId}/complete:
    post:
      tags: ["Reports"]
      operationId: "completeChunkedUpload"
      summary: "Finalize a chunked upload"
      description: "Verifies total size, computes the ciphertext SHA-256, marks the attachment READY."
      security:
        - ApiKeyAuth: []
      parameters:
        - { name: code, in: path, required: true, schema: { type: string } }
        - { name: uploadId, in: path, required: true, schema: { type: string } }
      responses:
        "201":
          description: "Attachment finalized."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AttachmentCreated"
        "400": { description: "Size mismatch (received != declared)." }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { description: "Upload not in an uploadable state." }

  /integrations/reports/{code}/attachments/large/{id}/download:
    get:
      tags: ["Reports"]
      operationId: "downloadLargeAttachment"
      summary: "Stream a large attachment's ciphertext"
      description: |
        Streams the encrypted, self-describing chunk file for client-side decryption.
        Framing (`X-WB-Framing: len32-iv12-gcm`): a sequence of
        `[uint32_be len][iv(12) || ciphertext || tag(16)]` per chunk — read each frame,
        derive the IV from its first 12 bytes, decrypt with the content key, concatenate.
        Metadata headers (base64): `X-WB-Filename-Enc`, `X-WB-Filename-IV`,
        `X-WB-Mime-Enc`, `X-WB-Mime-IV`, `X-WB-Total-Chunks`, `X-WB-Content-Hash`.
        The server never decrypts.
      security:
        - ApiKeyAuth: []
      parameters:
        - { name: code, in: path, required: true, schema: { type: string } }
        - { name: id, in: path, required: true, schema: { type: integer } }
      responses:
        "200":
          description: "Ciphertext stream (application/octet-stream)."
          content:
            application/octet-stream:
              schema: { type: string, format: binary }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /integrations/reports:
    get:
      tags: ["Reports"]
      operationId: "listReports"
      summary: "List channel reports (metadata + ciphertext)"
      description: |
        Returns a paginated list of the channel's reports. Items include metadata and
        ciphertext only; clear-text content is NEVER returned. Decryption happens only
        on the manager side with the private key.
      security:
        - ApiKeyAuth: []
      parameters:
        - name: page
          in: query
          required: false
          description: "Page number (1-based)."
          schema:
            type: integer
            minimum: 1
            default: 1
        - name: pageSize
          in: query
          required: false
          description: "Items per page."
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 20
        - name: status
          in: query
          required: false
          description: "Optional filter by report status."
          schema:
            type: string
            example: "under_review"
      responses:
        "200":
          description: "Paginated list of report METADATA only (no ciphertext, no key_wrappers)."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ReportList"
              examples:
                default:
                  value:
                    page: 1
                    pageSize: 20
                    total: 42
                    items:
                      - id: 128
                        anonymous_token: "WB-7F3K-9QA2"
                        category: "CORRUPTION_BRIBERY"
                        subcategory_d231: null
                        status: "IN_INVESTIGATION"
                        submission_channel: "API"
                        received_at: "2026-06-03T10:12:45Z"
                        acknowledgment_deadline_at: "2026-06-10T10:12:45Z"
                        investigation_deadline_at: "2026-09-03T10:12:45Z"
                        unread_from_reporter: 0
                        open_retaliation_signals: 0
                        meeting_requests: 0
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"

  /integrations/register:
    post:
      tags: ["Onboarding"]
      operationId: "registerIntegrator"
      summary: "Self-service: register an integrator and get the master (provisioning) key"
      description: |
        PUBLIC endpoint (no auth, rate-limited). Registers an integrator and returns the
        MASTER key (scope `tenants:provision`), shown ONCE. Use it server-to-server to
        create per-company channels via POST /integrations/tenants.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string }
                slug: { type: string, description: "Integrator namespace prefix; derived from name if omitted." }
                contact_email: { type: string, format: email }
      responses:
        "201":
          description: "Integrator registered; master key returned once."
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  integrator: { type: object }
                  api_key: { type: string, description: "MASTER key (scope tenants:provision). Shown once." }
                  key_prefix: { type: string }
                  scopes: { type: array, items: { type: string } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "409": { description: "Slug already taken (SLUG_TAKEN)." }
        "429": { $ref: "#/components/responses/RateLimited" }

  /integrations/tenants:
    post:
      tags: ["Onboarding"]
      operationId: "provisionTenant"
      summary: "Provision a company channel and get its per-channel API key"
      description: |
        Uses the MASTER key (scope `tenants:provision`). Creates (idempotently) a channel for
        one of your companies and returns its dedicated API key (default scopes
        `reports:submit`, `reports:read`, `status:read`). The channel is OWNED by the calling
        integrator: another integrator cannot provision or reuse it (409 OWNERSHIP_CONFLICT).
      security:
        - ProvisioningAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string }
                company_id: { type: string }
                slug: { type: string, description: "Company sub-slug; final slug = <integrator>-<sub>." }
                legal_name: { type: string }
                jurisdiction: { type: string, example: "IT" }
                locale: { type: string, example: "it" }
                scopes: { type: array, items: { type: string } }
                label: { type: string }
      responses:
        "201":
          description: "Channel provisioned; per-channel API key returned once."
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  tenant:
                    type: object
                    properties:
                      id: { type: integer }
                      slug: { type: string }
                      db_name: { type: string }
                      already_existed: { type: boolean }
                  api_key: { type: string }
                  key_prefix: { type: string }
                  scopes: { type: array, items: { type: string } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { description: "PAYMENT_REQUIRED — trial cap reached or subscription not active." }
        "409": { description: "OWNERSHIP_CONFLICT — slug owned by another integrator." }
    get:
      tags: ["Onboarding"]
      operationId: "listIntegratorTenants"
      summary: "List the channels you provisioned (metadata only)"
      security:
        - ProvisioningAuth: []
      responses:
        "200":
          description: "Channels created by this integrator."
        "401":
          $ref: "#/components/responses/Unauthorized"

  /integrations/keyholders:
    post:
      tags: ["Channel administration"]
      operationId: "enrollKeyHolder"
      summary: "Enroll a key holder on your channel"
      description: |
        Enrolls a key holder. The RSA key pair is generated CLIENT-SIDE: send ONLY the
        public key (zero-knowledge). At most one ACTIVE key holder per `controls_key`.
      security: [{ ApiKeyAuth: [] }]   # scope: keyholders:manage
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/KeyHolderInput" }
      responses:
        "201":
          description: "Key holder enrolled."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/KeyHolderCreated" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409":
          description: "Conflict — KEY_TYPE_TAKEN (a key type already has an active holder) or EMAIL_TAKEN."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }
    get:
      tags: ["Channel administration"]
      operationId: "listKeyHolders"
      summary: "List key holders of your channel"
      security: [{ ApiKeyAuth: [] }]   # scope: keyholders:manage
      responses:
        "200":
          description: "Key holders (no private material, no password)."
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items: { $ref: "#/components/schemas/KeyHolderItem" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /integrations/keyholders/{id}:
    delete:
      tags: ["Channel administration"]
      operationId: "deactivateKeyHolder"
      summary: "Deactivate a key holder"
      security: [{ ApiKeyAuth: [] }]   # scope: keyholders:manage
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: integer }
      responses:
        "200":
          description: "Deactivated."
          content:
            application/json:
              schema: { type: object, properties: { success: { type: boolean } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /integrations/delegates:
    post:
      tags: ["Channel administration"]
      operationId: "inviteDelegate"
      summary: "Invite a delegate / manager to your channel"
      security: [{ ApiKeyAuth: [] }]   # scope: delegates:manage
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/DelegateInput" }
      responses:
        "201":
          description: "Invited."
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  id: { type: integer }
                  invite_token: { type: string, example: "wbi_…" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    get:
      tags: ["Channel administration"]
      operationId: "listDelegates"
      summary: "List delegates of your channel"
      security: [{ ApiKeyAuth: [] }]   # scope: delegates:manage
      responses:
        "200":
          description: "Delegates."
          content:
            application/json:
              schema:
                type: object
                properties:
                  delegates:
                    type: array
                    items: { $ref: "#/components/schemas/DelegateItem" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

components:
  securitySchemes:
    ApiKeyAuth:
      type: apiKey
      in: header
      name: X-API-Key
      description: |
        Per-tenant API key, format `wbk_<prefix>_<secret>`. Created and revoked by the
        channel manager from the console ("API & Integrations" section). Shown only once
        at creation. Server-to-server use only; never expose it client-side.
        Scopes (assigned at key creation, least-privilege): `reports:submit`,
        `reports:read`, `status:read`, and the optional channel-admin scopes
        `keyholders:manage`, `delegates:manage`. A key can only act on its own channel.
    ProvisioningAuth:
      type: apiKey
      in: header
      name: X-API-Key
      description: |
        The MASTER (provisioning) key returned by POST /integrations/register — single
        scope `tenants:provision`. Used ONLY to create company channels via
        POST /integrations/tenants. Not tenant-scoped; server-to-server only.

  schemas:
    AttachmentUpload:
      type: object
      required: [original_filename_encrypted, filename_iv, mime_type_encrypted, content_ciphertext, content_iv, content_auth_tag]
      description: |
        All fields are base64. Encrypt the file (and its filename/mime-type) client-side
        with the report's content AES key. The server stores ciphertext only.
      properties:
        original_filename_encrypted: { type: string, description: "base64 — AES-256-GCM ciphertext of the filename" }
        filename_iv: { type: string, description: "base64 — 12-byte IV for the filename" }
        mime_type_encrypted: { type: string, description: "base64 — AES-256-GCM ciphertext of the mime-type" }
        content_ciphertext: { type: string, description: "base64 — AES-256-GCM ciphertext of the file bytes (max 50 MB)" }
        content_iv: { type: string, description: "base64 — 12-byte IV for the file content" }
        content_auth_tag: { type: string, description: "base64 — 16-byte GCM auth tag for the file content" }
    AttachmentCreated:
      type: object
      properties:
        success: { type: boolean }
        attachment_id: { type: integer }
        file_size_bytes: { type: integer, description: "Size of the stored ciphertext in bytes." }
    AttachmentList:
      type: object
      properties:
        code: { type: string }
        attachments:
          type: array
          items:
            type: object
            properties:
              id: { type: integer }
              file_size_bytes: { type: integer }
              antivirus_scan_result: { type: string, enum: [PENDING, CLEAN, INFECTED, SKIPPED], description: "Client-side AV outcome; PENDING by default. AV on ciphertext is not possible server-side (zero-knowledge)." }
              antivirus_scanned_at: { type: string, format: date-time, nullable: true }
              created_at: { type: string, format: date-time }
              storage: { type: string, enum: [inline, chunked], description: "inline = in DB (≤50MB); chunked = on disk (large)." }
              download_path: { type: string, nullable: true, description: "Present for chunked attachments: relative path to stream the ciphertext." }
    ChunkedUploadInit:
      type: object
      required: [original_filename_encrypted, filename_iv, mime_type_encrypted, mime_iv, total_chunks, total_size_bytes]
      description: "Encrypted filename/mime metadata (base64) + chunk count + total plaintext size. Each chunk is encrypted separately; per-chunk IVs travel in the chunk bodies."
      properties:
        original_filename_encrypted: { type: string, description: "base64 — AES-GCM ciphertext(+tag) of the filename." }
        filename_iv: { type: string, description: "base64 — 12-byte IV for the filename." }
        mime_type_encrypted: { type: string, description: "base64 — AES-GCM ciphertext(+tag) of the mime-type." }
        mime_iv: { type: string, description: "base64 — 12-byte IV for the mime-type." }
        total_chunks: { type: integer, description: "Number of chunks that will be uploaded." }
        total_size_bytes: { type: integer, description: "Total PLAINTEXT size in bytes (≤ per-file max)." }
    ChunkedUploadInitResponse:
      type: object
      properties:
        success: { type: boolean }
        upload_id: { type: string, description: "Opaque session id (UUID)." }
        received_chunks: { type: integer }
        total_chunks: { type: integer }
        chunk_max_bytes: { type: integer, description: "Maximum PLAINTEXT size of a single chunk." }
    ChunkedUploadStatus:
      type: object
      properties:
        upload_id: { type: string }
        received_chunks: { type: integer }
        total_chunks: { type: integer }
        next_chunk_index: { type: integer, description: "Index of the next chunk to send (= received_chunks)." }
        status: { type: string, enum: [UPLOADING, READY, ABORTED] }
    KeyHolderInput:
      type: object
      required: [name, email, role, controls_key, rsa_public_key_pem]
      properties:
        name: { type: string, example: "Mario Rossi" }
        email: { type: string, format: email }
        role:
          type: string
          enum: [ODV_MEMBER, LEGAL_RPD, COMPLIANCE, EXTERNAL_AUDITOR]
        controls_key:
          type: string
          enum: [K_content, K_identity, K_metadata]
        rsa_public_key_pem:
          type: string
          description: "RSA public key (SPKI PEM) generated client-side. Private key NEVER sent."
          example: "-----BEGIN PUBLIC KEY-----\n..."
        password:
          type: string
          description: "Optional. Key-holder portal password; if omitted, a random one is generated and returned once."
    KeyHolderCreated:
      type: object
      properties:
        success: { type: boolean }
        id: { type: integer }
        controls_key: { type: string }
        role: { type: string }
        generated_password:
          type: string
          description: "Present ONLY if no password was provided. Shown once."
    KeyHolderItem:
      type: object
      properties:
        id: { type: integer }
        name: { type: string }
        email: { type: string }
        role: { type: string }
        controls_key: { type: string }
        active: { type: integer, description: "1 = active, 0 = deactivated" }
        created_at: { type: string, format: date-time }
    DelegateInput:
      type: object
      required: [email]
      properties:
        email: { type: string, format: email }
        name: { type: string }
        role:
          type: string
          enum: [GESTORE, DELEGATO_CREAZIONE]
          default: GESTORE
    DelegateItem:
      type: object
      properties:
        email: { type: string }
        name: { type: string, nullable: true }
        role: { type: string }
        status: { type: string, enum: [INVITED, ACCEPTED, REVOKED] }
    KeyWrapper:
      type: object
      description: |
        A per-compartment AES-256 key, wrapped (RSA-OAEP, SHA-256) to the public key of the
        key-holder controlling that compartment. The server stores wrappers but cannot unwrap
        them (no private key).
      required:
        - keyholder_id
        - key_type
        - encrypted_aes_key
      properties:
        keyholder_id:
          type: string
          description: "id of the key-holder (from GET /tenants/{slug}/info)."
          example: "kh_1"
        key_type:
          type: string
          enum: ["K_content", "K_identity", "K_metadata"]
          description: "Compartment this AES key encrypts."
          example: "K_content"
        encrypted_aes_key:
          type: string
          description: "AES-256 key wrapped with the key-holder RSA public key (RSA-OAEP SHA-256), base64."
          example: "BASE64_RSA_OAEP..."

    SubmitReportRequest:
      type: object
      description: |
        Already-encrypted report (zero-knowledge). Each compartment (content / identity /
        metadata) is AES-256-GCM encrypted client-side; its AES key is RSA-OAEP-wrapped in
        key_wrappers. All *_ciphertext / *_iv (12B) / *_auth_tag (16B) are base64.
      required:
        - content_ciphertext
        - content_iv
        - content_auth_tag
        - key_wrappers
        - category
      properties:
        content_ciphertext: { type: string, example: "BASE64..." }
        content_iv:         { type: string, example: "BASE64_12B" }
        content_auth_tag:   { type: string, example: "BASE64_16B" }
        identity_ciphertext: { type: string, nullable: true, description: "null if the whistleblower stays anonymous." }
        identity_iv:         { type: string, nullable: true }
        identity_auth_tag:   { type: string, nullable: true }
        metadata_ciphertext: { type: string, description: "Defaults to an encrypted empty object if omitted." }
        metadata_iv:         { type: string }
        metadata_auth_tag:   { type: string }
        key_wrappers:
          type: array
          minItems: 1
          items:
            $ref: "#/components/schemas/KeyWrapper"
        category:
          type: string
          description: "Report category code (must be a valid channel category)."
          example: "CORRUPTION_BRIBERY"
        subcategory_d231:
          type: string
          nullable: true
          description: "Optional D.Lgs. 231/2001 subcategory."
        submission_channel:
          type: string
          description: "Origin channel. For API integrations use \"API\"."
          example: "API"

    SubmitReportResponse:
      type: object
      required:
        - success
        - anonymous_token
        - tenant_slug
      properties:
        success:
          type: boolean
          example: true
        anonymous_token:
          type: string
          description: "Tracking code. Hand it to the whistleblower; it is the only way to follow the report."
          example: "WB-7F3K-9QA2"
        tenant_slug:
          type: string
          example: "acme"
        message:
          type: string
          example: "Segnalazione ricevuta e cifrata. Conserva il codice per verificare lo stato."

    Deadlines:
      type: object
      description: "Regulatory deadlines (Legislative Decree 24/2023)."
      properties:
        acknowledgment_deadline_at:
          type: string
          format: date-time
          nullable: true
          description: "Deadline to acknowledge receipt (within 7 days)."
          example: "2026-06-10T10:12:45Z"
        investigation_deadline_at:
          type: string
          format: date-time
          nullable: true
          description: "Deadline to provide feedback (within 3 months)."
          example: "2026-09-03T10:12:45Z"

    ReportStatus:
      type: object
      description: "Metadata-only status of a report. No content, no ciphertext."
      required:
        - code
        - status
        - created_at
      properties:
        code:
          type: string
          example: "WB-7F3K-9QA2"
        status:
          type: string
          example: "under_review"
        created_at:
          type: string
          format: date-time
          example: "2026-06-03T10:12:45Z"
        last_update_at:
          type: string
          format: date-time
          example: "2026-06-05T08:30:00Z"
        deadlines:
          $ref: "#/components/schemas/Deadlines"

    ReportListItem:
      type: object
      description: |
        Report metadata ONLY — the list NEVER returns ciphertext nor key_wrappers
        (decryption happens manager-side in the portal with the private key). To read
        a single report's encrypted content, the manager uses the portal, not /v1.
      properties:
        id: { type: integer }
        anonymous_token: { type: string, description: "Tracking code of the report.", example: "WB-7F3K-9QA2" }
        category: { type: string, description: "UPPERCASE enum code (see /info for the channel's categories).", example: "CORRUPTION_BRIBERY" }
        subcategory_d231: { type: string, nullable: true }
        status: { type: string, description: "Report status enum.", example: "IN_INVESTIGATION" }
        submission_channel: { type: string, nullable: true, example: "API" }
        received_at: { type: string, format: date-time }
        acknowledgment_deadline_at: { type: string, format: date-time, nullable: true }
        acknowledged_at: { type: string, format: date-time, nullable: true }
        investigation_deadline_at: { type: string, format: date-time, nullable: true }
        investigation_deadline_extended: { type: integer, description: "1 if the deadline was extended." }
        closed_at: { type: string, format: date-time, nullable: true }
        legal_hold: { type: integer, description: "1 if under legal hold." }
        unread_from_reporter: { type: integer }
        open_retaliation_signals: { type: integer }
        meeting_requests: { type: integer }

    ReportList:
      type: object
      required:
        - page
        - pageSize
        - total
        - items
      properties:
        page:
          type: integer
          example: 1
        pageSize:
          type: integer
          example: 20
        total:
          type: integer
          example: 42
        items:
          type: array
          items:
            $ref: "#/components/schemas/ReportListItem"

    Error:
      type: object
      required:
        - error
      properties:
        error:
          type: object
          required:
            - code
            - message
          properties:
            code:
              type: string
              description: "Machine-readable error code."
              example: "API_KEY_INVALID"
            message:
              type: string
              description: "Human-readable description."
              example: "The provided API key is invalid or revoked."

  responses:
    BadRequest:
      description: "Malformed request."
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            error:
              code: "BAD_REQUEST"
              message: "Request body is invalid."
    Unauthorized:
      description: "Missing or invalid API key."
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          examples:
            apiKeyRequired:
              value:
                error:
                  code: "API_KEY_REQUIRED"
                  message: "The X-API-Key header is required."
            apiKeyInvalid:
              value:
                error:
                  code: "API_KEY_INVALID"
                  message: "The provided API key is invalid or revoked."
    Forbidden:
      description: "Insufficient scope or suspended tenant."
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          examples:
            insufficientScope:
              value:
                error:
                  code: "INSUFFICIENT_SCOPE"
                  message: "The API key lacks the required scope for this endpoint."
            tenantSuspended:
              value:
                error:
                  code: "TENANT_SUSPENDED"
                  message: "The tenant/channel is suspended."
    NotFound:
      description: "Tenant or report not found."
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          examples:
            tenantNotFound:
              value:
                error:
                  code: "TENANT_NOT_FOUND"
                  message: "The tenant bound to this API key was not found."
            reportNotFound:
              value:
                error:
                  code: "REPORT_NOT_FOUND"
                  message: "No report exists for the given tracking code in this channel."
    RateLimited:
      description: "Per-key request quota exceeded (default 60 req/min)."
      headers:
        RateLimit-Limit:
          description: "Request quota for the window."
          schema:
            type: integer
        RateLimit-Remaining:
          description: "Remaining requests in the current window."
          schema:
            type: integer
        RateLimit-Reset:
          description: "Seconds until the window resets."
          schema:
            type: integer
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            error:
              code: "RATE_LIMIT_EXCEEDED"
              message: "API quota exceeded. Retry with backoff."
