🧩 Exercícios: Stack Enterprise TypeScript: NestJS e Next.js App Router
Aplicações Full Stack • Trilha Progressiva em 4 Níveis
O fio condutor é a TecLoja nas partes 3 e 4 do Catálogo: fullstack_tecloja_03_nest_react, cujo products.service.ts ainda não forma um app Nest, e fullstack_tecloja_04_nest_nextjs, cuja vitrine Next.js tem os produtos escritos no próprio código.
🎯 Nível 1: Fundamentos
Problema 06.1 — Diagnóstico: o serviço que o Nest não consegue injetar
Contexto: Para transformar o products.service.ts da TecLoja 03 num app Nest, a equipe criou um ProductsController e fez o ProductsService depender de um ProductsRepository. Dois desenvolvedores, duas versões, dois sintomas diferentes:
- Versão 1: o app sobe normalmente, mas a primeira chamada a
GET /productsresponde500, e o log mostraCannot read properties of undefined (reading 'findAll'). OProductsServiceestá nosproviders, mas a classe não tem@Injectable(). - Versão 2: o app nem sobe:
Nest can't resolve dependencies of the ProductsController (?). Please make sure that the argument ProductsService at index [0] is available in the ProductsModule context.
Requisitos de Execução:
- Explique por que a Versão 1 sobe sem erro e só falha na chamada, enquanto a Versão 2 falha na subida.
- Diga o que falta em cada versão.
- Escreva o
ProductsModulecompleto e correto: repositório, serviço e controller, com a injeção pelo construtor. - Explique por que vale pôr
@Injectable()também num provider que hoje não tem dependências.
Resultado Esperado
Versão 1: sem @Injectable, o TypeScript não grava os tipos do construtor; o Nest não sabe
o que injetar e passa undefined → o erro só aparece quando o método usa a dependência.
Versão 2: o ProductsService não está nos providers do módulo → erro na subida.
Módulo corrigido: GET /products → 200 com a lista.
📤 Instruções de Entrega (Microsoft Teams)
- Salve o arquivo como:
Atividade_06_1_SeuNome.md - Envie na tarefa:
Atividade Cap 06 - Stack Next.js e NestJS
🔑 Gabarito de Código & Solução Comentada
Por que os sintomas são diferentes: o Nest descobre o que injetar lendo os metadados design:paramtypes que o TypeScript grava (com emitDecoratorMetadata) para classes que têm um decorator. Sem @Injectable(), a classe não tem decorator, os tipos do construtor não são gravados, e o Nest chama new ProductsService() sem argumentos. Não há erro na subida: this.repo simplesmente fica undefined até alguém usá-lo. Na Versão 2, o controller tem decorator (@Controller), então o Nest sabe que precisa de um ProductsService. Como ele não está registrado no módulo, a subida para com o erro de dependência.
import { Controller, Get, Injectable, Module } from "@nestjs/common";
@Injectable()
export class ProductsRepository {
private produtos = [{ id: 1, name: "Teclado Mecânico RGB Pro", price: 349.9 }];
findAll() { return this.produtos; }
}
@Injectable() // grava os tipos do construtor
export class ProductsService {
constructor(private readonly repo: ProductsRepository) {}
findAll() { return this.repo.findAll(); }
}
@Controller("products")
export class ProductsController {
constructor(private readonly service: ProductsService) {}
@Get() findAll() { return this.service.findAll(); }
}
@Module({
controllers: [ProductsController],
providers: [ProductsService, ProductsRepository], // tudo que é injetado precisa estar aqui
exports: [ProductsService], // se outro módulo (ex.: Pedidos) for usar
})
export class ProductsModule {}
Comentários:
- Reproduzindo os três casos com NestJS 10: sem
@Injectablee sem dependências, a classe funciona. Sem@Injectablee com dependências, sobe e quebra na chamada. Fora dosproviders, não sobe. - Por isso a regra é
@Injectable()em todo provider: no dia em que alguém acrescentar uma dependência ao construtor, o erro silencioso da Versão 1 não acontece. - Prefira o erro na subida ao erro na chamada: o primeiro aparece no seu terminal, e o segundo na tela do cliente.
🔍 Nível 2: Prática
Problema 06.2 — DTO, ValidationPipe e o ataque de mass assignment
Contexto: O cadastro de produtos da TecLoja recebe o JSON e repassa o corpo inteiro ao serviço. Num teste de segurança, alguém enviou {"nome":"TV","preco":-1,"estoque":2.5,"desconto":90}, e o produto foi gravado com preço negativo, estoque fracionado e um campo desconto que nem existia na tela. Além disso, GET /produtos/abc derruba a rota com NaN.
Requisitos de Execução:
- Escreva o
CriarProdutoDtocomclass-validator:nomecom 3 ou mais letras,precopositivo,estoqueinteiro positivo. - Ligue o
ValidationPipeglobalmente de forma que campos fora do DTO sejam recusados com400. - Use um pipe para que
GET /produtos/:idsó aceite número. - Mostre as respostas para o JSON do contexto, para
GET /produtos/abce paraGET /produtos/99(inexistente).
Resultado Esperado
POST {"nome":"TV","preco":-1,"estoque":2.5,"desconto":90}
→ 400 {"message":["property desconto should not exist","nome must be longer than or equal to 3 characters",
"preco must be a positive number","estoque must be an integer number"],"error":"Bad Request","statusCode":400}
GET /produtos/abc → 400 "Validation failed (numeric string is expected)"
GET /produtos/99 → 404 "produto 99 não encontrado"
📤 Instruções de Entrega (Microsoft Teams)
- Salve o arquivo como:
Atividade_06_2_SeuNome.ts - Envie na tarefa:
Atividade Cap 06 - Stack Next.js e NestJS
🔑 Gabarito de Código & Solução Comentada
import { Body, Controller, Get, NotFoundException, Param, ParseIntPipe, Post } from "@nestjs/common";
import { IsInt, IsPositive, IsString, MinLength } from "class-validator";
export class CriarProdutoDto {
@IsString() @MinLength(3) nome!: string;
@IsPositive() preco!: number;
@IsInt() @IsPositive() estoque!: number;
}
@Controller("produtos")
export class ProdutosController {
constructor(private readonly servico: ProdutosService) {}
@Get(":id")
buscar(@Param("id", ParseIntPipe) id: number) { return this.servico.buscar(id); }
@Post()
criar(@Body() dto: CriarProdutoDto) { return this.servico.criar(dto); }
}
// no serviço: produto inexistente vira 404 sem nenhum código de resposta no controller
buscar(id: number) {
const p = this.produtos.find((x) => x.id === id);
if (!p) throw new NotFoundException(`produto ${id} não encontrado`);
return p;
}
// main.ts
app.useGlobalPipes(new ValidationPipe({
whitelist: true, // remove do objeto o que não está no DTO
forbidNonWhitelisted: true, // ... e, em vez de remover em silêncio, responde 400
transform: true, // entrega ao controller uma instância de CriarProdutoDto
}));
Comentários:
- Mass assignment: sem
whitelist, um"role": "ADMIN"no cadastro de usuário, ou um"preco": 0.01num pedido, chega ao banco. O DTO é a lista do que o cliente pode mandar. whitelistsozinho remove o campo em silêncio.forbidNonWhitelistedavisa o cliente, o que ajuda quem integra a API a achar o erro.ParseIntPipetransforma"7"em7e recusa"abc"com400antes do controller. Sem ele,Number("abc")viraNaN, e a busca falha de um jeito confuso.- As mensagens acima são as que o NestJS 10 com
class-validatordevolve de fato: todas as violações juntas, numa lista.
⚡ Nível 3: Integração
Problema 06.3 — A vitrine que nunca muda
Contexto: A vitrine da TecLoja 04 (web-nextjs/app/page.tsx) começa com export const revalidate = 60; // ISR a cada 60 segundos, mas os dois produtos estão escritos no próprio arquivo. A equipe de marketing mudou o preço no banco, esperou um minuto, e nada mudou na loja. Além disso, se a API cair, a ideia é mostrar uma mensagem com “tentar de novo”, e não uma tela de erro do Next.
Requisitos de Execução:
- Explique por que o
revalidate = 60não fez a vitrine mudar. - Reescreva a página como Server Component que busca
GET /produtosda API (endereço emAPI_URL), com o HTML reaproveitado por 60 segundos. - Extraia o botão “Comprar” para um Client Component, e diga por que ele precisa ser cliente e a página não.
- Crie o
error.tsxda rota, com a mensagem e o botão “Tentar de novo”.
Resultado Esperado
Explicação: o revalidate regera a página a cada 60 s, mas a página não busca nada de fora;
regerar um array fixo produz o mesmo HTML.
page.tsx: async, fetch(`${API_URL}/produtos`, { next: { revalidate: 60 } }), sem "use client"
BotaoComprar.tsx: "use client" + useState
error.tsx: "use client", recebe { error, reset }
Preço alterado no banco → aparece na vitrine em até ~60 s
📤 Instruções de Entrega (Microsoft Teams)
- Salve o arquivo como:
Atividade_06_3_SeuNome.zip(page.tsx,BotaoComprar.tsx,error.tsx) - Envie na tarefa:
Atividade Cap 06 - Stack Next.js e NestJS
🔑 Gabarito de Código & Solução Comentada
// app/page.tsx — Server Component: roda no servidor, pode ser async e chamar a API direto.
import { BotaoComprar } from "./BotaoComprar";
type Produto = { id: number; nome: string; preco: number; estoque: number };
export default async function CatalogoPage() {
const resp = await fetch(`${process.env.API_URL}/produtos`, {
next: { revalidate: 60 }, // ISR: reaproveita o HTML por 60 s e regera em segundo plano
});
if (!resp.ok) throw new Error(`API respondeu ${resp.status}`); // cai no error.tsx da rota
const produtos: Produto[] = await resp.json();
return (
<main>
<h1>TecLoja</h1>
{produtos.map((p) => (
<article key={p.id}>
<h2>{p.nome}</h2>
<p>R$ {p.preco.toFixed(2)}</p>
<BotaoComprar produtoId={p.id} disponivel={p.estoque > 0} />
</article>
))}
</main>
);
}
// app/BotaoComprar.tsx
"use client"; // Client Component: tem estado e eventos, roda no navegador
import { useState } from "react";
export function BotaoComprar({ produtoId, disponivel }: { produtoId: number; disponivel: boolean }) {
const [adicionado, setAdicionado] = useState(false);
return (
<button disabled={!disponivel || adicionado} onClick={() => setAdicionado(true)}>
{adicionado ? "No carrinho" : disponivel ? `Comprar #${produtoId}` : "Esgotado"}
</button>
);
}
// app/error.tsx
"use client"; // error.tsx precisa ser Client Component: recebe o erro e uma função para tentar de novo
export default function Erro({ error, reset }: { error: Error; reset: () => void }) {
return (
<main>
<h2>Não foi possível carregar o catálogo.</h2>
<p>{error.message}</p>
<button onClick={() => reset()}>Tentar de novo</button>
</main>
);
}
Comentários:
- Por que o
revalidatenão adiantava: ele controla quando a página é gerada de novo, não de onde vêm os dados. O ISR só faz sentido quando a página lê algo de fora: API, banco ou CMS. - Por que o botão é cliente e a página não:
onClickeuseStatesó existem no navegador. A página só lê dados e monta HTML, então fica no servidor. O JavaScript enviado ao navegador é só o do botão, não o da página inteira. revalidate: 60vai nofetch: no Next 15, umfetchsem opções não entra em cache. Para estoque em tempo real, usecache: "no-store"(é o que o Módulo 06 do projeto faz).- O
throwquando!resp.oké o que leva aoerror.tsx. Sem ele, a página tentaria fazer.mapno JSON de erro da API.
🏆 Nível 4: Desafio Corporativo
Problema 06.4 — Área administrativa com BFF: o token nunca chega ao navegador
Contexto: A TecLoja 04 terá uma área /admin para cadastrar produtos. A primeira proposta guardava o JWT do NestJS no localStorage e chamava a API direto do navegador. A equipe de segurança vetou pelos motivos do Cap. 02: um único XSS rouba o token. A decisão é usar o Next.js como BFF: o navegador só conversa com o Next, e o Next conversa com o NestJS.
Requisitos de Execução:
- Escreva o Route Handler
app/api/auth/login/route.ts: repassa as credenciais ao NestJS e guarda o JWT num cookieHttpOnly, devolvendo ao navegador só os dados do usuário. - Escreva o
middleware.tsque manda para/login?voltar=...quem acessa/admin/...sem o cookie. - Escreva
app/api/admin/produtos/route.ts: lê o cookie, chamaPOST /produtosdo NestJS comAuthorization: Bearer, e devolve ao navegador o mesmo status do NestJS. - Explique por que o
middlewaredo Next não substitui o guardADMINdo NestJS.
Resultado Esperado
POST /api/auth/login (ok) → 200 {"usuario":{...}} + Set-Cookie: tecloja_token=...; HttpOnly
GET /admin/produtos (sem cookie) → 307 para /login?voltar=%2Fadmin%2Fprodutos
POST /api/admin/produtos (CUSTOMER) → 403 (vindo do guard do NestJS)
POST /api/admin/produtos (dados inválidos) → 400 com a lista do ValidationPipe
document.cookie no navegador → não mostra o tecloja_token
📤 Instruções de Entrega (Microsoft Teams)
- Salve o arquivo como:
Atividade_06_4_SeuNome.zip(os três arquivos + a explicação) - Envie na tarefa:
Atividade Cap 06 - Stack Next.js e NestJS
🔑 Gabarito de Código & Solução Comentada
// app/api/auth/login/route.ts — Route Handler: o Next.js como BFF (Backend for Frontend).
import { cookies } from "next/headers";
import { NextResponse } from "next/server";
export async function POST(request: Request) {
const credenciais = await request.json();
const resp = await fetch(`${process.env.API_URL}/auth/login`, { // servidor → servidor
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(credenciais),
});
if (!resp.ok) return NextResponse.json({ erro: "credenciais inválidas" }, { status: 401 });
const { token, usuario } = await resp.json();
(await cookies()).set("tecloja_token", token, { // Next 15: cookies() é assíncrono
httpOnly: true, // o JavaScript da página não lê o token
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: 60 * 60,
});
return NextResponse.json({ usuario }); // o navegador nunca vê o JWT
}
// middleware.ts (raiz do projeto Next) — roda antes de qualquer rota casada pelo matcher.
import { NextResponse, type NextRequest } from "next/server";
export function middleware(request: NextRequest) {
const token = request.cookies.get("tecloja_token")?.value;
if (!token) {
const login = new URL("/login", request.url);
login.searchParams.set("voltar", request.nextUrl.pathname); // volta para onde estava
return NextResponse.redirect(login);
}
return NextResponse.next();
}
export const config = { matcher: ["/admin/:path*"] }; // só as rotas de administração
// app/api/admin/produtos/route.ts — o BFF repassa ao NestJS com o token do cookie.
import { cookies } from "next/headers";
import { NextResponse } from "next/server";
export async function POST(request: Request) {
const token = (await cookies()).get("tecloja_token")?.value;
if (!token) return NextResponse.json({ erro: "não autenticado" }, { status: 401 });
const resp = await fetch(`${process.env.API_URL}/produtos`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
body: JSON.stringify(await request.json()),
});
// devolve ao navegador o status e o corpo do NestJS (201, 400 do ValidationPipe, 403 do guard)
return NextResponse.json(await resp.json(), { status: resp.status });
}
Comentários:
- Por que o
middlewarenão substitui o guard: ele só confere se existe um cookie, não se o token é válido nem qual é o papel. Mais importante: o NestJS pode ser chamado sem passar pelo Next (outro cliente,curl, um app mobile). A regra de quem pode cadastrar produto mora na API, como no Cap. 02. Omiddlewareé experiência do usuário: evita mostrar uma tela que daria 403. - Repassar o status do NestJS (
{ status: resp.status }) é o que permite à tela mostrar o 400 com os campos inválidos ou o 403. Um BFF que responde sempre 200 esconde os erros. - O cookie usa
SameSite=Lax: ele não vai emPOSTvindos de outros sites, o que mitiga CSRF nas rotas/api/admin/.... Para operações sensíveis, some a isso um token anti-CSRF. - Os três arquivos foram conferidos com os tipos do Next 15 (
tsccommoduleResolution: "bundler"). O Módulo 07 da TecLoja 04 traz a mesma ideia em Next 14, comcookies()síncrono.