Pular para o conteúdo
Cursos

DevClub

LógicaFront-endBack-endMobile

IA Club

IA na prática
Estudar programaçãoEstudar IA
TutorialIntermediáriocódigo testado

API NestJS: CRUD de tarefas com Prisma e PostgreSQL

Implemente e teste o CRUD HTTP de uma agenda com NestJS 11, Prisma 7 e PostgreSQL 18, incluindo DTO, migration, erros e smoke test real.

Rodolfo Mori11 min de leitura

Você vai construir a Agenda Clara, uma API REST que grava tarefas no PostgreSQL e responde corretamente quando o JSON é inválido, o id não existe ou o mesmo compromisso é cadastrado duas vezes. Migration, typecheck, build e um smoke test com oito verificações foram executados de verdade.

O projeto completo está em blog/examples/api-nestjs/. Ele usa NestJS 11.2.1, Nest CLI 11.0.24, TypeScript 5.9.3, Prisma 7.9.1 com @prisma/adapter-pg e a imagem postgres:18.6-alpine. Se essas peças ainda parecem nomes isolados, o guia de NestJS mostra primeiro o mapa do framework; aqui a gente monta o produto inteiro.

A requisição atravessa uma esteira com postos definidos

Imagine uma central de encomendas. O pacote chega com uma etiqueta, passa por uma triagem, vai ao balcão responsável, recebe a decisão operacional e só então entra no estoque. Cada posto sabe fazer uma coisa e entrega o resultado ao seguinte.

Na API, a requisição HTTP é o pacote; o DTO descreve a etiqueta permitida; o ValidationPipe faz a triagem; o controller recebe a rota; o service decide a regra; o PrismaService conversa com o banco; e PostgreSQL guarda a versão durável. Na volta, o resultado percorre o caminho inverso até virar JSON.

A analogia termina aí. Não existem caixas físicas viajando pelo Nest. O framework chama funções, resolve objetos no contêiner de dependências e usa o adapter HTTP do Express. O modelo mental serve para localizar responsabilidades, não para substituir o fluxo técnico:

text
cliente HTTP

Express → ValidationPipe → TasksController → TasksService

                          PrismaService → adapter-pg → PostgreSQL

                         objeto ou exceção HTTP na resposta

Essa separação responde uma pergunta importante: “por que não colocar Prisma direto no controller?”. Porque o controller deve traduzir HTTP; a regra de negócio precisa continuar testável e reutilizável sem depender da rota. A lição de módulos, controllers e providers aprofunda o trio.

Prepare um projeto reproduzível, sem instalação global

Use Node 24.x. O exemplo traz versões exatas e package-lock.json, por isso npm ci reproduz o conjunto testado:

bash
cd blog/examples/api-nestjs
cp .env.example .env
npm ci
added 461 packages, and audited 462 packages in 3s

91 packages are looking for funding 3 high severity vulnerabilities

Não execute npm audit fix --force no impulso. A seção de auditoria mostra a origem e a troca incompatível que o npm propõe. Primeiro vamos provar o comportamento pedido.

As dependências centrais do package.json estão fixadas assim:

json
{
  "engines": { "node": "24.x" },
  "dependencies": {
    "@nestjs/common": "11.2.1",
    "@nestjs/config": "4.0.4",
    "@nestjs/core": "11.2.1",
    "@nestjs/mapped-types": "2.1.1",
    "@nestjs/platform-express": "11.2.1",
    "@prisma/adapter-pg": "7.9.1",
    "@prisma/client": "7.9.1",
    "class-transformer": "0.5.1",
    "class-validator": "0.15.1"
  },
  "devDependencies": {
    "@nestjs/cli": "11.0.24",
    "prisma": "7.9.1",
    "typescript": "5.9.3"
  }
}

O CLI fica local. npx nest --version usa a cópia registrada no projeto, sem depender do que outra aplicação instalou globalmente:

bash
node --version
npm --version
npx nest --version
npx prisma --version
v24.16.0 11.13.0 11.0.24 prisma : 7.9.1 @prisma/client : 7.9.1 Node.js : v24.16.0 TypeScript : 5.9.3 Query Compiler : enabled

