From c3200bc53eadbe923bf372c0ea62993f468a4129 Mon Sep 17 00:00:00 2001 From: Adrian Flores Cortes Date: Sun, 25 Jan 2026 06:43:25 -0600 Subject: [PATCH] [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 --- src/shared/services/base.service.ts | 560 +++++++++++++++++++--------- 1 file changed, 386 insertions(+), 174 deletions(-) diff --git a/src/shared/services/base.service.ts b/src/shared/services/base.service.ts index 41a87ae..e368b92 100644 --- a/src/shared/services/base.service.ts +++ b/src/shared/services/base.service.ts @@ -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 { 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 { - constructor(protected readonly repository: Repository) {} +/** + * 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 { + * 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 { + 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 } + tenantId: string, + filters: BasePaginationFilters & Record = {}, + options: QueryOptions = {} ): Promise> { - 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; + 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(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 { - return this.repository.findOne({ - where: { - id, - tenantId: ctx.tenantId, - deletedAt: null, - } as unknown as FindOptionsWhere, - }); - } - - /** - * Find one record by criteria - */ - async findOne( - ctx: ServiceContext, - where: FindOptionsWhere - ): Promise { - return this.repository.findOne({ - where: { - tenantId: ctx.tenantId, - deletedAt: null, - ...where, - } as FindOptionsWhere, - }); - } - - /** - * Find records by custom options - */ - async find( - ctx: ServiceContext, - options: FindManyOptions - ): Promise { - return this.repository.find({ - ...options, - where: { - tenantId: ctx.tenantId, - deletedAt: null, - ...(options.where || {}), - } as FindOptionsWhere, - }); - } - - /** - * Create a new record - */ - async create( - ctx: ServiceContext, - data: DeepPartial - ): Promise { - const entity = this.repository.create({ - ...data, - tenantId: ctx.tenantId, - createdById: ctx.userId, - } as DeepPartial); - - return this.repository.save(entity); - } - - /** - * Update an existing record - */ - async update( - ctx: ServiceContext, + async findById( id: string, - data: DeepPartial + tenantId: string, + options: QueryOptions = {} ): Promise { - 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); + 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 { - 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, - { - deletedAt: new Date(), - deletedById: ctx.userId, - } as any - ); - - return true; + const rows = await query(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 { - const result = await this.repository.delete({ - id, - tenantId: ctx.tenantId, - } as unknown as FindOptionsWhere); - - return (result.affected ?? 0) > 0; + async findByIdOrFail( + id: string, + tenantId: string, + options: QueryOptions = {} + ): Promise { + 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 - ): Promise { - return this.repository.count({ - where: { - tenantId: ctx.tenantId, - deletedAt: null, - ...where, - } as unknown as FindOptionsWhere, - }); - } - - /** - * Check if a record exists + * Verifica si existe un registro */ async exists( - ctx: ServiceContext, - where: FindOptionsWhere + id: string, + tenantId: string, + options: QueryOptions = {} ): Promise { - 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 { + 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 { + 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 = {}, + options: QueryOptions = {} + ): Promise { + 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( + fn: (client: PoolClient) => Promise + ): Promise { + 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, + additionalFields: Record = {} + ): { 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 + ): { 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;