openapi: 3.0.3
info:
  title: App Ofertas — API
  description: |
    API REST com JWT (HS256).

    **Sem autenticação:** leitura de cidades, categorias, empresas, comentários públicos, catálogo de tipos de oferta, **documentos legais** (`/api/documentos-legais`) e **`POST /api/analise/imagem`** (pré-validação de imagem). **`GET /api/ofertas`** e **`GET /api/ofertas/{id}`** são públicos mas aceitam **Bearer opcional** para aplicar o público-alvo da oferta (ver schema `audience`). Isto **não** inclui notificações.

    **Com autenticação (sempre com `Authorization: Bearer`):** perfil (`/api/usuario/...`), seguir empresas, ofertas guardadas/lidas, dispositivo FCM e **`GET /api/notificacoes`** (feed pessoal; **não existe** endpoint público de notificações — sem token → `401`).

    **Obter o JWT:** `POST /api/autenticacao/google` (app recomendada), `POST /api/autenticacao/codigo/solicitar` + `confirmar`, ou login/registo com **senha** (legado / painel).

    **Documentação interativa:** [`/documentacao/`](/documentacao/) — guia, Swagger e OpenAPI YAML.

    **I.A. de Marketing — Agente Consultor (v2)** (assistente de ofertas para loja física): conversa adaptativa com até **4 turnos** dirigida pelo backend, exclusiva para contas **empresa** (`tipo_utilizador: empresa`) com JWT — `GET /api/ia-marketing/contexto`, `POST /api/ia-marketing/sessao`, `POST /api/ia-marketing/sessao/{sessao_id}/passo`, `POST /api/ia-marketing/texto-vitrine`, `DELETE /api/ia-marketing/sessao/{sessao_id}`. As rotas legadas `/api/oraculo/...` foram removidas. Cada turno traz uma `pergunta` com `modo_resposta` (`escolha_unica`, `escolha_com_outro`, `texto_livre`, `confirmacao`); ao encerrar, devolve 2–4 sugestões alinhadas ao catálogo.

    **Painel empresa — ofertas, revisão e métricas:** rotas `GET|POST|PATCH|DELETE /api/empresa/{empresa_id}/ofertas[...]`, `GET /api/empresa/{empresa_id}/acesso-conta`, `POST /api/empresa/{empresa_id}/perfil/revisao` (fila de aprovação) e `GET .../metricas` (JWT, `tipo_utilizador: empresa`, `usuario_empresa_acesso`). O acesso à publicação é por **plano / assinatura** (ver `empresa_acesso`). **Resposta a comentários pela API** e **notificações lidas** continuam descritas em **`documentacao/API_OFERTAS.md`** onde ainda forem lacunas.

    **Pré-validação de imagens (app):** `POST /api/analise/imagem` (**sem autenticação**) — envio multipart com **ou** ficheiro `image` **ou** campo `url`; resposta inclui `aprovado`, percentuais por classe (`analise`), `predominante` e `medidor_risco_percentual`. Detalhes em **`documentacao/API_MODERACAO_IMAGEM.md`**.
  version: 1.0.0
servers:
  - url: https://ofertup.shop
    description: Produção (exemplo)
  - url: /
    description: Raiz relativa (dev / mesmo host)
tags:
  - name: Autenticação e conta
    description: |
      **Fluxo JWT (app recomendado)**
      1. `POST /api/autenticacao/google` com `id_token` do Google Sign-In **ou** `POST /api/autenticacao/codigo/solicitar` e depois `POST /api/autenticacao/codigo/confirmar` com email + código.
      2. Na primeira vez o servidor **cria** o registo em `usuarios` (`provedor_login` = `google` ou `email_codigo`); nas seguintes reutiliza o mesmo `id` — o JWT (`access_token`) é o mesmo formato que antes.
      3. Cabeçalho nas rotas protegidas: `Authorization: Bearer <access_token>`.

      **Legado / painel:** `POST /api/autenticacao/token` e `registro` com **email e senha** (contas com `senha_hash`).

      **Algoritmo:** HS256.

      **Claims úteis no payload (JWT):** `sub` (id do utilizador), `email`, `tipo_utilizador`, `empresas` (ids), `admin` (opcional), `exp`, `iat`, e opcionalmente `nome`.

      **Erros comuns:** `401` com `erro: credenciais_invalidas` ou `nao_autorizado`; `503` com `banco_indisponivel` se o MySQL não estiver acessível.

      **Notificações:** `GET /api/notificacoes` **obriga** JWT (o mesmo token obtido por Google, código por email ou login com senha). Não há rota pública: anónimos recebem `401`. O feed é filtrado por empresas que segue e por ofertas guardadas / empresas seguidas.
  - name: Dados públicos
    description: |
      Leitura **sem** cabeçalho `Authorization` — cidades, categorias, empresas, ofertas, comentários, pacotes, catálogo de tipos, documentos legais (termos, privacidade, LGPD).
      **`GET /api/ofertas` e `GET /api/ofertas/{id}`** são públicos e aceitam `Authorization: Bearer` **opcional**: com sessão, o servidor aplica o público-alvo (`audience`: seguidores, não seguidores, proximidade); sem token, ofertas que exigem sessão para o segmento não entram no resultado.

      **Não inclui notificações:** o feed (`GET /api/notificacoes`) exige JWT e está na tag «Autenticação e conta».
  - name: Catálogo de tipos de oferta
    description: Manifesto e definições JSON dos tipos dinâmicos de oferta.
  - name: I.A. de Marketing
    description: |
      Agente Consultor: até **4 turnos** dirigidos pelo backend, perguntas geradas em função do perfil da empresa,
      do histórico de respostas e (quando configurado) da DeepSeek; quatro modos de resposta possíveis
      (`escolha_unica`, `escolha_com_outro`, `texto_livre`, `confirmacao`). Ao encerrar, devolve sugestões finais
      validadas pelo catálogo. Apenas `tipo_utilizador: empresa` com vínculo em `usuario_empresa_acesso`.
      Migrações: `sql/migracoes/2026_05_oraculo_sessao.sql`, `sql/migracoes/2026_08_oraculo_agente_v2.sql`.
  - name: Empresa — ofertas e métricas
    description: |
      Gestão de ofertas na tabela `ofertas` com `ServicoGestaoOfertaEmpresa`: janela máxima de **7 dias de calendário** (inclusive),
      débito de crédito na 1ª publicação e em **reativações** após pausa, **imutabilidade** do conteúdo após publicar
      (PATCH só `pausada`), exclusão (soft delete) só após **`min_exclusao_em`** (1º dia útil após `valid_until`, timezone `America/Asuncion`).
      Exige JWT, `tipo_utilizador: empresa` e vínculo ativo com papel `proprietario`, `gerente` ou `editor`.
  - name: Empresa — perfil e revisão
    description: |
      Pedidos de alteração ao perfil público (`empresas`) sujeitos à fila `revisao_alteracao`.
      Aprovação manual ou automática (imagens) via `bin/console app:revisao:*`. Variável `MODERACAO_TERMO_PROIBIDO` para texto.
  - name: Moderação de imagem
    description: |
      Pré-validação com NSFWJS — **rota pública** (`POST /api/analise/imagem`). Ver `documentacao/API_MODERACAO_IMAGEM.md`.
