- Add MetricsPage and useOnboarding hook - Update superadmin controller and service - Add module documentation (docs/01-modulos/) - Add CONTEXT-MAP.yml and Sprint 5 execution report - Update project status and task traces 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
7.9 KiB
7.9 KiB
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
- Servicio CRUD generico
- Controlador base reutilizable
- DTOs de paginacion estandar
- Filtros y ordenamiento
- 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
abstract class BaseCrudService<T> {
constructor(
protected readonly repository: Repository<T>,
protected readonly entityName: string
) {}
async findAll(options: FindAllOptions): Promise<PaginatedResponse<T>> {
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<T> {
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<T> {
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<T> {
const entity = await this.findById(id);
Object.assign(entity, dto, { updated_at: new Date() });
return this.repository.save(entity);
}
async delete(id: string): Promise<void> {
const entity = await this.findById(id);
entity.deleted_at = new Date();
await this.repository.save(entity);
}
async hardDelete(id: string): Promise<void> {
await this.findById(id);
await this.repository.delete(id);
}
async restore(id: string): Promise<T> {
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
abstract class BaseCrudController<T> {
constructor(protected readonly service: BaseCrudService<T>) {}
@Get()
async findAll(@Query() query: PaginationDto): Promise<PaginatedResponse<T>> {
return this.service.findAll(query);
}
@Get(':id')
async findById(@Param('id') id: string): Promise<T> {
return this.service.findById(id);
}
@Post()
async create(@Body() dto: CreateDto): Promise<T> {
return this.service.create(dto);
}
@Put(':id')
async update(@Param('id') id: string, @Body() dto: UpdateDto): Promise<T> {
return this.service.update(id, dto);
}
@Delete(':id')
async delete(@Param('id') id: string): Promise<void> {
return this.service.delete(id);
}
}
DTOs Estandar
PaginationDto
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
interface PaginatedResponse<T> {
items: T[];
meta: {
total: number;
page: number;
limit: number;
totalPages: number;
hasNext: boolean;
hasPrev: boolean;
};
}
FilterDto
class FilterDto {
@IsOptional()
@IsDateString()
createdFrom?: string;
@IsOptional()
@IsDateString()
createdTo?: string;
@IsOptional()
@IsString()
status?: string;
@IsOptional()
@IsBoolean()
includeDeleted?: boolean;
}
Campos Estandar de Entidad
// 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
-- 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
// 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
// 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
// Extender servicio
@Injectable()
export class ProductsService extends BaseCrudService<Product> {
constructor(
@InjectRepository(Product)
repository: Repository<Product>
) {
super(repository, 'Product');
}
// Metodos adicionales especificos
async findByCategory(categoryId: string): Promise<Product[]> {
return this.repository.find({
where: { category_id: categoryId, tenant_id: this.tenantId, deleted_at: null }
});
}
}
// Extender controlador
@Controller('products')
export class ProductsController extends BaseCrudController<Product> {
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
- BaseCrudService funciona
- BaseCrudController funciona
- Paginacion correcta
- Filtros funcionan
- Soft delete funciona
- RLS aplicado
Ultima actualizacion: 2026-01-07