Sistema operacional e arquitetura variam; as versões acima são as partes que precisam bater para comparar resultados.

Suba PostgreSQL 18.6 no destino correto

O .env.example usa credenciais conhecidas somente para o laboratório. O arquivo .env está no .gitignore; em produção, substitua essa cópia por segredos entregues pelo ambiente ou por um gerenciador apropriado.

bash
POSTGRES_USER=agenda
POSTGRES_PASSWORD=troque-esta-senha-local
POSTGRES_DB=agenda
POSTGRES_PORT=55442
DATABASE_URL=postgresql://agenda:troque-esta-senha-local@127.0.0.1:55442/agenda?schema=public
PORT=3100

O Compose publica o banco apenas em 127.0.0.1 e usa um volume nomeado. Para PostgreSQL 18, o destino é /var/lib/postgresql:

yaml
services:
  postgres:
    image: postgres:18.6-alpine
    environment:
      POSTGRES_USER: ${POSTGRES_USER:?defina POSTGRES_USER no .env}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?defina POSTGRES_PASSWORD no .env}
      POSTGRES_DB: ${POSTGRES_DB:?defina POSTGRES_DB no .env}
    ports:
      - "127.0.0.1:${POSTGRES_PORT:-55442}:5432"
    volumes:
      - agenda_pg_data:/var/lib/postgresql
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
      interval: 2s
      timeout: 3s
      retries: 20
      start_period: 5s

volumes:
  agenda_pg_data:

Na linha 18, a imagem oficial mudou PGDATA para um diretório específico da versão e passou a declarar o volume pai. Copiar o mount antigo /var/lib/postgresql/data não é uma variação inocente: o container 18 pode abortar para proteger a organização dos dados.

Inicie e espere o healthcheck:

bash
docker compose up -d --wait
docker compose exec -T postgres \
  psql -U agenda -d agenda -tAc 'SHOW server_version;'
Container api-nestjs-postgres-1 Healthy 18.6

No teste, o mount inspecionado foi volume /var/lib/postgresql e a imagem resolveu para o digest sha256:d3e1620b530c944afa6e887d22eb899824da68e19c52024bf98f5220c88a65b2. Digest prova o artefato usado; a tag continua sendo a interface legível do Compose.

Descreva a tabela e gere um Client compatível com Nest

Prisma 7 separa a configuração da CLI do schema. prisma.config.ts carrega a URL do ambiente e aponta para o histórico de migrations:

ts
import 'dotenv/config';
import { defineConfig, env } from 'prisma/config';

export default defineConfig({
  schema: 'prisma/schema.prisma',
  migrations: { path: 'prisma/migrations' },
  datasource: { url: env('DATABASE_URL') },
});

O schema modela uma tarefa e duas regras do banco. O índice ajuda a leitura por status e horário; a constraint única impede título e início repetidos, mesmo se duas requisições chegarem quase juntas.

prisma
generator client {
  provider     = "prisma-client"
  output       = "../src/generated/prisma"
  moduleFormat = "cjs"
}

datasource db {
  provider = "postgresql"
}

enum StatusTarefa {
  PENDENTE
  CONCLUIDA
}

model Tarefa {
  id           Int          @id @default(autoincrement())
  titulo       String       @db.VarChar(100)
  descricao    String?      @db.VarChar(500)
  inicio       DateTime     @db.Timestamptz(3)
  status       StatusTarefa @default(PENDENTE)
  criadaEm     DateTime     @default(now()) @map("criada_em") @db.Timestamptz(3)
  atualizadaEm DateTime     @updatedAt @map("atualizada_em") @db.Timestamptz(3)

  @@unique([titulo, inicio], map: "tarefa_titulo_inicio_unico")
  @@index([status, inicio], map: "tarefa_status_inicio_idx")
  @@map("tarefas")
}