paths:
  /api/autenticacao/token:
    post:
      operationId: autenticacaoEmitirToken
      tags: [Autenticação e conta]
      summary: Login com senha (legado)
      description: |
        Valida `email` e `senha` quando a conta tem `senha_hash` (ex.: admin, registo antigo). Contas criadas só com Google ou código **não** têm senha — use `/autenticacao/google` ou `/autenticacao/codigo/confirmar`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CredenciaisLogin' }
            examples:
              demo:
                summary: Conta de teste (ajuste à sua base)
                value:
                  email: admin@local.test
                  senha: sua_senha_aqui
      responses:
        '200':
          description: Credenciais válidas; token JWT emitido.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/RespostaToken' }
              examples:
                ok:
                  summary: Sucesso
                  value:
                    tipo: Bearer
                    access_token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwiZW1haWwiOiJhZG1pbkBsb2NhbC50ZXN0Iiwibm9tZSI6IkFkbWluIiwiaWF0IjoxNzE1MjY0ODAwLCJleHAiOjE3MTUyNjgzMDB9.exemplo_assinatura
                    expira_em_segundos: 3600
        '400':
          $ref: '#/components/responses/Erro400'
        '401':
          $ref: '#/components/responses/Erro401Credenciais'
        '503':
          $ref: '#/components/responses/Erro503'
  /api/autenticacao/registro:
    post:
      operationId: autenticacaoRegistro
      tags: [Autenticação e conta]
      summary: Registo com senha (legado)
      description: |
        Cria conta com email e senha (`senha_hash`). Para a app cliente o fluxo recomendado é **Google** ou **email + código**; use este endpoint para painel, testes ou migrações.

        Devolve o mesmo formato de token que o login, mais o `id` da nova conta.

        Email duplicado → `409` com `erro: email_ja_registrado`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CorpoRegisto' }
            examples:
              novo_utilizador:
                summary: Registo com nome
                value:
                  email: maria.silva@example.com
                  senha: MinhaSenh4Segura!
                  nome: Maria Silva
              so_email_senha:
                summary: Só email e senha
                value:
                  email: joao@example.com
                  senha: OutraSenh4!
      responses:
        '201':
          description: Conta criada; token JWT já utilização imediata.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/RespostaRegisto' }
              examples:
                criado:
                  summary: Conta nova
                  value:
                    id: 42
                    tipo: Bearer
                    access_token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.exemplo
                    expira_em_segundos: 3600
        '400':
          $ref: '#/components/responses/Erro400'
        '409':
          description: Email já existente
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErroJson' }
              examples:
                duplicado:
                  value:
                    erro: email_ja_registrado
                    mensagem: Este email já está registado.
        '503':
          $ref: '#/components/responses/Erro503'
  /api/autenticacao/google:
    post:
      operationId: autenticacaoGoogle
      tags: [Autenticação e conta]
      summary: Login com Google (id_token)
      description: |
        Envia o **ID token** JWT devolvido pelo Google Sign-In na app (Android/iOS/Web). O servidor valida assinatura e `aud` com as chaves públicas do Google.

        Configure `GOOGLE_OAUTH_CLIENT_IDS` no `.env` com **todos** os client IDs OAuth que a app usa (separados por vírgula), para o `aud` do token ser aceite.

        Primeiro login: cria utilizador com `provedor_login=google` e `id_externo_provedor=sub`. Se já existir conta com o mesmo email (ex.: registo por senha), associa o Google a essa linha e remove a senha.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CorpoGoogleIdToken' }
            example:
              id_token: eyJhbGciOiJSUzI1NiIsImtpZCI6IjEuLi4ifQ...
      responses:
        '200':
          description: Token JWT emitido (mesmo formato que login por senha).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/RespostaToken' }
        '400':
          $ref: '#/components/responses/Erro400'
        '401':
          description: ID token Google inválido ou rejeitado
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErroJson' }
        '403':
          description: Conta inativa
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErroJson' }
        '409':
          description: Conflito de vínculo Google/email
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErroJson' }
        '503':
          description: Google não configurado, JWKS indisponível ou banco indisponível
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErroJson' }
  /api/autenticacao/codigo/solicitar:
    post:
      operationId: autenticacaoCodigoSolicitar
      tags: [Autenticação e conta]
      summary: Pedir código por email
      description: |
        Gera um código numérico (TTL configurável) e envia por email (SMTP via `MAIL_*` no `.env`).
        Com `APP_DEBUG=true` e sem SMTP, a resposta pode incluir `codigo_desenvolvimento` (apenas para testes).
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CorpoCodigoSolicitar' }
      responses:
        '200':
          description: Pedido aceite (envio depende do SMTP).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/RespostaCodigoSolicitar' }
        '400':
          $ref: '#/components/responses/Erro400'
        '429':
          description: Pedido de código em cooldown
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErroJson' }
        '503':
          $ref: '#/components/responses/Erro503'
  /api/autenticacao/codigo/confirmar:
    post:
      operationId: autenticacaoCodigoConfirmar
      tags: [Autenticação e conta]
      summary: Confirmar código e obter JWT
      description: |
        Valida o código recebido por email. Se for a primeira vez com esse email, cria conta com `provedor_login=email_codigo` (sem senha).
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CorpoCodigoConfirmar' }
      responses:
        '200':
          description: JWT emitido
          content:
            application/json:
              schema: { $ref: '#/components/schemas/RespostaToken' }
        '400':
          $ref: '#/components/responses/Erro400'
        '401':
          description: Código incorreto ou expirado
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErroJson' }
        '429':
          description: Muitas tentativas
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErroJson' }
        '503':
          $ref: '#/components/responses/Erro503'
  /api/cidades:
    get:
      summary: Listar cidades
      tags: [Dados públicos]
      responses:
        '200':
          description: Array no formato do asset `cidades.json`
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/CidadeAsset' }
        '503': { $ref: '#/components/responses/Erro503' }
  /api/documentos-legais:
    get:
      operationId: listarDocumentosLegais
      summary: Listar documentos legais (app)
      description: |
        Textos públicos para a app (termos, política de privacidade, LGPD, cookies).
        Ordenação pelo campo `ordem` na base; só registos `ativo = 1`.
        Migração de referência: `sql/migracoes/2026_10_documentos_legais_app.sql`.
      tags: [Dados públicos]
      responses:
        '200':
          description: Lista de resumos (sem o corpo completo).
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/DocumentoLegalResumo' }
        '503': { $ref: '#/components/responses/Erro503' }
  /api/documentos-legais/{slug}:
    get:
      operationId: obterDocumentoLegalPorSlug
      summary: Detalhe de documento legal (texto integral)
      tags: [Dados públicos]
      parameters:
        - name: slug
          in: path
          required: true
          schema: { type: string }
          example: politica-de-privacidade
      responses:
        '200':
          description: Objeto com `corpo` em texto simples (UTF-8).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/DocumentoLegalDetalhe' }
        '404': { $ref: '#/components/responses/Erro404' }
        '503': { $ref: '#/components/responses/Erro503' }
  /api/categorias:
    get:
      summary: Listar nomes de categorias de empresa
      tags: [Dados públicos]
      responses:
        '200':
          content:
            application/json:
              schema:
                type: array
                items: { type: string }
        '503': { $ref: '#/components/responses/Erro503' }
  /api/empresas:
    get:
      summary: Listar empresas
      tags: [Dados públicos]
      parameters:
        - in: query
          name: cidade_id
          schema: { type: integer }
        - in: query
          name: cidade
          description: Nome da cidade (case-insensitive); usado se `cidade_id` ausente
          schema: { type: string }
        - in: query
          name: categoria
          schema: { type: string }
        - in: query
          name: limite
          schema: { type: integer, default: 50, minimum: 1, maximum: 200 }
        - in: query
          name: cursor
          description: Offset de paginação
          schema: { type: integer, default: 0, minimum: 0 }
        - in: query
          name: lat
          schema: { type: number, format: float }
        - in: query
          name: lon
          schema: { type: number, format: float }
      responses:
        '200':
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/EmpresaAsset' }
        '503': { $ref: '#/components/responses/Erro503' }
  /api/empresas/{id}:
    get:
      summary: Detalhe da empresa
      tags: [Dados públicos]
      parameters:
        - $ref: '#/components/parameters/IdEmpresa'
        - in: query
          name: lat
          schema: { type: number }
        - in: query
          name: lon
          schema: { type: number }
      responses:
        '200':
          content:
            application/json:
              schema: { $ref: '#/components/schemas/EmpresaAsset' }
        '400': { $ref: '#/components/responses/Erro400' }
        '404': { $ref: '#/components/responses/Erro404' }
        '503': { $ref: '#/components/responses/Erro503' }
  /api/empresas/{id}/comentarios:
    get:
      summary: Comentários da empresa
      tags: [Dados públicos]
      description: |
        Leitura pública. `company_reply` é preenchido quando existe `resposta_empresa_json` na base.
        **Gravar resposta pela empresa** (autenticado) ainda não está exposto na API — ver `documentacao/API_OFERTAS.md`.
      parameters:
        - $ref: '#/components/parameters/IdEmpresa'
      responses:
        '200':
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/ComentarioAsset' }
        '400': { $ref: '#/components/responses/Erro400' }
        '503': { $ref: '#/components/responses/Erro503' }
  /api/ofertas:
    get:
      summary: Listar ofertas
      tags: [Dados públicos]
      description: |
        Lista ofertas visíveis (publicadas, no prazo, etc.). Query `lat`/`lon` usada para distância na resposta e para o filtro de **proximidade** quando a oferta o exige.
        **Bearer opcional:** com `Authorization`, utilizadores autenticados veem também ofertas segmentadas para seguidores / não seguidores; anónimos (sem cabeçalho) não vêem essas campanhas.
      parameters:
        - in: query
          name: cidade_id
          schema: { type: integer }
        - in: query
          name: cidade
          schema: { type: string }
        - in: query
          name: empresa_id
          schema: { type: string }
        - in: query
          name: apenas_ativas
          schema: { type: boolean, default: false }
        - in: query
          name: incluir_empresa
          description: Adiciona objeto `company` como no merge do app Flutter
          schema: { type: boolean, default: false }
        - in: query
          name: incluir_pausadas
          description: Se true, inclui ofertas com `pausada`; por defeito só publicadas
          schema: { type: boolean, default: false }
        - in: query
          name: limite
          schema: { type: integer, default: 100, minimum: 1, maximum: 500 }
        - in: query
          name: cursor
          schema: { type: integer, default: 0 }
        - in: query
          name: lat
          schema: { type: number }
        - in: query
          name: lon
          schema: { type: number }
      responses:
        '200':
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/OfertaAsset' }
        '503': { $ref: '#/components/responses/Erro503' }
  /api/ofertas/{id}:
    get:
      summary: Detalhe da oferta
      tags: [Dados públicos]
      description: |
        Para **anónimos / consumidor**, só ofertas **visíveis no feed público** (publicadas, não pausadas, dentro do prazo, não removidas) e que cumpram o **público-alvo** (`audience`) com o contexto do pedido (sessão opcional em `Authorization`, `lat`/`lon` quando a oferta exige proximidade).
        Caso contrário responde **404** com `erro: oferta_indisponivel` (alinhado a QR e partilhas).
      parameters:
        - $ref: '#/components/parameters/IdOferta'
        - in: query
          name: incluir_empresa
          schema: { type: boolean }
        - in: query
          name: lat
          schema: { type: number }
        - in: query
          name: lon
          schema: { type: number }
      responses:
        '200':
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OfertaAsset' }
        '400': { $ref: '#/components/responses/Erro400' }
        '404': { $ref: '#/components/responses/Erro404' }
        '503': { $ref: '#/components/responses/Erro503' }
  /api/empresa/{empresa_id}/perfil/revisao:
    post:
      operationId: empresaSolicitarRevisaoPerfil
      tags: [Empresa — perfil e revisão]
      summary: Pedir alteração ao perfil público (fila de aprovação)
      description: |
        Não altera `empresas` imediatamente. Cria registo em `revisao_alteracao`.
        Texto moderado no pedido (`MODERACAO_TERMO_PROIBIDO`). Imagens podem marcar `requer_verificacao_imagem`
        para auto-processamento via `bin/console app:revisao:processar-pendentes` (ou legado `processar-imagens`).
      security: [{ bearerAuth: [] }]
      parameters:
        - name: empresa_id
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/EmpresaPerfilRevisaoCorpo' }
      responses:
        '202':
          description: Pedido aceite para revisão.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/RespostaRevisaoPendente' }
        '400': { $ref: '#/components/responses/Erro400' }
        '401': { $ref: '#/components/responses/Erro401' }
        '403': { $ref: '#/components/responses/Erro403' }
        '404': { $ref: '#/components/responses/Erro404' }
        '503': { $ref: '#/components/responses/Erro503' }
  /api/empresa/{empresa_id}/perfil/revisao/ultima:
    get:
      operationId: empresaObterUltimaRevisaoPerfil
      tags: [Empresa — perfil e revisão]
      summary: Última revisão de perfil (estado e motivo)
      description: |
        Último registo em `revisao_alteracao` com tipo `empresa_perfil` para esta empresa (qualquer estado).
        Útil para o app mostrar pendência ou recusa sem expor o payload completo.
      security: [{ bearerAuth: [] }]
      parameters:
        - name: empresa_id
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          description: "`revisao` é `null` se nunca houve pedido."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/RespostaUltimaRevisaoPerfil' }
        '401': { $ref: '#/components/responses/Erro401' }
        '403': { $ref: '#/components/responses/Erro403' }
        '503': { $ref: '#/components/responses/Erro503' }
  /api/empresa/{empresa_id}/acesso-conta:
    get:
      operationId: empresaObterAcessoConta
      tags: [Empresa — perfil e revisão]
      summary: Estado do plano e permissão para publicar ofertas
      description: |
        Dados em `empresa_acesso` + `plano_catalogo`. Inclui `portal_parceiro_url` (variável de ambiente `PORTAL_PARCEIRO_URL_LOGIN`) para o app abrir o portal web.
      security: [{ bearerAuth: [] }]
      parameters:
        - name: empresa_id
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          content:
            application/json:
              schema: { $ref: '#/components/schemas/EmpresaAcessoConta' }
        '401': { $ref: '#/components/responses/Erro401' }
        '403': { $ref: '#/components/responses/Erro403' }
        '503': { $ref: '#/components/responses/Erro503' }
  /api/empresa/{empresa_id}/ofertas:
    get:
      operationId: empresaListarOfertas
      tags: [Empresa — ofertas e métricas]
      summary: Listar ofertas da empresa (painel)
      security: [{ bearerAuth: [] }]
      parameters:
        - name: empresa_id
          in: path
          required: true
          schema: { type: string }
        - name: limite
          in: query
          schema: { type: integer, default: 50, minimum: 1, maximum: 200 }
        - name: cursor
          in: query
          schema: { type: integer, default: 0, minimum: 0 }
      responses:
        '200':
          description: |
            Inclui `estado` (`rascunho`, `publicando`, `rejeitada`, `em_revisao_conteudo`, `rejeitada_alteracao`, `ao_vivo`, `pausada`, `encerrada`, `agendada`).
            `publicando` — pedido de publicação em fila; `rejeitada` — último pedido de publicação recusado; `em_revisao_conteudo` / `rejeitada_alteracao` — alteração pós-período em revisão ou recusada (ver `revisao_motivo`).
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/OfertaEmpresaPainel' }
        '401': { $ref: '#/components/responses/Erro401' }
        '403': { $ref: '#/components/responses/Erro403' }
        '503': { $ref: '#/components/responses/Erro503' }
    post:
      operationId: empresaCriarOuAtualizarOferta
      tags: [Empresa — ofertas e métricas]
      summary: Criar ou atualizar rascunho; pedido de publicação passa por revisão
      security: [{ bearerAuth: [] }]
      parameters:
        - name: empresa_id
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CorpoEmpresaOfertaUpsert' }
      responses:
        '200':
          description: |
            Com `publicar: true`, a oferta permanece rascunho até aprovação; resposta inclui `estado` `publicando` e `revisao`.
            A publicação exige plano activo (`empresa_acesso`); ver `GET /api/empresa/{empresa_id}/acesso-conta`.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OfertaEmpresaPainel' }
        '400': { $ref: '#/components/responses/Erro400' }
        '401': { $ref: '#/components/responses/Erro401' }
        '403':
          description: |
            Inclui `erro: acesso_bloqueado` quando o plano da empresa não permite publicar; o JSON pode incluir `portal_parceiro_url`.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErroJson' }
        '409':
          description: Oferta imutável, já publicada, datas inválidas, etc. (não confundir com plano inactivo — esse caso é **403**).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErroJson' }
        '503': { $ref: '#/components/responses/Erro503' }
  /api/empresa/{empresa_id}/ofertas/{oferta_id}:
    get:
      operationId: empresaObterOferta
      tags: [Empresa — ofertas e métricas]
      summary: Detalhe para gestão
      security: [{ bearerAuth: [] }]
      parameters:
        - name: empresa_id
          in: path
          required: true
          schema: { type: string }
        - name: oferta_id
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OfertaEmpresaPainel' }
        '401': { $ref: '#/components/responses/Erro401' }
        '403': { $ref: '#/components/responses/Erro403' }
        '404': { $ref: '#/components/responses/Erro404' }
        '503': { $ref: '#/components/responses/Erro503' }
    patch:
      operationId: empresaPatchOferta
      tags: [Empresa — ofertas e métricas]
      summary: Pausar ou reativar (`pausada`)
      security: [{ bearerAuth: [] }]
      parameters:
        - name: empresa_id
          in: path
          required: true
          schema: { type: string }
        - name: oferta_id
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                pausada: { type: boolean }
      responses:
        '200':
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OfertaEmpresaPainel' }
        '400': { $ref: '#/components/responses/Erro400' }
        '401': { $ref: '#/components/responses/Erro401' }
        '403':
          description: |
            Inclui `erro: acesso_bloqueado` ao reativar sem plano activo; o JSON pode incluir `portal_parceiro_url`.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErroJson' }
        '404': { $ref: '#/components/responses/Erro404' }
        '409':
          description: Regras de pausa/reativação ou estado da oferta (ex. já activa)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErroJson' }
        '503': { $ref: '#/components/responses/Erro503' }
    delete:
      operationId: empresaExcluirOferta
      tags: [Empresa — ofertas e métricas]
      summary: Arquivar / exclusão lógica quando permitido
      security: [{ bearerAuth: [] }]
      parameters:
        - name: empresa_id
          in: path
          required: true
          schema: { type: string }
        - name: oferta_id
          in: path
          required: true
          schema: { type: string }
      responses:
        '204':
          description: Sucesso; sem corpo.
        '401': { $ref: '#/components/responses/Erro401' }
        '403': { $ref: '#/components/responses/Erro403' }
        '404': { $ref: '#/components/responses/Erro404' }
        '409':
          description: "Código típico exclusao_bloqueada; o JSON pode incluir min_exclusao_em."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErroJson' }
        '503': { $ref: '#/components/responses/Erro503' }
  /api/empresa/{empresa_id}/ofertas/{oferta_id}/metricas:
    get:
      operationId: empresaMetricasOferta
      tags: [Empresa — ofertas e métricas]
      summary: Agregados de eventos por oferta
      security: [{ bearerAuth: [] }]
      parameters:
        - name: empresa_id
          in: path
          required: true
          schema: { type: string }
        - name: oferta_id
          in: path
          required: true
          schema: { type: string }
        - name: granularidade
          in: query
          schema:
            type: string
            enum: [dia, hora]
            default: dia
        - name: de
          in: query
          description: Início do intervalo (`Y-m-d H:i:s`); com `ate` omisso, o servidor usa últimos 30 dias.
          schema: { type: string }
        - name: ate
          in: query
          schema: { type: string }
      responses:
        '200':
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MetricasOfertaResposta' }
        '401': { $ref: '#/components/responses/Erro401' }
        '403': { $ref: '#/components/responses/Erro403' }
        '404': { $ref: '#/components/responses/Erro404' }
        '503': { $ref: '#/components/responses/Erro503' }
  /api/pacotes-creditos:
    get:
      deprecated: true
      summary: Pacotes de créditos (legado)
      tags: [Dados públicos]
      description: |
        **Deprecado.** A publicação deixou de usar saldo de créditos; o painel usa planos (`empresa_acesso`).
        Mantido por compatibilidade; pode ser removido numa versão futura.
      responses:
        '200':
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/PacoteCredito' }
        '503': { $ref: '#/components/responses/Erro503' }
  /api/notificacoes:
    get:
      summary: Feed de notificações (JWT)
      tags: [Autenticação e conta]
      description: |
        **Apenas utilizador autenticado.** O token pode ter sido emitido por `POST /autenticacao/google`, `POST /autenticacao/codigo/confirmar` ou login com senha (`/autenticacao/token`).

        Sem cabeçalho `Authorization` ou com token inválido → **`401`** (não há listagem anónima).

        Conteúdo filtrado ao contexto do utilizador:
        - notificação ligada a **empresa** → só aparece se seguir essa empresa;
        - notificação ligada a **oferta** → se seguir a empresa dona da oferta **ou** tiver a oferta nas guardadas.

        Sem seguir ninguém e sem ofertas guardadas → resposta `[]` (válido).

        **Estado lido** (`usuario_notificacao_lida`) e endpoints para marcar como lida **não estão implementados** — ver `documentacao/API_OFERTAS.md`.
      security: [{ bearerAuth: [] }]
      parameters:
        - in: query
          name: limite
          schema: { type: integer, default: 50, minimum: 1, maximum: 200 }
      responses:
        '200':
          description: Lista de notificações (sem campo `lida` nesta versão).
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/NotificacaoAsset' }
        '401':
          $ref: '#/components/responses/Erro401'
        '503': { $ref: '#/components/responses/Erro503' }
  /api/catalogo/tipos-oferta/manifest:
    get:
      summary: Manifesto do catálogo de tipos de oferta
      tags: [Catálogo de tipos de oferta]
      responses:
        '200':
          description: |
            Mesma forma que `ofertas_tipos_manifest.json`; cada `entries[].path` aponta para `/api/catalogo/tipos-oferta/{id}`.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ManifestoCatalogo' }
        '404': { $ref: '#/components/responses/Erro404' }
        '503': { $ref: '#/components/responses/Erro503' }
  /api/catalogo/tipos-oferta/catalogo-completo:
    get:
      summary: Catálogo completo de tipos (resposta única)
      description: |
        Devolve `schema_version`, `remote_catalog_url` e `tipos` (array com todas as definições JSON).
        Preferível ao cliente móvel em relação a manifest + N pedidos por tipo.
      tags: [Catálogo de tipos de oferta]
      responses:
        '200':
          description: Mesma forma que o cache serializado no app (`schema_version`, `remote_catalog_url`, `tipos[]`).
          content:
            application/json:
              schema:
                type: object
                required: [schema_version, remote_catalog_url, tipos]
                properties:
                  schema_version: { type: integer }
                  remote_catalog_url: { type: string }
                  tipos:
                    type: array
                    items:
                      type: object
                      additionalProperties: true
        '404': { $ref: '#/components/responses/Erro404' }
        '503': { $ref: '#/components/responses/Erro503' }
  /api/catalogo/tipos-oferta/{id}:
    get:
      summary: Definição JSON de um tipo de oferta
      tags: [Catálogo de tipos de oferta]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
        '400': { $ref: '#/components/responses/Erro400' }
        '404': { $ref: '#/components/responses/Erro404' }
        '503': { $ref: '#/components/responses/Erro503' }
  /api/usuario/eu:
    get:
      operationId: usuarioPerfilEu
      tags: [Autenticação e conta]
      summary: Perfil do utilizador autenticado
      description: |
        Requer `Authorization: Bearer <token>`. O `sub` do JWT identifica o utilizador autenticado.
        Inclui `cidade_preferida` resolvida via FK para `cidades`, se definida.
        Inclui `tipo_utilizador` e, para `tipo_utilizador` = `empresa`, a lista `empresas_geridas` (vínculos com `usuario_empresa_acesso`).
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Dados do perfil
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PerfilUsuario' }
              examples:
                completo:
                  summary: Utilizador com cidade preferida
                  value:
                    id: 1
                    email: admin@local.test
                    nome_completo: Admin Local
                    url_avatar: https://example.com/avatar.png
                    cidade_preferida:
                      id: 3
                      nome_cidade: Encarnación
                      nome_estado: Itapúa
                    provedor_login: email
                    tipo_utilizador: consumidor
                    empresas_geridas: []
                conta_empresa:
                  summary: Utilizador empresa com empresas geridas
                  value:
                    id: 7
                    email: dono@example.com
                    nome_completo: João Proprietário
                    url_avatar: null
                    cidade_preferida: null
                    provedor_login: google
                    tipo_utilizador: empresa
                    empresas_geridas:
                      - empresa_id: "1"
                        papel: proprietario
                        ativo: true
        '401':
          $ref: '#/components/responses/Erro401'
        '404':
          description: Utilizador não encontrado (token válido mas linha apagada)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErroJson' }
        '503':
          $ref: '#/components/responses/Erro503'
  /api/usuario/lead-cadastro-empresa:
    post:
      operationId: usuarioLeadCadastroEmpresa
      tags: [Autenticação e conta]
      summary: Enviar pedido de interesse em registar negócio (lead)
      description: |
        Requer JWT. **Não** cria linha em `empresas` nem vínculo em `usuario_empresa_acesso`.
        Grava um registo para contacto comercial. Máximo **5** pedidos por utilizador nas últimas 24 horas.
        O servidor acrescenta `utilizador_conta` (id, nome_completo, email da conta) ao JSON guardado.
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CorpoLeadCadastroEmpresa' }
            example:
              nome: Pastelaria Sol
              categoria: Alimentação
              telefone: "+595981..."
              email: contacto@example.com
              site: https://example.com
              morada:
                rua: Rua Central 100
                bairro: Centro
                cidade: Encarnación
                latitude: -27.33
                longitude: -55.87
              criado_em: "2026-05-10T12:00:00.000Z"
              origem: app_cadastro_proprietario_v1
      responses:
        '201':
          description: Lead criado
          content:
            application/json:
              schema:
                type: object
                required: [id, mensagem]
                properties:
                  id: { type: integer, format: int64 }
                  mensagem: { type: string }
        '400':
          $ref: '#/components/responses/Erro400'
        '401':
          $ref: '#/components/responses/Erro401'
        '429':
          description: Limite de pedidos em 24 h
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErroJson' }
        '503':
          $ref: '#/components/responses/Erro503'
  /api/usuario/perfil:
    put:
      operationId: usuarioAtualizarPerfil
      tags: [Autenticação e conta]
      summary: Atualizar nome, avatar ou cidade preferida
      description: |
        Corpo JSON parcial: só os campos enviados são atualizados.
        `cidade_id: null` limpa a cidade preferida (SQL `cidade_preferida_id = NULL`).
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CorpoAtualizarPerfil' }
            examples:
              nome_e_cidade:
                summary: Nome e cidade Encarnación (id 3 após seeds típicos)
                value:
                  nome_completo: Maria Costa
                  cidade_id: 3
              limpar_cidade:
                summary: Remover cidade preferida
                value:
                  cidade_id: null
      responses:
        '204':
          description: Sucesso; resposta sem corpo.
        '400':
          $ref: '#/components/responses/Erro400'
        '401':
          $ref: '#/components/responses/Erro401'
        '503':
          $ref: '#/components/responses/Erro503'
  /api/usuario/dispositivo:
    put:
      operationId: usuarioAtualizarDispositivo
      tags: [Autenticação e conta]
      summary: Registar token FCM e plataforma push
      description: |
        Para notificações push (Firebase Cloud Messaging). Atualiza `token_fcm`, `plataforma_push` e `token_fcm_atualizado_em`.
        Envie pelo menos `token_fcm` ou `plataforma`.
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CorpoDispositivo' }
            examples:
              android:
                summary: Android
                value:
                  token_fcm: fcm_token_exemplo_long_string_from_firebase
                  plataforma: android
              ios:
                summary: iOS
                value:
                  token_fcm: apns_fcm_token_exemplo
                  plataforma: ios
      responses:
        '204':
          description: Sucesso; sem corpo.
        '400':
          $ref: '#/components/responses/Erro400'
        '401':
          $ref: '#/components/responses/Erro401'
        '503':
          $ref: '#/components/responses/Erro503'
  /api/usuario/seguindo:
    get:
      operationId: usuarioListarSeguindo
      tags: [Autenticação e conta]
      summary: Listar IDs de empresas seguidas
      description: Lista de identificadores de empresas que o utilizador segue.
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Objeto com array de ids string (mesmos ids que `empresas.id`).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ListaIds' }
              examples:
                exemplo:
                  value:
                    ids: ['1', '2', '5']
        '401':
          $ref: '#/components/responses/Erro401'
        '503':
          $ref: '#/components/responses/Erro503'
    put:
      operationId: usuarioSubstituirSeguindo
      tags: [Autenticação e conta]
      summary: Substituir lista de empresas seguidas
      description: |
        O corpo **substitui por completo** o conjunto guardado (apaga associações anteriores e insere as novas).
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ListaIds' }
            examples:
              seguir_tres:
                value:
                  ids: ['1', '2', '3']
      responses:
        '204':
          description: Sucesso; sem corpo.
        '400':
          $ref: '#/components/responses/Erro400'
        '401':
          $ref: '#/components/responses/Erro401'
        '503':
          $ref: '#/components/responses/Erro503'
  /api/usuario/ofertas-guardadas:
    get:
      operationId: usuarioListarOfertasGuardadas
      tags: [Autenticação e conta]
      summary: Listar IDs de ofertas guardadas (favoritos)
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ListaIds' }
              examples:
                exemplo:
                  value:
                    ids: ['of_1', 'of_demo_pct']
        '401':
          $ref: '#/components/responses/Erro401'
        '503':
          $ref: '#/components/responses/Erro503'
    put:
      operationId: usuarioSubstituirOfertasGuardadas
      tags: [Autenticação e conta]
      summary: Substituir ofertas guardadas
      description: Substituição total da lista em `usuario_oferta_guardada`.
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ListaIds' }
            examples:
              exemplo:
                value:
                  ids: ['of_1', 'of_2']
      responses:
        '204':
          description: Sucesso; sem corpo.
        '400':
          $ref: '#/components/responses/Erro400'
        '401':
          $ref: '#/components/responses/Erro401'
        '503':
          $ref: '#/components/responses/Erro503'
  /api/usuario/ofertas-lidas:
    get:
      operationId: usuarioListarOfertasLidas
      tags: [Autenticação e conta]
      summary: Listar IDs de ofertas já lidas (badge “nova”)
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ListaIds' }
              examples:
                exemplo:
                  value:
                    ids: ['of_1']
        '401':
          $ref: '#/components/responses/Erro401'
        '503':
          $ref: '#/components/responses/Erro503'
    put:
      operationId: usuarioSubstituirOfertasLidas
      tags: [Autenticação e conta]
      summary: Substituir conjunto de ofertas lidas
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ListaIds' }
            examples:
              exemplo:
                value:
                  ids: ['of_1', 'of_2', 'of_demo_nxm']
      responses:
        '204':
          description: Sucesso; sem corpo.
        '400':
          $ref: '#/components/responses/Erro400'
        '401':
          $ref: '#/components/responses/Erro401'
        '503':
          $ref: '#/components/responses/Erro503'
  /api/usuario/eventos:
    post:
      operationId: usuarioRegistarEventos
      tags: [Autenticação e conta]
      summary: Registar eventos de comportamento (batch)
      description: |
        Recebe um batch de eventos de interação para alimentar o perfil de afinidade do utilizador.
        Aplica decay temporal ao perfil atual (half-life 30 dias) e soma os pesos dos novos eventos
        nas dimensões `empresa_id`, `categoria`, `tipo_oferta` e `cidade_id`. Também regista cada
        evento no log curto `usuario_evento_comportamento` (retenção 90 dias).

        Tipos de evento e pesos default em `comportamento_pesos_acao` (ver `documentacao/API_OFERTAS.md` §3).
      security: [{ bearerAuth: [] }]
      parameters:
        - name: devolver_perfil
          in: query
          required: false
          schema: { type: boolean, default: false }
          description: Se `true`, devolve `200` com `{inseridos, perfil}` em vez de `204`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CorpoEventosComportamento' }
            examples:
              exemplo:
                value:
                  eventos:
                    - tipo: abrir_detalhe_oferta
                      oferta_id: of_1
                      empresa_id: emp_1
                      categoria: Restaurante
                      tipo_oferta: desconto_percentual
                      cidade_id: 3
                      ts: "2026-05-10T20:13:00Z"
                    - tipo: guardar_oferta
                      oferta_id: of_2
                      empresa_id: emp_2
                      categoria: Cafe
      responses:
        '204':
          description: Sucesso; sem corpo (default).
        '200':
          description: Sucesso com perfil atualizado (quando `devolver_perfil=true`).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/RespostaRegistarEventos' }
        '400':
          $ref: '#/components/responses/Erro400'
        '401':
          $ref: '#/components/responses/Erro401'
        '503':
          $ref: '#/components/responses/Erro503'
  /api/usuario/logs-erro-api:
    post:
      operationId: usuarioEnviarLogsErroApi
      tags: [Autenticação e conta]
      summary: Enviar relatórios de falha da app (5xx / JSON inválido em 2xx)
      description: |
        A app guarda localmente (SQLite) o contexto de cada falha atribuível ao servidor:
        método, caminho, query, corpo do pedido, corpo da resposta (truncado), `id_chamada` (UUID)
        e instante UTC. Este endpoint recebe um lote (máx. 50) e grava em `app_log_erro_api`.
        O utilizador é sempre o do JWT (`usuario_id`). Duplicados (`usuario_id` + `id_chamada`)
        são ignorados. Em sucesso a app pode esvaziar a fila local.
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CorpoLogsErroApi' }
            examples:
              exemplo:
                value:
                  registos:
                    - id_chamada: 550e8400-e29b-41d4-a716-446655440000
                      ocorrido_em: '2026-05-10T22:30:00.000Z'
                      motivo: http_5xx
                      requisicao:
                        metodo: GET
                        caminho: /api/ofertas
                        query: { incluir_empresa: 'true', limite: '50' }
                        corpo: null
                      resposta:
                        status: 500
                        corpo_texto: '{"erro":"erro_interno"}'
                      erro:
                        codigo: erro_interno
                        mensagem: Erro interno.
                      contexto_dispositivo:
                        plataforma: android
      responses:
        '200':
          description: Número de linhas aceites (duplicados não contam em `aceites`).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/RespostaLogsErroApi' }
        '400':
          $ref: '#/components/responses/Erro400'
        '401':
          $ref: '#/components/responses/Erro401'
        '503':
          $ref: '#/components/responses/Erro503'
  /api/usuario/feed-ofertas:
    get:
      operationId: usuarioFeedOfertas
      tags: [Autenticação e conta]
      summary: Feed personalizado de ofertas
      description: |
        Devolve a lista de ofertas elegíveis ordenada por afinidade
        (perfil de comportamento) + proximidade (GPS) + recência. Cada item
        traz `affinity_score` e `affinity_breakdown`.

        Por defeito o feed **esconde ofertas com que o utilizador já
        interagiu** (guardadas em favoritos ou marcadas como lidas/abriu
        detalhe). Estas continuam visíveis no separador "Guardadas" e
        respectivos endpoints (`/api/usuario/ofertas-guardadas`,
        `/api/usuario/ofertas-lidas`). Usa `excluir_interagidas=false` para
        forçar a inclusão (debug / paridade com `/api/ofertas`).

        Coldstart (perfil vazio): cai para `0.35·proximidade + 0.10·recencia`.
      security: [{ bearerAuth: [] }]
      parameters:
        - name: cidade_id
          in: query
          required: false
          schema: { type: integer }
        - name: cidade
          in: query
          required: false
          schema: { type: string }
        - name: empresa_id
          in: query
          required: false
          schema: { type: string }
        - name: apenas_ativas
          in: query
          required: false
          schema: { type: boolean, default: true }
        - name: incluir_pausadas
          in: query
          required: false
          schema: { type: boolean, default: false }
        - name: incluir_empresa
          in: query
          required: false
          schema: { type: boolean, default: true }
        - name: excluir_interagidas
          in: query
          required: false
          description: |
            Quando `true` (padrão) remove do resultado as ofertas presentes em
            `ofertas-guardadas` ou `ofertas-lidas` do utilizador autenticado.
          schema: { type: boolean, default: true }
        - name: limite
          in: query
          required: false
          schema: { type: integer, default: 200, minimum: 1, maximum: 500 }
        - name: cursor
          in: query
          required: false
          schema: { type: integer, default: 0 }
        - name: lat
          in: query
          required: false
          schema: { type: number }
        - name: lon
          in: query
          required: false
          schema: { type: number }
      responses:
        '200':
          description: Lista de ofertas anotadas com score, ordenada desc.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/OfertaPersonalizada' }
        '401':
          $ref: '#/components/responses/Erro401'
        '503':
          $ref: '#/components/responses/Erro503'
  /api/usuario/perfil-comportamento:
    get:
      operationId: usuarioObterPerfilComportamento
      tags: [Autenticação e conta]
      summary: Devolve o perfil JSON de comportamento (debug e transparência)
      description: |
        Estrutura completa do perfil de afinidade do utilizador autenticado.
        Útil para debug do feed e para o cliente mostrar ao utilizador o que
        a app "percebeu" sobre o seu gosto.
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Perfil JSON.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PerfilComportamento' }
        '401':
          $ref: '#/components/responses/Erro401'
        '503':
          $ref: '#/components/responses/Erro503'
  /api/ia-marketing/contexto:
    get:
      operationId: iaMarketingContexto
      tags: [I.A. de Marketing]
      summary: Perfil da empresa, ementa do catálogo, ofertas recentes e meta-dados do agente
      description: |
        Devolve o material que o app precisa para abrir a tela do consultor:
        `perfil_empresa`, `ementa_catalogo`, `ofertas_recentes`, `objetivos`, `categorias`,
        `turnos_max` e `ia_configurada`. Query **`empresa_id` obrigatória** (validação de permissão).
      security: [{ bearerAuth: [] }]
      parameters:
        - name: empresa_id
          in: query
          required: true
          schema: { type: string }
          description: ID da empresa que o utilizador gere.
      responses:
        '200':
          description: Contexto para o Agente Consultor.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OraculoContextoResposta' }
        '400':
          $ref: '#/components/responses/Erro400'
        '401':
          $ref: '#/components/responses/Erro401'
        '403':
          $ref: '#/components/responses/Erro403'
        '503':
          $ref: '#/components/responses/Erro503'
  /api/ia-marketing/sessao:
    post:
      operationId: iaMarketingCriarSessao
      tags: [I.A. de Marketing]
      summary: Iniciar sessão e devolver a 1ª pergunta gerada dinamicamente
      description: |
        Cria registro em `oraculo_sessao` (expira em 48h, motor `agente_v2`) e gera **a 1ª pergunta**
        (turno 1) já no retorno. Cada turno seguinte usa `POST /sessao/{id}/passo`.
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/OraculoCriarSessaoRequest' }
            examples:
              minimo:
                value:
                  empresa_id: '1'
              completo:
                value:
                  empresa_id: '1'
                  categoria_negocio: Alimentação
                  notas_livres: Queremos encher terça de manhã.
      responses:
        '201':
          description: Sessão criada; devolve a primeira pergunta + progresso.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OraculoCriarSessaoResposta' }
        '400':
          $ref: '#/components/responses/Erro400'
        '401':
          $ref: '#/components/responses/Erro401'
        '403':
          $ref: '#/components/responses/Erro403'
        '500':
          description: Falha ao gravar sessão
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErroJson' }
        '503':
          $ref: '#/components/responses/Erro503'
  /api/ia-marketing/sessao/{sessao_id}/passo:
    post:
      operationId: iaMarketingPasso
      tags: [I.A. de Marketing]
      summary: Registrar resposta e devolver próxima pergunta ou sugestões finais
      description: |
        Loop conversacional do Agente Consultor (até 4 turnos). `resposta` aceita 4 formatos
        (ver `OraculoPassoRequest`). O servidor força encerramento ao bater `turnos_max`.
        Quando o diagnóstico fecha, a resposta inclui `concluido: true` e `sugestoes[]`.
      security: [{ bearerAuth: [] }]
      parameters:
        - $ref: '#/components/parameters/SessaoOraculoId'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/OraculoPassoRequest' }
            examples:
              escolha_simples:
                value:
                  resposta: trazer_pessoas_novas
              escolha_com_outro:
                value:
                  resposta:
                    opcao_id: outro
                    texto_outro: Quero limpar estoque de inverno.
              texto_livre:
                value:
                  resposta:
                    texto_livre: Combo do almoço de quarta e quinta
              confirmacao:
                value:
                  resposta:
                    confirmacao: true
      responses:
        '200':
          description: Próxima pergunta ou sugestões finais.
          content:
            application/json:
              schema:
                oneOf:
                  - { $ref: '#/components/schemas/OraculoPassoRespostaIntermedia' }
                  - { $ref: '#/components/schemas/OraculoPassoRespostaFinal' }
        '400':
          $ref: '#/components/responses/Erro400'
        '401':
          $ref: '#/components/responses/Erro401'
        '403':
          $ref: '#/components/responses/Erro403'
        '404':
          $ref: '#/components/responses/Erro404'
        '410':
          $ref: '#/components/responses/Erro410'
        '503':
          $ref: '#/components/responses/Erro503'
  /api/ia-marketing/texto-vitrine:
    post:
      operationId: iaMarketingTextoVitrine
      tags: [I.A. de Marketing]
      summary: Gerar título e descrição para vitrine (cliente final)
      description: |
        Recebe um rascunho de oferta validado pelo catálogo e devolve título e descrição para o cliente final.
        Pode usar IA (DeepSeek) quando configurada; caso contrário usa templates determinísticos.
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [empresa_id, rascunho]
              properties:
                empresa_id: { type: string }
                rascunho: { type: object, additionalProperties: true }
                indicacao_lojista: { type: string, nullable: true }
                regras_simples:
                  type: array
                  items: { type: string }
      responses:
        '200':
          description: Texto gerado.
          content:
            application/json:
              schema:
                type: object
                properties:
                  title: { type: string }
                  description: { type: string }
                  motor: { type: string }
                  aviso_ia: { type: string, nullable: true }
        '400':
          $ref: '#/components/responses/Erro400'
        '401':
          $ref: '#/components/responses/Erro401'
        '403':
          $ref: '#/components/responses/Erro403'
        '503':
          $ref: '#/components/responses/Erro503'
  /api/ia-marketing/sessao/{sessao_id}:
    delete:
      operationId: iaMarketingApagarSessao
      tags: [I.A. de Marketing]
      summary: Abandonar sessão
      security: [{ bearerAuth: [] }]
      parameters:
        - $ref: '#/components/parameters/SessaoOraculoId'
      responses:
        '204':
          description: Removida.
        '401':
          $ref: '#/components/responses/Erro401'
        '403':
          $ref: '#/components/responses/Erro403'
        '404':
          $ref: '#/components/responses/Erro404'
        '503':
          $ref: '#/components/responses/Erro503'
  /api/analise/imagem:
    post:
      operationId: analiseImagemModeracao
      tags: [Moderação de imagem]
      summary: Verificar se uma imagem é aceite pela política da plataforma (público)
      description: |
        **Rota pública — não exige JWT.**

        **Multipart/form-data.** Envie **exatamente uma** fonte:

        - **`image`**: ficheiro (JPEG, PNG, WebP, …), **ou**
        - **`url`**: URL `http`/`https` pública da mesma imagem (o servidor descarrega para temporário; redes privadas e localhost são bloqueados).

        **Resposta `200`:** `aprovado`, `predominante` (classe + probabilidade 0–1 + `percentual`), `analise` (todas as classes na mesma forma), `medidor_risco_percentual` (soma Porn+Hentai+Sexy em %). O ficheiro temporário é sempre apagado.

        Erros comuns: `entrada_invalida` (ficheiro e URL em simultâneo ou nenhum), `imagem_invalida`, `url_proibida`, `analise_indisponivel` (classificador ou formato rejeitado).

        Guia completo: `/documentacao/API_MODERACAO_IMAGEM.md`.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              properties:
                image:
                  type: string
                  format: binary
                  description: Ficheiro imagem (uso exclusivo alternativo a `url`).
                url:
                  type: string
                  format: uri
                  description: URL pública da imagem (uso exclusivo alternativo a `image`).
      responses:
        '200':
          description: Decisão da política + detalhe das probabilidades do modelo (percentuais e predominante).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/RespostaAnaliseImagem' }
              examples:
                aceite:
                  value:
                    aprovado: true
                    predominante:
                      classe: Neutral
                      probabilidade: 0.9448
                      percentual: 94.48
                    analise:
                      - { classe: Neutral, probabilidade: 0.9448, percentual: 94.48 }
                      - { classe: Drawing, probabilidade: 0.031, percentual: 3.1 }
                    medidor_risco_percentual: 5.2
                recusado:
                  value:
                    aprovado: false
                    predominante:
                      classe: Sexy
                      probabilidade: 0.937
                      percentual: 93.7
                    analise:
                      - { classe: Sexy, probabilidade: 0.937, percentual: 93.7 }
                      - { classe: Neutral, probabilidade: 0.061, percentual: 6.1 }
                    medidor_risco_percentual: 93.85
        '400':
          description: Entrada inválida, imagem inválida, URL ou descarga inválida
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErroJson' }
        '422':
          description: Classificador rejeitou o conteúdo ou formato
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErroJson' }
        '503':
          description: Serviço temporário ou classificador offline
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErroJson' }
  /api/exemplo/protegido:
    get:
      operationId: exemploJwtPing
      tags: [Autenticação e conta]
      summary: Ping autenticado (testar Bearer token)
      description: |
        Rota mínima para validar que o token é aceite pelo middleware JWT.
        Útil no Swagger após clicar **Authorize** e colar o `access_token`.
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Token válido; eco parcial dos claims.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/RespostaExemploProtegido' }
              examples:
                ok:
                  value:
                    mensagem: Rota protegida acessada com sucesso.
                    usuario:
                      id: 1
                      email: admin@local.test
        '401':
          $ref: '#/components/responses/Erro401'
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: |
        Cole o valor **só do token** (sem a palavra `Bearer`). O Swagger envia `Authorization: Bearer <valor>`.

        O valor é o `access_token` devolvido por: **Google** (`POST /api/autenticacao/google`), **email+código** (`/codigo/confirmar`), **login com senha** (`/autenticacao/token`) ou **registo** (`/autenticacao/registro`). Precisa do mesmo token para **notificações** e restantes rotas protegidas.
  parameters:
    IdEmpresa:
      name: id
      in: path
      required: true
      schema: { type: string }
    IdOferta:
      name: id
      in: path
      required: true
      schema: { type: string }
    SessaoOraculoId:
      name: sessao_id
      in: path
      required: true
      schema:
        type: string
        format: uuid
      description: Identificador devolvido por `POST /api/ia-marketing/sessao`.
  responses:
    Erro400:
      description: JSON inválido ou campos obrigatórios em falta
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErroJson' }
          examples:
            json_invalido:
              value:
                erro: json_invalido
                mensagem: Corpo da requisição deve ser JSON válido com email e senha.
            parametros:
              value:
                erro: parametros_invalidos
                mensagem: Informe email e senha.
    Erro401:
      description: Cabeçalho `Authorization` ausente, mal formado, token inválido ou expirado
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErroJson' }
          examples:
            sem_token:
              value:
                erro: nao_autorizado
                mensagem: Token ausente ou inválido.
            expirado:
              value:
                erro: nao_autorizado
                mensagem: Token inválido ou expirado.
    Erro401Credenciais:
      description: Combinação email/senha não confere a nenhum utilizador ativo
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErroJson' }
          examples:
            credenciais:
              value:
                erro: credenciais_invalidas
                mensagem: Email ou senha incorretos.
    Erro404:
      description: Não encontrado ou recurso indisponível para o contexto
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErroJson' }
          examples:
            generico:
              value:
                erro: nao_encontrado
                mensagem: Recurso não encontrado.
            oferta_indisponivel:
              summary: Detalhe público da oferta (pausada, fora do prazo ou rascunho)
              value:
                erro: oferta_indisponivel
                mensagem: Oferta indisponível.
    Erro403:
      description: Conta não é empresa ou sem permissão para a empresa indicada
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErroJson' }
          examples:
            proibido:
              value:
                erro: proibido
                mensagem: Não tens permissão para gerir esta empresa.
    Erro410:
      description: Sessão do Oráculo expirada
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErroJson' }
          examples:
            expirada:
              value:
                erro: sessao_expirada
                mensagem: Esta sessão expirou. Cria uma nova.
    Erro503:
      description: Banco de dados indisponível
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErroJson' }
  schemas:
    RespostaAnaliseImagem:
      type: object
      required: [aprovado, predominante, analise, medidor_risco_percentual]
      properties:
        aprovado:
          type: boolean
          description: Resultado das regras de negócio (`ServicoModeracaoImagemNsfw::decidirAprovacao`), não só o classe predominante.
        predominante:
          $ref: '#/components/schemas/AnaliseImagemClassePontos'
          description: Primeira entrada de `analise` (maior probabilidade devolvida pelo modelo).
        analise:
          type: array
          description: Todas as classes ordenadas pela probabilidade decrescente (formato NSFWJS).
          items: { $ref: '#/components/schemas/AnaliseImagemClassePontos' }
        medidor_risco_percentual:
          type: number
          format: float
          description: |
            Soma das probabilidades **Porn + Hentai + Sexy** × 100 (referência visual; a decisão final usa `aprovado` e as regras documentadas).
    AnaliseImagemClassePontos:
      type: object
      required: [classe, probabilidade, percentual]
      properties:
        classe:
          type: string
          example: Neutral
        probabilidade:
          type: number
          format: float
          description: Probabilidade entre 0 e 1 (valor bruto do modelo).
          example: 0.4335
        percentual:
          type: number
          format: float
          description: Mesma probabilidade × 100, arredondada a 2 casas decimais.
          example: 43.35
    ErroJson:
      type: object
      required: [erro, mensagem]
      properties:
        erro:
          type: string
          description: Código estável para a app tratar (ex. `credenciais_invalidas`)
        mensagem:
          type: string
          description: Mensagem legível em português
        portal_parceiro_url:
          type: string
          format: uri
          description: Opcional — presente em alguns erros `acesso_bloqueado` (ofertas).
    EmpresaAcessoConta:
      type: object
      required:
        - empresa_id
        - situacao
        - pode_publicar_ofertas
        - portal_parceiro_url
      properties:
        empresa_id: { type: string }
        situacao:
          type: string
          enum: [em_teste, assinatura_ativa, requer_regularizacao]
        plano_codigo:
          type: string
          nullable: true
        plano_nome_ui:
          type: string
          nullable: true
        teste_fim_utc:
          type: string
          format: date-time
          nullable: true
          description: Null = teste sem data de fim
        assinatura_fim_utc:
          type: string
          format: date-time
          nullable: true
        pode_publicar_ofertas: { type: boolean }
        portal_parceiro_url:
          type: string
          description: Valor de PORTAL_PARCEIRO_URL_LOGIN (pode ser string vazia)
    CredenciaisLogin:
      type: object
      required: [email, senha]
      properties:
        email:
          type: string
          format: email
          example: admin@local.test
        senha:
          type: string
          format: password
          minLength: 1
          example: sua_senha
    CorpoRegisto:
      type: object
      required: [email, senha]
      properties:
        email:
          type: string
          format: email
        senha:
          type: string
          format: password
          minLength: 1
        nome:
          type: string
          description: Nome completo opcional; incluído no claim nome do JWT após registo
    CorpoGoogleIdToken:
      type: object
      required: [id_token]
      properties:
        id_token:
          type: string
          description: ID token (JWT) retornado pelo SDK Google Sign-In
    CorpoCodigoSolicitar:
      type: object
      required: [email]
      properties:
        email:
          type: string
          format: email
    CorpoCodigoConfirmar:
      type: object
      required: [email, codigo]
      properties:
        email:
          type: string
          format: email
        codigo:
          type: string
          description: Código numérico recebido por email
          example: "482913"
    RespostaCodigoSolicitar:
      type: object
      required: [mensagem, enviado]
      properties:
        mensagem:
          type: string
        enviado:
          type: boolean
        detalhe:
          type: string
        codigo_desenvolvimento:
          type: string
          description: Só com APP_DEBUG e sem SMTP
        aviso:
          type: string
    RespostaToken:
      type: object
      required: [tipo, access_token, expira_em_segundos]
      properties:
        tipo:
          type: string
          example: Bearer
          description: "Sempre Bearer; usar no cabeçalho Authorization"
        access_token:
          type: string
          description: "JWT HS256; claims típicos sub, email, nome, tipo_utilizador, exp, iat"
        expira_em_segundos:
          type: integer
          example: 3600
          description: "TTL em segundos (alinhado a JWT_TTL_SEGUNDOS no .env)"
        perfil_contexto:
          type: object
          description: |
            Presente nas respostas de login (Google, código, senha). Para `tipo_utilizador` = `empresa`, inclui
            `empresas_geridas` (cada item com `acesso_resumo`) e `portal_parceiro_url`.
          properties:
            tipo_utilizador:
              type: string
            portal_parceiro_url:
              type: string
              description: URL do portal (login); pode ser vazia se não configurada no servidor
            empresas_geridas:
              type: array
              items:
                type: object
                required: [empresa_id, papel, ativo]
                properties:
                  empresa_id: { type: string }
                  papel: { type: string }
                  ativo: { type: boolean }
                  acesso_resumo:
                    $ref: '#/components/schemas/EmpresaAcessoResumo'
    EmpresaAcessoResumo:
      type: object
      required: [situacao, plano_codigo, plano_nome_ui, pode_publicar_ofertas]
      properties:
        situacao: { type: string }
        plano_codigo: { type: string }
        plano_nome_ui: { type: string }
        teste_fim_utc:
          type: string
          format: date-time
          nullable: true
        assinatura_fim_utc:
          type: string
          format: date-time
          nullable: true
        pode_publicar_ofertas: { type: boolean }
    RespostaRegisto:
      allOf:
        - $ref: '#/components/schemas/RespostaToken'
        - type: object
          required: [id]
          properties:
            id:
              type: integer
              description: "Identificador numérico da conta recém-criada"
    CorpoLeadCadastroEmpresa:
      type: object
      required: [nome, categoria]
      description: |
        Dados do negócio. É obrigatório pelo menos um de `telefone` ou `email`.
        O servidor valida nome, categoria e contacto; `morada` é opcional.
      properties:
        nome: { type: string }
        categoria: { type: string }
        telefone: { type: string }
        email: { type: string }
        site: { type: string }
        morada:
          type: object
          additionalProperties: true
          properties:
            rua: { type: string }
            bairro: { type: string }
            cidade: { type: string }
            latitude: { type: number, nullable: true }
            longitude: { type: number, nullable: true }
        criado_em: { type: string, format: date-time }
        origem: { type: string }
        contacto_pessoa:
          type: object
          description: Opcional — nome da pessoa de contacto (app)
          additionalProperties: true
    CorpoAtualizarPerfil:
      type: object
      description: Pelo menos um campo deve ser enviado (senão HTTP 400).
      properties:
        nome_completo:
          type: string
          nullable: true
        url_avatar:
          type: string
          nullable: true
          format: uri
        cidade_id:
          type: integer
          nullable: true
          description: "FK cidades.id; null remove a cidade preferida"
    CorpoDispositivo:
      type: object
      description: Pelo menos um dos dois campos deve estar presente.
      properties:
        token_fcm:
          type: string
          nullable: true
        plataforma:
          type: string
          nullable: true
          enum: [android, ios]
    RespostaExemploProtegido:
      type: object
      properties:
        mensagem:
          type: string
        usuario:
          type: object
          properties:
            id: { type: integer, nullable: true }
            email: { type: string, nullable: true }
    ListaIds:
      type: object
      required: [ids]
      properties:
        ids:
          type: array
          items: { type: string }
    CidadeAsset:
      type: object
      properties:
        cidade: { type: string }
        estado: { type: string }
        localizacao:
          type: object
          properties:
            latitude: { type: number }
            longitude: { type: number }
    DocumentoLegalResumo:
      type: object
      description: Item da listagem (sem corpo longo).
      properties:
        slug: { type: string }
        titulo: { type: string }
        resumo: { type: string, nullable: true }
        versao: { type: string }
        atualizado_em:
          type: string
          format: date-time
          nullable: true
          description: ISO 8601 quando disponível
    DocumentoLegalDetalhe:
      allOf:
        - { $ref: '#/components/schemas/DocumentoLegalResumo' }
        - type: object
          properties:
            corpo:
              type: string
              description: Texto integral (parágrafos com linhas em branco).
    EmpresaAsset:
      type: object
      description: Chaves alinhadas a `empresas.json`
      properties:
        id: { type: string, description: Identificador da empresa (sempre presente) }
        cidade_id:
          type: integer
          nullable: true
          description: FK `cidades.id`; sempre presente na resposta (null se a empresa não tiver cidade associada)
        company_name: { type: string }
        category: { type: string }
        is_open: { type: boolean }
        is_favorited: { type: boolean }
        distance_meters: { type: integer }
        address: { type: object, additionalProperties: true }
        images: { type: object, additionalProperties: true }
        opening_hours: { type: object, additionalProperties: true }
        rating: { type: number }
        description: { type: string }
    OfertaAsset:
      type: object
      description: Oferta; com `incluir_empresa`, inclui `company` (EmpresaAsset)
      properties:
        id: { type: string }
        company_id: { type: string }
        company: { $ref: '#/components/schemas/EmpresaAsset' }
        is_new: { type: boolean }
        offer_kind: { type: string }
        title: { type: string }
        description: { type: string }
        category: { type: string }
        valid_from: { type: string }
        valid_until: { type: string }
        moeda: { type: string }
        pausada: { type: boolean }
        params: { type: object, additionalProperties: true }
        preco_de: { type: number }
        preco_para: { type: number }
        discount_percent: { type: integer }
        image: { type: string }
        audience:
          $ref: '#/components/schemas/OfertaAudienceV1'
          description: "Normalizado na resposta; `segmento: todos` e `exige_proximidade: false` quando a coluna `audiencia_json` está vazia."
        is_draft:
          type: boolean
          description: Presente nas respostas internas; `true` quando ainda não publicada na app.
        publicada_em:
          type: string
          nullable: true
          description: Data/hora da publicação (null = rascunho).
        min_exclusao_em:
          type: string
          nullable: true
          description: Primeiro instante em que `DELETE` é permitido (ver tag Empresa).
    OfertaAudienceV1:
      type: object
      description: Público-alvo v1 (coluna `ofertas.audiencia_json`).
      properties:
        v: { type: integer, enum: [1] }
        segmento:
          type: string
          enum: [todos, seguidores, nao_seguidores]
        exige_proximidade: { type: boolean }
        raio_km: { type: number, minimum: 0.5, maximum: 100 }
    OfertaEmpresaPainel:
      allOf:
        - $ref: '#/components/schemas/OfertaAsset'
        - type: object
          properties:
            estado:
              type: string
              enum: [rascunho, publicando, rejeitada, em_revisao_conteudo, rejeitada_alteracao, ao_vivo, pausada, encerrada, agendada]
              description: Estado derivado para o painel lojista.
            revisao_motivo:
              type: string
              nullable: true
              description: Presente quando `estado` é `rejeitada` ou `rejeitada_alteracao` (último pedido recusado).
            revisao:
              type: object
              nullable: true
              description: Quando `publicar` foi enviado neste pedido e criou fila.
              properties:
                id: { type: integer }
                status: { type: string, enum: [pendente] }
    EmpresaPerfilRevisaoCorpo:
      type: object
      description: Campos do perfil alinhados ao asset público / formulário do painel.
      properties:
        company_name: { type: string }
        category: { type: string }
        description: { type: string }
        cidade_id: { type: integer, nullable: true }
        address: { type: object, additionalProperties: true }
        images: { type: object, additionalProperties: true }
        opening_hours: { type: object, additionalProperties: true }
    RespostaRevisaoPendente:
      type: object
      properties:
        revisao_id: { type: integer }
        status: { type: string, enum: [pendente] }
        mensagem: { type: string }
    RespostaUltimaRevisaoPerfil:
      type: object
      properties:
        revisao:
          nullable: true
          type: object
          properties:
            id: { type: integer }
            status: { type: string, enum: [pendente, aprovado, rejeitado] }
            criado_em: { type: string }
            processado_em: { type: string, nullable: true }
            motivo: { type: string, nullable: true }
    CorpoEmpresaOfertaUpsert:
      type: object
      description: |
        Corpo alinhado ao asset público. Cada gravação com `id` de rascunho existente volta a criar revisão pendente.
        Após o fim do período (`valid_until` antes de hoje), alterações a oferta já publicada entram em `oferta_atualizar_encerrada` até aprovação automática ou manual.
        Créditos debitados na aprovação da **primeira** publicação (`app:revisao:aprovar` ou `app:revisao:processar-pendentes`).
      properties:
        id:
          type: string
          description: Opcional; se enviado, atualiza o rascunho com esse id.
        publicar:
          type: boolean
          default: false
        title: { type: string }
        description: { type: string }
        category: { type: string }
        company_id: { type: string }
        moeda: { type: string }
        offer_kind: { type: string }
        params:
          type: object
          additionalProperties: true
        valid_from: { type: string }
        valid_until: { type: string }
        pausada: { type: boolean }
        audience:
          $ref: '#/components/schemas/OfertaAudienceV1'
          description: |
            Opcional no corpo. Omisso ou `todos` sem proximidade grava `audiencia_json` nulo (público amplo).
            `seguidores` / `nao_seguidores` exigem utilizador autenticado na vitrine; anónimos não vêem a oferta.
    MetricasOfertaResposta:
      type: object
      required: [oferta_id, empresa_id, granularidade, intervalo, totais_por_tipo, serie]
      properties:
        oferta_id: { type: string }
        empresa_id: { type: string }
        granularidade:
          type: string
          enum: [dia, hora]
        intervalo:
          type: object
          properties:
            de: { type: string }
            ate: { type: string }
        totais_por_tipo:
          type: object
          additionalProperties: { type: integer }
        unicos_por_tipo:
          type: object
          description: Utilizadores distintos por tipo de evento no mesmo intervalo.
          additionalProperties: { type: integer }
        resumo:
          type: object
          description: Indicadores derivados dos utilizadores únicos.
          properties:
            utilizadores_unicos_com_visualizacao: { type: integer }
            utilizadores_unicos_abriram_detalhe: { type: integer }
            utilizadores_unicos_guardaram: { type: integer }
            taxa_detalhe_sobre_visualizacao_unicos:
              type: number
              format: float
              nullable: true
        serie:
          type: array
          items:
            type: object
            properties:
              bucket: { type: string }
              tipo_evento: { type: string }
              total: { type: integer }
              seguidores: { type: integer }
              nao_seguidores: { type: integer }
    PacoteCredito:
      type: object
      properties:
        id: { type: string }
        nome: { type: string }
        subtitulo: { type: string }
        creditos: { type: integer }
        preco_reais: { type: number }
        destaque: { type: boolean }
    ComentarioAsset:
      type: object
      properties:
        id: { type: string }
        user_display_name: { type: string }
        rating: { type: integer }
        type: { type: string }
        text: { type: string }
        created_at: { type: string }
        company_reply:
          nullable: true
          type: object
          additionalProperties: true
    NotificacaoAsset:
      type: object
      description: |
        Feed atual **não** inclui estado «lida» nem filtra por `usuario_notificacao_lida`.
        Marcar como lida e expor `lida` no JSON são **planeados** — ver `documentacao/API_OFERTAS.md`.
      properties:
        id: { type: string }
        tipo: { type: string }
        titulo: { type: string }
        resumo: { type: string }
        criado_em: { type: string }
        empresa_id: { type: string }
        oferta_id: { type: string }
    ManifestoCatalogo:
      type: object
      properties:
        schema_version: { type: integer }
        remote_catalog_url: { type: string }
        entries:
          type: array
          items:
            type: object
            properties:
              id: { type: string }
              path: { type: string }
    PerfilUsuario:
      type: object
      description: Resposta de `GET /api/usuario/eu` (alinhada ao código).
      properties:
        id: { type: integer }
        email: { type: string }
        nome_completo: { type: string, nullable: true }
        url_avatar: { type: string, nullable: true }
        cidade_preferida:
          nullable: true
          type: object
          properties:
            id: { type: integer }
            nome_cidade: { type: string }
            nome_estado: { type: string }
        provedor_login: { type: string }
        tipo_utilizador:
          type: string
          enum: [consumidor, empresa, admin]
          description: Valor da coluna `usuarios.tipo_utilizador`.
        empresas_geridas:
          type: array
          description: Lista vazia se não for `empresa`; caso contrário vínculos em `usuario_empresa_acesso`.
          items:
            type: object
            required: [empresa_id, papel, ativo]
            properties:
              empresa_id: { type: string }
              papel:
                type: string
                enum: [proprietario, gerente, editor]
              ativo: { type: boolean }
    EventoComportamento:
      type: object
      required: [tipo]
      description: |
        Evento atómico de interação do utilizador. `tipo` deve constar em
        `comportamento_pesos_acao` (ver `documentacao/API_OFERTAS.md` §3.3).
        Pelo menos um campo de dimensão (`empresa_id`, `categoria`, `tipo_oferta` ou `cidade_id`)
        deve estar preenchido para o evento influenciar o perfil; eventos sem dimensão são
        registados no log mas não contam para afinidade.

        O servidor grava `seguia_empresa` no log quando há `empresa_id` (ver propriedade homónima).
      properties:
        tipo:
          type: string
          example: abrir_detalhe_oferta
          description: Identificador do tipo de evento (`comportamento_pesos_acao.tipo_evento`).
        oferta_id:
          type: string
          nullable: true
        empresa_id:
          type: string
          nullable: true
        cidade_id:
          type: integer
          nullable: true
        categoria:
          type: string
          nullable: true
          description: Texto da categoria da oferta (`ofertas.categoria_texto`).
        tipo_oferta:
          type: string
          nullable: true
          description: Tipo dinâmico da oferta (`ofertas.tipo_oferta`).
        ts:
          type: string
          format: date-time
          nullable: true
          description: Timestamp do cliente (informativo; o servidor regista `criado_em` próprio).
        seguia_empresa:
          type: integer
          enum: [-1, 0, 1]
          description: |
            Opcional no pedido; o servidor define ao persistir: `1` seguia a empresa, `0` não seguia,
            `-1` sem empresa_id ou indeterminado. Usado em métricas por oferta.
    CorpoLogsErroApi:
      type: object
      required: [registos]
      properties:
        registos:
          type: array
          minItems: 1
          maxItems: 50
          items: { $ref: '#/components/schemas/RegistoLogErroApi' }
    RegistoLogErroApi:
      type: object
      required: [id_chamada, ocorrido_em, motivo, requisicao, resposta]
      properties:
        id_chamada:
          type: string
          format: uuid
        ocorrido_em:
          type: string
          format: date-time
        motivo:
          type: string
          enum: [http_5xx, payload_invalido]
        requisicao:
          type: object
          required: [metodo, caminho, query]
          properties:
            metodo: { type: string, example: GET }
            caminho: { type: string, example: /api/ofertas }
            query:
              type: object
              additionalProperties: { type: string }
            corpo:
              description: Objeto JSON do pedido ou texto bruto (nullable).
              nullable: true
        resposta:
          type: object
          required: [status, corpo_texto]
          properties:
            status: { type: integer }
            corpo_texto: { type: string }
        erro:
          type: object
          nullable: true
          properties:
            codigo: { type: string, nullable: true }
            mensagem: { type: string }
            detalhe: { type: string, nullable: true }
        contexto_dispositivo:
          type: object
          nullable: true
          properties:
            plataforma: { type: string }
    RespostaLogsErroApi:
      type: object
      required: [aceites, recebidos]
      properties:
        aceites:
          type: integer
          description: Linhas inseridas (INSERT IGNORE; duplicados = 0).
        recebidos:
          type: integer
          description: Tamanho do array `registos` após validação básica.
    CorpoEventosComportamento:
      type: object
      required: [eventos]
      properties:
        eventos:
          type: array
          minItems: 1
          maxItems: 200
          items: { $ref: '#/components/schemas/EventoComportamento' }
    RespostaRegistarEventos:
      type: object
      required: [inseridos, perfil]
      properties:
        inseridos:
          type: integer
          description: Número de eventos que foram efetivamente inseridos.
        perfil: { $ref: '#/components/schemas/PerfilComportamento' }
    PerfilComportamento:
      type: object
      description: |
        Perfil agregado de afinidade do utilizador, com decay temporal aplicado.
        Cada mapa de dimensão tem chave = id (string) e valor = peso acumulado (>= 0).
      properties:
        schema:
          type: integer
          description: Versão do schema do JSON (atual = 1).
        atualizado_em:
          type: string
          format: date-time
        totais:
          type: object
          properties:
            eventos: { type: integer }
            peso_acumulado: { type: number }
        empresas:
          type: object
          additionalProperties: { type: number }
        categorias:
          type: object
          additionalProperties: { type: number }
        tipos_oferta:
          type: object
          additionalProperties: { type: number }
        cidades:
          type: object
          additionalProperties: { type: number }
        ultimas_ofertas_vistas:
          type: array
          maxItems: 50
          items: { type: string }
    OfertaPersonalizada:
      allOf:
        - $ref: '#/components/schemas/OfertaAsset'
        - type: object
          required: [affinity_score]
          properties:
            affinity_score:
              type: number
              minimum: 0
              maximum: 1
              description: Score combinado afinidade + proximidade + recência (0–1).
            affinity_breakdown:
              type: object
              properties:
                afinidade: { type: number }
                proximidade: { type: number }
                recencia: { type: number }
    OraculoObjetivoItem:
      type: object
      required: [id, rotulo, ajuda_curta]
      properties:
        id: { type: string }
        rotulo: { type: string }
        ajuda_curta: { type: string }
    OraculoTipoAcaoResumo:
      type: object
      properties:
        id: { type: string }
        nome_simples: { type: string }
        etiqueta_curta: { type: string }
        campos:
          type: array
          items:
            type: object
            properties:
              key: { type: string }
              label: { type: string }
              widget: { type: string }
    OraculoContextoResposta:
      type: object
      properties:
        empresa_id: { type: string }
        perfil_empresa:
          type: object
          nullable: true
          properties:
            nome_empresa: { type: string }
            categoria_texto: { type: string }
            descricao: { type: string }
        objetivos:
          type: array
          items: { $ref: '#/components/schemas/OraculoObjetivoItem' }
        tipos_acao:
          type: array
          items: { $ref: '#/components/schemas/OraculoTipoAcaoResumo' }
        ementa_catalogo:
          type: array
          description: Versão enxuta do catálogo (id, nome, etiqueta_curta, "quando_usar", limites duros) usada como referência rápida pela IA.
          items: { $ref: '#/components/schemas/OraculoEmentaCatalogoItem' }
        ofertas_recentes:
          type: array
          description: Últimas ofertas publicadas pela empresa, em formato compacto (sem dados pessoais).
          items: { $ref: '#/components/schemas/OraculoOfertaRecente' }
        categorias:
          type: array
          items: { type: string }
        dica_ia_marketing: { type: string }
        dica_oraculo: { type: string, description: Espelho de dica_ia_marketing (compatibilidade). }
        catalogo_tipos_disponivel: { type: boolean }
        aviso_catalogo: { type: string, nullable: true }
        ia_configurada:
          type: boolean
          description: >
            true se a variável DEEPSEEK_API_KEY está definida no ambiente do servidor
            (a cada turno do agente, e na geração final de sugestões, a IA é tentada antes do fallback determinístico).
        turnos_max:
          type: integer
          description: Limite duro de turnos da conversa (sempre 4 nesta versão).
    ModoResposta:
      type: string
      enum: [escolha_unica, escolha_com_outro, texto_livre, confirmacao]
      description: Modo de resposta esperado pela pergunta atual.
    OpcaoConsultor:
      type: object
      required: [id, rotulo]
      properties:
        id: { type: string }
        rotulo: { type: string }
        exemplo_real:
          type: string
          description: Frase em itálico mostrada abaixo do rótulo, com um exemplo real do nicho do lojista.
    PerguntaConsultor:
      type: object
      required: [texto, modo_resposta, eixo_alvo, permite_texto_livre, opcoes]
      properties:
        texto: { type: string }
        modo_resposta: { $ref: '#/components/schemas/ModoResposta' }
        eixo_alvo:
          type: string
          enum: [objetivo, urgencia, publico, alavanca, produto_foco, restricao]
        permite_texto_livre: { type: boolean }
        placeholder:
          type: string
          nullable: true
          description: Dica curta para campo `texto_livre` (não preencher quando o modo é uma das escolhas).
        opcoes:
          type: array
          items: { $ref: '#/components/schemas/OpcaoConsultor' }
    ProgressoDiagnostico:
      type: object
      required: [turno_atual, turnos_max, preenchido, total_relevante, percentual]
      properties:
        turno_atual: { type: integer, minimum: 0, maximum: 4 }
        turnos_max: { type: integer, enum: [4] }
        preenchido:
          type: integer
          description: Quantas variáveis relevantes (objetivo, urgencia, publico, alavanca) já foram entendidas.
        total_relevante:
          type: integer
          description: Total de variáveis relevantes para encerrar (= 4).
        percentual: { type: integer, minimum: 0, maximum: 100 }
    OraculoEmentaCatalogoItem:
      type: object
      required: [id, nome, quando_usar]
      properties:
        id: { type: string }
        nome: { type: string }
        etiqueta_curta: { type: string }
        quando_usar: { type: string }
        limites:
          type: array
          items:
            type: object
            properties:
              key: { type: string }
              required: { type: boolean }
              min: {}
              max: {}
        cross_rules:
          type: array
          items: { type: string }
    OraculoOfertaRecente:
      type: object
      properties:
        tipo_oferta: { type: string, nullable: true }
        titulo: { type: string }
        valido_de: { type: string, nullable: true, description: ISO date }
        valido_ate: { type: string, nullable: true, description: ISO date }
        percentual_desconto: { type: integer, nullable: true }
    OraculoCriarSessaoRequest:
      type: object
      required: [empresa_id]
      properties:
        empresa_id: { type: string }
        categoria_negocio: { type: string, nullable: true }
        notas_livres: { type: string, nullable: true }
    OraculoCriarSessaoResposta:
      type: object
      required: [sessao_id, empresa_id, concluido, pergunta, turno_atual, turnos_max, progresso, categoria_reconhecida]
      properties:
        sessao_id: { type: string, format: uuid }
        empresa_id: { type: string }
        concluido:
          type: boolean
          enum: [false]
        pergunta: { $ref: '#/components/schemas/PerguntaConsultor' }
        turno_atual: { type: integer, minimum: 1, maximum: 4 }
        turnos_max: { type: integer, enum: [4] }
        progresso: { $ref: '#/components/schemas/ProgressoDiagnostico' }
        motor_pergunta:
          type: string
          enum: [ia, template]
          description: Origem da pergunta gerada — `ia` (DeepSeek) ou `template` (fallback determinístico).
        categoria_reconhecida: { type: boolean }
        aviso_categoria: { type: string, nullable: true }
    OraculoPassoRequest:
      type: object
      required: [resposta]
      properties:
        resposta:
          description: |
            Aceita quatro formatos (cada um casa com um `modo_resposta` da pergunta corrente):
            - `escolha_unica` → string com o `id` da opção.
            - `escolha_com_outro` → objeto `{ opcao_id, texto_outro? }` (texto_outro obrigatório quando `opcao_id="outro"`).
            - `texto_livre` → objeto `{ texto_livre }`.
            - `confirmacao` → objeto `{ confirmacao: true|false }`.
          oneOf:
            - { type: string, description: Id da opção escolhida. }
            - type: object
              required: [opcao_id]
              properties:
                opcao_id: { type: string }
                texto_outro: { type: string, nullable: true }
            - type: object
              required: [texto_livre]
              properties:
                texto_livre: { type: string }
            - type: object
              required: [confirmacao]
              properties:
                confirmacao: { type: boolean }
    OraculoRascunhoOferta:
      type: object
      description: Alinhado a `recursos_painel_empresa/exemplo_oferta_rascunho_payload.json` (chaves em inglês).
      properties:
        title: { type: string }
        description: { type: string }
        category: { type: string }
        company_id: { type: string }
        moeda: { type: string }
        offer_kind: { type: string }
        params: { type: object, additionalProperties: true }
        valid_from: { type: string, format: date }
        valid_until: { type: string, format: date }
        pausada: { type: boolean }
        _painel_meta:
          type: object
          additionalProperties: true
    OraculoSugestaoItem:
      type: object
      required: [oferta_kind, justificativa_curta, titulo, descricao, regras_simples, rascunho]
      properties:
        oferta_kind: { type: string }
        momento_ideal:
          type: string
          nullable: true
          description: Sugestão de dias/horários para focar a campanha no setor.
        insights_marketing:
          type: array
          nullable: true
          items: { type: string }
        indicacao_lojista:
          type: string
          nullable: true
          description: Texto para o dono da loja (calendário, comunicação no balcão).
        justificativa_curta: { type: string }
        titulo: { type: string }
        descricao: { type: string }
        regras_simples:
          type: array
          items: { type: string }
        rascunho: { $ref: '#/components/schemas/OraculoRascunhoOferta' }
    OraculoPassoRespostaIntermedia:
      type: object
      required: [sessao_id, concluido, pergunta, turno_atual, turnos_max, progresso]
      properties:
        sessao_id: { type: string }
        concluido:
          type: boolean
          enum: [false]
        pergunta: { $ref: '#/components/schemas/PerguntaConsultor' }
        turno_atual: { type: integer, minimum: 1, maximum: 4 }
        turnos_max: { type: integer, enum: [4] }
        progresso: { $ref: '#/components/schemas/ProgressoDiagnostico' }
        motor_pergunta:
          type: string
          enum: [ia, template]
    OraculoPassoRespostaFinal:
      type: object
      required: [sessao_id, concluido, sugestoes, progresso]
      properties:
        sessao_id: { type: string }
        concluido:
          type: boolean
          enum: [true]
        sugestoes:
          type: array
          items: { $ref: '#/components/schemas/OraculoSugestaoItem' }
        motor:
          type: string
          enum: [ia, deterministico]
          description: Origem das sugestões finais após validação no servidor.
        aviso_ia:
          type: string
          nullable: true
          description: Motivo quando se usou fallback determinístico após tentar a IA.
        aviso: { type: string, nullable: true }
        progresso: { $ref: '#/components/schemas/ProgressoDiagnostico' }
