Aplicações Full Stack • Trilha Progressiva em 4 Níveis

O fio condutor é o ParkManager (fullstack_pi_01_parkmanager): um frontend React de estacionamento que guarda tudo no localStorage e não tem API. Ao longo dos quatro níveis, você escreve a API Express das estadias (entrada e saída de veículos) e liga a tela a ela com TanStack Query.


🎯 Nível 1: Fundamentos

Problema 05.1 — Diagnóstico: a API que cai inteira

Contexto: A primeira versão da API do ParkManager usa Express 4. Num teste, o banco ficou fora do ar por alguns segundos e, em vez de responder erro só para quem chamou a rota, o servidor inteiro parou, derrubando os outros usuários. A rota era esta:

app.get("/api/estadias/:id", async (req, res) => {
  const estadia = await repositorio.buscar(req.params.id);   // lança erro se o banco cair
  res.json(estadia);
});

Requisitos de Execução:

  1. Explique, em até 5 linhas, por que um erro nessa rota derruba o processo e não vira uma resposta 500.
  2. Escreva o embrulho assincrono que repassa a promessa rejeitada para o Express, e reescreva a rota com ele.
  3. Escreva o handler de erros que responde 404 para uma classe ErroHttp com status, e 500 sem stack trace para o resto. Diga onde ele precisa ser registrado.
  4. Faça a rota responder 404 {"erro":"estadia não encontrada"} quando buscar devolver undefined.

Resultado Esperado

Explicação: no Express 4, uma rota async que lança vira uma promessa rejeitada que
ninguém trata; o Express não a vê, e o Node.js encerra o processo.
GET /api/estadias/e1   (banco fora)  → 500 {"erro":"erro interno"}  e o servidor segue no ar
GET /api/estadias/e99                → 404 {"erro":"estadia não encontrada"}

📤 Instruções de Entrega (Microsoft Teams)

  1. Salve o arquivo como: Atividade_05_1_SeuNome.md
  2. Envie na tarefa: Atividade Cap 05 - Stack Express e React
🔑 Gabarito de Código & Solução Comentada

Por que cai: o Express 4 chama a função da rota e ignora o que ela devolve. Uma função async devolve uma promessa; quando ela é rejeitada, ninguém está ouvindo. A rejeição sem tratamento (unhandled rejection) faz o Node.js 15 ou mais novo encerrar o processo. O Express só leva ao handler de erros o que é lançado de forma síncrona ou passado para next(err). O Express 5 resolveu isso: ele aguarda a promessa e repassa a rejeição sozinho.

import express, { type NextFunction, type Request, type Response } from "express";

export class ErroHttp extends Error {
  constructor(public status: number, message: string) { super(message); }
}

// Repassa a rejeição para o Express, que chama o handler de erros.
export const assincrono =
  (fn: (req: Request, res: Response) => Promise<unknown>) =>
  (req: Request, res: Response, next: NextFunction) => { fn(req, res).catch(next); };

app.get("/api/estadias/:id", assincrono(async (req, res) => {
  const estadia = await repositorio.buscar(req.params.id);
  if (!estadia) throw new ErroHttp(404, "estadia não encontrada");
  res.json(estadia);
}));

// 4 parâmetros: é assim que o Express sabe que é um handler de ERRO.
export function tratarErros(err: unknown, _req: Request, res: Response, _next: NextFunction) {
  if (err instanceof ErroHttp) {
    res.status(err.status).json({ erro: err.message });
  } else {
    console.error(err);                                  // o detalhe fica no log do servidor
    res.status(500).json({ erro: "erro interno" });      // o cliente não vê o stack trace
  }
}

app.use(tratarErros);   // DEPOIS de todas as rotas

Comentários:

  • O handler precisa ser o último app.use: o Express procura o próximo middleware de erro depois do ponto em que o erro aconteceu.
  • Mandar o stack trace para o cliente revela nomes de arquivos, versões e às vezes consultas SQL. Ele vai para o log, nunca para a resposta.
  • Alternativa: atualizar para o Express 5 e dispensar o embrulho.

🔍 Nível 2: Prática

Problema 05.2 — Rota de entrada de veículo com validação

