openapi: 3.1.0
info:
  title: RemaFleet Public API
  version: "1.0.0"
  description: |
    REST JSON API for integrators. Authenticate with an API key
    (`rfsk_live_*`). All endpoints return:

      Success:  { "status": "OK", "payload": ... }
      Error:    { "status": "ER", "payload": { "code": "E_*", "message": "..." } }

    Two key types:
    - **Global**  — cross-tenant. Optional `?tenant_id=<uuid>` filter.
    - **Per-Tenant** — every query is auto-filtered to the key's tenant.

    Scope catalog: see `GET /api-keys/scopes` (internal endpoint).
servers:
  - url: https://iotgw00.agetech.remafleet.com/api/v1
    description: Production
components:
  securitySchemes:
    bearer:
      type: http
      scheme: bearer
      bearerFormat: rfsk_live_<prefix>_<secret>
      description: >-
        API key RemaFleet (`rfsk_live_...`). El scope de tenant queda fijado
        por la key (PerTenant) o se filtra con `?tenant_id=` (Global).
    jwt:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: >-
        JWT emitido por rema-auth (auth.agetech.remafleet.com), ES256. El
        claim `dom` se usa como tenant_id (`system` ⇒ acceso global) y los
        scopes `gw:<recurso>:<acción>` autorizan los endpoints. Requiere que
        el gateway tenga configurado IOTGW_AUTH_JWKS.
  schemas:
    Envelope:
      type: object
      properties:
        status: { type: string, enum: [OK, ER] }
        payload: {}
    Error:
      type: object
      properties:
        code: { type: string, example: E_VALIDATION }
        message: { type: string }
    Device:
      type: object
      properties:
        imei: { type: string }
        name: { type: string, nullable: true }
        tenantId: { type: string, nullable: true }
        protocolCode: { type: string }
        msisdn: { type: string, nullable: true }
        iccid: { type: string, nullable: true }
        model: { type: string, nullable: true, description: "Modelo del device (uno del catálogo GET /device-models)" }
        comments: { type: string, nullable: true, maxLength: 1000 }
        firstSeenAt: { type: string, format: date-time, nullable: true }
        lastSeenAt: { type: string, format: date-time, nullable: true }
        online: { type: boolean }
    DeviceListResp:
      type: object
      properties:
        items: { type: array, items: { $ref: "#/components/schemas/Device" } }
        total: { type: integer }
        limit: { type: integer }
        offset: { type: integer }
    Geofence:
      type: object
      properties:
        id: { type: string }
        tenantId: { type: string, nullable: true }
        name: { type: string }
        active: { type: boolean }
        shapeType: { type: string }
        geom:
          description: GeoJSON MultiPolygon
          type: object
    Alarm:
      type: object
      properties:
        time: { type: string, format: date-time }
        id: { type: string }
        tenantId: { type: string, nullable: true }
        severity: { type: string, enum: [critical, high, normal, low] }
        title: { type: string }
        body: { type: string }
        imei: { type: string, nullable: true }
        acknowledgedAt: { type: string, format: date-time, nullable: true }
        acknowledgedBy: { type: string, nullable: true }
    AlarmRule:
      type: object
      properties:
        id: { type: string }
        tenantId: { type: string }
        name: { type: string }
        source: { type: string, enum: [device_event, geofence_event, trip] }
        eventType: { type: string }
        severity: { type: string }
        channels: { type: array, items: { type: string, enum: [push, email, telegram] } }
        conditions: { type: object }
        cooldownSeconds: { type: integer }
        active: { type: boolean }
    Tenant:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        active: { type: boolean }
        timezone: { type: string }
        metadata: { type: object }
    User:
      type: object
      properties:
        id: { type: string }
        username: { type: string }
        name: { type: string, nullable: true }
        role: { type: string, enum: [admin, operator, viewer] }
        active: { type: boolean }
    SmsSend:
      type: object
      properties:
        id: { type: string }
        phone: { type: string }
        text: { type: string }
        status: { type: string, enum: [pending, success, failed] }
        sentAt: { type: string, format: date-time }
        imei: { type: string, nullable: true }
    MultimediaAsset:
      type: object
      properties:
        id: { type: string }
        deviceImei: { type: string }
        mimeType: { type: string }
        sizeBytes: { type: integer }
        filename: { type: string }
        channelId: { type: integer, nullable: true }
        alarmTime: { type: string, format: date-time, nullable: true }
        receivedAt: { type: string, format: date-time }
    VehiclePhoto:
      type: object
      properties:
        id: { type: string }
        contentType: { type: string }
        size: { type: integer }
        uploadedAt: { type: string, format: date-time }
        url: { type: string, nullable: true, description: "Presigned GET URL (o URL pública si el bucket lo es)" }
    Vehicle:
      type: object
      description: "Detalle de vehículo. En GET /vehicles/{id} incluye photos, devices y primaryDevice."
      properties:
        id: { type: string }
        tenantId: { type: string, nullable: true }
        tenantName: { type: string, nullable: true }
        plate: { type: string, nullable: true, description: "Placa" }
        name: { type: string }
        code: { type: string, nullable: true }
        brand: { type: string, nullable: true }
        color: { type: string, nullable: true }
        model: { type: string, nullable: true }
        year: { type: integer, nullable: true }
        type: { type: string, description: "Tipo de vehículo para icono — ver GET /vehicle-types", default: car }
        serial: { type: string, nullable: true, description: "Número de serie" }
        engineNumber: { type: string, nullable: true, description: "Número/serie del motor" }
        chassisNumber: { type: string, nullable: true, description: "Número/serie del chasis (VIN)" }
        primaryDeviceImei: { type: string, nullable: true }
        groups:
          type: array
          description: "Grupos a los que pertenece el vehículo (N:M)"
          items:
            type: object
            properties:
              id: { type: string }
              name: { type: string }
              color: { type: string, nullable: true }
        active: { type: boolean }
        metadata: { type: object }
        photos: { type: array, items: { $ref: "#/components/schemas/VehiclePhoto" } }
        devices:
          type: array
          items:
            type: object
            properties:
              imei: { type: string }
              name: { type: string, nullable: true }
              protocolCode: { type: string }
              lastSeenAt: { type: string, format: date-time }
              isPrimary: { type: boolean }
        primaryDevice:
          type: object
          nullable: true
          description: "El device marcado como principal (mismo shape que un item de devices)"
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
    VehicleGroup:
      type: object
      properties:
        id: { type: string }
        tenantId: { type: string, nullable: true }
        tenantName: { type: string, nullable: true }
        name: { type: string }
        description: { type: string, nullable: true }
        color: { type: string, nullable: true }
        active: { type: boolean }
        metadata: { type: object }
        vehicleCount: { type: integer }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
    DeviceLive:
      type: object
      description: "Estado vivo de un device para el mapa de flota."
      properties:
        imei: { type: string }
        name: { type: string, nullable: true }
        protocolCode: { type: string }
        tenantId: { type: string, nullable: true }
        online: { type: boolean }
        motionState:
          type: string
          enum: [moving, idle, off, disconnected]
          description: "Estado operativo para el color de la UI (paridad con el panel): moving=verde, idle=amarillo (ralentí>5min), off=negro, disconnected=rojo (>3h sin reportar)."
        location:
          type: object
          nullable: true
          properties:
            lat: { type: number }
            lng: { type: number }
        address: { type: string, nullable: true, description: "Dirección (reverse geocoding cacheado en Redis; null hasta que se resuelve en background)" }
        speed: { type: integer, nullable: true, description: "km/h" }
        heading: { type: integer, nullable: true, description: "grados 0-359" }
        moving: { type: boolean, nullable: true, description: "speed > 5 km/h" }
        acc: { type: boolean, nullable: true, description: "ignición / motor" }
        located: { type: boolean, nullable: true }
        satellites: { type: integer, nullable: true }
        positionAt: { type: string, format: date-time, nullable: true }
        updatedAt: { type: string, format: date-time, nullable: true }
        vehicle:
          type: object
          nullable: true
          description: "Vehículo asignado al device (null si no tiene). LEFT JOIN."
          properties:
            id: { type: string }
            code: { type: string, nullable: true }
            plate: { type: string, nullable: true }
            name: { type: string }
            type: { type: string }
            color: { type: string, nullable: true }
            serial: { type: string, nullable: true }
            engineNumber: { type: string, nullable: true }
            chassisNumber: { type: string, nullable: true }
    FleetDashboard:
      type: object
      properties:
        fleet:
          type: object
          properties:
            total: { type: integer }
            moving: { type: integer }
            blocked: { type: integer }
            engineOn: { type: integer }
            connected: { type: integer }
        rankings:
          type: object
          properties:
            distance:
              type: array
              description: "Top 10 vehículos por km del día"
              items:
                type: object
                properties:
                  vehicleId: { type: string }
                  name: { type: string }
                  plate: { type: string, nullable: true }
                  km: { type: number }
            maxSpeed:
              type: array
              description: "Top 10 vehículos por velocidad máxima del día"
              items:
                type: object
                properties:
                  vehicleId: { type: string }
                  name: { type: string }
                  plate: { type: string, nullable: true }
                  maxSpeed: { type: integer }
        timezone: { type: string }
        generatedAt: { type: string, format: date-time }
