Skip to content

API de Requisições (Kanban Admin)

Board de requisições do painel administrativo: cada card é uma requisição, cada coluna é um status cadastrável.

Duas coleções independentes:

Recurso Rota base Papel no board
Status de requisição /api/v1/admin/requisition-status As colunas, ordenadas por index
Requisições /api/v1/admin/requisitions Os cards, ligados por status_id

Permissão (RBAC) — leia antes de integrar

Duas camadas guardam estas rotas:

  1. user_type='admin' (ADR-061). Token de customer → 403 FORBIDDEN.
  2. Permissão por nome de handler em users_type (ADR-061). As rotas são novas, então perfis existentes não as enxergam: só root passa até você conceder os nomes abaixo. Faltando → 401 Access Denied: You do not have permission for ....
Lista em users_type Nomes a conceder
view requisition_get, requisition_status_get, requisition_file_download
create requisition_post, requisition_status_post, requisition_file_upload
update requisition_put, requisition_status_put
delete requisition_delete, requisition_status_delete, requisition_file_delete

🗂️ Status de Requisição (colunas)

GET /api/v1/admin/requisition-status — Listar colunas

Headers:

Authorization: Bearer <admin_token>
Content-Type: application/json

Query Parameters:

Parâmetro Tipo Padrão Descrição
_id OID - ID específico do status
query str (JSON) null Query MongoDB customizada
sort str (JSON) {"index":1} Ordenação — o default já é a ordem das colunas
qty_docs_page int 100 Documentos por página
current_page int 0 Página atual

Response (200 OK):

{
  "docs": [
    {
      "_id": "507f1f77bcf86cd799439011",
      "name": "A fazer",
      "index": 0,
      "color": "#607D8B",
      "register_date": "2026-08-31T10:00:00Z",
      "register_update_date": "2026-08-31T10:00:00Z"
    }
  ],
  "links": [
    {"link_type": "GET", "rel": "self", "href": "/api/v1/admin/requisition-status?current_page=0"},
    {"link_type": "POST", "rel": "insert document", "href": "/api/v1/admin/requisition-status"}
  ],
  "msg": "ok",
  "pagination": {"current_page": 0, "qty_docs_page": 100, "qty_of_pages": 1, "qty_total_docs": 4}
}

O envelope é o mesmo de todo cadastro da API: docs + links (HATEOAS) + msg + pagination. Nos exemplos seguintes o links é omitido por brevidade.

POST /api/v1/admin/requisition-status — Criar coluna

Request Body (lista, como nos demais cadastros):

