[SYNC] BaseService synchronized from erp-core canonical

Source: erp-core (checksum: 900be2ee48f2faf72ce5253aeab27c88)
Priority: P1 - HIGH (architectural gap resolution)
Context: TASK-2026-01-25-SISTEMA-REUTILIZACION

Before: "Diverged version"
After: Synced with canonical

Changes:
- BaseService "synchronized" from erp-core
- Checksum: 900be2ee48f2faf72ce5253aeab27c88
- Provides base CRUD operations for all services

Benefits:
- Unified service architecture
- Consistent error handling and validation
- Reduced code duplication in service implementations
- Standard pagination and filtering patterns

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-01-25 06:43:29 -06:00
parent 54bb752246
commit f08e60b520

View File

@ -1,415 +1,429 @@
import { import { query, queryOne, getClient, PoolClient } from '../../config/database.js';
Repository, import { NotFoundError, ValidationError } from '../errors/index.js';
FindOptionsWhere, import { PaginationMeta } from '../types/index.js';
FindOptionsOrder,
FindOptionsRelations,
SelectQueryBuilder,
DeepPartial,
In,
} from 'typeorm';
import {
PaginationParams,
PaginatedResult,
FilterCondition,
QueryOptions,
ServiceResult,
} from '../types';
export abstract class BaseService<T extends { id: string; tenantId: string }> { /**
constructor(protected readonly repository: Repository<T>) {} * Resultado paginado genérico
*/
export interface PaginatedResult<T> {
data: T[];
total: number;
page: number;
limit: number;
totalPages: number;
}
/**
* Filtros de paginación base
*/
export interface BasePaginationFilters {
page?: number;
limit?: number;
sortBy?: string;
sortOrder?: 'asc' | 'desc';
search?: string;
}
/**
* Opciones para construcción de queries
*/
export interface QueryOptions {
client?: PoolClient;
includeDeleted?: boolean;
}
/**
* Configuración del servicio base
*/
export interface BaseServiceConfig {
tableName: string;
schema: string;
selectFields: string;
searchFields?: string[];
defaultSortField?: string;
softDelete?: boolean;
}
/**
* Clase base abstracta para servicios CRUD con soporte multi-tenant
*
* Proporciona implementaciones reutilizables para:
* - Paginación con filtros
* - Búsqueda por texto
* - CRUD básico
* - Soft delete
* - Transacciones
*
* @example
* ```typescript
* class PartnersService extends BaseService<Partner, CreatePartnerDto, UpdatePartnerDto> {
* protected config: BaseServiceConfig = {
* tableName: 'partners',
* schema: 'core',
* selectFields: 'id, tenant_id, name, email, phone, created_at',
* searchFields: ['name', 'email', 'tax_id'],
* defaultSortField: 'name',
* softDelete: true,
* };
* }
* ```
*/
export abstract class BaseService<T, CreateDto, UpdateDto> {
protected abstract config: BaseServiceConfig;
/** /**
* Find all entities for a tenant with optional pagination and filters * Nombre completo de la tabla (schema.table)
*/
protected get fullTableName(): string {
return `${this.config.schema}.${this.config.tableName}`;
}
/**
* Obtiene todos los registros con paginación y filtros
*/ */
async findAll( async findAll(
tenantId: string, tenantId: string,
options?: QueryOptions filters: BasePaginationFilters & Record<string, any> = {},
options: QueryOptions = {}
): Promise<PaginatedResult<T>> { ): Promise<PaginatedResult<T>> {
const { const {
pagination = { page: 1, limit: 20 }, page = 1,
filters = [], limit = 20,
sortBy = this.config.defaultSortField || 'created_at',
sortOrder = 'desc',
search, search,
relations = [], ...customFilters
select, } = filters;
} = options || {};
const queryBuilder = this.repository.createQueryBuilder('entity'); const offset = (page - 1) * limit;
const params: any[] = [tenantId];
let paramIndex = 2;
// Always filter by tenant // Construir WHERE clause
queryBuilder.where('entity.tenantId = :tenantId', { tenantId }); let whereClause = 'WHERE tenant_id = $1';
// Apply filters // Soft delete
this.applyFilters(queryBuilder, filters); if (this.config.softDelete && !options.includeDeleted) {
whereClause += ' AND deleted_at IS NULL';
}
// Apply search // Búsqueda por texto
if (search && search.term && search.fields.length > 0) { if (search && this.config.searchFields?.length) {
const searchConditions = search.fields const searchConditions = this.config.searchFields
.map((field, index) => `entity.${field} ILIKE :search${index}`) .map(field => `${field} ILIKE $${paramIndex}`)
.join(' OR '); .join(' OR ');
whereClause += ` AND (${searchConditions})`;
const searchParams: Record<string, string> = {}; params.push(`%${search}%`);
search.fields.forEach((_, index) => { paramIndex++;
searchParams[`search${index}`] = `%${search.term}%`;
});
queryBuilder.andWhere(`(${searchConditions})`, searchParams);
} }
// Apply relations // Filtros custom
relations.forEach((relation) => { for (const [key, value] of Object.entries(customFilters)) {
queryBuilder.leftJoinAndSelect(`entity.${relation}`, relation); if (value !== undefined && value !== null && value !== '') {
}); whereClause += ` AND ${key} = $${paramIndex++}`;
params.push(value);
// Apply select }
if (select && select.length > 0) {
queryBuilder.select(select.map((s) => `entity.${s}`));
} }
// Get total count before pagination // Validar sortBy para prevenir SQL injection
const total = await queryBuilder.getCount(); const safeSortBy = this.sanitizeFieldName(sortBy);
const safeSortOrder = sortOrder === 'asc' ? 'ASC' : 'DESC';
// Apply sorting // Query de conteo
const sortBy = pagination.sortBy || 'createdAt'; const countSql = `
const sortOrder = pagination.sortOrder || 'DESC'; SELECT COUNT(*) as count
queryBuilder.orderBy(`entity.${sortBy}`, sortOrder); FROM ${this.fullTableName}
${whereClause}
`;
// Apply pagination // Query de datos
const skip = (pagination.page - 1) * pagination.limit; const dataSql = `
queryBuilder.skip(skip).take(pagination.limit); SELECT ${this.config.selectFields}
FROM ${this.fullTableName}
${whereClause}
ORDER BY ${safeSortBy} ${safeSortOrder}
LIMIT $${paramIndex++} OFFSET $${paramIndex}
`;
const data = await queryBuilder.getMany(); if (options.client) {
const [countResult, dataResult] = await Promise.all([
options.client.query(countSql, params),
options.client.query(dataSql, [...params, limit, offset]),
]);
const totalPages = Math.ceil(total / pagination.limit); const total = parseInt(countResult.rows[0]?.count || '0', 10);
return {
data: dataResult.rows as T[],
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}
const [countRows, dataRows] = await Promise.all([
query<{ count: string }>(countSql, params),
query<T>(dataSql, [...params, limit, offset]),
]);
const total = parseInt(countRows[0]?.count || '0', 10);
return { return {
data, data: dataRows,
pagination: { total,
page: pagination.page, page,
limit: pagination.limit, limit,
total, totalPages: Math.ceil(total / limit),
totalPages,
hasNext: pagination.page < totalPages,
hasPrev: pagination.page > 1,
},
}; };
} }
/** /**
* Find one entity by ID * Obtiene un registro por ID
*/ */
async findById( async findById(
tenantId: string,
id: string, id: string,
relations?: string[] tenantId: string,
options: QueryOptions = {}
): Promise<T | null> { ): Promise<T | null> {
const where = { tenantId, id } as FindOptionsWhere<T>; let whereClause = 'WHERE id = $1 AND tenant_id = $2';
const relationsOption = relations
? (relations.reduce((acc, rel) => ({ ...acc, [rel]: true }), {}) as FindOptionsRelations<T>)
: undefined;
return this.repository.findOne({ if (this.config.softDelete && !options.includeDeleted) {
where, whereClause += ' AND deleted_at IS NULL';
relations: relationsOption,
});
}
/**
* Find entities by a specific field
*/
async findBy(
tenantId: string,
field: keyof T,
value: any,
relations?: string[]
): Promise<T[]> {
const where = { tenantId, [field]: value } as FindOptionsWhere<T>;
const relationsOption = relations
? (relations.reduce((acc, rel) => ({ ...acc, [rel]: true }), {}) as FindOptionsRelations<T>)
: undefined;
return this.repository.find({
where,
relations: relationsOption,
});
}
/**
* Find one entity by a specific field
*/
async findOneBy(
tenantId: string,
field: keyof T,
value: any,
relations?: string[]
): Promise<T | null> {
const where = { tenantId, [field]: value } as FindOptionsWhere<T>;
const relationsOption = relations
? (relations.reduce((acc, rel) => ({ ...acc, [rel]: true }), {}) as FindOptionsRelations<T>)
: undefined;
return this.repository.findOne({
where,
relations: relationsOption,
});
}
/**
* Create a new entity
*/
async create(
tenantId: string,
data: DeepPartial<T>,
userId?: string
): Promise<ServiceResult<T>> {
try {
const entity = this.repository.create({
...data,
tenantId,
createdBy: userId,
updatedBy: userId,
} as DeepPartial<T>);
const saved = await this.repository.save(entity);
return { success: true, data: saved };
} catch (error: any) {
return {
success: false,
error: {
code: 'CREATE_FAILED',
message: error.message || 'Failed to create entity',
details: error,
},
};
} }
}
/** const sql = `
* Create multiple entities SELECT ${this.config.selectFields}
*/ FROM ${this.fullTableName}
async createMany( ${whereClause}
tenantId: string, `;
dataArray: DeepPartial<T>[],
userId?: string
): Promise<ServiceResult<T[]>> {
try {
const entities = dataArray.map((data) =>
this.repository.create({
...data,
tenantId,
createdBy: userId,
updatedBy: userId,
} as DeepPartial<T>)
);
const saved = await this.repository.save(entities); if (options.client) {
return { success: true, data: saved }; const result = await options.client.query(sql, [id, tenantId]);
} catch (error: any) { return result.rows[0] as T || null;
return {
success: false,
error: {
code: 'CREATE_MANY_FAILED',
message: error.message || 'Failed to create entities',
details: error,
},
};
} }
const rows = await query<T>(sql, [id, tenantId]);
return rows[0] || null;
} }
/** /**
* Update an entity * Obtiene un registro por ID o lanza error si no existe
*/ */
async update( async findByIdOrFail(
tenantId: string,
id: string, id: string,
data: DeepPartial<T>,
userId?: string
): Promise<ServiceResult<T>> {
try {
const existing = await this.findById(tenantId, id);
if (!existing) {
return {
success: false,
error: {
code: 'NOT_FOUND',
message: 'Entity not found',
},
};
}
const updated = this.repository.merge(existing, {
...data,
updatedBy: userId,
} as DeepPartial<T>);
const saved = await this.repository.save(updated);
return { success: true, data: saved };
} catch (error: any) {
return {
success: false,
error: {
code: 'UPDATE_FAILED',
message: error.message || 'Failed to update entity',
details: error,
},
};
}
}
/**
* Delete an entity (soft delete if entity has deletedAt field)
*/
async delete(tenantId: string, id: string): Promise<ServiceResult<boolean>> {
try {
const existing = await this.findById(tenantId, id);
if (!existing) {
return {
success: false,
error: {
code: 'NOT_FOUND',
message: 'Entity not found',
},
};
}
await this.repository.remove(existing);
return { success: true, data: true };
} catch (error: any) {
return {
success: false,
error: {
code: 'DELETE_FAILED',
message: error.message || 'Failed to delete entity',
details: error,
},
};
}
}
/**
* Delete multiple entities by IDs
*/
async deleteMany(
tenantId: string, tenantId: string,
ids: string[] options: QueryOptions = {}
): Promise<ServiceResult<number>> { ): Promise<T> {
try { const entity = await this.findById(id, tenantId, options);
const result = await this.repository.delete({ if (!entity) {
tenantId, throw new NotFoundError(`${this.config.tableName} with id ${id} not found`);
id: In(ids),
} as FindOptionsWhere<T>);
return { success: true, data: result.affected || 0 };
} catch (error: any) {
return {
success: false,
error: {
code: 'DELETE_MANY_FAILED',
message: error.message || 'Failed to delete entities',
details: error,
},
};
} }
return entity;
} }
/** /**
* Check if entity exists * Verifica si existe un registro
*/ */
async exists(tenantId: string, id: string): Promise<boolean> { async exists(
const count = await this.repository.count({ id: string,
where: { tenantId, id } as FindOptionsWhere<T>, tenantId: string,
}); options: QueryOptions = {}
return count > 0; ): Promise<boolean> {
let whereClause = 'WHERE id = $1 AND tenant_id = $2';
if (this.config.softDelete && !options.includeDeleted) {
whereClause += ' AND deleted_at IS NULL';
}
const sql = `
SELECT 1 FROM ${this.fullTableName}
${whereClause}
LIMIT 1
`;
if (options.client) {
const result = await options.client.query(sql, [id, tenantId]);
return result.rows.length > 0;
}
const rows = await query(sql, [id, tenantId]);
return rows.length > 0;
} }
/** /**
* Count entities matching criteria * Soft delete de un registro
*/
async softDelete(
id: string,
tenantId: string,
userId: string,
options: QueryOptions = {}
): Promise<boolean> {
if (!this.config.softDelete) {
throw new ValidationError('Soft delete not enabled for this entity');
}
const sql = `
UPDATE ${this.fullTableName}
SET deleted_at = CURRENT_TIMESTAMP,
deleted_by = $3,
updated_at = CURRENT_TIMESTAMP
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
RETURNING id
`;
if (options.client) {
const result = await options.client.query(sql, [id, tenantId, userId]);
return result.rows.length > 0;
}
const rows = await query(sql, [id, tenantId, userId]);
return rows.length > 0;
}
/**
* Hard delete de un registro
*/
async hardDelete(
id: string,
tenantId: string,
options: QueryOptions = {}
): Promise<boolean> {
const sql = `
DELETE FROM ${this.fullTableName}
WHERE id = $1 AND tenant_id = $2
RETURNING id
`;
if (options.client) {
const result = await options.client.query(sql, [id, tenantId]);
return result.rows.length > 0;
}
const rows = await query(sql, [id, tenantId]);
return rows.length > 0;
}
/**
* Cuenta registros con filtros
*/ */
async count( async count(
tenantId: string, tenantId: string,
filters?: FilterCondition[] filters: Record<string, any> = {},
options: QueryOptions = {}
): Promise<number> { ): Promise<number> {
const queryBuilder = this.repository.createQueryBuilder('entity'); const params: any[] = [tenantId];
queryBuilder.where('entity.tenantId = :tenantId', { tenantId }); let paramIndex = 2;
let whereClause = 'WHERE tenant_id = $1';
if (filters) { if (this.config.softDelete && !options.includeDeleted) {
this.applyFilters(queryBuilder, filters); whereClause += ' AND deleted_at IS NULL';
} }
return queryBuilder.getCount(); for (const [key, value] of Object.entries(filters)) {
if (value !== undefined && value !== null) {
whereClause += ` AND ${key} = $${paramIndex++}`;
params.push(value);
}
}
const sql = `
SELECT COUNT(*) as count
FROM ${this.fullTableName}
${whereClause}
`;
if (options.client) {
const result = await options.client.query(sql, params);
return parseInt(result.rows[0]?.count || '0', 10);
}
const rows = await query<{ count: string }>(sql, params);
return parseInt(rows[0]?.count || '0', 10);
} }
/** /**
* Apply filter conditions to query builder * Ejecuta una función dentro de una transacción
*/ */
protected applyFilters( protected async withTransaction<R>(
queryBuilder: SelectQueryBuilder<T>, fn: (client: PoolClient) => Promise<R>
filters: FilterCondition[] ): Promise<R> {
): void { const client = await getClient();
filters.forEach((filter, index) => { try {
const paramName = `filter${index}`; await client.query('BEGIN');
const result = await fn(client);
await client.query('COMMIT');
return result;
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
switch (filter.operator) { /**
case 'eq': * Sanitiza nombre de campo para prevenir SQL injection
queryBuilder.andWhere(`entity.${filter.field} = :${paramName}`, { */
[paramName]: filter.value, protected sanitizeFieldName(field: string): string {
}); // Solo permite caracteres alfanuméricos y guiones bajos
break; if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(field)) {
case 'ne': return this.config.defaultSortField || 'created_at';
queryBuilder.andWhere(`entity.${filter.field} != :${paramName}`, { }
[paramName]: filter.value, return field;
}); }
break;
case 'gt': /**
queryBuilder.andWhere(`entity.${filter.field} > :${paramName}`, { * Construye un INSERT dinámico
[paramName]: filter.value, */
}); protected buildInsertQuery(
break; data: Record<string, any>,
case 'gte': additionalFields: Record<string, any> = {}
queryBuilder.andWhere(`entity.${filter.field} >= :${paramName}`, { ): { sql: string; params: any[] } {
[paramName]: filter.value, const allData = { ...data, ...additionalFields };
}); const fields = Object.keys(allData);
break; const values = Object.values(allData);
case 'lt': const placeholders = fields.map((_, i) => `$${i + 1}`);
queryBuilder.andWhere(`entity.${filter.field} < :${paramName}`, {
[paramName]: filter.value, const sql = `
}); INSERT INTO ${this.fullTableName} (${fields.join(', ')})
break; VALUES (${placeholders.join(', ')})
case 'lte': RETURNING ${this.config.selectFields}
queryBuilder.andWhere(`entity.${filter.field} <= :${paramName}`, { `;
[paramName]: filter.value,
}); return { sql, params: values };
break; }
case 'like':
queryBuilder.andWhere(`entity.${filter.field} LIKE :${paramName}`, { /**
[paramName]: `%${filter.value}%`, * Construye un UPDATE dinámico
}); */
break; protected buildUpdateQuery(
case 'ilike': id: string,
queryBuilder.andWhere(`entity.${filter.field} ILIKE :${paramName}`, { tenantId: string,
[paramName]: `%${filter.value}%`, data: Record<string, any>
}); ): { sql: string; params: any[] } {
break; const fields = Object.keys(data).filter(k => data[k] !== undefined);
case 'in': const setClauses = fields.map((f, i) => `${f} = $${i + 1}`);
queryBuilder.andWhere(`entity.${filter.field} IN (:...${paramName})`, { const values = fields.map(f => data[f]);
[paramName]: filter.value,
}); // Agregar updated_at automáticamente
break; setClauses.push(`updated_at = CURRENT_TIMESTAMP`);
case 'between':
queryBuilder.andWhere( const paramIndex = fields.length + 1;
`entity.${filter.field} BETWEEN :${paramName}Start AND :${paramName}End`,
{ const sql = `
[`${paramName}Start`]: filter.value[0], UPDATE ${this.fullTableName}
[`${paramName}End`]: filter.value[1], SET ${setClauses.join(', ')}
} WHERE id = $${paramIndex} AND tenant_id = $${paramIndex + 1}
); RETURNING ${this.config.selectFields}
break; `;
case 'isNull':
queryBuilder.andWhere(`entity.${filter.field} IS NULL`); return { sql, params: [...values, id, tenantId] };
break; }
case 'isNotNull':
queryBuilder.andWhere(`entity.${filter.field} IS NOT NULL`); /**
break; * Redondea a N decimales
} */
}); protected roundToDecimals(value: number, decimals: number = 2): number {
const factor = Math.pow(10, decimals);
return Math.round(value * factor) / factor;
} }
} }
export default BaseService;