Contexto: No ParkManager, a entrada de um veículo hoje é um setStays([...]) direto na tela, sem regra nenhuma: dá para colocar dois carros na mesma vaga, ou o mesmo carro duas vezes. A tela envia este JSON (os nomes vêm de web-react/src/types.ts):

{ "plate": "abc1d23", "model": "Onix", "color": "Prata", "category": "Avulso", "block": "A", "slot": 7 }

Requisitos de Execução:

  1. Escreva o schema Zod Entrada: placa no formato antigo (ABC-1234) ou Mercosul (ABC1D23), convertida para maiúsculas; category Avulso ou Mensalista; block A, B ou C; slot inteiro de 1 a 40.
  2. Escreva POST /api/estadias, que cria a estadia com status: "Estacionado" e entryTime agora, e responde 201 com o header Location.
  3. Responda 409 se a vaga já estiver ocupada ou se a mesma placa já estiver no pátio.
  4. Mostre a resposta para uma placa 12345 na vaga 41.

Resultado Esperado

POST (válido)              → 201  Location: /api/estadias/e1   plate: "ABC1D23"
POST (mesma vaga A7)       → 409  {"erro":"vaga A7 ocupada"}
POST (mesma placa, vaga 8) → 409  {"erro":"veículo ABC1D23 já está no pátio"}
POST (12345, vaga 41)      → 400  {"erro":"dados inválidos","campos":{"plate":["placa inválida"],"slot":["Number must be less than or equal to 40"]}}

📤 Instruções de Entrega (Microsoft Teams)

  1. Salve o arquivo como: Atividade_05_2_SeuNome.ts
  2. Envie na tarefa: Atividade Cap 05 - Stack Express e React
🔑 Gabarito de Código & Solução Comentada
import { z } from "zod";

// Placa antiga (ABC-1234) ou Mercosul (ABC1D23)
const PLACA = /^[A-Z]{3}-?\d[A-Z0-9]\d{2}$/;

export const Entrada = z.object({
  plate: z.string().toUpperCase().regex(PLACA, "placa inválida"),
  model: z.string().min(2),
  color: z.string().min(3),
  category: z.enum(["Avulso", "Mensalista"]),
  block: z.enum(["A", "B", "C"]),
  slot: z.number().int().min(1).max(40),
});

router.post("/", assincrono(async (req, res) => {
  const dados = Entrada.parse(req.body);                       // 400 pelo handler de erros
  const ocupada = [...estadias.values()].some(
    (e) => e.status === "Estacionado" && e.block === dados.block && e.slot === dados.slot);
  if (ocupada) throw new ErroHttp(409, `vaga ${dados.block}${dados.slot} ocupada`);
  const dentro = [...estadias.values()].some((e) => e.status === "Estacionado" && e.plate === dados.plate);
  if (dentro) throw new ErroHttp(409, `veículo ${dados.plate} já está no pátio`);

  const estadia = {
    ...dados, id: `e${seq++}`, entryTime: agora().toISOString(),
    exitTime: null, status: "Estacionado" as const, cost: null,
  };
  estadias.set(estadia.id, estadia);
  res.status(201).location(`/api/estadias/${estadia.id}`).json(estadia);
}));

Comentários:

  • toUpperCase() vem antes do regex: abc1d23 é aceita e gravada como ABC1D23. Assim a regra “mesma placa” não é burlada com letras minúsculas.
  • 409 Conflict, e não 400: os dados estão bem formados, mas conflitam com o estado atual do pátio.
  • Com banco de verdade, a vaga ocupada também deve virar uma restrição no banco (índice único parcial em (block, slot) onde status = 'Estacionado'). Duas entradas simultâneas podem passar as duas pela checagem em memória antes de qualquer uma gravar. O Cap. 07 trata disso.

⚡ Nível 3: Integração

Problema 05.3 — A tela do pátio com TanStack Query

Contexto: Com a API pronta, a tela do pátio precisa ler dela. A primeira tentativa copiou o padrão do fullstack_tarefas_07_spring_react (HomePage.jsx): useEffect para buscar, useState para guardar e, depois de cada ação, um setStays(prev => ...) à mão. Ao trocar o filtro “No pátio / Finalizadas”, a lista às vezes mostra o resultado do filtro anterior, e depois de registrar uma saída a aba “Finalizadas” continua desatualizada.