O moduleFormat = "cjs" não é nostalgia. O Nest CLI compila este projeto como CommonJS e a receita oficial do Nest pede que o Client gerado use o mesmo formato. Sem alinhar os dois lados, a aplicação pode compilar um import que o runtime não consegue carregar.

Migration é histórico executável, não sincronização mágica

Durante o desenvolvimento do modelo, o comando que cria uma migration é:

bash
npx prisma migrate dev --name init

Ele foi executado sobre um banco vazio e gerou prisma/migrations/20260823025213_init/migration.sql. O trecho decisivo é SQL normal, revisável:

sql
CREATE TYPE "StatusTarefa" AS ENUM ('PENDENTE', 'CONCLUIDA');

CREATE TABLE "tarefas" (
    "id" SERIAL NOT NULL,
    "titulo" VARCHAR(100) NOT NULL,
    "descricao" VARCHAR(500),
    "inicio" TIMESTAMPTZ(3) NOT NULL,
    "status" "StatusTarefa" NOT NULL DEFAULT 'PENDENTE',
    "criada_em" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
    "atualizada_em" TIMESTAMPTZ(3) NOT NULL,
    CONSTRAINT "tarefas_pkey" PRIMARY KEY ("id")
);

CREATE UNIQUE INDEX "tarefa_titulo_inicio_unico"
ON "tarefas"("titulo", "inicio");

Como o exemplo já inclui o arquivo, você aplica o histórico com:

bash
npm run prisma:migrate
1 migration found in prisma/migrations

Applying migration 20260823025213_init

The following migration(s) have been applied: migrations/ └─ 20260823025213_init/ └─ migration.sql

All migrations have been successfully applied.

migrate dev altera o ciclo de desenvolvimento e usa mecanismos próprios para detectar mudanças; migrate deploy apenas aplica arquivos pendentes. Em produção, o segundo pertence ao pipeline. O guia de Prisma mostra por que db push não substitui um histórico revisado.

Falhe cedo quando o ambiente estiver incompleto

ConfigModule.env, mas “ler” não significa “validar”. A função abaixo recusa URL ausente, protocolo errado e porta impossível antes de aceitar requisições:

ts
export function validarAmbiente(
  configuracao: Record<string, unknown>,
) {
  const databaseUrl = configuracao.DATABASE_URL;

  if (typeof databaseUrl !== 'string' || databaseUrl.trim() === '') {
    throw new Error('DATABASE_URL é obrigatória. Copie .env.example para .env.');
  }

  const url = new URL(databaseUrl);
  if (!['postgresql:', 'postgres:'].includes(url.protocol)) {
    throw new Error('DATABASE_URL precisa usar o protocolo postgresql://.');
  }

  const port = Number(configuracao.PORT ?? 3000);
  if (!Number.isInteger(port) || port < 1 || port > 65535) {
    throw new Error('PORT precisa ser um número inteiro entre 1 e 65535.');
  }

  return { ...configuracao, DATABASE_URL: databaseUrl, PORT: port };
}

Sem DATABASE_URL, o processo realmente encerrou com código 1:

bash
env -u DATABASE_URL -u PORT node dist/main.js
ERROR [ExceptionHandler] Error: DATABASE_URL é obrigatória. Copie .env.example para .env. at Object.validarAmbiente [as validate] (/private/tmp/devclub-api-nestjs.yWX9rn/dist/config/env.js:7:15) at ConfigModule.forRoot (/private/tmp/devclub-api-nestjs.yWX9rn/node_modules/@nestjs/config/dist/config.module.js:88:45)

Esse é um erro saudável: a API não fica “de pé” fingindo que consegue persistir. Nunca coloque o valor real da URL na mensagem ou em um commit.

Injete um PrismaService em vez de abrir conexões espalhadas

Injeção de dependência, ou DI, significa que a classe declara do que precisa e o contêiner do Nest fornece a instância. Pense numa empresa que entrega a ferramenta oficial a cada setor: o setor não compra um equipamento escondido, e a empresa controla montagem e encerramento. No código, o constructor é o pedido e o módulo é o cadastro que permite atendê-lo.

