FECOT
O dado mais sensível de uma federação de taekwondo (quem tem qual faixa) não tinha registro de quem aprovou.
- fastapi
- nextjs
- postgresql
- typescript
- tailwind
O problema e as restrições
O FECOT é o órgão regional que certifica quem tem qual faixa de taekwondo em quatro estados brasileiros. Esse único fato, a graduação, é o motivo de uma federação existir: ela decide em qual categoria um atleta compete, quem pode dar aula, e se o certificado da federação significa alguma coisa para a liga nacional à qual ela responde. Antes dessa reescrita, esse dado vivia num sistema em que a história de como uma mudança aconteceu não fazia parte do desenho.
O cliente, Luciano Bezerra da Cunha, trouxe um monólito em Ruby on Rails que funcionava mas estava envelhecendo, e uma prioridade clara: graduação não podia ser um campo como outro qualquer. Todo o resto (modernizar a stack, tipar as duas pontas da API) importava, mas a restrição de verdade por trás desse projeto era que a autoridade de uma federação depende inteiramente de as pessoas confiarem nos seus registros, e um registro que qualquer um com acesso de admin pode editar em silêncio é só uma opinião com data.
A segunda restrição era quem de fato usa esse sistema e como. Atletas não se auto-cadastram num laptop; são adolescentes e adultos cadastrados em lote pelo próprio professor, muitas vezes ali mesmo no tatame, sem e-mail à mão e sem nenhum momento em que entregar um link de “clique aqui para definir sua senha” faça sentido. Qualquer desenho de autenticação que assumisse um fluxo de auto-registro estaria resolvendo um problema que essa federação não tem, enquanto ignora o que ela tem de verdade.
A terceira restrição era honestidade operacional sobre a escala: um desenvolvedor só, construindo para uma federação esportiva regional numa única VPS, sem ambiente de staging, sem função de segurança dedicada, e sem time de plataforma ou investimento por trás de nada disso. Todo controle (tratamento de senha, autorização, segurança de upload) precisava ser algo que uma pessoa conseguisse raciocinar por completo, porque não existe um segundo revisor nem um time de resposta a incidente por trás.
A quarta restrição veio da própria hierarquia do domínio: professores, gestores de academia e administradores, e cada um deles também é, inevitavelmente, um atleta com a própria faixa e o próprio histórico de treino. Um professor sem pelo menos faixa preta de primeiro grau falha nas regras do próprio esporte para exercer o papel, antes mesmo do software entrar em cena. Qualquer modelo construído precisava representar que um gestor ou professor é uma especialização de “atleta”, a mesma entidade de base carregando um papel a mais.
A última restrição era continuidade: o sistema em Rails já carregava anos de regra de negócio real: qual graduação exige qual faixa para dar aula, como os níveis de graduação são nomeados e ordenados, quem pode cadastrar quem. Uma reescrita que “modernizasse” a stack perdendo uma dessas regras em silêncio seria um resultado pior do que não reescrever. Toda regra visível no código antigo precisava de uma decisão deliberada sobre se ela seguiria adiante.
Arquitetura
Um professor, um gestor de academia ou um admin abre o frontend em Next.js e faz tudo através de um único cliente de API tipado. Não existe outro caminho do navegador até o backend, e nenhum componente atravessa essa fronteira com um fetch solto e sem tipo. Esse gargalo único é o que permite afirmar, com confiança, que uma mudança de contrato de um lado é pega do outro: se o formato de resposta do backend muda, os tipos do frontend param de compilar em vez de simplesmente renderizar undefined em silêncio.
O backend em FastAPI é o único processo com acesso de escrita ao PostgreSQL. Duas camadas de autorização ficam na frente de toda escrita: guardas de papel no nível do endpoint (um endpoint simplesmente recusa um papel que não deveria estar ali), e regras contextuais avaliadas no próprio modelo de domínio: um professor edita os próprios alunos, um gestor de academia edita qualquer um da sua academia, um admin edita qualquer um. As duas camadas rodam no backend, nunca no frontend: a imposição precisa viver onde um usuário não consegue contornar editando o que é renderizado no navegador; a constituição desse projeto diz isso explicitamente, e a suíte de testes é o que de fato cobra isso.
O fluxo de graduação é onde essa arquitetura fica mais visível na prática. Nada no sistema (nenhum endpoint, nenhum atalho de admin, nenhum script interno) escreve direto no campo graduation de um atleta. A única forma dele mudar é por um GraduationRequest: um professor, gestor ou admin cria um, que tira um retrato automático da graduação atual do atleta; só um admin pode aprovar ou rejeitar; e a decisão, o revisor e o horário ficam registrados permanentemente, seja a solicitação aprovada ou recusada. Um atleta pode ter no máximo uma solicitação pendente por vez, o que fecha a única brecha que deixaria alguém contornar o rastro de auditoria de solicitação única simplesmente abrindo outra antes da primeira ser resolvida.
A autenticação fica por baixo de tudo isso como uma camada deliberadamente simples: um JWT stateless, guardado no navegador, revalidado contra o banco a cada requisição em vez de aceito de cara. Essa revalidação é o que faz desativar uma conta significar alguma coisa de verdade. O token em si não expira por até 24h, mas o backend confere se o atleta por trás dele ainda está ativo antes de aceitar qualquer coisa que o token afirme. O upload de avatar segue o mesmo instinto de verificar em vez de confiar: toda imagem é decodificada, achatada sobre fundo branco, redimensionada e regravada como WebP antes de ir para o disco, então nada que um usuário envia é servido de volta byte a byte. Um executável renomeado ou uma imagem com payload embutido não sobrevive inteiro a essa travessia.