security:
  - bearer: []
  - jwt: []
paths:
  /devices:
    get:
      summary: List devices
      parameters:
        - { in: query, name: limit, schema: { type: integer, default: 50, maximum: 200 } }
        - { in: query, name: offset, schema: { type: integer, default: 0 } }
        - { in: query, name: search, schema: { type: string } }
        - { in: query, name: protocol_code, schema: { type: string } }
        - { in: query, name: tenant_id, schema: { type: string } }
        - { in: query, name: sort_by, schema: { type: string, enum: [imei, name, last_seen_at, first_seen_at] } }
        - { in: query, name: sort_dir, schema: { type: string, enum: [asc, desc], default: desc } }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Envelope"
                  - properties: { payload: { $ref: "#/components/schemas/DeviceListResp" } }
  /devices/live:
    get:
      summary: "Snapshot de TODOS los devices en un request: ubicación, estado, orientación y velocidad (scope: devices:read)"
      parameters:
        - { in: query, name: tenant_id, schema: { type: string } }
        - { in: query, name: protocol_code, schema: { type: string } }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  items: { type: array, items: { $ref: "#/components/schemas/DeviceLive" } }
                  total: { type: integer }
  /devices/{imei}/summary:
    parameters: [ { in: path, name: imei, required: true, schema: { type: string } } ]
    get:
      summary: "Resumen del rango para un device (default: hoy en tz del tenant): distancia, viajes, velocidad y eventos (scope: devices:read)"
      parameters:
        - { in: query, name: since, schema: { type: string, format: date-time }, description: "ISO 8601; default inicio de hoy (tz tenant)" }
        - { in: query, name: until, schema: { type: string, format: date-time }, description: "ISO 8601; default now()" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  imei: { type: string }
                  range:
                    type: object
                    properties:
                      since: { type: string, format: date-time }
                      until: { type: string, format: date-time }
                  timezone: { type: string }
                  distanceKm: { type: number }
                  maxSpeed: { type: integer, description: "km/h (máx entre trips y tracking)" }
                  trips:
                    type: object
                    properties:
                      count: { type: integer }
                      drivingTimeSeconds: { type: integer }
                      avgSpeed: { type: integer }
                  tracking:
                    type: object
                    properties:
                      points: { type: integer }
                      firstAt: { type: string, format: date-time, nullable: true }
                      lastAt: { type: string, format: date-time, nullable: true }
                  events:
                    type: object
                    properties:
                      total: { type: integer }
                      byType: { type: object, additionalProperties: { type: integer } }
  /devices/{imei}/share:
    parameters: [ { in: path, name: imei, required: true, schema: { type: string } } ]
    post:
      summary: "Genera un token público temporal para compartir ubicación/velocidad del device (scope: devices:read)"
      parameters:
        - { in: query, name: ttl, schema: { type: integer }, description: "TTL en segundos (default 3600, máx 604800)" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  token: { type: string }
                  ttl: { type: integer }
                  expiresAt: { type: string, format: date-time }
                  url: { type: string, description: "Ruta pública GET /api/v1/share/{token}" }
  /share/{token}:
    parameters: [ { in: path, name: token, required: true, schema: { type: string } } ]
    get:
      summary: "PÚBLICO (sin auth, solo token temporal): ubicación (lat/lng + texto), velocidad y estado del device compartido"
      security: []
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  imei: { type: string }
                  name: { type: string, nullable: true }
                  online: { type: boolean }
                  location:
                    type: object
                    nullable: true
                    properties: { lat: { type: number }, lng: { type: number } }
                  address: { type: string, nullable: true, description: "Ubicación en texto (reverse geocoding cacheado)" }
                  speed: { type: integer, nullable: true, description: "km/h" }
                  heading: { type: integer, nullable: true }
                  moving: { type: boolean, nullable: true }
                  acc: { type: boolean, nullable: true }
                  expiresInSeconds: { type: integer, nullable: true }
        "404": { description: "E_SHARE_INVALID — token inválido o expirado" }
  /devices/{imei}:
    parameters:
      - { in: path, name: imei, required: true, schema: { type: string } }
    get:
      summary: Get device detail
      responses: { "200": { description: OK } }
    put:
      summary: "Edit device (name, code, tenant_id, msisdn, iccid, model, comments) (scope: devices:write)"
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name:      { type: string, nullable: true }
                code:      { type: string, nullable: true }
                tenant_id: { type: string, nullable: true }
                msisdn:    { type: string, nullable: true }
                iccid:     { type: string, nullable: true }
                model:     { type: string, nullable: true, description: "Uno del catálogo GET /device-models" }
                comments:  { type: string, nullable: true, maxLength: 1000 }
      responses: { "200": { description: OK } }
  /device-models:
    get:
      summary: "Catálogo de modelos de dispositivo (grupos por marca)"
      responses: { "200": { description: OK } }
  /user-access/{userId}/devices:
    parameters: [ { in: path, name: userId, required: true, schema: { type: string } } ]
    get: { summary: "Acceso a dispositivos de un usuario (allDevices + lista) (scope: devices:read)", responses: { "200": { description: OK } } }
    put:
      summary: "Define el acceso a dispositivos de un usuario (scope: devices:write)"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                all_devices: { type: boolean, description: "true = todos los dispositivos del tenant" }
                imeis:       { type: array, items: { type: string }, description: "Dispositivos específicos si all_devices=false" }
      responses: { "200": { description: OK } }
  /devices/{imei}/tracking:
    parameters:
      - { in: path, name: imei, required: true, schema: { type: string } }
      - { in: query, name: since, schema: { type: string, format: date-time } }
      - { in: query, name: until, schema: { type: string, format: date-time } }
      - { in: query, name: limit, schema: { type: integer, default: 200, maximum: 1000 } }
    get: { summary: Recent tracking points, responses: { "200": { description: OK } } }
  /devices/{imei}/state:
    parameters:
      - { in: path, name: imei, required: true, schema: { type: string } }
    get: { summary: Runtime state snapshot, responses: { "200": { description: OK } } }
  /devices/{imei}/events:
    parameters:
      - { in: path, name: imei, required: true, schema: { type: string } }
      - { in: query, name: since, schema: { type: string, format: date-time } }
      - { in: query, name: until, schema: { type: string, format: date-time } }
      - { in: query, name: event_type, schema: { type: string } }
      - { in: query, name: limit, schema: { type: integer, default: 200 } }
    get: { summary: Device events history, responses: { "200": { description: OK } } }
  /devices/{imei}/trips:
    parameters:
      - { in: path, name: imei, required: true, schema: { type: string } }
    get: { summary: Aggregated trips, responses: { "200": { description: OK } } }
  /devices/{imei}/geofences:
    parameters:
      - { in: path, name: imei, required: true, schema: { type: string } }
    get: { summary: Geofences assigned to the device, responses: { "200": { description: OK } } }
  /devices/{imei}/commands:
    parameters:
      - { in: path, name: imei, required: true, schema: { type: string } }
    get: { summary: List commands available for the device, responses: { "200": { description: OK } } }
    post:
      summary: Execute a command (GPRS or SMS)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [command]
              properties:
                command: { type: string }
                transport: { type: string, enum: [gprs, sms] }
                params: { type: object }
                phone: { type: string }
                queueIfOffline: { type: boolean }
                skipWakeup: { type: boolean }
      responses: { "200": { description: OK } }
  /devices/{imei}/multimedia:
    parameters:
      - { in: path, name: imei, required: true, schema: { type: string } }
      - { in: query, name: since, schema: { type: string, format: date-time } }
      - { in: query, name: until, schema: { type: string, format: date-time } }
      - { in: query, name: channel, schema: { type: integer } }
      - { in: query, name: type, schema: { type: string, enum: [image, video, audio] } }
    get: { summary: List multimedia assets of the device, responses: { "200": { description: OK } } }
  /devices/{imei}/multimedia/recording/status:
    parameters:
      - { in: path, name: imei, required: true, schema: { type: string } }
      - { in: query, name: channelId, schema: { type: integer, default: 1 } }
    get: { summary: HLS recording status, responses: { "200": { description: OK } } }
  /devices/{imei}/multimedia/recording/start:
    parameters: [ { in: path, name: imei, required: true, schema: { type: string } } ]
    post:
      summary: Start HLS recording for an active stream
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [channelId]
              properties:
                channelId: { type: integer }
                instanceId: { type: string }
      responses: { "200": { description: OK } }
  /devices/{imei}/multimedia/recording/stop:
    parameters: [ { in: path, name: imei, required: true, schema: { type: string } } ]
    post:
      summary: Stop HLS recording (uploads to R2, persists asset)
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [channelId], properties: { channelId: { type: integer } } }
      responses: { "200": { description: OK } }
  /devices/{imei}/multimedia/request:
    parameters: [ { in: path, name: imei, required: true, schema: { type: string } } ]
    post:
      summary: Trigger VIDEOUPLOAD push from the camera (jimi_jtt808)
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                channels: { type: string, default: "1-2" }
                type: { type: integer, enum: [0, 1, 2], default: 2 }
                alarmId: { type: string }
                host: { type: string }
                port: { type: integer }
      responses: { "200": { description: OK } }
  /multimedia/{id}:
    parameters: [ { in: path, name: id, required: true, schema: { type: string } } ]
    get: { summary: Asset metadata, responses: { "200": { description: OK } } }
    delete: { summary: Soft delete (status=deleted), responses: { "200": { description: OK } } }
  /multimedia/{id}/url:
    parameters: [ { in: path, name: id, required: true, schema: { type: string } } ]
    get: { summary: Presigned R2 GET URL (5 min TTL), responses: { "200": { description: OK } } }
  /devices/{imei}/streams:
    parameters:
      - { in: path, name: imei, required: true, schema: { type: string } }
      - { in: query, name: channel, schema: { type: integer } }
    get: { summary: List active RTP/JT1078 streams of the device (scope multimedia:read), responses: { "200": { description: OK } } }
  /devices/{imei}/streams/teardown:
    parameters: [ { in: path, name: imei, required: true, schema: { type: string } } ]
    post:
      summary: Tear down the RTP stream + HLS transcoder of a device+channel (scope multimedia:write)
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [channel], properties: { channel: { type: integer } } }
      responses: { "200": { description: OK } }
  /devices/{imei}/hls/{channel}/index.m3u8:
    parameters:
      - { in: path, name: imei, required: true, schema: { type: string } }
      - { in: path, name: channel, required: true, schema: { type: integer } }
      - { in: query, name: api_key, schema: { type: string }, description: "Optional token for native players that can't set headers" }
    get: { summary: HLS playlist for the device+channel live/playback stream (scope multimedia:read), responses: { "200": { description: OK }, "503": { description: HLS not ready yet } } }
  /devices/{imei}/hls/{channel}/{file}:
    parameters:
      - { in: path, name: imei, required: true, schema: { type: string } }
      - { in: path, name: channel, required: true, schema: { type: integer } }
      - { in: path, name: file, required: true, schema: { type: string } }
    get: { summary: HLS segment (.ts) referenced by the playlist (scope multimedia:read), responses: { "200": { description: OK } } }
  /devices/{imei}/command-log:
    parameters:
      - { in: path, name: imei, required: true, schema: { type: string } }
      - { in: query, name: limit, schema: { type: integer, default: 200, maximum: 1000 } }
    get: { summary: Command queue + responses history of the device (scope multimedia:read) — used to poll resource_list/ACKs, responses: { "200": { description: OK } } }
  /devices/{imei}/recordings:
    parameters: [ { in: path, name: imei, required: true, schema: { type: string } } ]
    get:
      summary: Cached playback resource-list fragments for a day+channel (scope multimedia:read)
      parameters:
        - { in: query, name: channel, required: true, schema: { type: integer } }
        - { in: query, name: day, required: true, schema: { type: string, format: date } }
      responses: { "200": { description: OK } }
    post:
      summary: Upsert the cached playback resource list for a day+channel (scope multimedia:write)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [channel, day, items]
              properties:
                channel: { type: integer }
                day: { type: string, format: date }
                items: { type: array, items: { type: object } }
                total_count: { type: integer }
      responses: { "200": { description: OK } }
  /sms:
    get:
      summary: SMS history
      parameters:
        - { in: query, name: phone, schema: { type: string } }
        - { in: query, name: imei, schema: { type: string } }
        - { in: query, name: status, schema: { type: string, enum: [pending, success, failed] } }
        - { in: query, name: providerId, schema: { type: string } }
        - { in: query, name: since, schema: { type: string, format: date-time } }
        - { in: query, name: until, schema: { type: string, format: date-time } }
      responses: { "200": { description: OK } }
    post:
      summary: Send SMS
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [phone, text]
              properties:
                phone: { type: string }
                text: { type: string }
                providerId: { type: string }
                imei: { type: string }
                source: { type: string, enum: [manual, alarm, command, test] }
      responses: { "200": { description: OK } }
  /sms/{id}:
    parameters: [ { in: path, name: id, required: true, schema: { type: string } } ]
    get: { summary: SMS detail, responses: { "200": { description: OK } } }
  /sms-providers:
    get: { summary: List SMS providers visible to the key, responses: { "200": { description: OK } } }
  /geofences:
    get:
      summary: List geofences
      parameters:
        - { in: query, name: active, schema: { type: boolean } }
        - { in: query, name: search, schema: { type: string } }
        - { in: query, name: tenant_id, schema: { type: string } }
      responses: { "200": { description: OK } }
    post:
      summary: Create geofence
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, geom]
              properties:
                name: { type: string }
                geom: { type: object, description: "GeoJSON Polygon, MultiPolygon, or Feature wrapping either" }
                tenant_id: { type: string }
                active: { type: boolean, default: true }
                metadata: { type: object }
      responses: { "200": { description: OK } }
  /geofences/{id}:
    parameters: [ { in: path, name: id, required: true, schema: { type: string } } ]
    get: { summary: Geofence detail (geom as GeoJSON), responses: { "200": { description: OK } } }
    put: { summary: Update geofence, responses: { "200": { description: OK } } }
    delete: { summary: Archive geofence (active=false), responses: { "200": { description: OK } } }
  /geofences/{id}/permanent:
    parameters: [ { in: path, name: id, required: true, schema: { type: string } } ]
    delete: { summary: "Permanently delete (deleted state, never listed again). Requires active=false and no assigned devices; 409 otherwise", responses: { "200": { description: OK }, "409": { description: "Active or has assigned devices" } } }
  /geofences/{id}/devices:
    parameters: [ { in: path, name: id, required: true, schema: { type: string } } ]
    get: { summary: Devices assigned to the geofence, responses: { "200": { description: OK } } }
  /geofences/{id}/assignments:
    parameters: [ { in: path, name: id, required: true, schema: { type: string } } ]
    post:
      summary: Assign device(s) to the geofence
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [imeis]
              properties: { imeis: { type: array, items: { type: string } } }
      responses: { "200": { description: OK } }
  /geofences/{id}/assignments/{imei}:
    parameters:
      - { in: path, name: id, required: true, schema: { type: string } }
      - { in: path, name: imei, required: true, schema: { type: string } }
    delete: { summary: Unassign device (emits synthetic exit if inside), responses: { "200": { description: OK } } }
  /geofences/{id}/events:
    parameters: [ { in: path, name: id, required: true, schema: { type: string } } ]
    get: { summary: Recent events for a geofence, responses: { "200": { description: OK } } }
  /geofence-events:
    get:
      summary: Global geofence-events feed
      parameters:
        - { in: query, name: imei, schema: { type: string } }
        - { in: query, name: geofence_id, schema: { type: string } }
        - { in: query, name: event_type, schema: { type: string, enum: [enter, exit] } }
        - { in: query, name: since, schema: { type: string, format: date-time } }
        - { in: query, name: until, schema: { type: string, format: date-time } }
      responses: { "200": { description: OK } }
  /alarms:
    get:
      summary: List alarms
      parameters:
        - { in: query, name: severity, schema: { type: string } }
        - { in: query, name: imei, schema: { type: string } }
        - { in: query, name: acknowledged, schema: { type: boolean } }
        - { in: query, name: since, schema: { type: string, format: date-time } }
        - { in: query, name: until, schema: { type: string, format: date-time } }
      responses: { "200": { description: OK } }
  /alarms/{time}/{id}:
    parameters:
      - { in: path, name: time, required: true, schema: { type: string, format: date-time } }
      - { in: path, name: id, required: true, schema: { type: string } }
    get: { summary: Alarm detail, responses: { "200": { description: OK } } }
  /alarms/{time}/{id}/ack:
    parameters:
      - { in: path, name: time, required: true, schema: { type: string, format: date-time } }
      - { in: path, name: id, required: true, schema: { type: string } }
    post: { summary: Acknowledge alarm, responses: { "200": { description: OK } } }
  /alarms/{time}/{id}/unack:
    parameters:
      - { in: path, name: time, required: true, schema: { type: string, format: date-time } }
      - { in: path, name: id, required: true, schema: { type: string } }
    post: { summary: Revert acknowledgement, responses: { "200": { description: OK } } }
  /alarm-rules:
    get:
      summary: List alarm rules
      parameters:
        - { in: query, name: source, schema: { type: string, enum: [device_event, geofence_event, trip] } }
        - { in: query, name: event_type, schema: { type: string } }
        - { in: query, name: active, schema: { type: boolean } }
      responses: { "200": { description: OK } }
    post: { summary: Create alarm rule, responses: { "200": { description: OK } } }
  /alarm-rules/{id}:
    parameters: [ { in: path, name: id, required: true, schema: { type: string } } ]
    get: { summary: Rule detail, responses: { "200": { description: OK } } }
    put: { summary: Update rule, responses: { "200": { description: OK } } }
    delete: { summary: Delete rule, responses: { "200": { description: OK } } }
  /event-types:
    get:
      summary: Catalog of event_type strings observed (for building rules/filters)
      parameters:
        - { in: query, name: source, schema: { type: string, enum: [device_event, geofence_event, trip] } }
      responses: { "200": { description: OK } }
  /recipients:
    get:
      summary: "List notification recipients of the tenant (email/sms/telegram/push)"
      description: "Requires gw:alarms:read. Cross-tenant keys must pick a tenant via ?tenant_id (400 otherwise)."
      parameters:
        - { in: query, name: channel, schema: { type: string, enum: [push, email, telegram, sms] } }
        - { in: query, name: active, schema: { type: boolean } }
      responses: { "200": { description: OK } }
    post:
      summary: "Create notification recipient (requires gw:alarms:write)"
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [channel, target]
              properties:
                channel: { type: string, enum: [push, email, telegram, sms] }
                subtype: { type: string, enum: [web, fcm, apns], description: "push only" }
                target:
                  type: object
                  description: "email: {email}; sms: {phone, provider_id?}; telegram: {chat_id}; push web: {endpoint, keys}"
                filters:
                  description: "Event filter. null = recipient receives ALL events. Otherwise { rules: [{ imei: string|null, event_type: string|null }] } — null field = any (Todos/Todas); recipient receives events matching ANY rule."
                  nullable: true
                  type: object
                  properties:
                    rules:
                      type: array
                      maxItems: 50
                      items:
                        type: object
                        properties:
                          imei: { type: string, nullable: true }
                          event_type: { type: string, nullable: true }
                label: { type: string }
                active: { type: boolean }
      responses: { "200": { description: OK } }
  /recipients/{rid}:
    parameters: [ { in: path, name: rid, required: true, schema: { type: string } } ]
    put: { summary: "Update recipient (label/active/target; requires gw:alarms:write)", responses: { "200": { description: OK } } }
    delete: { summary: "Delete recipient (requires gw:alarms:write)", responses: { "200": { description: OK } } }
  /recipients/{rid}/test:
    parameters: [ { in: path, name: rid, required: true, schema: { type: string } } ]
    post: { summary: "Send a test message through the recipient's channel (requires gw:alarms:write)", responses: { "200": { description: OK } } }
  /tenants:
    get: { summary: List tenants, responses: { "200": { description: OK } } }
    post: { summary: "Create tenant (Global key only)", responses: { "200": { description: OK } } }
  /tenants/{id}:
    parameters: [ { in: path, name: id, required: true, schema: { type: string } } ]
    get: { summary: Tenant detail, responses: { "200": { description: OK } } }
    put: { summary: Update tenant, responses: { "200": { description: OK } } }
    delete: { summary: "Soft delete tenant (Global key only)", responses: { "200": { description: OK } } }
  /tenants/{id}/devices:
    parameters: [ { in: path, name: id, required: true, schema: { type: string } } ]
    get: { summary: Devices of the tenant, responses: { "200": { description: OK } } }
  /tenants/{id}/geofences:
    parameters: [ { in: path, name: id, required: true, schema: { type: string } } ]
    get: { summary: Geofences of the tenant, responses: { "200": { description: OK } } }
  /users:
    get: { summary: "List users (Global key only)", responses: { "200": { description: OK } } }
    post: { summary: "Create user (Global key only)", responses: { "200": { description: OK } } }
  /users/{id}:
    parameters: [ { in: path, name: id, required: true, schema: { type: string } } ]
    get: { summary: "User detail (Global key only)", responses: { "200": { description: OK } } }
    put: { summary: "Update user (Global key only)", responses: { "200": { description: OK } } }
    delete: { summary: "Delete user (Global key only)", responses: { "200": { description: OK } } }
  /users/{id}/password:
    parameters: [ { in: path, name: id, required: true, schema: { type: string } } ]
    post:
      summary: "Change user password (Global key only)"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [newPassword]
              properties: { newPassword: { type: string, minLength: 8 } }
      responses: { "200": { description: OK } }
  /vehicles:
    get:
      summary: "List vehicles (scope: vehicles:read)"
      parameters:
        - { in: query, name: active, schema: { type: boolean } }
        - { in: query, name: search, schema: { type: string, description: "matches name, plate or code" } }
        - { in: query, name: tenant_id, schema: { type: string } }
        - { in: query, name: limit, schema: { type: integer, default: 100, maximum: 500 } }
        - { in: query, name: offset, schema: { type: integer, default: 0 } }
      responses: { "200": { description: OK } }
    post:
      summary: "Create vehicle (scope: vehicles:write)"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name:      { type: string }
                plate:     { type: string, description: "Placa" }
                code:      { type: string, description: "Código interno" }
                brand:     { type: string, description: "Marca" }
                color:     { type: string }
                model:     { type: string, description: "Modelo" }
                year:      { type: integer, minimum: 1900, maximum: 2100 }
                type:      { type: string, description: "Tipo para icono — ver GET /vehicle-types", default: car }
                serial:         { type: string, description: "Número de serie" }
                engine_number:  { type: string, description: "Número/serie del motor" }
                chassis_number: { type: string, description: "Número/serie del chasis (VIN)" }
                tenant_id: { type: string }
                group_ids: { type: array, items: { type: string }, description: "IDs de grupos a los que pertenece (N:M)" }
                active:    { type: boolean, default: true }
                metadata:  { type: object }
      responses: { "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/Vehicle" } } } } }
  /vehicles/dashboard:
    get:
      summary: "Dashboard de flota: estado (total/moving/blocked/engineOn/connected) + rankings del día (distancia, velocidad máxima) (scope: vehicles:read)"
      responses: { "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/FleetDashboard" } } } } }
  /vehicle-types:
    get:
      summary: "Catálogo de tipos de vehículo para iconos (scope: vehicles:read)"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  types: { type: array, items: { type: string } }
                  default: { type: string }
  /vehicles/{id}:
    parameters: [ { in: path, name: id, required: true, schema: { type: string } } ]
    get:
      summary: "Vehicle detail — incluye fotos (presigned URLs), devices asignados y dispositivo principal (scope: vehicles:read)"
      responses: { "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/Vehicle" } } } } }
    put: { summary: "Update vehicle (scope: vehicles:write)", responses: { "200": { description: OK } } }
    delete: { summary: "Soft delete vehicle, active=false (scope: vehicles:write)", responses: { "200": { description: OK } } }
  /vehicles/{id}/devices:
    parameters: [ { in: path, name: id, required: true, schema: { type: string } } ]
    post:
      summary: "Assign device(s) to the vehicle. Si no había principal, el primero se marca como principal (scope: vehicles:write)"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                imeis: { type: array, items: { type: string } }
                imei:  { type: string, description: "Alternativa a imeis para asignar uno solo" }
      responses: { "200": { description: OK } }
  /vehicles/{id}/devices/{imei}:
    parameters:
      - { in: path, name: id, required: true, schema: { type: string } }
      - { in: path, name: imei, required: true, schema: { type: string } }
    delete: { summary: "Unassign device. Si era el principal, se limpia (scope: vehicles:write)", responses: { "200": { description: OK } } }
  /vehicles/{id}/primary-device:
    parameters: [ { in: path, name: id, required: true, schema: { type: string } } ]
    put:
      summary: "Set/clear the primary device. El device debe estar asignado al vehículo (scope: vehicles:write)"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties: { imei: { type: string, nullable: true, description: "null para quitar el principal" } }
      responses: { "200": { description: OK } }
  /vehicles/{id}/photos:
    parameters: [ { in: path, name: id, required: true, schema: { type: string } } ]
    get:
      summary: "List vehicle photos with presigned URLs (scope: vehicles:read)"
      responses: { "200": { description: OK } }
    post:
      summary: "Upload a photo (máx 3 por vehículo, máx 10 MB, jpg/png/webp/gif). Se guarda en S3 bajo vehicles/ (scope: vehicles:write)"
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file: { type: string, format: binary }
      responses:
        "200": { description: OK }
        "409": { description: "E_PHOTO_LIMIT — ya tiene el máximo de fotos" }
        "503": { description: "E_STORAGE_DISABLED — S3 no configurado" }
  /vehicles/{id}/photos/{photoId}:
    parameters:
      - { in: path, name: id, required: true, schema: { type: string } }
      - { in: path, name: photoId, required: true, schema: { type: string } }
    delete: { summary: "Delete a vehicle photo (S3 + metadata) (scope: vehicles:write)", responses: { "200": { description: OK } } }
  /vehicles/{id}/drivers:
    parameters: [ { in: path, name: id, required: true, schema: { type: string } } ]
    get: { summary: "Choferes asignados al vehículo con horario (Flota↔Personal) (scope: vehicles:read)", responses: { "200": { description: OK } } }
    post:
      summary: "Asignar chofer al vehículo con horario (scope: vehicles:write)"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [personal_id, mode]
              properties:
                personal_id:  { type: string }
                mode:         { type: string, enum: [cyclic, single], description: "cyclic=semanal por días; single=una fecha" }
                days_of_week: { type: array, items: { type: integer, minimum: 1, maximum: 7 }, description: "ISO 1=Lun..7=Dom (modo cyclic)" }
                date:         { type: string, format: date-time, description: "modo single: inicio del rango" }
                date_end:     { type: string, format: date-time, description: "modo single: fin del rango (≥ date; default = date)" }
                start_time:   { type: string, example: "08:00" }
                end_time:     { type: string, example: "17:00" }
      responses: { "200": { description: OK } }
  /vehicles/{id}/drivers/{driverId}:
    parameters:
      - { in: path, name: id, required: true, schema: { type: string } }
      - { in: path, name: driverId, required: true, schema: { type: string } }
    put: { summary: "Editar la asignación de chofer (scope: vehicles:write)", responses: { "200": { description: OK } } }
    delete: { summary: "Quitar el chofer del vehículo (scope: vehicles:write)", responses: { "200": { description: OK } } }
  /vehicle-groups:
    get:
      summary: "List vehicle groups (scope: vehicle-groups:read)"
      parameters:
        - { in: query, name: active, schema: { type: boolean } }
        - { in: query, name: search, schema: { type: string } }
        - { in: query, name: tenant_id, schema: { type: string } }
      responses: { "200": { description: OK } }
    post:
      summary: "Create vehicle group (scope: vehicle-groups:write)"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name:        { type: string }
                description: { type: string }
                color:       { type: string }
                tenant_id:   { type: string }
                active:      { type: boolean, default: true }
                metadata:    { type: object }
      responses: { "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/VehicleGroup" } } } } }
  /vehicle-groups/{id}:
    parameters: [ { in: path, name: id, required: true, schema: { type: string } } ]
    get: { summary: "Group detail with vehicleCount (scope: vehicle-groups:read)", responses: { "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/VehicleGroup" } } } } } }
    put: { summary: "Update group (scope: vehicle-groups:write)", responses: { "200": { description: OK } } }
    delete: { summary: "Soft delete group, active=false (scope: vehicle-groups:write)", responses: { "200": { description: OK } } }
  /vehicle-groups/{id}/vehicles:
    parameters: [ { in: path, name: id, required: true, schema: { type: string } } ]
    get: { summary: "Vehicles in the group (scope: vehicle-groups:read)", responses: { "200": { description: OK } } }
    post:
      summary: "Add vehicle(s) to the group — N:M (scope: vehicle-groups:write)"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                vehicleIds: { type: array, items: { type: string } }
                vehicleId:  { type: string, description: "Alternativa para uno solo" }
      responses: { "200": { description: OK } }
  /vehicle-groups/{id}/vehicles/{vehicleId}:
    parameters:
      - { in: path, name: id, required: true, schema: { type: string } }
      - { in: path, name: vehicleId, required: true, schema: { type: string } }
    delete: { summary: "Remove vehicle from the group (scope: vehicle-groups:write)", responses: { "200": { description: OK } } }
  /personal:
    get:
      summary: "List personal/conductores (scope: personal:read)"
      parameters:
        - { in: query, name: active, schema: { type: boolean } }
        - { in: query, name: search, schema: { type: string }, description: "Busca en name/code/national_id/phone/email" }
        - { in: query, name: group_id, schema: { type: string } }
        - { in: query, name: tenant_id, schema: { type: string } }
      responses: { "200": { description: OK } }
    post:
      summary: "Create personal (scope: personal:write). national_id único por tenant"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name:                   { type: string }
                code:                   { type: string }
                national_id:            { type: string, description: "Cédula/identificación" }
                national_id_expiration: { type: string, format: date-time }
                birth_date:             { type: string, format: date-time }
                gender:                 { type: string, enum: [m, f, o] }
                weight:                 { type: string, description: "Libras" }
                height:                 { type: string, description: "Metros, N.NN" }
                marital_status:         { type: string, enum: [s, c, d, v] }
                address:                { type: string }
                phone:                  { type: string }
                email:                  { type: string }
                group_id:               { type: string, nullable: true }
                tenant_id:              { type: string }
                active:                 { type: boolean, default: true }
                metadata:               { type: object }
      responses:
        "200": { description: OK }
        "409": { description: "E_PERSONAL_DUPLICATE_ID — cédula ya asignada" }
  /personal/dashboard:
    get: { summary: "Resumen de Personal: totales, licencias por vencer/vencidas, ranking por grupo y próximos vencimientos (scope: personal:read)", responses: { "200": { description: OK } } }
  /personal/{id}:
    parameters: [ { in: path, name: id, required: true, schema: { type: string } } ]
    get: { summary: "Personal detail — incluye foto con presigned URL (scope: personal:read)", responses: { "200": { description: OK } } }
    put: { summary: "Update personal (scope: personal:write)", responses: { "200": { description: OK }, "409": { description: "E_PERSONAL_DUPLICATE_ID" } } }
    delete: { summary: "Soft delete personal, active=false (scope: personal:write)", responses: { "200": { description: OK } } }
  /personal/{id}/photo:
    parameters: [ { in: path, name: id, required: true, schema: { type: string } } ]
    post:
      summary: "Upload/replace personal photo (única, máx 10 MB, jpg/png/webp/gif). Se guarda en S3 bajo personal/ (scope: personal:write)"
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file: { type: string, format: binary }
      responses:
        "200": { description: OK }
        "503": { description: "E_STORAGE_DISABLED — S3 no configurado" }
    delete: { summary: "Delete personal photo (S3 + metadata) (scope: personal:write)", responses: { "200": { description: OK } } }
  /personal-groups:
    get:
      summary: "List personal groups (scope: personal:read)"
      parameters:
        - { in: query, name: active, schema: { type: boolean } }
        - { in: query, name: search, schema: { type: string } }
        - { in: query, name: tenant_id, schema: { type: string } }
      responses: { "200": { description: OK } }
    post:
      summary: "Create personal group (scope: personal:write)"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name:        { type: string }
                description: { type: string }
                color:       { type: string }
                tenant_id:   { type: string }
                active:      { type: boolean, default: true }
                metadata:    { type: object }
      responses: { "200": { description: OK } }
  /personal-groups/{id}:
    parameters: [ { in: path, name: id, required: true, schema: { type: string } } ]
    get: { summary: "Group detail with memberCount (scope: personal:read)", responses: { "200": { description: OK } } }
    put: { summary: "Update group (scope: personal:write)", responses: { "200": { description: OK } } }
    delete: { summary: "Soft delete group, active=false (scope: personal:write)", responses: { "200": { description: OK } } }
  /personal-groups/{id}/members:
    parameters: [ { in: path, name: id, required: true, schema: { type: string } } ]
    get: { summary: "Personal in the group (scope: personal:read)", responses: { "200": { description: OK } } }
    post:
      summary: "Add personal to the group — 1:N, setea personal.group_id (scope: personal:write)"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                personalIds: { type: array, items: { type: string } }
                personalId:  { type: string, description: "Alternativa para uno solo" }
      responses: { "200": { description: OK } }
  /personal-groups/{id}/members/{personalId}:
    parameters:
      - { in: path, name: id, required: true, schema: { type: string } }
      - { in: path, name: personalId, required: true, schema: { type: string } }
    delete: { summary: "Remove personal from the group (scope: personal:write)", responses: { "200": { description: OK } } }
  /workshops:
    get:
      summary: "List workshops (scope: workshops:read)"
      parameters:
        - { in: query, name: active, schema: { type: boolean } }
        - { in: query, name: search, schema: { type: string }, description: "Busca en name/description/location" }
        - { in: query, name: tenant_id, schema: { type: string } }
      responses: { "200": { description: OK } }
    post:
      summary: "Create workshop (scope: workshops:write)"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name:        { type: string }
                description: { type: string }
                location:    { type: string }
                geofence_id: { type: string, nullable: true, description: "Geocerca asociada (opcional)" }
                tenant_id:   { type: string }
                active:      { type: boolean, default: true }
                metadata:    { type: object }
      responses: { "200": { description: OK } }
  /workshops/dashboard:
    get: { summary: "Resumen: nº talleres, vehículos en talleres, mantenimientos pendientes/finalizados (scope: workshops:read)", responses: { "200": { description: OK } } }
  /workshops/{id}:
    parameters: [ { in: path, name: id, required: true, schema: { type: string } } ]
    get: { summary: "Workshop detail with vehicleCount/personnelCount (scope: workshops:read)", responses: { "200": { description: OK } } }
    put: { summary: "Update workshop (scope: workshops:write)", responses: { "200": { description: OK } } }
    delete: { summary: "Soft delete workshop, active=false (scope: workshops:write)", responses: { "200": { description: OK } } }
  /workshops/{id}/vehicles:
    parameters: [ { in: path, name: id, required: true, schema: { type: string } } ]
    get: { summary: "Vehicles assigned to the workshop (con forcedInside) (scope: workshops:read)", responses: { "200": { description: OK } } }
    post:
      summary: "Assign vehicle(s) to the workshop — M:N (scope: workshops:write)"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                vehicleIds: { type: array, items: { type: string } }
                vehicleId:  { type: string, description: "Alternativa para uno solo" }
      responses: { "200": { description: OK } }
  /workshops/{id}/vehicles/{vehicleId}:
    parameters:
      - { in: path, name: id, required: true, schema: { type: string } }
      - { in: path, name: vehicleId, required: true, schema: { type: string } }
    patch:
      summary: "Toggle forced_inside (scope: workshops:write)"
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, properties: { forcedInside: { type: boolean } } }
      responses: { "200": { description: OK } }
    delete: { summary: "Remove vehicle from the workshop (scope: workshops:write)", responses: { "200": { description: OK } } }
  /workshops/{id}/personnel:
    parameters: [ { in: path, name: id, required: true, schema: { type: string } } ]
    get: { summary: "Personnel assigned to the workshop (scope: workshops:read)", responses: { "200": { description: OK } } }
    post:
      summary: "Add personal (del módulo Personal) to the workshop with role (scope: workshops:write)"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [personalId, role]
              properties:
                personalId: { type: string }
                role:       { type: string, enum: [responsable, jefe_taller, mecanico] }
      responses: { "200": { description: OK }, "409": { description: "E_ALREADY_ASSIGNED" } }
  /workshops/{id}/personnel/{personnelId}:
    parameters:
      - { in: path, name: id, required: true, schema: { type: string } }
      - { in: path, name: personnelId, required: true, schema: { type: string } }
    delete: { summary: "Remove personnel from the workshop (scope: workshops:write)", responses: { "200": { description: OK } } }
  /maintenance:
    get:
      summary: "List maintenance/services (scope: workshops:read)"
      parameters:
        - { in: query, name: status, schema: { type: string, enum: [pdt, fin] } }
        - { in: query, name: vehicle_id, schema: { type: string } }
        - { in: query, name: type, schema: { type: string } }
        - { in: query, name: search, schema: { type: string } }
      responses: { "200": { description: OK } }
    post:
      summary: "Create maintenance (scope: workshops:write)"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [vehicle_id, type]
              properties:
                vehicle_id:    { type: string }
                type:          { type: string, description: "llantas|motor|aceite|... (19 tipos)" }
                date_ini:      { type: string, format: date-time }
                date_fin:      { type: string, format: date-time }
                next_mode:     { type: string, enum: [none, odo, date, both], default: none }
                next_date:     { type: string, format: date-time }
                next_odo:      { type: string }
                mechanic_name: { type: string }
                personnel_id:  { type: string, nullable: true, description: "workshop_personnel.id (opcional)" }
                observations:  { type: string }
                cost:          { type: string }
                status:        { type: string, enum: [pdt, fin], default: pdt }
      responses: { "200": { description: OK } }
  /maintenance/{id}:
    parameters: [ { in: path, name: id, required: true, schema: { type: string } } ]
    get: { summary: "Maintenance detail (scope: workshops:read)", responses: { "200": { description: OK } } }
    put: { summary: "Update maintenance (scope: workshops:write)", responses: { "200": { description: OK } } }
    delete: { summary: "Soft delete maintenance, status=del (scope: workshops:write)", responses: { "200": { description: OK } } }
  /projects:
    get:
      summary: "List projects (scope: projects:read)"
      parameters:
        - { in: query, name: active, schema: { type: boolean } }
        - { in: query, name: search, schema: { type: string } }
        - { in: query, name: tenant_id, schema: { type: string } }
      responses: { "200": { description: OK } }
    post:
      summary: "Create project (scope: projects:write). code único por tenant, regex ^[a-z][a-z0-9_-]*$"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name:        { type: string }
                code:        { type: string }
                description: { type: string }
                geofence_id: { type: string, nullable: true }
                start_date:  { type: string, format: date-time }
                end_date:    { type: string, format: date-time }
                tenant_id:   { type: string }
                active:      { type: boolean, default: true }
                metadata:    { type: object }
      responses: { "200": { description: OK }, "409": { description: "E_PROJECT_DUPLICATE_CODE" } }
  /projects/next-code:
    get: { summary: "Autosugerencia de código (incrementa el último) (scope: projects:read)", responses: { "200": { description: OK } } }
  /projects/{id}:
    parameters: [ { in: path, name: id, required: true, schema: { type: string } } ]
    get: { summary: "Project detail with vehicleCount (scope: projects:read)", responses: { "200": { description: OK } } }
    put: { summary: "Update project (scope: projects:write)", responses: { "200": { description: OK }, "409": { description: "E_PROJECT_DUPLICATE_CODE" } } }
    delete: { summary: "Soft delete project, active=false (scope: projects:write)", responses: { "200": { description: OK } } }
  /projects/{id}/vehicles:
    parameters: [ { in: path, name: id, required: true, schema: { type: string } } ]
    get: { summary: "Vehicles assigned to the project (scope: projects:read)", responses: { "200": { description: OK } } }
    post:
      summary: "Assign vehicle(s) to the project — M:N (scope: projects:write)"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                vehicleIds: { type: array, items: { type: string } }
                vehicleId:  { type: string }
      responses: { "200": { description: OK } }
  /projects/{id}/vehicles/{vehicleId}:
    parameters:
      - { in: path, name: id, required: true, schema: { type: string } }
      - { in: path, name: vehicleId, required: true, schema: { type: string } }
    delete: { summary: "Remove vehicle from the project (scope: projects:write)", responses: { "200": { description: OK } } }
  /projects/{id}/dashboard:
    parameters: [ { in: path, name: id, required: true, schema: { type: string } } ]
    get:
      summary: "Dashboard del proyecto: km + horas por vehículo en rango, posición y foto (scope: projects:read)"
      parameters:
        - { in: query, name: date_from, required: true, schema: { type: string, format: date-time } }
        - { in: query, name: date_to, required: true, schema: { type: string, format: date-time } }
      responses: { "200": { description: OK } }
  /monitoring/summary:
    get: { summary: "Resumen de Monitoreo: alarmas importantes (no ack), comunes y desconexiones (3 niveles) (scope: alarms:read)", responses: { "200": { description: OK } } }
  /monitoring/ack-all:
    post:
      summary: "Marcar todas las alarmas no reconocidas como leídas (scope: alarms:write)"
      parameters:
        - { in: query, name: severity, schema: { type: string, enum: [critical, high, normal, low] }, description: "Opcional: solo de esa severidad" }
      responses: { "200": { description: OK } }