[
  {"name": "Em andamento", "index": 1, "color": "#2E7D32"}
]
Campo Tipo Obrigatório Default Descrição
name str - Nome da coluna (1-100 chars). Unique
index int \| null null Ordem no board (null = sem posição, vai para o fim)
color str \| null null Hexadecimal (#RGB, #RRGGBB ou #RRGGBBAA)

Nome repetido → 409 DUPLICATE_KEY. Cor fora do padrão hexadecimal → 422.

PUT /api/v1/admin/requisition-status — Atualizar coluna

Merge parcial: só os campos enviados mudam (os omitidos mantêm o valor gravado).

[
  {"_id": "507f1f77bcf86cd799439011", "index": 2}
]

DELETE /api/v1/admin/requisition-status — Excluir coluna

Corpo com os documentos a excluir. Recusa com 409 se ainda houver requisição naquela coluna — mover os cards primeiro, senão o board ficaria com card sem coluna onde desenhar.

{
  "error": {
    "code": "CONFLICT",
    "message": "Status em uso por requisições — mova os cards antes.",
    "timestamp": "2026-08-31T12:00:00Z"
  }
}

📋 Requisições (cards)

GET /api/v1/admin/requisitions — Listar cards

Query Parameters:

Parâmetro Tipo Padrão Descrição
_id OID - ID específico da requisição
query str (JSON) null Query MongoDB customizada
sort str (JSON) {"register_update_date":-1} Ordenação
qty_docs_page int 10 Documentos por página
current_page int 0 Página atual

Exemplos:

# Todos os cards (paginado)
GET /api/v1/admin/requisitions?qty_docs_page=100

# Só a coluna "Em andamento"
GET /api/v1/admin/requisitions?query={"status_id":"507f1f77bcf86cd799439012"}

# Só os urgentes
GET /api/v1/admin/requisitions?query={"priority":"urgent"}

# Busca por título (regex, case-insensitive)
GET /api/v1/admin/requisitions?query={"name":{"$regex":"cobranca","$options":"i"}}

Response (200 OK):

{
  "docs": [
    {
      "_id": "507f1f77bcf86cd799439099",
      "name": "Corrigir cobrança duplicada",
      "status_id": "507f1f77bcf86cd799439012",
      "status_name": "Em andamento",
      "description": ["Cliente relatou duas cobranças em julho.", "Verificar webhook."],
      "files": [
        {
          "file_id": "507f1f77bcf86cd7994390aa",
          "filename": "comprovante.pdf",
          "content_type": "application/pdf",
          "size": 184320
        }
      ],
      "priority": "high",
      "document_created_by_id": "507f1f77bcf86cd799439001",
      "register_date": "2026-08-31T10:05:00Z",
      "register_update_date": "2026-08-31T11:20:00Z"
    }
  ],
  "msg": "ok",
  "pagination": {"current_page": 0, "qty_docs_page": 10, "qty_of_pages": 1, "qty_total_docs": 1}
}

status_name vem resolvido

A API já faz o $lookup na coleção de status. O board não precisa cruzar as duas listas no cliente — mas continue usando status_id para agrupar (o nome pode mudar).

POST /api/v1/admin/requisitions — Criar card

[
  {
    "name": "Corrigir cobrança duplicada",
    "status_id": "507f1f77bcf86cd799439012",
    "description": ["Cliente relatou duas cobranças em julho.", "Verificar webhook."],
    "priority": "high"
  }
]
Campo Tipo Obrigatório Default Descrição
name str - Título do card (1-200 chars)
status_id OID - Coluna do board. Tem de existir
description list[str] [] Itens de texto da descrição
priority str medium String livre (máx. 50). Convenção: low | medium | high | urgent

status_id inexistente → 422 (field: "status_id").

priority não é validado pela API

Qualquer texto até 50 caracteres é aceito. Padronize no cliente: se um lugar gravar alta e outro high, o board agrupa errado e a API não avisa. Use a convenção low / medium / high / urgent.

files não entra aqui

Anexo é upload próprio (POST .../{id}/files). O campo files é ignorado no POST e no PUT — só os endpoints de arquivo mantêm binário e ponteiro em sincronia.

PUT /api/v1/admin/requisitions — Atualizar card

Merge parcial. Mover de coluna é só isto:

[
  {"_id": "507f1f77bcf86cd799439099", "status_id": "507f1f77bcf86cd799439013"}
]

Campos aceitos: name, status_id, description, priority.

DELETE /api/v1/admin/requisitions — Excluir card

Corpo com os documentos a excluir. Os anexos do card são apagados do GridFS junto — não sobra binário órfão no bucket.


📎 Anexos

POST /api/v1/admin/requisitions/{id}/files — Enviar anexo

multipart/form-data, campo file. Máximo 10 MB. Sem allow-list de MIME: anexo de requisição é documento arbitrário (PDF, imagem, planilha, zip).

curl -X POST "https://fdplay-api.infraifd.com/api/v1/admin/requisitions/{id}/files" \
  -H "Authorization: Bearer $TOKEN" \
  -F "file=@comprovante.pdf"

Devolve a requisição atualizada, já com o anexo em files. Acima de 10 MB → 422.

DELETE /api/v1/admin/requisitions/{id}/files/{file_id} — Remover anexo

Apaga o ponteiro e o binário. file_id que não está no card → 404.

GET /api/v1/admin/requisitions/files/{file_id} — Baixar anexo

Autenticado (diferente do avatar, que é público), com três formas de provar quem é — as duas por query existem porque o navegador não manda header em <img>, <a download> ou aba nova (ADR-0101/0103):

# 1. Header Bearer — o caminho normal (curl, Dio, fetch com header)
curl "https://fdplay-api.infraifd.com/api/v1/admin/requisitions/files/{file_id}" \
  -H "Authorization: Bearer $TOKEN" -o anexo.pdf

# 2. Access token (JWT) na query — a URL que o painel admin monta hoje
https://fdplay-api.infraifd.com/api/v1/admin/requisitions/files/{file_id}?token=$TOKEN

# 3. archive_token na query — token de sessão dedicado a URLs de arquivo
#    (vem na resposta do login, ao lado do access_token)
https://fdplay-api.infraifd.com/api/v1/admin/requisitions/files/{file_id}?archive_token=$ARCHIVE_TOKEN

Qualquer uma das três passa pela mesma exigência de user_type='admin': token de customer → 403. Expirado, inválido ou desconhecido → 403. file_id que não está em nenhuma requisição → 404 (a rota só serve anexo do kanban, não qualquer arquivo do GridFS).

Content-Disposition é inline para imagem e PDF — abre na aba, serve de pré-visualização — e attachment para o resto, com o nome original.

Token na URL é credencial

Query string entra em log de acesso e Referer: link de anexo colado em chat funciona até o token expirar. Vale para os dois modos, mas ?token= é o pior caso — o access JWT autentica a API inteira, não só arquivos. Prefira o header quando o cliente puder mandá-lo, e ?archive_token= quando não puder (ADR-0103 documenta o trade-off).


🧩 Montando o board (Flutter/Dart)

Duas chamadas: colunas e cards. O agrupamento é por status_id.

import 'dart:convert';
import 'package:http/http.dart' as http;

class KanbanBoard {
    final List<dynamic> columns;              // ordenadas por `index`
    final Map<String, List<dynamic>> cards;   // status_id -> cards

    KanbanBoard({required this.columns, required this.cards});
}

Future<KanbanBoard> fetchBoard(String token) async {
    final headers = {
        'Authorization': 'Bearer $token',
        'Content-Type': 'application/json',
    };

    // 1. Colunas — já vêm ordenadas por `index` (sort default da rota)
    final columnsResponse = await http.get(
        Uri.parse('${AppConfig.baseUrl}/admin/requisition-status'),
        headers: headers,
    );
    final columns = jsonDecode(columnsResponse.body)['docs'] as List;

    // 2. Cards — subir o teto de pagina; o default e 10
    final cardsUri = Uri.parse('${AppConfig.baseUrl}/admin/requisitions')
        .replace(queryParameters: {'qty_docs_page': '200'});
    final cardsResponse = await http.get(cardsUri, headers: headers);
    final docs = jsonDecode(cardsResponse.body)['docs'] as List;

    // 3. Agrupar por status_id (nunca por status_name — o nome pode mudar)
    final grouped = <String, List<dynamic>>{
        for (final column in columns) column['_id'] as String: <dynamic>[],
    };
    for (final card in docs) {
        grouped[card['status_id'] as String]?.add(card);
    }

    return KanbanBoard(columns: columns, cards: grouped);
}

Drag & drop — mover o card de coluna:

Future<void> moveCard(String token, String cardId, String newStatusId) async {
    final response = await http.put(
        Uri.parse('${AppConfig.baseUrl}/admin/requisitions'),
        headers: {
            'Authorization': 'Bearer $token',
            'Content-Type': 'application/json',
        },
        body: jsonEncode([
            {'_id': cardId, 'status_id': newStatusId},
        ]),
    );

    if (response.statusCode != 200) {
        // 422 = coluna inexistente; 401 = perfil sem `requisition_put`; 403 = nao e admin
        throw Exception(jsonDecode(response.body)['error']['message']);
    }
}

Anexar arquivo:

Future<void> uploadAttachment(String token, String cardId, File file) async {
    final request = http.MultipartRequest(
        'POST',
        Uri.parse('${AppConfig.baseUrl}/admin/requisitions/$cardId/files'),
    )
        ..headers['Authorization'] = 'Bearer $token'
        ..files.add(await http.MultipartFile.fromPath('file', file.path));

    final response = await request.send();
    if (response.statusCode != 200) {
        throw Exception('Falha no upload (${response.statusCode})');
    }
}

Exibir/abrir o anexoImage.network e launchUrl não mandam header, então a URL leva o archive_token (ADR-0101):

String attachmentUrl(String fileId, String archiveToken) =>
    Uri.parse('${AppConfig.baseUrl}/admin/requisitions/files/$fileId')
        .replace(queryParameters: {'archive_token': archiveToken})
        .toString();

// Miniatura no card (imagem e PDF voltam com Content-Disposition: inline)
Image.network(attachmentUrl(fileId, archiveToken));

🐛 Erros

Código Quando
401 Not authenticated Nenhuma credencial chegou: header Authorization ausente, ou presente sem o prefixo Bearer (colar o token cru no header cai aqui)
401 Access Denied: You do not have permission for ... Perfil sem o nome do handler na lista correspondente de users_type
403 FORBIDDEN Token de customer (rota exige user_type='admin'), ou archive_token inválido/expirado
403 Access Denied: Could not validate token. Header Bearer presente, mas o token é inválido, vazio ou malformado
404 NOT_FOUND Requisição inexistente, ou file_id que não pertence ao card
409 CONFLICT Excluir coluna que ainda tem cards
409 DUPLICATE_KEY name de coluna repetido
422 VALIDATION_ERROR status_id inexistente, cor inválida, anexo > 10 MB, priority > 50 chars

Referências