CRUDs con NestJS y Prisma en TrueRate - Rommel Romero
←Volver al blog
Fase 1 — El Backend Core

Del schema a la API: construyendo los CRUDs de TrueRate

Cómo construí los CRUDs de las 6 entidades de TrueRate conectadas a PostgreSQL con Prisma, DTOs validados con class-validator y el patrón de andamiaje.

NestJSPrismaPostgreSQLTypeScriptTrueRateBuild in Public

En los artículos anteriores dejé dos cimientos: un modelo relacional de 8 tablas en PostgreSQL y un backend en NestJS conectado a la base de datos mediante Prisma con migraciones.

Pero tener un schema impecable no sirve de nada si la API no es capaz de leer y escribir sobre él. En este post te cuento cómo pasé del esquema a los endpoints: construyendo los CRUDs de las seis entidades de negocio de TrueRate.


1. Patrón de trabajo: esqueleto, conexión y lógica

La estrategia para no ahogarme en complejidad fue seguir un patrón claro que repito en cada entidad:

  1. Crear el esqueleto (module, controller, service).
  2. Conectarlo a la base de datos con Prisma, con operaciones básicas (listar y crear).
  3. Dejar la lógica pesada (login, motor de cálculo, transiciones de estado) para después.

Hacer esto te permite comprobar rápido que la ruta de la API funciona contra la base de datos antes de meterte con reglas de negocio pesadas. Así te aseguras de que la estructura base responde bien antes de seguir escribiendo código encima.


2. De las entidades a Prisma

Cuando generas un recurso con el CLI de Nest, este crea automáticamente una carpeta entities/ con una clase que representa la forma del recurso. Es un patrón pensado para TypeORM, donde las entidades se definen con decoradores como @Entity().

En nuestro caso esa carpeta sobró desde el principio: Prisma ya define cada modelo en su schema.prisma y genera los tipos en src/generated/prisma. Definir una entidad a mano habría sido duplicar información y arriesgarme a que se desincronizara con el schema real.

Así que eliminé las carpetas entities/ de todos los módulos y dejo que Prisma sea la única fuente de la forma de los datos.


3. El patrón de cada servicio

Cada service de TrueRate sigue la misma estructura: inyecta PrismaService y delega cada operación en el método correspondiente del ORM.

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

  create(createProjectDto: CreateProjectDto) {
    return this.prisma.projects.create({ data: createProjectDto });
  }

  findAll() {
    return this.prisma.projects.findMany();
  }

  findOne(id: string) {
    return this.prisma.projects.findUnique({ where: { project_id: id } });
  }

  update(id: string, updateProjectDto: UpdateProjectDto) {
    return this.prisma.projects.update({
      where: { project_id: id },
      data: updateProjectDto,
    });
  }

  remove(id: string) {
    return this.prisma.projects.delete({ where: { project_id: id } });
  }
}

Fíjate en dos detalles:


4. Validación con class-validator

Cada entrada que llega a la API pasa por un DTO validado con class-validator, activado mediante el ValidationPipe global de NestJS. La regla que apliqué fue:

export class CreateProjectDto {
  @IsUUID()
  user_id: string;

  @IsUUID()
  client_id: string;

  @IsString()
  name: string;

  @IsNumber()
  @IsOptional()
  agreed_price?: number;

  @IsNumber()
  @IsOptional()
  hourly_rate?: number;

  @IsIn(['backlog', 'active', 'paused', 'completed', 'canceled'])
  @IsOptional()
  status?: string;
}

Aquí hay una decisión que quiero destacar: los campos con default en la base de datos (agreed_price, hourly_rate, status) los puse opcionales en el DTO. El @default() actúa como fallback, si el frontend no los envía, la base de datos los rellena con su valor por defecto. Pero eso no significa que en la interfaz no se pidan, es el frontend el que decide exigirlos. Cada capa tiene su responsabilidad.

Además, status lo limité con @IsIn([...]) para que solo acepte los estados válidos del proyecto, evitando que un valor erróneo llegue a la base de datos.


5. IDs con ParseUUIDPipe

Las claves primarias de TrueRate son UUIDs. El CLI de Nest genera los métodos con un id numérico (+id), pero eso no encaja con nuestro modelo. En cada controlador sustituí el parseo numérico por el pipe nativo de NestJS ParseUUIDPipe:

@Get(':id')
findOne(@Param('id', ParseUUIDPipe) id: string) {
  return this.projectsService.findOne(id);
}

Este pipe valida que el parámetro sea un UUID real antes de llegar al servicio. Así, una petición como /projects/abc (que no es un UUID) devuelve un error claro en lugar de una consulta fallida a la base de datos.


6. Evolucionar el esquema: la migración de status en payments

Al construir el CRUD de pagos me di cuenta de que necesitaba saber si un cobro estaba pagado o pendiente. En un modelo de pagos fraccionados (un adelanto y el resto al terminar), esto es imprescindible: el freelancer debe ver qué cobros ya entraron y cuáles están por llegar.

Gracias al enfoque schema-first, añadir el campo fue trivial:

  1. Añadí status String @default("pending") al modelo payments en schema.prisma.
  2. Ejecuté prisma migrate dev --name add_payment_status.
  3. Prisma generó el SQL de la migración y lo aplicó a la base de datos.
ALTER TABLE "payments" ADD COLUMN "status" VARCHAR NOT NULL DEFAULT 'pending';

Y con el campo ya en el schema, el @default("pending") hace que cualquier pago nuevo arranque como pendiente

Con los seis CRUDs ya conectados a la base de datos y validados — clients, projects, time_entries, payments, fixed_costs y project_expenses — el backend de TrueRate está listo para dar el siguiente paso. En el próximo artículo le toca a la autenticación: usuarios reales con JWT.


← Más artículos