openapi: 3.1.0
info:
  title: Venezuela Ayuda — API del hub central
  version: 1.0.0
  description: |
    API de coordinación de datos tras el terremoto de Venezuela.

    **Venezuela Ayuda** opera el **hub central**: los sitios socios **publican**
    reportes vía `POST /api/v1/reports` (cerrado por API key) y **leen** el
    conjunto unificado por `GET /api/v1/reports` (abierto, sin PII).

    El recurso es único: **`reports`**. El `id` (uuid global) es la identidad
    estable de un reporte; el `type` es un **discriminador del payload/respuesta**,
    NUNCA va en la ruta. Toda mutación (crear/modificar) deja un rastro inmutable y
    atribuible en un **audit log** append-only, consultable (proyectado, sin PII)
    por `GET /api/v1/reports/{id}/history`.

    ## Tipos de reporte (`type`)
    `type` es un **conjunto cerrado de 5 valores** — el catálogo del hub. Es el
    mismo en escritura (`POST /api/v1/reports`, campo del reporte) y en lectura
    (`GET /api/v1/reports`, parámetro requerido):

    | `type` | Qué representa |
    |---|---|
    | `missing_person` | Persona desaparecida / se busca |
    | `checkin` | "Estoy a salvo" / "necesito ayuda" |
    | `help_request` | Solicitud de ayuda (médica, agua, rescate…) |
    | `help_offer` | Oferta de ayuda / recursos disponibles |
    | `damaged_building` | Edificio o estructura dañada |

    `missing_person` y `checkin` son dos tipos separados porque describen
    situaciones opuestas (a alguien lo están buscando vs. alguien reporta que está
    a salvo o necesita ayuda). Cualquier otro valor de `type` se rechaza (400 en
    lectura, fila rechazada en escritura).

    ## Autenticación y cómo pedir una key
    La **lectura** (`GET`) es abierta: no necesita key. La **escritura**
    (`POST`/`PATCH`) requiere el header `x-api-key`.

    Las API keys **no son autoservicio**: las emite el equipo de Venezuela Ayuda,
    una por colaborador. **Solicita la tuya** escribiendo a hola@maw.dev (dinos
    quién eres y qué datos vas a publicar). Recibirás una key `va_live_…` que se
    muestra **una sola vez**. Trátala como un secreto de servidor: nunca la pongas
    en una app web ni en el navegador.

    ## Atribución
    No mandes `source`: se estampa server-side desde tu key (no falsificable).
    Manda `source_url` (link a tu registro) y un `external_id` estable por reporte.

    ## Idempotencia (por socio)
    El upsert es por `(source, external_id)`: reenviar el mismo `external_id`
    actualiza **tu** fila, no duplica. La idempotencia es **por socio**: dos
    socios distintos reportando la misma entidad quedan como dos filas (la fusión
    cross-fuente es un proceso aparte).

    ## Coerciones silenciosas (importante)
    - `missing_person` siempre se guarda con `status=LOOKING_FOR_SOMEONE` (se
      ignora cualquier `status` que mandes).
    - `status`/`urgency`/`severity`/`category` inválidos para el tipo caen a un
      default (no se rechaza la fila): checkin→SAFE, help_request status→OPEN,
      urgency→MEDIUM, severity→PARTIAL.
    - Coordenadas fuera del bounding box de Venezuela se descartan (null) —
      salvo en `help_request`, que las **exige** (ver **Ubicación**).
    - Los campos de texto se recortan a su largo máximo (ver `maxLength`).

    ## Ubicación
    `help_request` **requiere** coordenadas (`latitude` + `longitude`) dentro de
    Venezuela. Sin ellas (o si caen fuera del país) la fila se **rechaza**
    (`status: rejected`, con el motivo en `results[].error`) — no se escribe a
    medias. Los demás tipos aceptan coordenadas opcionales y solo las descartan si
    caen fuera de Venezuela.

    ## Rate limit
    ~120 solicitudes/60s por socio (best-effort) y máx 200 reportes por solicitud.
    Ante 429, respeta el header `Retry-After`.

    ## Privacidad
    `contact` / teléfonos se guardan en campos privados y **nunca** se devuelven
    por la lectura pública.

    ## Lectura — `GET /api/v1/reports` (abierta, sin PII)
    Sin API key (para maximizar difusión). Rate-limit best-effort por IP. Lee de
    las vistas `public_*` (nunca de las tablas crudas); `phone_private`/`contact`
    **nunca** se exponen. Selecciona el catálogo con el parámetro `type` (los 5
    valores de arriba).

    Paginación por **cursor estable**: orden `created_at` asc con desempate por
    `id`. Pasá `since` = el `next_cursor` de la página anterior para traer la
    siguiente. `next_cursor` es `null` cuando ya no hay más filas.

      GET /api/v1/reports?type=help_request&limit=100
      GET /api/v1/reports?type=checkin&city=Caracas&since=2026-06-26T10:00:00Z|<uuid>

