Em produção2026-04 – atualDesenvolvedor full-stack, único

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.

A lista de atletas do FECOT, mostrando a mesma tabela com um administrador, atletas, gestores de academia e um professor lado a lado, cada um com faixa, academia e status. Todo nome e documento mostrado é dado fictício de seed.
A decisão de 'uma única tabela Athlete com papéis', visível: um administrador da federação, um gestor de academia e um atleta comum são o mesmo tipo de linha, distinguidos só por papel e graduação.

Arquitetura

FECOT: architecture A Next.js frontend and a FastAPI backend deploy as two separate processes behind nginx on one VPS. The frontend calls the backend only through a single typed API client. The backend is the only writer to PostgreSQL. A teacher or manager submits a graduation request; only the backend's approval endpoint can change an athlete's graduation field, and every request is either approved or rejected by an admin, with the reviewer and timestamp recorded. Next.js frontend one typed API client Teacher / Manager requests a graduation change FastAPI backend only writer to the database Admin approves or rejects, never edits directly PostgreSQL athletes, academies, graduation_requests graduation field: writable only through the approval endpoint

Decisões

DECISION 01/04 · Mudança de graduação

EscolhidoA graduação nunca é editável diretamente, nem por admin. Só muda por um fluxo de solicitação e aprovação, que tira um retrato da graduação atual e registra quem pediu, quem revisou e quando

DescartadoDeixar o admin editar o campo de graduação direto, confiando num log de auditoria para registrar a mudança depois

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

EscolhidoUma única tabela Athlete para todo mundo, com um enum de papel (atleta, professor, gestor de academia, admin) e helpers hierárquicos de permissão

DescartadoTabelas separadas User e Athlete, ligadas por chave estrangeira

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

EscolhidoJWT stateless (24h, HS256) guardado em localStorage, revalidado contra o banco a cada requisição

DescartadoCookie httpOnly com sessão mantida no servidor

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

EscolhidoUm atleta cadastrado sem senha escolhida recebe o próprio CPF como senha inicial

DescartadoMandar um link de convite por e-mail para o atleta definir a própria 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 poro 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ção

  • Um papel de professor ou gestor de academia exige pelo menos 1º Dan

    Garantido porvalidado na criação e edição do atleta, e na associação professor-academia; violação retorna 422

  • Desativar a conta de um atleta revoga o acesso na hora, mesmo com um JWT ainda válido

    Garantido poro backend recarrega o atleta do banco a cada requisição autenticada e rejeita conta inativa, em vez de confiar nas claims guardadas no token

  • Um atleta nunca tem mais de uma solicitação de graduação pendente ao mesmo tempo

    Garantido poruma checagem de unicidade na criação da solicitação retorna 409 na duplicata

  • Um avatar nunca é servido como o arquivo exato que o usuário enviou

    Garantido portodo 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

105testes de backend passandosuíte real rodada em 2026-08-29: 105 passed em 69.51s
158arquivos de código-fonte (backend + frontend)contagem de arquivos .py/.ts/.tsx fora de node_modules/venv, 2026-08-29
20níveis de graduação modelados, do 10º Gub ao 10º Danconstante GRADUATIONS, backend/app/core/graduations.py
4papéis de atleta num único modeloenum Athlete.role, backend/app/models/athlete.py
10commitscontagem de git log --oneline na main, 2026-08-29
desde abril de 2026em produção contínuadata do primeiro commit da reescrita

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