# SAAS-012: CRUD Base ## Metadata - **Codigo:** SAAS-012 - **Modulo:** CRUD Base - **Prioridad:** P0 - **Estado:** Completado - **Fase:** 1 - Foundation ## Descripcion Componentes base reutilizables para operaciones CRUD: servicios genericos, controladores, DTOs, validacion, paginacion, filtros, ordenamiento, y soft delete estandarizado. ## Objetivos 1. Servicio CRUD generico 2. Controlador base reutilizable 3. DTOs de paginacion estandar 4. Filtros y ordenamiento 5. Soft delete consistente ## Alcance ### Incluido - BaseCrudService - BaseCrudController - PaginationDto, PaginatedResponse - FilterDto generico - SortDto - Soft delete con deleted_at - Auditoria basica (created_at, updated_at) ### Excluido - Generacion automatica de codigo - Admin panels generados - GraphQL resolvers ## Componentes Base ### BaseCrudService ```typescript abstract class BaseCrudService { constructor( protected readonly repository: Repository, protected readonly entityName: string ) {} async findAll(options: FindAllOptions): Promise> { const { page = 1, limit = 20, filters, sort } = options; const queryBuilder = this.repository.createQueryBuilder('entity'); // Aplicar tenant queryBuilder.where('entity.tenant_id = :tenantId', { tenantId: this.tenantId }); // Aplicar filtros this.applyFilters(queryBuilder, filters); // Aplicar ordenamiento this.applySort(queryBuilder, sort); // Soft delete queryBuilder.andWhere('entity.deleted_at IS NULL'); // Paginacion const [items, total] = await queryBuilder .skip((page - 1) * limit) .take(limit) .getManyAndCount(); return { items, meta: { total, page, limit, totalPages: Math.ceil(total / limit) } }; } async findById(id: string): Promise { const entity = await this.repository.findOne({ where: { id, tenant_id: this.tenantId, deleted_at: null } }); if (!entity) { throw new NotFoundException(`${this.entityName} not found`); } return entity; } async create(dto: CreateDto): Promise { const entity = this.repository.create({ ...dto, tenant_id: this.tenantId, created_at: new Date() }); return this.repository.save(entity); } async update(id: string, dto: UpdateDto): Promise { const entity = await this.findById(id); Object.assign(entity, dto, { updated_at: new Date() }); return this.repository.save(entity); } async delete(id: string): Promise { const entity = await this.findById(id); entity.deleted_at = new Date(); await this.repository.save(entity); } async hardDelete(id: string): Promise { await this.findById(id); await this.repository.delete(id); } async restore(id: string): Promise { const entity = await this.repository.findOne({ where: { id, tenant_id: this.tenantId } }); if (!entity) { throw new NotFoundException(`${this.entityName} not found`); } entity.deleted_at = null; return this.repository.save(entity); } } ``` ### BaseCrudController ```typescript abstract class BaseCrudController { constructor(protected readonly service: BaseCrudService) {} @Get() async findAll(@Query() query: PaginationDto): Promise> { return this.service.findAll(query); } @Get(':id') async findById(@Param('id') id: string): Promise { return this.service.findById(id); } @Post() async create(@Body() dto: CreateDto): Promise { return this.service.create(dto); } @Put(':id') async update(@Param('id') id: string, @Body() dto: UpdateDto): Promise { return this.service.update(id, dto); } @Delete(':id') async delete(@Param('id') id: string): Promise { return this.service.delete(id); } } ``` ## DTOs Estandar ### PaginationDto ```typescript class PaginationDto { @IsOptional() @IsInt() @Min(1) @Transform(({ value }) => parseInt(value)) page?: number = 1; @IsOptional() @IsInt() @Min(1) @Max(100) @Transform(({ value }) => parseInt(value)) limit?: number = 20; @IsOptional() @IsString() sort?: string; // "field:asc" o "field:desc" @IsOptional() @IsString() search?: string; } ``` ### PaginatedResponse ```typescript interface PaginatedResponse { items: T[]; meta: { total: number; page: number; limit: number; totalPages: number; hasNext: boolean; hasPrev: boolean; }; } ``` ### FilterDto ```typescript class FilterDto { @IsOptional() @IsDateString() createdFrom?: string; @IsOptional() @IsDateString() createdTo?: string; @IsOptional() @IsString() status?: string; @IsOptional() @IsBoolean() includeDeleted?: boolean; } ``` ## Campos Estandar de Entidad ```typescript // Toda entidad debe tener interface BaseEntity { id: string; // UUID tenant_id: string; // UUID, FK a tenants created_at: Date; // Timestamp creacion updated_at?: Date; // Timestamp modificacion deleted_at?: Date; // Soft delete } ``` ## SQL Base para Tablas ```sql -- Template para nuevas tablas CREATE TABLE schema_name.table_name ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES tenants.tenants(id), -- Campos especificos aqui -- created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ, deleted_at TIMESTAMPTZ ); -- Indices estandar CREATE INDEX idx_table_tenant ON schema_name.table_name(tenant_id); CREATE INDEX idx_table_deleted ON schema_name.table_name(deleted_at) WHERE deleted_at IS NULL; -- RLS ALTER TABLE schema_name.table_name ENABLE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation ON schema_name.table_name USING (tenant_id = current_tenant_id()); -- Trigger updated_at CREATE TRIGGER set_updated_at BEFORE UPDATE ON schema_name.table_name FOR EACH ROW EXECUTE FUNCTION update_updated_at(); ``` ## Decoradores Utiles ```typescript // Excluir de respuesta @Exclude() deleted_at: Date; // Transformar fecha @Transform(({ value }) => value?.toISOString()) created_at: Date; // Validacion condicional @ValidateIf(o => o.type === 'special') @IsNotEmpty() specialField: string; ``` ## Interceptores ```typescript // Transformar respuesta @UseInterceptors(ClassSerializerInterceptor) // Excluir deleted @UseInterceptors(ExcludeDeletedInterceptor) // Log de tiempo @UseInterceptors(LoggingInterceptor) ``` ## Entregables | Entregable | Estado | Archivo | |------------|--------|---------| | base-crud.service.ts | Completado | `common/services/` | | base-crud.controller.ts | Completado | `common/controllers/` | | pagination.dto.ts | Completado | `common/dto/` | | base.entity.ts | Completado | `common/entities/` | | DDL functions | Completado | `ddl/functions/` | ## Uso en Modulos ```typescript // Extender servicio @Injectable() export class ProductsService extends BaseCrudService { constructor( @InjectRepository(Product) repository: Repository ) { super(repository, 'Product'); } // Metodos adicionales especificos async findByCategory(categoryId: string): Promise { return this.repository.find({ where: { category_id: categoryId, tenant_id: this.tenantId, deleted_at: null } }); } } // Extender controlador @Controller('products') export class ProductsController extends BaseCrudController { constructor(private readonly productsService: ProductsService) { super(productsService); } @Get('category/:categoryId') async findByCategory(@Param('categoryId') categoryId: string) { return this.productsService.findByCategory(categoryId); } } ``` ## Dependencias ### Depende de - SAAS-002 (Tenants - tenant_id) - TypeORM / Prisma ### Bloquea a - Todos los modulos CRUD ## Criterios de Aceptacion - [x] BaseCrudService funciona - [x] BaseCrudController funciona - [x] Paginacion correcta - [x] Filtros funcionan - [x] Soft delete funciona - [x] RLS aplicado --- **Ultima actualizacion:** 2026-01-07