servers:
  - url: https://terremoto.hazlohoy.org

paths:
  /api/v1/reports:
    post:
      summary: Crear reportes en el hub (push autenticado, batch)
      operationId: createReports
      description: |
        Éxito parcial dentro de un 200: el upsert es **por tabla**, así que un error
        de DB en una tabla marca **todas** las filas de esa tabla como `status:error`,
        mientras filas de otras tablas del mismo lote pueden quedar `upserted`. Un 200
        no garantiza que todo se escribió. El cliente DEBE **reconciliar `results` fila
        por fila usando `external_id`**: reintentar solo las `error` (transitorias), no
        reintentar las `rejected` (permanentes); las `upserted` ya quedaron. Los
        conteos `accepted`/`rejected`/`errored` agregan esos estados. El HTTP **503**
        es **solo** cuando toda la escritura falló (nada aceptado) → reintentá el lote.
      security:
        - ApiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [reports]
              properties:
                reports:
                  type: array
                  maxItems: 200
                  items:
                    $ref: "#/components/schemas/Report"
      responses:
        "200":
          description: Procesado (puede incluir rechazos/errores por reporte).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/IngestResponse"
        "400":
          description: Cuerpo JSON inválido o falta 'reports'.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "401":
          description: API key inválida o ausente.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "403":
          description: La key no tiene permiso de escritura.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "413":
          description: Más de 200 reportes, o payload demasiado grande.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "429":
          description: Rate limit.
          headers:
            Retry-After:
              schema: { type: integer }
              description: Segundos a esperar antes de reintentar.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "503":
          description: Falla de servicio/DB — toda la escritura falló; reintenta.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    get:
      summary: Leer reportes unificados del hub (abierto, sin PII)
      operationId: listReports
      description: |
        Lectura abierta (sin API key). Devuelve filas de la vista pública
        correspondiente a `type`, paginadas por cursor estable (`since` +
        `next_cursor`). Nunca incluye teléfonos ni contactos.
      parameters:
        - name: type
          in: query
          required: true
          description: Catálogo del hub (los 5 valores). missing_person/checkin salen de la misma vista, separados por status.
          schema:
            type: string
            enum: [missing_person, checkin, help_request, help_offer, damaged_building]
        - name: since
          in: query
          required: false
          description: "Cursor de la página anterior (`next_cursor`): `created_at|id` (también acepta un timestamp ISO pelón). Trae filas posteriores."
          schema: { type: string }
        - name: limit
          in: query
          required: false
          description: Máximo de filas a devolver. Default 100, máx 500. Valores inválidos caen al default.
          schema: { type: integer, default: 100, minimum: 1, maximum: 500 }
        - name: city
          in: query
          required: false
          description: Filtro parcial por ciudad (case-insensitive, substring).
          schema: { type: string }
      responses:
        "200":
          description: Página de reportes + cursor para la siguiente.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ReportsResponse"
        "400":
          description: Parámetro 'type' inválido o ausente.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "429":
          description: Rate limit (best-effort por IP).
          headers:
            Retry-After:
              schema: { type: integer }
              description: Segundos a esperar antes de reintentar.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "503":
          description: Falla de servicio/DB — reintenta.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }

  /api/v1/reports/{id}:
    parameters:
      - name: id
        in: path
        required: true
        description: id global del reporte (uuid). Es la identidad estable; el `type` NO va en la ruta.
        schema: { type: string, format: uuid }
    get:
      summary: Leer un reporte por id (abierto, sin PII)
      operationId: getReport
      description: |
        Lectura abierta (sin API key). El `id` es global y único; se resuelve
        entre las 4 vistas `public_*` (sin PII). La respuesta incluye el campo
        `type` (discriminador) y los campos públicos de la vista correspondiente.
        Reportes moderados (ocultos) responden 404.
      responses:
        "200":
          description: El reporte (proyección pública + `type`).
          content:
            application/json:
              schema:
                type: object
                required: [report]
                properties:
                  report: { $ref: "#/components/schemas/ReportItem" }
        "400":
          description: id inválido (no es un uuid).
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "404":
          description: No existe un reporte con ese id (o está oculto).
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "429":
          description: Rate limit (best-effort por IP).
          headers:
            Retry-After:
              schema: { type: integer }
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "503":
          description: Falla de servicio/DB — reintenta.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    patch:
      summary: Modificar parcialmente un reporte (autenticado)
      operationId: patchReport
      description: |
        Modificación parcial, cerrada por API key (scope `write`). Edición
        **cross-cliente permitida** (el hub es colaborativo): cualquier socio
        puede editar cualquier reporte; la trazabilidad la garantiza el audit log
        inmutable y atribuible (ver `GET /{id}/history`), no prohibir la edición.

        **Inmutables** (se rechaza con 400 si se intentan cambiar): `id`,
        `source`, `external_id`. El creador original se PRESERVA; el editor solo
        queda registrado en el audit. Solo se aceptan **campos mutables** del
        `type` correspondiente; un campo desconocido o no modificable → 400.

        El `type` en el body es opcional y solo confirma el discriminador: si se
        envía, debe coincidir con el type real del `id` (si no → 400). El `id` de
        la ruta determina el reporte; no se puede reclasificar por la ruta.

        A diferencia del POST (que clampa valores laxos a un default), el PATCH
        **rechaza** enums/coordenadas inválidos con 400 — una edición explícita
        con un dato inválido es un error del cliente, no algo que adivinar.
      security:
        - ApiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ReportPatch"
      responses:
        "200":
          description: El reporte ya modificado (proyección pública + `type`).
          content:
            application/json:
              schema:
                type: object
                required: [report]
                properties:
                  report: { $ref: "#/components/schemas/ReportItem" }
        "400":
          description: id inválido, body inválido, campo inmutable/no-modificable, o valor inválido.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "401":
          description: API key inválida o ausente.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "403":
          description: La key no tiene permiso de escritura.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "404":
          description: No existe un reporte con ese id.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "429":
          description: Rate limit.
          headers:
            Retry-After:
              schema: { type: integer }
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "503":
          description: Falla de servicio/DB — reintenta.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }

  /api/v1/reports/{id}/history:
    parameters:
      - name: id
        in: path
        required: true
        description: id global del reporte (uuid).
        schema: { type: string, format: uuid }
    get:
      summary: Audit trail de un reporte (abierto, proyectado sin PII)
      operationId: getReportHistory
      description: |
        Rastro de auditoría del reporte, en orden cronológico inmutable. Abierto
        (sin API key) pero **proyectado**: solo `action`, `occurred_at`, `source`
        y los campos PÚBLICOS que cambiaron (`from`→`to`). NUNCA expone PII
        (`contact`/teléfonos/`manage_token`/`risk_answers`) ni forense
        (`ip`/`user_agent`) — eso vive solo en el audit log interno. Un reporte
        sin mutaciones por la API (orgánico/preexistente) devuelve `history` vacío.
      responses:
        "200":
          description: Rastro de auditoría proyectado.
          content:
            application/json:
              schema:
                type: object
                required: [id, history]
                properties:
                  id: { type: string, format: uuid }
                  history:
                    type: array
                    items: { $ref: "#/components/schemas/AuditEventPublic" }
        "400":
          description: id inválido (no es un uuid).
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "429":
          description: Rate limit (best-effort por IP).
          headers:
            Retry-After:
              schema: { type: integer }
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "503":
          description: Falla de servicio/DB — reintenta.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }

