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:
user_type='admin'(ADR-061). Token de customer → 403 FORBIDDEN.- 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órootpassa até você conceder os nomes abaixo. Faltando → 401Access 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:
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):
| 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).
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:
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 anexo — Image.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¶
- Admin vs Customer — o que cada perfil enxerga
- API de Administração — demais rotas do painel
- Swagger:
/api/v1/docs(tag requisitions)