🧩 Exercícios: Stack PERN: Express, PostgreSQL, Prisma ORM e React
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:
- Explique, em até 5 linhas, por que um erro nessa rota derruba o processo e não vira uma resposta 500.
- Escreva o embrulho
assincronoque repassa a promessa rejeitada para o Express, e reescreva a rota com ele. - Escreva o handler de erros que responde
404para uma classeErroHttpcom status, e500sem stack trace para o resto. Diga onde ele precisa ser registrado. - Faça a rota responder
404 {"erro":"estadia não encontrada"}quandobuscardevolverundefined.
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)
- Salve o arquivo como:
Atividade_05_1_SeuNome.md - 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:
- Escreva o schema Zod
Entrada: placa no formato antigo (ABC-1234) ou Mercosul (ABC1D23), convertida para maiúsculas;categoryAvulsoouMensalista;blockA,BouC;slotinteiro de 1 a 40. - Escreva
POST /api/estadias, que cria a estadia comstatus: "Estacionado"eentryTimeagora, e responde201com o headerLocation. - Responda
409se a vaga já estiver ocupada ou se a mesma placa já estiver no pátio. - Mostre a resposta para uma placa
12345na 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)
- Salve o arquivo como:
Atividade_05_2_SeuNome.ts - 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 doregex:abc1d23é aceita e gravada comoABC1D23. 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)ondestatus = '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:
- Escreva o componente
PatioViewcomuseQuery, listando as estadias deGET /api/estadias?status=.... - Coloque o filtro na
queryKeye explique por que isso resolve “a lista mostra o filtro anterior”. - Registre a saída (
PATCH /api/estadias/:id/saida) comuseMutatione atualize as duas abas com uma única invalidação. - Mostre na tela a mensagem de erro que a API devolve (por exemplo, o
409de 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)
- Salve o arquivo como:
Atividade_05_3_SeuNome.tsx - 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
QueryClientProviderprecisa envolver a aplicação (emmain.tsx), senãouseQuerylanç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:
- 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. - Escreva
PATCH /api/estadias/:id/saida: gravaexitTimeecost, muda o status paraFinalizado, responde404para id inexistente e409se a saída já foi registrada. - Torne a API testável no tempo: o “agora” é injetado (
criarApp(agora)), para o teste simular 2 h 10 min sem esperar. - Configure o
vite.config.tsdoweb-reactpara que/apichegue 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)
- Salve o arquivo como:
Atividade_05_4_SeuNome.zip(API + teste +vite.config.ts) - 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.ceilimplementa 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 usanew Date(), e o teste controla o tempo. - O
costé calculado no servidor. Se a tela calculasse e enviasse o valor, qualquer cliente poderia mandarcost: 0. - Para ir além: trocar o
Mappor Prisma e PostgreSQL (Cap. 07) e subir tudo com Docker Compose (Cap. 15).