components:
  securitySchemes:
    ApiKeyAuth:
      type: apiKey
      in: header
      name: x-api-key
  schemas:
    Error:
      type: object
      properties:
        error: { type: string }
    # El reporte es una UNIÓN DISCRIMINADA por `type`: cada tipo tiene sus
    # propios campos requeridos y enums. Mandá el objeto que corresponde a tu
    # `type`. `source` se ignora si se manda (se estampa desde la key).
    Report:
      oneOf:
        - $ref: "#/components/schemas/MissingPersonReport"
        - $ref: "#/components/schemas/CheckinReport"
        - $ref: "#/components/schemas/HelpRequestReport"
        - $ref: "#/components/schemas/HelpOfferReport"
        - $ref: "#/components/schemas/DamagedBuildingReport"
      discriminator:
        propertyName: type
        mapping:
          missing_person: "#/components/schemas/MissingPersonReport"
          checkin: "#/components/schemas/CheckinReport"
          help_request: "#/components/schemas/HelpRequestReport"
          help_offer: "#/components/schemas/HelpOfferReport"
          damaged_building: "#/components/schemas/DamagedBuildingReport"

    # Campos comunes a todo reporte.
    CommonReportFields:
      type: object
      required: [external_id]
      properties:
        external_id: { type: string, maxLength: 200, description: "Tu id estable. Requerido (idempotencia por (source, external_id))." }
        source_url: { type: string, maxLength: 500, description: "Link de vuelta a tu registro." }
        city: { type: string, maxLength: 80 }
        latitude: { type: number, description: "Se descarta si cae fuera de Venezuela." }
        longitude: { type: number }
        contact: { type: string, maxLength: 30, description: "PRIVADO — se guarda, nunca se devuelve por la lectura pública." }

    MissingPersonReport:
      allOf:
        - $ref: "#/components/schemas/CommonReportFields"
        - type: object
          required: [type, name]
          properties:
            type: { type: string, const: missing_person }
            name: { type: string, maxLength: 80 }
            message: { type: string, maxLength: 500, description: "Última información / nota." }
            place_name: { type: string, maxLength: 120 }
            photo_url: { type: string, maxLength: 500 }
          description: "Se guarda siempre con status LOOKING_FOR_SOMEONE (no se envía status)."

    CheckinReport:
      allOf:
        - $ref: "#/components/schemas/CommonReportFields"
        - type: object
          required: [type, name]
          properties:
            type: { type: string, const: checkin }
            name: { type: string, maxLength: 80 }
            status: { type: string, enum: [SAFE, NEEDS_HELP, LOOKING_FOR_SOMEONE], default: SAFE, description: "Inválido → SAFE." }
            message: { type: string, maxLength: 500 }
            place_name: { type: string, maxLength: 120 }
            photo_url: { type: string, maxLength: 500 }

    HelpRequestReport:
      allOf:
        - $ref: "#/components/schemas/CommonReportFields"
        - type: object
          required: [type, category, description]
          properties:
            type: { type: string, const: help_request }
            category: { type: string, enum: [medical, food, water, shelter, transportation, electricity, rescue, tools], description: "Inválida → fila rechazada." }
            description: { type: string, maxLength: 800 }
            urgency: { type: string, enum: [LOW, MEDIUM, HIGH, CRITICAL], default: MEDIUM, description: "Inválida → MEDIUM." }
            status: { type: string, enum: [OPEN, IN_PROGRESS, RESOLVED], default: OPEN, description: "Inválido → OPEN." }
            place_name: { type: string, maxLength: 120 }

    HelpOfferReport:
      allOf:
        - $ref: "#/components/schemas/CommonReportFields"
        - type: object
          required: [type, category]
          properties:
            type: { type: string, const: help_offer }
            category: { type: string, enum: [transportation, food, shelter, medical, supplies, translation], description: "Inválida → fila rechazada." }
            description: { type: string, maxLength: 800 }
            availability: { type: string, maxLength: 200 }
            available: { type: boolean, default: true, description: "false / \"false\" / 0 / \"no\" = no disponible." }

    DamagedBuildingReport:
      allOf:
        - $ref: "#/components/schemas/CommonReportFields"
        - type: object
          required: [type, place_name]
          properties:
            type: { type: string, const: damaged_building }
            place_name: { type: string, maxLength: 120, description: "Si falta, se usa `name`." }
            name: { type: string, maxLength: 120, description: "Alternativa a place_name." }
            description: { type: string, maxLength: 800 }
            severity: { type: string, enum: [CRACKS, PARTIAL, COLLAPSE_RISK, COLLAPSED], default: PARTIAL, description: "Inválida → PARTIAL." }
            photo_url: { type: string, maxLength: 500 }
    IngestResponse:
      type: object
      required: [accepted, rejected, errored, results]
      properties:
        accepted: { type: integer, description: Filas escritas (upserted). }
        rejected: { type: integer, description: "Rechazos de validación (permanentes — no reintentar)." }
        errored: { type: integer, description: "Fallos de DB (transitorios — reintentar)." }
        results:
          type: array
          items:
            type: object
            required: [external_id, status]
            properties:
              external_id: { type: [string, "null"] }
              status: { type: string, enum: [upserted, rejected, error] }
              report_id: { type: string, description: "Id canónico del hub; presente solo en upserted. Estable: re-postear el mismo external_id devuelve el mismo report_id." }
              error: { type: string, description: "Presente solo en rejected/error." }

    # ── Lectura (GET /api/v1/reports) ──────────────────────────────────────
    # Forma pública por vista: SIN PII (sin phone_private/contact). Los `reports`
    # son del schema que corresponde al `type` consultado. Todos exponen `id`,
    # `created_at` y la atribución (`source`/`source_url`).
    ReportsResponse:
      type: object
      required: [reports, next_cursor]
      properties:
        reports:
          type: array
          description: "Filas de la vista del `type` consultado."
          items:
            oneOf:
              - $ref: "#/components/schemas/CheckinPublic"
              - $ref: "#/components/schemas/HelpRequestPublic"
              - $ref: "#/components/schemas/HelpOfferPublic"
              - $ref: "#/components/schemas/DamagedBuildingPublic"
        next_cursor:
          type: [string, "null"]
          description: "`created_at|id` del último item; pasalo como `since` para la siguiente página. `null` cuando no hay más."

    CheckinPublic:
      type: object
      description: "Reporte de persona. type=missing_person → status=LOOKING_FOR_SOMEONE; type=checkin → status in (SAFE, NEEDS_HELP)."
      properties:
        id: { type: string }
        name: { type: [string, "null"] }
        status: { type: string, enum: [SAFE, NEEDS_HELP, LOOKING_FOR_SOMEONE] }
        city: { type: [string, "null"] }
        latitude: { type: [number, "null"] }
        longitude: { type: [number, "null"] }
        message: { type: [string, "null"] }
        photo_url: { type: [string, "null"] }
        created_at: { type: string }
        found_at: { type: [string, "null"] }
        place_name: { type: [string, "null"] }
        source: { type: [string, "null"] }
        source_url: { type: [string, "null"] }

    HelpRequestPublic:
      type: object
      description: "Solicitud de ayuda."
      properties:
        id: { type: string }
        category: { type: [string, "null"] }
        description: { type: [string, "null"] }
        urgency: { type: [string, "null"], enum: [LOW, MEDIUM, HIGH, CRITICAL, null] }
        city: { type: [string, "null"] }
        latitude: { type: [number, "null"] }
        longitude: { type: [number, "null"] }
        status: { type: [string, "null"], enum: [OPEN, IN_PROGRESS, RESOLVED, null] }
        created_at: { type: string }
        place_name: { type: [string, "null"] }
        items: { type: [array, "null"], items: { type: string } }
        source: { type: [string, "null"] }
        source_url: { type: [string, "null"] }

    HelpOfferPublic:
      type: object
      description: "Oferta de ayuda / recursos disponibles."
      properties:
        id: { type: string }
        category: { type: [string, "null"] }
        description: { type: [string, "null"] }
        city: { type: [string, "null"] }
        latitude: { type: [number, "null"] }
        longitude: { type: [number, "null"] }
        availability: { type: [string, "null"] }
        available: { type: [boolean, "null"] }
        created_at: { type: string }
        source: { type: [string, "null"] }
        source_url: { type: [string, "null"] }

    DamagedBuildingPublic:
      type: object
      description: "Edificio o estructura dañada."
      properties:
        id: { type: string }
        place_name: { type: [string, "null"] }
        description: { type: [string, "null"] }
        severity: { type: [string, "null"], enum: [CRACKS, PARTIAL, COLLAPSE_RISK, COLLAPSED, null] }
        city: { type: [string, "null"] }
        latitude: { type: [number, "null"] }
        longitude: { type: [number, "null"] }
        photo_url: { type: [string, "null"] }
        status: { type: [string, "null"] }
        created_at: { type: string }
        verified_at: { type: [string, "null"], description: "Fecha de verificación por la plataforma (null = sin verificar). NO se expone quién verificó." }
        source: { type: [string, "null"] }
        source_url: { type: [string, "null"] }
        risk_level: { type: [string, "null"], enum: [ROJO, AMARILLO, NINGUNA, null] }
        risk_priority: { type: [boolean, "null"] }

    # ── Un reporte por id (GET/PATCH /reports/{id}) ────────────────────────
    # La proyección pública de la vista + el discriminador `type`. missing_person
    # y checkin comparten CheckinPublic (se distinguen por `status`).
    ReportItem:
      description: "Un reporte: su `type` (discriminador) + los campos públicos de su vista (sin PII)."
      oneOf:
        - allOf:
            - type: object
              required: [type]
              properties: { type: { type: string, enum: [missing_person, checkin] } }
            - $ref: "#/components/schemas/CheckinPublic"
        - allOf:
            - type: object
              required: [type]
              properties: { type: { type: string, const: help_request } }
            - $ref: "#/components/schemas/HelpRequestPublic"
        - allOf:
            - type: object
              required: [type]
              properties: { type: { type: string, const: help_offer } }
            - $ref: "#/components/schemas/HelpOfferPublic"
        - allOf:
            - type: object
              required: [type]
              properties: { type: { type: string, const: damaged_building } }
            - $ref: "#/components/schemas/DamagedBuildingPublic"

    # ── Patch parcial (PATCH /reports/{id}) ────────────────────────────────
    # Solo campos MUTABLES del type. `type` es opcional (confirma el discriminador).
    # NO incluye id/source/external_id (inmutables → 400 si se envían). `contact`
    # se acepta (se guarda en el campo privado, nunca se expone por lectura).
    ReportPatch:
      description: "Modificación parcial. Manda solo los campos a cambiar, válidos para el `type` del reporte."
      oneOf:
        - $ref: "#/components/schemas/CheckinsPatch"
        - $ref: "#/components/schemas/HelpRequestPatch"
        - $ref: "#/components/schemas/HelpOfferPatch"
        - $ref: "#/components/schemas/DamagedBuildingPatch"

    CheckinsPatch:
      type: object
      description: "Campos mutables de un checkin / missing_person."
      minProperties: 1
      properties:
        type: { type: string, enum: [missing_person, checkin] }
        name: { type: string, maxLength: 80 }
        status: { type: string, enum: [SAFE, NEEDS_HELP, LOOKING_FOR_SOMEONE] }
        city: { type: [string, "null"], maxLength: 80 }
        latitude: { type: number, description: "Requiere longitude; dentro del bounding box VE o 400." }
        longitude: { type: number }
        message: { type: [string, "null"], maxLength: 500 }
        found_at: { type: string, format: date-time }
        place_name: { type: [string, "null"], maxLength: 120 }
        photo_url: { type: [string, "null"], maxLength: 500 }
        contact: { type: [string, "null"], maxLength: 30, description: "PRIVADO — nunca se devuelve por lectura." }
      additionalProperties: false

    HelpRequestPatch:
      type: object
      description: "Campos mutables de un help_request."
      minProperties: 1
      properties:
        type: { type: string, const: help_request }
        category: { type: string, enum: [medical, food, water, shelter, transportation, electricity, rescue, tools] }
        description: { type: string, maxLength: 800 }
        urgency: { type: string, enum: [LOW, MEDIUM, HIGH, CRITICAL] }
        status: { type: string, enum: [OPEN, IN_PROGRESS, RESOLVED] }
        city: { type: [string, "null"], maxLength: 80 }
        latitude: { type: number }
        longitude: { type: number }
        place_name: { type: [string, "null"], maxLength: 120 }
        contact: { type: [string, "null"], maxLength: 30, description: "PRIVADO." }
      additionalProperties: false

    HelpOfferPatch:
      type: object
      description: "Campos mutables de un help_offer."
      minProperties: 1
      properties:
        type: { type: string, const: help_offer }
        category: { type: string, enum: [transportation, food, shelter, medical, supplies, translation] }
        description: { type: [string, "null"], maxLength: 800 }
        availability: { type: [string, "null"], maxLength: 200 }
        available: { type: boolean }
        city: { type: [string, "null"], maxLength: 80 }
        latitude: { type: number }
        longitude: { type: number }
        contact: { type: [string, "null"], maxLength: 30, description: "PRIVADO." }
      additionalProperties: false

    DamagedBuildingPatch:
      type: object
      description: "Campos mutables de un damaged_building."
      minProperties: 1
      properties:
        type: { type: string, const: damaged_building }
        place_name: { type: string, maxLength: 120 }
        description: { type: [string, "null"], maxLength: 800 }
        severity: { type: string, enum: [CRACKS, PARTIAL, COLLAPSE_RISK, COLLAPSED] }
        status: { type: string, enum: [OPEN, IN_PROGRESS, RESOLVED] }
        city: { type: [string, "null"], maxLength: 80 }
        latitude: { type: number }
        longitude: { type: number }
        photo_url: { type: [string, "null"], maxLength: 500 }
        contact: { type: [string, "null"], maxLength: 30, description: "PRIVADO." }
        risk_level: { type: string, enum: [ROJO, AMARILLO, NINGUNA] }
        risk_priority: { type: boolean }
      additionalProperties: false

    # ── Evento del audit trail (GET /reports/{id}/history) ──────────────────
    # Proyección PÚBLICA. NUNCA incluye PII (contact/phone/manage_token/
    # risk_answers) ni forense (ip/user_agent) — eso vive solo en el log interno.
    AuditEventPublic:
      type: object
      required: [action, occurred_at, source, changes]
      properties:
        action: { type: string, enum: [CREATE, UPDATE, DELETE], description: "Tipo de mutación: alta, edición o borrado. Un borrado deja su rastro acá aunque el reporte ya no exista." }
        occurred_at: { type: string, format: date-time }
        source: { type: [string, "null"], description: "Socio que ejecutó la acción (snapshot del source)." }
        changes:
          type: object
          description: "Campos PÚBLICOS que cambiaron. Clave = nombre del campo; valor = { from, to }. En CREATE, `from` es null (alta desde nada)."
          additionalProperties:
            type: object
            properties:
              from: { description: "Valor anterior (null en CREATE)." }
              to: { description: "Valor nuevo." }