Arquitetura
Decisões
DECISION 01/04 · Mudança de graduação
A graduação define em qual categoria o atleta compete, quem pode dar aula, e a própria credibilidade da federação como guardiã desse registro; um log passivo só documenta uma mudança ruim depois que ela aconteceu. A federação precisava de algo que impedisse a mudança de fato, com o papel como efeito colateral dessa trava
Custo aceitoCorrigir um erro de digitação na graduação de alguém exige solicitação mais aprovação separada, até para quem poderia simplesmente corrigir direto. É de propósito: o atalho mais perigoso é exatamente o que está sendo removido
DECISION 02/04 · Modelo de usuário
Todo usuário desse domínio, inclusive a equipe da federação, também é literalmente um atleta com faixa e histórico de treino; separar o conceito em dois só criaria dois registros que precisariam ficar sincronizados para sempre
Custo aceitoA elegibilidade por papel (professor precisa de pelo menos 1º Dan) vive em validadores Python em vez de numa constraint de banco. Um INSERT SQL direto ainda poderia criar um professor inválido; a garantia fica na borda da aplicação em vez do schema
DECISION 03/04 · Estratégia de sessão
Backend e frontend rodam como dois processos separados, possivelmente em domínios diferentes; um token stateless evita construir infraestrutura de sessão e configuração de cookie cross-origin para uma plataforma desse porte
Custo aceitoSem refresh token e sem lista de revogação no servidor. Um token roubado vale até expirar. O que de fato garante segurança aqui é que desativar a conta de um atleta corta o acesso na hora, porque o backend recarrega o atleta do banco a cada requisição em vez de confiar no que o token afirma
DECISION 04/04 · Primeira senha
Atletas são cadastrados em lote pelo professor, muitas vezes no próprio tatame, sem estar presentes para escolher nada, e a plataforma não tem infraestrutura de e-mail transacional para mandar um convite
Custo aceitoA senha inicial é adivinhável por definição: o CPF de uma pessoa costuma ser conhecido por quem está ao redor dela. Isso é documentado abertamente como fraqueza aceita: o atleta pode trocar depois do primeiro login, mas o sistema não força tecnicamente essa troca
Invariantes
A graduação de um atleta só muda por um GraduationRequest aprovado, nunca por atualização direta, nem por admin
Garantido por
o campo graduation está totalmente ausente do schema de atualização de atleta; não existe caminho de código que escreva nele fora do endpoint de aprovaçãoUm papel de professor ou gestor de academia exige pelo menos 1º Dan
Garantido por
validado na criação e edição do atleta, e na associação professor-academia; violação retorna 422Desativar a conta de um atleta revoga o acesso na hora, mesmo com um JWT ainda válido
Garantido por
o backend recarrega o atleta do banco a cada requisição autenticada e rejeita conta inativa, em vez de confiar nas claims guardadas no tokenUm atleta nunca tem mais de uma solicitação de graduação pendente ao mesmo tempo
Garantido por
uma checagem de unicidade na criação da solicitação retorna 409 na duplicataUm avatar nunca é servido como o arquivo exato que o usuário enviou
Garantido por
todo upload é decodificado, achatado, redimensionado e regravado como WebP via Pillow antes de ir para o disco, descartando os bytes originais
O que quebrou
- Sintoma
- Logo depois de um rebranding visual, o serviço de frontend na plataforma de hospedagem entrou em loop de reinício: o log mostrava o processo iniciando e ficando pronto repetidas vezes, em menos de um segundo cada vez, sem nenhum erro impresso em lugar nenhum
- Causa raiz
- Descartada uma causa de cada vez: falta de wget na imagem (presente via busybox), recurso de host insuficiente (memória e CPU com folga) e descompasso de porta no domínio (configurado corretamente para 3000). Restou a suspeita de que a instrução HEALTHCHECK do Docker estava interagindo mal com a checagem de saúde da própria plataforma de hospedagem. As duas provavelmente competiam, e a plataforma matava o container como não saudável antes mesmo do período inicial do HEALTHCHECK ter passado
- Correção
- Removida a instrução HEALTHCHECK do Dockerfile do frontend por completo. O roteamento HTTP da própria plataforma já confirma que o serviço está acessível; um segundo sinal de saúde, redundante, estava causando mais dano que benefício
- Prevenção
- A mensagem do commit em si foi escrita como um registro de diagnóstico: o que foi descartado, em que ordem e por quê. Assim, a próxima surpresa de orquestração de container nessa plataforma começa dessa lista em vez de do zero
Resultados
Stack completa
Backend
- FastAPI (Python 3.12)
- SQLAlchemy 2 + Alembic
- JWT (HS256)
- bcrypt
Frontend
- Next.js 16 (App Router)
- React 19
- TypeScript
- Tailwind + shadcn/ui
Dados
- PostgreSQL 16
Infra
- systemd + nginx, uma única VPS
Tem interesse em um projeto como esse? Entre em contato.
Entrar em contato