PrismaService cria o adapter obrigatório, estende o Client gerado e fecha o pool quando a aplicação termina:

ts
import { Injectable, OnModuleDestroy } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient } from '../generated/prisma/client';

@Injectable()
export class PrismaService extends PrismaClient implements OnModuleDestroy {
  constructor(configuracao: ConfigService) {
    const connectionString =
      configuracao.getOrThrow<string>('DATABASE_URL');
    const adapter = new PrismaPg({ connectionString });

    super({ adapter, log: ['warn'] });
  }

  async onModuleDestroy() {
    await this.$disconnect();
  }
}

O módulo registra e exporta esse provider. Exportar é abrir uma porta explícita para outros módulos do Nest; não tem relação com o export de arquivo do TypeScript.

ts
@Module({
  providers: [PrismaService],
  exports: [PrismaService],
})
export class PrismaModule {}

TasksModule importa PrismaModule, então TasksService pode declarar constructor(private readonly prisma: PrismaService). O service não cria new PrismaService() e não precisa conhecer senha, host ou pool. A lição de Prisma com NestJS isola essa integração passo a passo.

DTO e ValidationPipe protegem a fronteira HTTP

TypeScript não valida JSON recebido da internet porque seus tipos desaparecem na execução. Um DTO, Data Transfer Object, é uma classe que descreve a entrada; os decorators deixam metadados que ValidationPipe consegue verificar em runtime.

ts
export class CreateTaskDto {
  @IsString()
  @MinLength(3)
  @MaxLength(100)
  titulo!: string;

  @IsOptional()
  @IsString()
  @MaxLength(500)
  descricao?: string;

  @IsISO8601({ strict: true })
  inicio!: string;

  @IsOptional()
  @IsEnum(StatusTarefa)
  status?: StatusTarefa;
}

Atualização aceita parte dos campos, e o filtro aceita apenas o enum conhecido:

ts
export class UpdateTaskDto extends PartialType(CreateTaskDto) {}

export class ListTasksDto {
  @IsOptional()
  @IsEnum(StatusTarefa)
  status?: StatusTarefa;
}

O pipe global atende todos os controllers. whitelist identifica propriedades decoradas; forbidNonWhitelisted devolve 400 em vez de ignorar um campo surpresa; transform cria instâncias dos DTOs e converte parâmetros suportados.

ts
app.setGlobalPrefix('api');
app.useGlobalPipes(
  new ValidationPipe({
    whitelist: true,
    forbidNonWhitelisted: true,
    transform: true,
  }),
);
app.enableShutdownHooks();

Para completar o bootstrap local, a aplicação escuta apenas em 127.0.0.1 e remove o cabeçalho que anunciaria Express:

ts
app.getHttpAdapter().getInstance().disable('x-powered-by');
await app.listen(port, '127.0.0.1');

Essas decisões reduzem exposição acidental; não transformam o laboratório em produção. A lição de DTO e ValidationPipe explica transformação, whitelist e regras que ainda pertencem ao service.

Controller traduz HTTP; service decide e persiste

O controller define método, caminho, origem dos dados e status da resposta. Ele não sabe qual código Prisma representa conflito:

ts
@Controller('tarefas')
export class TasksController {
  constructor(private readonly tarefas: TasksService) {}

  @Post()
  create(@Body() dto: CreateTaskDto) {
    return this.tarefas.create(dto);
  }

  @Get()
  findAll(@Query() filtros: ListTasksDto) {
    return this.tarefas.findAll(filtros.status);
  }

  @Get(':id')
  findOne(@Param('id', ParseIntPipe) id: number) {
    return this.tarefas.findOne(id);
  }

  @Patch(':id')
  update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateTaskDto) {
    return this.tarefas.update(id, dto);
  }

  @Delete(':id')
  @HttpCode(HttpStatus.NO_CONTENT)
  remove(@Param('id', ParseIntPipe) id: number) {
    return this.tarefas.remove(id);
  }
}

ParseIntPipe impede que abc chegue como id ao banco. No service, texto é normalizado na borda da regra e a data ISO vira Date:

ts
@Injectable()
export class TasksService {
  constructor(private readonly prisma: PrismaService) {}

  async create(dto: CreateTaskDto) {
    try {
      return await this.prisma.tarefa.create({
        data: {
          titulo: dto.titulo.trim(),
          descricao: dto.descricao?.trim(),
          inicio: new Date(dto.inicio),
          status: dto.status,
        },
      });
    } catch (erro) {
      this.tratarErroPrisma(erro);
    }
  }

  findAll(status?: StatusTarefa) {
    return this.prisma.tarefa.findMany({
      where: status ? { status } : undefined,
      orderBy: [{ inicio: 'asc' }, { id: 'asc' }],
    });
  }
}

Consulta, atualização e remoção compartilham a regra de existência:

ts
async findOne(id: number) {
  const tarefa = await this.prisma.tarefa.findUnique({ where: { id } });
  if (!tarefa) {
    throw new NotFoundException(`Tarefa ${id} não encontrada.`);
  }
  return tarefa;
}

async update(id: number, dto: UpdateTaskDto) {
  await this.findOne(id);
  return this.prisma.tarefa.update({
    where: { id },
    data: {
      ...dto,
      titulo: dto.titulo?.trim(),
      descricao: dto.descricao?.trim(),
      inicio: dto.inicio ? new Date(dto.inicio) : undefined,
    },
  });
}

async remove(id: number): Promise<void> {
  await this.findOne(id);
  await this.prisma.tarefa.delete({ where: { id } });
}

O projeto completo mantém try/catch também em update e delete para cobrir uma remoção concorrente entre a consulta e a escrita. O recorte acima deixa o fluxo principal visível.

Traduza o banco para HTTP sem vazar detalhes internos

PostgreSQL é quem garante a unicidade. Prisma representa a violação com P2002; o service traduz isso para ConflictException, que vira HTTP 409. P2025 representa o registro ausente durante uma operação:

ts
private tratarErroPrisma(erro: unknown): never {
  if (erro instanceof Prisma.PrismaClientKnownRequestError) {
    if (erro.code === 'P2002') {
      throw new ConflictException(
        'Já existe uma tarefa com este título neste horário.',
      );
    }

    if (erro.code === 'P2025') {
      throw new NotFoundException('Tarefa não encontrada.');
    }
  }

  throw erro;
}

O cliente recebe uma decisão de domínio, não nome de tabela, stack trace ou SQL. Isso não quer dizer que todo erro de banco deve virar 409: conexão perdida, permissão e falha inesperada continuam sendo erros do servidor.

Registre as peças no grafo de módulos

TasksModule reúne a funcionalidade. Ele importa acesso ao banco, registra o service e expõe o controller:

ts
@Module({
  imports: [PrismaModule],
  controllers: [TasksController],
  providers: [TasksService],
})
export class TasksModule {}

O módulo raiz configura o ambiente uma vez e importa tarefas:

ts
@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      cache: true,
      validate: validarAmbiente,
    }),
    TasksModule,
  ],
  controllers: [AppController],
})
export class AppModule {}

Aqui aparece a DI de verdade. O Nest lê os metadados dos módulos, cria ConfigService, cria PrismaService, entrega esse provider a TasksService e entrega o service ao controller. Se PrismaModule não exportasse o provider ou TasksModule não o importasse, o bootstrap falharia antes da primeira rota.

Execute o CRUD e leia cada resposta

Gere o Client, compile e inicie:

bash
npm run build
npm start

O Nest mapeou seis rotas: saúde, criar/listar e consultar/atualizar/remover por id. Confira a primeira:

bash
curl -i http://127.0.0.1:3100/api/saude
HTTP/1.1 200 OK Content-Type: application/json; charset=utf-8

{“status”:“ok”,“servico”:“agenda-clara-api”}

Agora envie um corpo com título curto, data que não é ISO e campo desconhecido:

bash
curl -i -X POST http://127.0.0.1:3100/api/tarefas \
  -H 'content-type: application/json' \
  --data '{"titulo":"x","inicio":"amanha","invasor":true}'