Requisitos de Execução:

  1. Escreva o componente PatioView com useQuery, listando as estadias de GET /api/estadias?status=....
  2. Coloque o filtro na queryKey e explique por que isso resolve “a lista mostra o filtro anterior”.
  3. Registre a saída (PATCH /api/estadias/:id/saida) com useMutation e atualize as duas abas com uma única invalidação.
  4. Mostre na tela a mensagem de erro que a API devolve (por exemplo, o 409 de saída repetida), e não só “HTTP 409”.

Resultado Esperado

Aba "No pátio": GET /api/estadias?status=Estacionado → lista com o botão "Registrar saída"
Clicar em "Registrar saída" → PATCH 200 → o carro some da aba e aparece em "Finalizadas" com o valor
Saída repetida → a tela mostra "saída já registrada"

📤 Instruções de Entrega (Microsoft Teams)

  1. Salve o arquivo como: Atividade_05_3_SeuNome.tsx
  2. Envie na tarefa: Atividade Cap 05 - Stack Express e React
🔑 Gabarito de Código & Solução Comentada
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";

type Status = "Estacionado" | "Finalizado";
type Estadia = { id: string; plate: string; block: string; slot: number; status: Status; cost: number | null };

async function api<T>(caminho: string, init?: RequestInit): Promise<T> {
  const resp = await fetch(`/api${caminho}`, { headers: { "Content-Type": "application/json" }, ...init });
  if (!resp.ok) {
    const corpo = await resp.json().catch(() => ({}));
    throw new Error(corpo.erro ?? `HTTP ${resp.status}`);   // mostra a mensagem da API (ex.: 409)
  }
  return resp.json() as Promise<T>;
}

export function PatioView() {
  const [status, setStatus] = useState<Status>("Estacionado");     // estado da TELA: useState
  const queryClient = useQueryClient();

  const estadias = useQuery({
    queryKey: ["estadias", { status }],                              // o filtro faz parte da chave
    queryFn: () => api<Estadia[]>(`/estadias?status=${status}`),
  });

  const registrarSaida = useMutation({
    mutationFn: (id: string) => api<Estadia>(`/estadias/${id}/saida`, { method: "PATCH" }),
    // invalida TODAS as listas de estadias (qualquer status): prefixo da chave
    onSuccess: () => queryClient.invalidateQueries({ queryKey: ["estadias"] }),
  });

  return (
    <section>
      <select value={status} onChange={(e) => setStatus(e.target.value as Status)}>
        <option value="Estacionado">No pátio</option>
        <option value="Finalizado">Finalizadas</option>
      </select>

      {estadias.isPending && <p>Carregando…</p>}
      {estadias.error && <p role="alert">Erro: {estadias.error.message}</p>}
      {registrarSaida.error && <p role="alert">{registrarSaida.error.message}</p>}

      <ul>
        {estadias.data?.map((e) => (
          <li key={e.id}>
            {e.plate} — vaga {e.block}{e.slot}
            {e.status === "Estacionado" ? (
              <button onClick={() => registrarSaida.mutate(e.id)} disabled={registrarSaida.isPending}>
                Registrar saída
              </button>
            ) : (
              <span> R$ {e.cost?.toFixed(2)}</span>
            )}
          </li>
        ))}
      </ul>
    </section>
  );
}

Comentários:

  • Por que o filtro na chave resolve: com useEffect, duas requisições (filtro antigo e novo) correm em paralelo, e a que termina por último ganha, mesmo que seja a antiga. Com ["estadias", { status }], cada filtro tem a sua entrada no cache, e a tela só mostra a entrada da chave atual.
  • Uma invalidação, duas abas: invalidateQueries({ queryKey: ["estadias"] }) casa pelo prefixo, então marca ["estadias", {status:"Estacionado"}] e ["estadias", {status:"Finalizado"}] como desatualizadas.
  • O filtro selecionado é estado da tela e fica em useState. Só o que vem da API passa pelo TanStack Query.
  • O QueryClientProvider precisa envolver a aplicação (em main.tsx), senão useQuery lança erro.

🏆 Nível 4: Desafio Corporativo

