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:
- Crear el esqueleto (
module,controller,service). - Conectarlo a la base de datos con Prisma, con operaciones básicas (listar y crear).
- 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:
- El nombre de la tabla en la base de datos es
projects, y Prisma lo expone comothis.prisma.projects. No hay SQL que escribir: el ORM traduce cada llamada a la query correspondiente (create→INSERT,findMany→SELECT, etc.). - El
updated_atlo gestiona Prisma automáticamente gracias al decorador@updatedAtdel schema, así que no aparece en los DTOs ni en el servicio.
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:
- Obligatorio si el dato es imprescindible para el registro (p. ej.
amounten un pago). - Opcional si la base de datos puede asumir un valor por defecto razonable (p. ej.
status, que arranca comobacklog).
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:
- Añadí
status String @default("pending")al modelopaymentsenschema.prisma. - Ejecuté
prisma migrate dev --name add_payment_status. - 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.