- 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>
368 lines
7.9 KiB
Markdown
368 lines
7.9 KiB
Markdown
# 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<T>
|
|
- BaseCrudController<T>
|
|
- 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<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
|
|
|
|
```typescript
|
|
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
|
|
```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<T> {
|
|
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<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
|
|
|
|
- [x] BaseCrudService funciona
|
|
- [x] BaseCrudController funciona
|
|
- [x] Paginacion correcta
|
|
- [x] Filtros funcionan
|
|
- [x] Soft delete funciona
|
|
- [x] RLS aplicado
|
|
|
|
---
|
|
|
|
**Ultima actualizacion:** 2026-01-07
|