HTTP/1.1 400 Bad Request

{“message”:[“property invasor should not exist”,“titulo must be longer than or equal to 3 characters”,“inicio must be a valid ISO 8601 date string”],“error”:“Bad Request”,“statusCode”:400}

O 400 nasceu no pipe; TasksService nem foi chamado. Envie uma tarefa válida:

bash
curl -i -X POST http://127.0.0.1:3100/api/tarefas \
  -H 'content-type: application/json' \
  --data '{
    "titulo":"Planejar retrospectiva",
    "descricao":"Separar aprendizados do ciclo",
    "inicio":"2026-09-18T13:30:00.000Z"
  }'
HTTP/1.1 201 Created

{“id”:4,“titulo”:“Planejar retrospectiva”,“descricao”:“Separar aprendizados do ciclo”,“inicio”:“2026-09-18T13:30:00.000Z”,“status”:“PENDENTE”,“criadaEm”:“2026-08-23T03:00:19.226Z”,“atualizadaEm”:“2026-08-23T03:00:19.226Z”}

O banco preencheu id, status e datas de auditoria. Repetir título e início aciona a constraint, e o service devolve o significado HTTP:

bash
curl -i -X POST http://127.0.0.1:3100/api/tarefas \
  -H 'content-type: application/json' \
  --data '{
    "titulo":"Planejar retrospectiva",
    "inicio":"2026-09-18T13:30:00.000Z"
  }'
HTTP/1.1 409 Conflict

{“message”:“Já existe uma tarefa com este título neste horário.”,“error”:“Conflict”,“statusCode”:409}

Um id que não existe percorre controller e service, mas para antes da escrita:

bash
curl -i http://127.0.0.1:3100/api/tarefas/999999
HTTP/1.1 404 Not Found

{“message”:“Tarefa 999999 não encontrada.”,“error”:“Not Found”,“statusCode”:404}

Filtre a agenda e depois conclua a tarefa:

bash
curl 'http://127.0.0.1:3100/api/tarefas?status=PENDENTE'

curl -i -X PATCH http://127.0.0.1:3100/api/tarefas/4 \
  -H 'content-type: application/json' \
  --data '{"status":"CONCLUIDA"}'
HTTP/1.1 200 OK

{“id”:4,“titulo”:“Planejar retrospectiva”,“descricao”:“Separar aprendizados do ciclo”,“inicio”:“2026-09-18T13:30:00.000Z”,“status”:“CONCLUIDA”,“criadaEm”:“2026-08-23T03:00:19.226Z”,“atualizadaEm”:“2026-08-23T03:00:31.965Z”}

Por fim, DELETE devolve 204 e nenhum corpo:

bash
curl -i -X DELETE http://127.0.0.1:3100/api/tarefas/4
HTTP/1.1 204 No Content

Prove que a linha chegou ao PostgreSQL

Antes da remoção, a mesma tarefa apareceu diretamente no psql:

bash
docker compose exec -T postgres \
  psql -U agenda -d agenda \
  -c 'SELECT id, titulo, status, inicio FROM tarefas ORDER BY id;'
id | titulo | status | inicio ----+------------------------+----------+------------------------ 4 | Planejar retrospectiva | PENDENTE | 2026-09-18 13:30:00+00 (1 row)

Isso fecha o circuito: não foi um array em memória nem um mock. A requisição atravessou Nest, Prisma, adapter pg e PostgreSQL. O guia de PostgreSQL explica constraint, índice e persistência pelo ponto de vista do banco.

Automatize o caminho feliz e os três erros

O smoke test inicia dist/main.js numa porta separada, usa fetch e valida as respostas contra o banco real. Este é o núcleo da função auxiliar:

js
async function requisitar(caminho, opcoes = {}, statusEsperado) {
  const resposta = await fetch(`${baseUrl}${caminho}`, {
    ...opcoes,
    headers: {
      'content-type': 'application/json',
      ...opcoes.headers,
    },
  });

  if (resposta.status !== statusEsperado) {
    const corpo = await resposta.text();
    throw new Error(
      `${opcoes.method ?? 'GET'} ${caminho}: esperado ${statusEsperado}, recebido ${resposta.status}: ${corpo}`,
    );
  }

  return resposta.status === 204 ? null : resposta.json();
}

O roteiro automatizado é: saúde 200, entrada ruim 400, criação 201, duplicata 409, filtro 200, id ausente 404, atualização 200 e remoção 204. O finally remove a tarefa se uma asserção intermediária falhar e encerra o servidor.

Execute a mesma sequência:

bash
npm run typecheck
npm run build
npm test

Typecheck e build terminaram sem diagnóstico. O smoke imprimiu:

✓ GET /api/saude -> 200 ✓ POST inválido -> 400 ✓ POST /api/tarefas -> 201 ✓ POST duplicado -> 409 ✓ GET com filtro -> 200 ✓ GET inexistente -> 404 ✓ PATCH de status -> 200 ✓ DELETE /api/tarefas/:id -> 204 smoke: 8 verificações passaram

O smoke não substitui toda a pirâmide de testes. Num produto, acrescente testes unitários para regras densas, e2e isolados por banco e contrato para clientes externos. Aqui ele prova o objetivo do tutorial com o mínimo de infraestrutura paralela.

Leia o audit sem aceitar uma troca cega

Rode a auditoria, mas não permita correção automática forçada:

bash
npm audit
npm ls prisma @prisma/config deepmerge-ts --all
3 high severity vulnerabilities

@prisma/client@7.9.1 └── prisma@7.9.1 └── @prisma/config@7.9.1 └── deepmerge-ts@7.1.5

DeepmergeTS has stack exhaustion when merging recursive object graphs GHSA-ggr8-5vv4-36mx

fixAvailable: prisma@6.12.0 (SemVer major)

O alerta é real e não deve ser escondido. Ao mesmo tempo, a sugestão automática voltaria do Prisma 7.9.1 solicitado para 6.12.0, mudando configuração, geração e adapter ensinados aqui. Não aplicamos downgrade nem override sem a equipe do Prisma declarar compatibilidade. A decisão responsável é registrar a cadeia, limitar quem controla configuração, acompanhar o advisory e atualizar para uma correção compatível quando publicada.

O risco não some porque o projeto é didático. Também não autoriza quebrar a arquitetura para produzir um relatório verde artificial.

O que ainda falta antes de abrir esta agenda na internet

Este laboratório já faz algumas escolhas defensivas: valida ambiente e JSON, rejeita campo inesperado, usa constraint no banco, não retorna erro Prisma, prende banco e API ao loopback, remove X-Powered-By, não registra credenciais e fecha o pool no desligamento.

Ainda faltam controles que dependem do produto:

  • autenticar a pessoa e autorizar quais tarefas ela pode ler ou alterar;
  • armazenar usuário ou organização em cada linha e filtrar todas as consultas;
  • aplicar rate limiting, tamanho de body e política explícita de CORS;
  • usar TLS, rede privada, backup e segredo rotacionável no ambiente real;
  • registrar eventos sem corpo sensível e observar latência e falhas;
  • revisar paginação: findMany sem limite não serve para uma agenda enorme;
  • definir fuso horário e regras de conflito mais ricas que título e instante.

JWT foi deixado fora de propósito: autenticação completa desviaria o foco do fluxo Nest–Prisma–PostgreSQL. Quando o CRUD estiver firme, siga guards e autenticação no NestJS e faça o service receber a identidade validada, não um userId confiado ao body.

Missão: impeça duas tarefas no mesmo horário

Hoje a constraint rejeita apenas a mesma combinação de titulo e inicio. Sua missão é impedir qualquer segunda tarefa no mesmo instante, mesmo com título diferente. Para esta regra deliberadamente simples, transforme inicio em campo único e remova a unicidade composta.

