[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:25 -06:00
parent 49f9359b0b
commit c3200bc53e

View File

@ -1,217 +1,429 @@
import { query, queryOne, getClient, PoolClient } from '../../config/database.js';
import { NotFoundError, ValidationError } from '../errors/index.js';
import { PaginationMeta } from '../types/index.js';
/**
* BaseService - Abstract Service with Common CRUD Operations
*
* Provides multi-tenant aware CRUD operations using TypeORM.
* All domain services should extend this base class.
*
* @module @shared/services
* Resultado paginado genérico
*/
import {
Repository,
FindOptionsWhere,
FindManyOptions,
DeepPartial,
ObjectLiteral,
} from 'typeorm';
export interface PaginationOptions {
page?: number;
limit?: number;
}
export interface PaginatedResult<T> {
data: T[];
meta: {
total: number;
page: number;
limit: number;
totalPages: number;
};
total: number;
page: number;
limit: number;
totalPages: number;
}
export interface ServiceContext {
tenantId: string;
userId?: string;
/**
* Filtros de paginación base
*/
export interface BasePaginationFilters {
page?: number;
limit?: number;
sortBy?: string;
sortOrder?: 'asc' | 'desc';
search?: string;
}
export abstract class BaseService<T extends ObjectLiteral> {
constructor(protected readonly repository: Repository<T>) {}
/**
* 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 records for a tenant with optional pagination
* 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(
ctx: ServiceContext,
options?: PaginationOptions & { where?: FindOptionsWhere<T> }
tenantId: string,
filters: BasePaginationFilters & Record<string, any> = {},
options: QueryOptions = {}
): Promise<PaginatedResult<T>> {
const page = options?.page || 1;
const limit = options?.limit || 20;
const skip = (page - 1) * limit;
const {
page = 1,
limit = 20,
sortBy = this.config.defaultSortField || 'created_at',
sortOrder = 'desc',
search,
...customFilters
} = filters;
const where = {
tenantId: ctx.tenantId,
deletedAt: null,
...options?.where,
} as unknown as FindOptionsWhere<T>;
const offset = (page - 1) * limit;
const params: any[] = [tenantId];
let paramIndex = 2;
const [data, total] = await this.repository.findAndCount({
where,
take: limit,
skip,
order: { createdAt: 'DESC' } as any,
});
// Construir WHERE clause
let whereClause = 'WHERE tenant_id = $1';
return {
data,
meta: {
// Soft delete
if (this.config.softDelete && !options.includeDeleted) {
whereClause += ' AND deleted_at IS NULL';
}
// Búsqueda por texto
if (search && this.config.searchFields?.length) {
const searchConditions = this.config.searchFields
.map(field => `${field} ILIKE $${paramIndex}`)
.join(' OR ');
whereClause += ` AND (${searchConditions})`;
params.push(`%${search}%`);
paramIndex++;
}
// Filtros custom
for (const [key, value] of Object.entries(customFilters)) {
if (value !== undefined && value !== null && value !== '') {
whereClause += ` AND ${key} = $${paramIndex++}`;
params.push(value);
}
}
// Validar sortBy para prevenir SQL injection
const safeSortBy = this.sanitizeFieldName(sortBy);
const safeSortOrder = sortOrder === 'asc' ? 'ASC' : 'DESC';
// Query de conteo
const countSql = `
SELECT COUNT(*) as count
FROM ${this.fullTableName}
${whereClause}
`;
// Query de datos
const dataSql = `
SELECT ${this.config.selectFields}
FROM ${this.fullTableName}
${whereClause}
ORDER BY ${safeSortBy} ${safeSortOrder}
LIMIT $${paramIndex++} OFFSET $${paramIndex}
`;
if (options.client) {
const [countResult, dataResult] = await Promise.all([
options.client.query(countSql, params),
options.client.query(dataSql, [...params, limit, offset]),
]);
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 {
data: dataRows,
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}
/**
* Find one record by ID for a tenant
* Obtiene un registro por ID
*/
async findById(ctx: ServiceContext, id: string): Promise<T | null> {
return this.repository.findOne({
where: {
id,
tenantId: ctx.tenantId,
deletedAt: null,
} as unknown as FindOptionsWhere<T>,
});
}
/**
* Find one record by criteria
*/
async findOne(
ctx: ServiceContext,
where: FindOptionsWhere<T>
): Promise<T | null> {
return this.repository.findOne({
where: {
tenantId: ctx.tenantId,
deletedAt: null,
...where,
} as FindOptionsWhere<T>,
});
}
/**
* Find records by custom options
*/
async find(
ctx: ServiceContext,
options: FindManyOptions<T>
): Promise<T[]> {
return this.repository.find({
...options,
where: {
tenantId: ctx.tenantId,
deletedAt: null,
...(options.where || {}),
} as FindOptionsWhere<T>,
});
}
/**
* Create a new record
*/
async create(
ctx: ServiceContext,
data: DeepPartial<T>
): Promise<T> {
const entity = this.repository.create({
...data,
tenantId: ctx.tenantId,
createdById: ctx.userId,
} as DeepPartial<T>);
return this.repository.save(entity);
}
/**
* Update an existing record
*/
async update(
ctx: ServiceContext,
async findById(
id: string,
data: DeepPartial<T>
tenantId: string,
options: QueryOptions = {}
): Promise<T | null> {
const existing = await this.findById(ctx, id);
if (!existing) {
return null;
let whereClause = 'WHERE id = $1 AND tenant_id = $2';
if (this.config.softDelete && !options.includeDeleted) {
whereClause += ' AND deleted_at IS NULL';
}
const updated = this.repository.merge(existing, {
...data,
updatedById: ctx.userId,
} as DeepPartial<T>);
const sql = `
SELECT ${this.config.selectFields}
FROM ${this.fullTableName}
${whereClause}
`;
return this.repository.save(updated);
}
/**
* Soft delete a record
*/
async softDelete(ctx: ServiceContext, id: string): Promise<boolean> {
const existing = await this.findById(ctx, id);
if (!existing) {
return false;
if (options.client) {
const result = await options.client.query(sql, [id, tenantId]);
return result.rows[0] as T || null;
}
await this.repository.update(
{ id, tenantId: ctx.tenantId } as unknown as FindOptionsWhere<T>,
{
deletedAt: new Date(),
deletedById: ctx.userId,
} as any
);
return true;
const rows = await query<T>(sql, [id, tenantId]);
return rows[0] || null;
}
/**
* Hard delete a record (use with caution)
* Obtiene un registro por ID o lanza error si no existe
*/
async hardDelete(ctx: ServiceContext, id: string): Promise<boolean> {
const result = await this.repository.delete({
id,
tenantId: ctx.tenantId,
} as unknown as FindOptionsWhere<T>);
return (result.affected ?? 0) > 0;
async findByIdOrFail(
id: string,
tenantId: string,
options: QueryOptions = {}
): Promise<T> {
const entity = await this.findById(id, tenantId, options);
if (!entity) {
throw new NotFoundError(`${this.config.tableName} with id ${id} not found`);
}
return entity;
}
/**
* Count records
*/
async count(
ctx: ServiceContext,
where?: FindOptionsWhere<T>
): Promise<number> {
return this.repository.count({
where: {
tenantId: ctx.tenantId,
deletedAt: null,
...where,
} as unknown as FindOptionsWhere<T>,
});
}
/**
* Check if a record exists
* Verifica si existe un registro
*/
async exists(
ctx: ServiceContext,
where: FindOptionsWhere<T>
id: string,
tenantId: string,
options: QueryOptions = {}
): Promise<boolean> {
const count = await this.count(ctx, where);
return count > 0;
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;
}
/**
* 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(
tenantId: string,
filters: Record<string, any> = {},
options: QueryOptions = {}
): Promise<number> {
const params: any[] = [tenantId];
let paramIndex = 2;
let whereClause = 'WHERE tenant_id = $1';
if (this.config.softDelete && !options.includeDeleted) {
whereClause += ' AND deleted_at IS NULL';
}
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);
}
/**
* Ejecuta una función dentro de una transacción
*/
protected async withTransaction<R>(
fn: (client: PoolClient) => Promise<R>
): Promise<R> {
const client = await getClient();
try {
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();
}
}
/**
* Sanitiza nombre de campo para prevenir SQL injection
*/
protected sanitizeFieldName(field: string): string {
// Solo permite caracteres alfanuméricos y guiones bajos
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(field)) {
return this.config.defaultSortField || 'created_at';
}
return field;
}
/**
* Construye un INSERT dinámico
*/
protected buildInsertQuery(
data: Record<string, any>,
additionalFields: Record<string, any> = {}
): { sql: string; params: any[] } {
const allData = { ...data, ...additionalFields };
const fields = Object.keys(allData);
const values = Object.values(allData);
const placeholders = fields.map((_, i) => `$${i + 1}`);
const sql = `
INSERT INTO ${this.fullTableName} (${fields.join(', ')})
VALUES (${placeholders.join(', ')})
RETURNING ${this.config.selectFields}
`;
return { sql, params: values };
}
/**
* Construye un UPDATE dinámico
*/
protected buildUpdateQuery(
id: string,
tenantId: string,
data: Record<string, any>
): { sql: string; params: any[] } {
const fields = Object.keys(data).filter(k => data[k] !== undefined);
const setClauses = fields.map((f, i) => `${f} = $${i + 1}`);
const values = fields.map(f => data[f]);
// Agregar updated_at automáticamente
setClauses.push(`updated_at = CURRENT_TIMESTAMP`);
const paramIndex = fields.length + 1;
const sql = `
UPDATE ${this.fullTableName}
SET ${setClauses.join(', ')}
WHERE id = $${paramIndex} AND tenant_id = $${paramIndex + 1}
RETURNING ${this.config.selectFields}
`;
return { sql, params: [...values, id, tenantId] };
}
/**
* 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;