openapi: 3.0.3
info:
  title: Reloj CR · Oferta software v1 · RFP-BIO-2026-01
  version: "1.0.0"
  description: |
    API REST de asistencia biométrica (Colombia, America/Bogota). Versionada en `/api/v1`.
    Auth: `X-API-Key`, `Authorization: Bearer <api-key>` o JWT de `POST /api/v1/auth/login`.
    HTTPS se termina en el reverse proxy. Sandbox: `SANDBOX=true` (archivo SQLite distinto).
    Rate limit: 80/min (10/min login). Códigos: AUTH_REQUIRED, FORBIDDEN, VALIDATION,
    INVALID_CREDENTIALS, TOTP_REQUIRED, INVALID_TOTP, INVALID_PIN, NOT_FOUND.
    Marcaciones ULID idempotentes. Webhooks HMAC opcionales (punch.created, terminal.offline, sync.failed).
  contact:
    name: Reloj CR
servers:
  - url: /
    description: Mismo origen
security:
  - ApiKey: []
  - Bearer: []
paths:
  /api/v1:
    get:
      summary: Versión y edición
      security: []
      responses:
        "200": { description: Metadatos }
  /api/v1/health:
    get:
      summary: Salud, sandbox, terminales, rate limit
      security: []
      responses:
        "200": { description: Estado }
  /api/v1/openapi:
    get:
      summary: Esta especificación
      security: []
      responses:
        "200": { description: YAML }
  /api/v1/catalog:
    get:
      summary: Países, zonas, sedes, tipos de evento
      security: []
      responses:
        "200": { description: Catálogo }
  /api/v1/auth/login:
    post:
      summary: JWT (email + password + totp opcional)
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, password]
              properties:
                email: { type: string }
                password: { type: string }
                totp: { type: string }
      responses:
        "200": { description: token + user }
        "401": { description: INVALID_CREDENTIALS / TOTP_REQUIRED / INVALID_TOTP }
  /api/v1/auth/me:
    get:
      summary: Sesión actual
      responses:
        "200": { description: Actor }
  /api/v1/auth/totp:
    post:
      summary: Activa TOTP para el usuario JWT
      responses:
        "200": { description: secreto Base64 }
  /api/v1/auth/oidc:
    get:
      summary: Stub OIDC/OAuth2
      security: []
      responses:
        "200": { description: issuer / nota de integración }
  /api/v1/employees:
    get:
      summary: Colaboradores (alcance RBAC)
      parameters:
        - in: query
          name: site
          schema: { type: string }
        - in: query
          name: includeDeleted
          schema: { type: string }
      responses:
        "200": { description: Lista }
    post:
      summary: Alta
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, code, siteId]
              properties:
                name: { type: string }
                code: { type: string }
                siteId: { type: string }
                role: { type: string }
                pin: { type: string }
      responses:
        "201": { description: Creado }
  /api/v1/employees/{id}:
    get:
      summary: Detalle
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
      responses:
        "200": { description: Empleado }
    patch:
      summary: Editar, trasladar, desactivar, baja (borra plantillas)
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                code: { type: string }
                role: { type: string }
                siteId: { type: string }
                active: { type: boolean }
                deleted: { type: boolean }
                pin: { type: string, nullable: true }
                revokeConsent: { type: boolean }
      responses:
        "200": { description: Actualizado }
  /api/v1/punches:
    get:
      summary: Incremental por since / cursor
      parameters:
        - in: query
          name: site
          schema: { type: string }
        - in: query
          name: since
          schema: { type: string, format: date-time }
        - in: query
          name: cursor
          schema: { type: string }
        - in: query
          name: afterId
          schema: { type: string }
        - in: query
          name: limit
          schema: { type: integer }
      responses:
        "200": { description: punches + nextCursor }
    post:
      summary: Alta ULID (kiosco autenticado o API)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PunchIn"
      responses:
        "201": { description: Creada }
        "200": { description: Idempotente }
  /api/v1/punches/stream:
    get:
      summary: SSE cada 4 s
      responses:
        "200": { description: text/event-stream }
  /api/v1/punches/pin:
    post:
      summary: Respaldo F08 (PIN supervisor + motivo). Token de terminal o usuario con escritura
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [employeeId, siteId, type, terminalId, reason, supervisorPin]
              properties:
                employeeId: { type: string }
                siteId: { type: string }
                type: { type: string }
                terminalId: { type: string }
                reason: { type: string }
                supervisorPin: { type: string }
      responses:
        "201": { description: Autorizada }
        "403": { description: INVALID_PIN }
  /api/v1/terminals/activate:
    post:
      summary: Activa un kiosco para una sede (operador o superior). Devuelve un token de terminal de 1 año
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [siteId]
              properties:
                siteId: { type: string, description: id o código de sede }
                label: { type: string }
      responses:
        "201": { description: "{ token, terminalId, site, expiresHours }" }
        "403": { description: Sede fuera del alcance del usuario }
  /api/v1/terminals/{id}/revoke:
    post:
      summary: Revoca el token de una terminal (gerente de sede o superior)
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "200": { description: Revocada }
        "404": { description: No existe o ya revocada }
  /api/v1/anomalies:
    get:
      summary: Anomalías P3
      responses:
        "200": { description: Lista }
    post:
      summary: Recalcular reglas del día
      responses:
        "200": { description: created + lista }
  /api/v1/anomalies/{id}:
    patch:
      summary: Marcar revisada
      parameters:
        - { in: path, name: id, required: true, schema: { type: string } }
      responses:
        "200": { description: ok }
  /api/v1/ai/briefing:
    get:
      summary: Briefing del día (cron)
      responses:
        "200": { description: 5 bullets }
    post:
      summary: Generar briefing
      responses:
        "200": { description: 5 bullets }
  /api/v1/ai/chat:
    post:
      summary: Pregunta a Reloj CR
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [question]
              properties:
                question: { type: string }
                day: { type: string }
                siteId: { type: string }
      responses:
        "200": { description: answer + cites }
  /api/v1/reports:
    get:
      summary: Novedades del día
      parameters:
        - { in: query, name: day, schema: { type: string } }
        - { in: query, name: site, schema: { type: string } }
        - { in: query, name: employee, schema: { type: string } }
        - { in: query, name: status, schema: { type: string } }
      responses:
        "200": { description: totals + rows }
  /api/v1/exports/pack:
    get:
      summary: Pack de portabilidad
      parameters:
        - in: query
          name: format
          schema: { type: string, enum: [json, csv, xlsx, pdf, zip] }
      responses:
        "200": { description: Archivo o JSON }
  /api/v1/corrections:
    get:
      summary: Historial de correcciones
      responses:
        "200": { description: Lista }
    post:
      summary: Corrección con motivo y aprobador (gerente+)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [punchId, reason]
              properties:
                punchId: { type: string }
                reason: { type: string }
                newTs: { type: string }
                newType: { type: string }
      responses:
        "201": { description: Registrada }
  /api/v1/schedules:
    get:
      responses:
        "200": { description: Turnos }
    post:
      responses:
        "201": { description: Upsert }
  /api/v1/exceptions:
    get:
      responses:
        "200": { description: Excepciones }
    post:
      responses:
        "201": { description: Creada }
  /api/v1/alerts:
    get:
      responses:
        "200": { description: Alertas }
    post:
      security: []
      responses:
        "201": { description: Creada (p. ej. sync.failed del kiosco) }
  /api/v1/alerts/{id}:
    patch:
      summary: Acusar alerta
      parameters:
        - { in: path, name: id, required: true, schema: { type: string } }
      responses:
        "200": { description: ok }
  /api/v1/settings:
    get:
      responses:
        "200": { description: Ajustes (sin hash de PIN) }
    patch:
      responses:
        "200": { description: Guardado }
  /api/v1/settings/retention:
    post:
      summary: Aplica retención ahora
      responses:
        "200": { description: ok }
  /api/v1/enrollment-audit:
    get:
      responses:
        "200": { description: F02 }
  /api/v1/users:
    get:
      summary: Usuarios (superadmin)
      responses:
        "200": { description: Lista }
  /api/health:
    get:
      summary: Salud legado
      security: []
      responses:
        "200": { description: Estado }
  /api/sites:
    get:
      security: []
      responses:
        "200": { description: Sedes }
  /api/employees:
    get:
      responses:
        "200": { description: Colaboradores }
    post:
      responses:
        "201": { description: Alta legado }
  /api/employees/{id}:
    patch:
      parameters:
        - { in: path, name: id, required: true, schema: { type: string } }
      responses:
        "200": { description: Actualizado }
  /api/employees/{id}/templates:
    post:
      parameters:
        - { in: path, name: id, required: true, schema: { type: string } }
      responses:
        "201": { description: Enrolado }
    delete:
      parameters:
        - { in: path, name: id, required: true, schema: { type: string } }
      responses:
        "200": { description: Wipe }
  /api/templates:
    get:
      responses:
        "200": { description: Galería }
  /api/identify:
    post:
      responses:
        "200": { description: matched | unknown }
  /api/punches:
    get:
      responses:
        "200": { description: Feed }
    post:
      requestBody:
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PunchIn"
      responses:
        "201": { description: Creada }
  /api/sync:
    post:
      responses:
        "200": { description: accepted + duplicates }
  /api/punches/export:
    get:
      responses:
        "200": { description: CSV }
  /api/terminals:
    get:
      responses:
        "200": { description: Terminales }
  /api/terminals/heartbeat:
    post:
      responses:
        "200": { description: ok }
  /api/audit:
    get:
      responses:
        "200": { description: Eventos }
components:
  securitySchemes:
    ApiKey:
      type: apiKey
      in: header
      name: X-API-Key
    Bearer:
      type: http
      scheme: bearer
  schemas:
    PunchIn:
      type: object
      required: [id, siteId, type, capturedAt, terminalId]
      properties:
        id: { type: string, description: ULID }
        siteId: { type: string }
        type: { type: string }
        capturedAt: { type: string, format: date-time }
        terminalId: { type: string }
        employeeId: { type: string, nullable: true }
        matchScore: { type: number, nullable: true }
        decision: { type: string }
        livenessHint: { type: string }
        offline: { type: boolean }
        method: { type: string }
        reason: { type: string }
