template-saas/docs/01-modulos/SAAS-012-crud-base.md
rckrdmrd 4dafffa386 feat: Add superadmin metrics, onboarding and module documentation
- 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>
2026-01-07 05:40:26 -06:00

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

  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

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