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 /products responde 500, e o log mostra Cannot read properties of undefined (reading 'findAll'). O ProductsService está nos providers, 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:

  1. Explique por que a Versão 1 sobe sem erro e só falha na chamada, enquanto a Versão 2 falha na subida.
  2. Diga o que falta em cada versão.
  3. Escreva o ProductsModule completo e correto: repositório, serviço e controller, com a injeção pelo construtor.
  4. 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)

  1. Salve o arquivo como: Atividade_06_1_SeuNome.md
  2. 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 @Injectable e sem dependências, a classe funciona. Sem @Injectable e com dependências, sobe e quebra na chamada. Fora dos providers, 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:

  1. Escreva o CriarProdutoDto com class-validator: nome com 3 ou mais letras, preco positivo, estoque inteiro positivo.
  2. Ligue o ValidationPipe globalmente de forma que campos fora do DTO sejam recusados com 400.
  3. Use um pipe para que GET /produtos/:id só aceite número.
  4. Mostre as respostas para o JSON do contexto, para GET /produtos/abc e para GET /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)

  1. Salve o arquivo como: Atividade_06_2_SeuNome.ts
  2. 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.01 num pedido, chega ao banco. O DTO é a lista do que o cliente pode mandar.
  • whitelist sozinho remove o campo em silêncio. forbidNonWhitelisted avisa o cliente, o que ajuda quem integra a API a achar o erro.
  • ParseIntPipe transforma "7" em 7 e recusa "abc" com 400 antes do controller. Sem ele, Number("abc") vira NaN, e a busca falha de um jeito confuso.
  • As mensagens acima são as que o NestJS 10 com class-validator devolve 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:

  1. Explique por que o revalidate = 60 não fez a vitrine mudar.
  2. Reescreva a página como Server Component que busca GET /produtos da API (endereço em API_URL), com o HTML reaproveitado por 60 segundos.
  3. Extraia o botão “Comprar” para um Client Component, e diga por que ele precisa ser cliente e a página não.
  4. Crie o error.tsx da 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)

  1. Salve o arquivo como: Atividade_06_3_SeuNome.zip (page.tsx, BotaoComprar.tsx, error.tsx)
  2. 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 revalidate nã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: onClick e useState só 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: 60 vai no fetch: no Next 15, um fetch sem opções não entra em cache. Para estoque em tempo real, use cache: "no-store" (é o que o Módulo 06 do projeto faz).
  • O throw quando !resp.ok é o que leva ao error.tsx. Sem ele, a página tentaria fazer .map no 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:

  1. Escreva o Route Handler app/api/auth/login/route.ts: repassa as credenciais ao NestJS e guarda o JWT num cookie HttpOnly, devolvendo ao navegador só os dados do usuário.
  2. Escreva o middleware.ts que manda para /login?voltar=... quem acessa /admin/... sem o cookie.
  3. Escreva app/api/admin/produtos/route.ts: lê o cookie, chama POST /produtos do NestJS com Authorization: Bearer, e devolve ao navegador o mesmo status do NestJS.
  4. Explique por que o middleware do Next não substitui o guard ADMIN do 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)

  1. Salve o arquivo como: Atividade_06_4_SeuNome.zip (os três arquivos + a explicação)
  2. 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 middleware nã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. O middleware é 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 em POST vindos 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 (tsc com moduleResolution: "bundler"). O Módulo 07 da TecLoja 04 traz a mesma ideia em Next 14, com cookies() síncrono.