Problema 05.4 — Saída com cobrança testável e o ambiente de desenvolvimento

Contexto: O dono do estacionamento definiu a tabela de preços: 15 minutos de tolerância, R$ 10 na primeira hora e R$ 5 por hora ou fração adicional. Mensalista não paga. A conta tem de estar certa, e testar com “espere 2 horas e confira” não é uma opção.

Requisitos de Execução:

  1. Escreva calcularValor(entrada, saida, categoria) como função pura e mostre o valor para 10 min, 1 h, 2 h 10 min e um mensalista que ficou 5 h.
  2. Escreva PATCH /api/estadias/:id/saida: grava exitTime e cost, muda o status para Finalizado, responde 404 para id inexistente e 409 se a saída já foi registrada.
  3. Torne a API testável no tempo: o “agora” é injetado (criarApp(agora)), para o teste simular 2 h 10 min sem esperar.
  4. Configure o vite.config.ts do web-react para que /api chegue ao Express na porta 3000 sem CORS em desenvolvimento.

Resultado Esperado

valor 10 min: 0      valor 1h: 10      valor 2h10: 20      valor mensalista 5h: 0
PATCH /api/estadias/e1/saida (2h10 depois) → 200 Finalizado R$ 20
PATCH /api/estadias/e1/saida (de novo)     → 409 {"erro":"saída já registrada"}
GET /api/estadias?status=Estacionado       → []

📤 Instruções de Entrega (Microsoft Teams)

  1. Salve o arquivo como: Atividade_05_4_SeuNome.zip (API + teste + vite.config.ts)
  2. Envie na tarefa: Atividade Cap 05 - Stack Express e React
🔑 Gabarito de Código & Solução Comentada
// Regra de cobrança: 15 min de tolerância; R$ 10 na 1ª hora; R$ 5 por hora ou fração adicional.
export function calcularValor(entrada: Date, saida: Date, categoria: "Avulso" | "Mensalista"): number {
  if (categoria === "Mensalista") return 0;
  const minutos = Math.ceil((saida.getTime() - entrada.getTime()) / 60_000);
  if (minutos <= 15) return 0;
  const horas = Math.ceil(minutos / 60);
  return 10 + (horas - 1) * 5;
}

export function criarApp(agora: () => Date = () => new Date()) {
  // ... estadias, router.get e router.post do 05.2 ...

  router.patch("/:id/saida", assincrono(async (req, res) => {
    const estadia = estadias.get(req.params.id);
    if (!estadia) throw new ErroHttp(404, "estadia não encontrada");
    if (estadia.status === "Finalizado") throw new ErroHttp(409, "saída já registrada");
    const saida = agora();
    estadia.exitTime = saida.toISOString();
    estadia.status = "Finalizado";
    estadia.cost = calcularValor(new Date(estadia.entryTime), saida, estadia.category);
    res.json(estadia);
  }));
  // ... app.use(express.json()), app.use("/api/estadias", router), handler de erros ...
  return app;
}
// teste: o relógio é uma variável que o teste avança
let relogio = new Date("2026-05-28T08:00:00Z");
const app = criarApp(() => relogio);
// ... POST da entrada às 08:00 ...
relogio = new Date("2026-05-28T10:10:00Z");                       // 2h10 depois, sem esperar
const r = await fetch(`${base}/e1/saida`, { method: "PATCH" });   // → 200, cost: 20
// web-react/vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

export default defineConfig({
  plugins: [react()],
  server: { proxy: { "/api": "http://localhost:3000" } },
});

Comentários:

  • 2 h 10 min = 130 min → 3 horas iniciadas → 10 + 2 × 5 = R$ 20. O Math.ceil implementa o “hora ou fração”.
  • A regra fica numa função pura porque é ela que muda quando o preço mudar, e ela se testa sem servidor, sem banco e sem relógio.
  • Injetar o relógio (agora) é o mesmo princípio de injetar o repositório: o código de produção usa new Date(), e o teste controla o tempo.
  • O cost é calculado no servidor. Se a tela calculasse e enviasse o valor, qualquer cliente poderia mandar cost: 0.
  • Para ir além: trocar o Map por Prisma e PostgreSQL (Cap. 07) e subir tudo com Docker Compose (Cap. 15).