Faça a mudança em quatro passos:

  1. altere o schema para criar a regra de unicidade que representa sua decisão;
  2. rode npm run prisma:migrate:dev -- --name horario_unico e revise o SQL;
  3. tente cadastrar dois títulos diferentes com o mesmo inicio e espere 409;
  4. acrescente esse cenário ao smoke test.

O critério de conclusão é objetivo: banco vazio recebe as migrations com npm run prisma:migrate, typecheck e build passam, o primeiro POST devolve 201, o segundo devolve 409 e o teste remove seus dados. Quando isso acontecer, você terá alterado contrato, banco, tratamento de erro e prova automatizada sem misturar as camadas da API.

Finalize o laboratório removendo somente os recursos deste Compose:

bash
docker compose down -v

O -v apaga o volume local da Agenda Clara. Não use esse comando num projeto com dados que precisam permanecer; em produção, migration e limpeza são operações separadas, revisadas e recuperáveis por backup.

  • nestjs
  • prisma
  • postgresql
  • api rest
  • crud
  • typescript
  • docker

Perguntas frequentes

O que esta API NestJS faz?
Ela cria, lista, filtra, consulta, atualiza e remove tarefas de agenda. Os dados ficam no PostgreSQL, o Prisma executa o acesso e o NestJS organiza validação, rotas, regras e injeção de dependência.
Prisma 7 precisa de adapter para PostgreSQL?
Sim. O exemplo usa @prisma/adapter-pg 7.9.1 e entrega o adapter ao PrismaClient. A URL usada pela CLI fica em prisma.config.ts e a aplicação lê a mesma DATABASE_URL pelo ConfigModule.
Quando a API devolve 400, 404 e 409?
O ValidationPipe devolve 400 para entrada inválida; o service lança 404 quando o id não existe; e uma violação da constraint de título e horário únicos é traduzida do código Prisma P2002 para 409.
Por que usar migrate deploy neste projeto local?
A migration já faz parte do exemplo. Migrate deploy comprova que um banco vazio consegue aplicar esse histórico sem criar ou reescrever arquivos. No desenvolvimento do schema, migrate dev continua sendo o comando certo.
O volume do PostgreSQL 18 deve montar em qual caminho?
A imagem oficial da linha 18 define o volume em /var/lib/postgresql. O destino antigo /var/lib/postgresql/data pertence às imagens 17 e anteriores e pode fazer o container 18 recusar ou organizar incorretamente os dados.
Esta API já está segura para publicar na internet?
Não. Ela valida entrada, não expõe a porta do banco fora do loopback e não versiona segredo, mas ainda precisa de autenticação, autorização, rate limiting, política de CORS, logs, monitoramento e gestão real de segredos.
Por que npm audit aponta três vulnerabilidades altas?
No lockfile testado, Prisma 7.9.1 traz @prisma/config, que usa deepmerge-ts 7.1.5 atingido por um aviso de exaustão de pilha. O npm sugere voltar para Prisma 6.12.0 com --force; o tutorial não faz esse downgrade incompatível e registra o risco para acompanhamento.

Dúvidas e comentários

Travou em algum passo? Pergunte aqui — a equipe e outros alunos respondem.

Todo o código deste artigo foi executado em Node 24.16.0 · npm 11.13.0 · NestJS 11.2.1 · Nest CLI 11.0.24 · TypeScript 5.9.3 · Prisma 7.9.1 · PostgreSQL 18.6 · Docker 29.5.3 · Compose 5.1.4, e as saídas exibidas são as reais — como produzimos este conteúdo.

Fontes consultadas

  1. NestJS — First steps — docs.nestjs.com
  2. NestJS — Providers e injeção de dependência — docs.nestjs.com
  3. NestJS — ValidationPipe — docs.nestjs.com
  4. NestJS — receita oficial com Prisma — docs.nestjs.com
  5. Prisma ORM — driver adapters — prisma.io
  6. Prisma Migrate — desenvolvimento e produção — prisma.io
  7. Docker — imagem oficial do PostgreSQL — hub.docker.com
  8. PostgreSQL — release 18.6 — postgresql.org

Continue por aqui