diff --git a/src/shared/services/base.service.ts b/src/shared/services/base.service.ts index 7a2763c..e368b92 100644 --- a/src/shared/services/base.service.ts +++ b/src/shared/services/base.service.ts @@ -1,415 +1,429 @@ -import { - Repository, - FindOptionsWhere, - FindOptionsOrder, - FindOptionsRelations, - SelectQueryBuilder, - DeepPartial, - In, -} from 'typeorm'; -import { - PaginationParams, - PaginatedResult, - FilterCondition, - QueryOptions, - ServiceResult, -} from '../types'; +import { query, queryOne, getClient, PoolClient } from '../../config/database.js'; +import { NotFoundError, ValidationError } from '../errors/index.js'; +import { PaginationMeta } from '../types/index.js'; -export abstract class BaseService { - constructor(protected readonly repository: Repository) {} +/** + * Resultado paginado genérico + */ +export interface PaginatedResult { + 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 { + * 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 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( tenantId: string, - options?: QueryOptions + filters: BasePaginationFilters & Record = {}, + options: QueryOptions = {} ): Promise> { const { - pagination = { page: 1, limit: 20 }, - filters = [], + page = 1, + limit = 20, + sortBy = this.config.defaultSortField || 'created_at', + sortOrder = 'desc', search, - relations = [], - select, - } = options || {}; + ...customFilters + } = filters; - const queryBuilder = this.repository.createQueryBuilder('entity'); + const offset = (page - 1) * limit; + const params: any[] = [tenantId]; + let paramIndex = 2; - // Always filter by tenant - queryBuilder.where('entity.tenantId = :tenantId', { tenantId }); + // Construir WHERE clause + let whereClause = 'WHERE tenant_id = $1'; - // Apply filters - this.applyFilters(queryBuilder, filters); + // Soft delete + if (this.config.softDelete && !options.includeDeleted) { + whereClause += ' AND deleted_at IS NULL'; + } - // Apply search - if (search && search.term && search.fields.length > 0) { - const searchConditions = search.fields - .map((field, index) => `entity.${field} ILIKE :search${index}`) + // Búsqueda por texto + if (search && this.config.searchFields?.length) { + const searchConditions = this.config.searchFields + .map(field => `${field} ILIKE $${paramIndex}`) .join(' OR '); - - const searchParams: Record = {}; - search.fields.forEach((_, index) => { - searchParams[`search${index}`] = `%${search.term}%`; - }); - - queryBuilder.andWhere(`(${searchConditions})`, searchParams); + whereClause += ` AND (${searchConditions})`; + params.push(`%${search}%`); + paramIndex++; } - // Apply relations - relations.forEach((relation) => { - queryBuilder.leftJoinAndSelect(`entity.${relation}`, relation); - }); - - // Apply select - if (select && select.length > 0) { - queryBuilder.select(select.map((s) => `entity.${s}`)); + // Filtros custom + for (const [key, value] of Object.entries(customFilters)) { + if (value !== undefined && value !== null && value !== '') { + whereClause += ` AND ${key} = $${paramIndex++}`; + params.push(value); + } } - // Get total count before pagination - const total = await queryBuilder.getCount(); + // Validar sortBy para prevenir SQL injection + const safeSortBy = this.sanitizeFieldName(sortBy); + const safeSortOrder = sortOrder === 'asc' ? 'ASC' : 'DESC'; - // Apply sorting - const sortBy = pagination.sortBy || 'createdAt'; - const sortOrder = pagination.sortOrder || 'DESC'; - queryBuilder.orderBy(`entity.${sortBy}`, sortOrder); + // Query de conteo + const countSql = ` + SELECT COUNT(*) as count + FROM ${this.fullTableName} + ${whereClause} + `; - // Apply pagination - const skip = (pagination.page - 1) * pagination.limit; - queryBuilder.skip(skip).take(pagination.limit); + // Query de datos + const dataSql = ` + 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(dataSql, [...params, limit, offset]), + ]); + + const total = parseInt(countRows[0]?.count || '0', 10); return { - data, - pagination: { - page: pagination.page, - limit: pagination.limit, - total, - totalPages, - hasNext: pagination.page < totalPages, - hasPrev: pagination.page > 1, - }, + data: dataRows, + total, + page, + limit, + totalPages: Math.ceil(total / limit), }; } /** - * Find one entity by ID + * Obtiene un registro por ID */ async findById( - tenantId: string, id: string, - relations?: string[] + tenantId: string, + options: QueryOptions = {} ): Promise { - const where = { tenantId, id } as FindOptionsWhere; - const relationsOption = relations - ? (relations.reduce((acc, rel) => ({ ...acc, [rel]: true }), {}) as FindOptionsRelations) - : undefined; + let whereClause = 'WHERE id = $1 AND tenant_id = $2'; - return this.repository.findOne({ - where, - relations: relationsOption, - }); - } - - /** - * Find entities by a specific field - */ - async findBy( - tenantId: string, - field: keyof T, - value: any, - relations?: string[] - ): Promise { - const where = { tenantId, [field]: value } as FindOptionsWhere; - const relationsOption = relations - ? (relations.reduce((acc, rel) => ({ ...acc, [rel]: true }), {}) as FindOptionsRelations) - : 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 { - const where = { tenantId, [field]: value } as FindOptionsWhere; - const relationsOption = relations - ? (relations.reduce((acc, rel) => ({ ...acc, [rel]: true }), {}) as FindOptionsRelations) - : undefined; - - return this.repository.findOne({ - where, - relations: relationsOption, - }); - } - - /** - * Create a new entity - */ - async create( - tenantId: string, - data: DeepPartial, - userId?: string - ): Promise> { - try { - const entity = this.repository.create({ - ...data, - tenantId, - createdBy: userId, - updatedBy: userId, - } as DeepPartial); - - 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, - }, - }; + if (this.config.softDelete && !options.includeDeleted) { + whereClause += ' AND deleted_at IS NULL'; } - } - /** - * Create multiple entities - */ - async createMany( - tenantId: string, - dataArray: DeepPartial[], - userId?: string - ): Promise> { - try { - const entities = dataArray.map((data) => - this.repository.create({ - ...data, - tenantId, - createdBy: userId, - updatedBy: userId, - } as DeepPartial) - ); + const sql = ` + SELECT ${this.config.selectFields} + FROM ${this.fullTableName} + ${whereClause} + `; - const saved = await this.repository.save(entities); - return { success: true, data: saved }; - } catch (error: any) { - return { - success: false, - error: { - code: 'CREATE_MANY_FAILED', - message: error.message || 'Failed to create entities', - details: error, - }, - }; + if (options.client) { + const result = await options.client.query(sql, [id, tenantId]); + return result.rows[0] as T || null; } + const rows = await query(sql, [id, tenantId]); + return rows[0] || null; } /** - * Update an entity + * Obtiene un registro por ID o lanza error si no existe */ - async update( - tenantId: string, + async findByIdOrFail( id: string, - data: DeepPartial, - userId?: string - ): Promise> { - 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); - - 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> { - 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, - ids: string[] - ): Promise> { - try { - const result = await this.repository.delete({ - tenantId, - id: In(ids), - } as FindOptionsWhere); - - 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, - }, - }; + 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; } /** - * Check if entity exists + * Verifica si existe un registro */ - async exists(tenantId: string, id: string): Promise { - const count = await this.repository.count({ - where: { tenantId, id } as FindOptionsWhere, - }); - return count > 0; + async exists( + id: string, + tenantId: string, + options: QueryOptions = {} + ): Promise { + 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 { + 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?: FilterCondition[] + filters: Record = {}, + options: QueryOptions = {} ): Promise { - const queryBuilder = this.repository.createQueryBuilder('entity'); - queryBuilder.where('entity.tenantId = :tenantId', { tenantId }); + const params: any[] = [tenantId]; + let paramIndex = 2; + let whereClause = 'WHERE tenant_id = $1'; - if (filters) { - this.applyFilters(queryBuilder, filters); + if (this.config.softDelete && !options.includeDeleted) { + 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( - queryBuilder: SelectQueryBuilder, - filters: FilterCondition[] - ): void { - filters.forEach((filter, index) => { - const paramName = `filter${index}`; + 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(); + } + } - switch (filter.operator) { - case 'eq': - queryBuilder.andWhere(`entity.${filter.field} = :${paramName}`, { - [paramName]: filter.value, - }); - break; - case 'ne': - queryBuilder.andWhere(`entity.${filter.field} != :${paramName}`, { - [paramName]: filter.value, - }); - break; - case 'gt': - queryBuilder.andWhere(`entity.${filter.field} > :${paramName}`, { - [paramName]: filter.value, - }); - break; - case 'gte': - queryBuilder.andWhere(`entity.${filter.field} >= :${paramName}`, { - [paramName]: filter.value, - }); - break; - case 'lt': - queryBuilder.andWhere(`entity.${filter.field} < :${paramName}`, { - [paramName]: filter.value, - }); - break; - case 'lte': - queryBuilder.andWhere(`entity.${filter.field} <= :${paramName}`, { - [paramName]: filter.value, - }); - break; - case 'like': - queryBuilder.andWhere(`entity.${filter.field} LIKE :${paramName}`, { - [paramName]: `%${filter.value}%`, - }); - break; - case 'ilike': - queryBuilder.andWhere(`entity.${filter.field} ILIKE :${paramName}`, { - [paramName]: `%${filter.value}%`, - }); - break; - case 'in': - queryBuilder.andWhere(`entity.${filter.field} IN (:...${paramName})`, { - [paramName]: filter.value, - }); - break; - case 'between': - queryBuilder.andWhere( - `entity.${filter.field} BETWEEN :${paramName}Start AND :${paramName}End`, - { - [`${paramName}Start`]: filter.value[0], - [`${paramName}End`]: filter.value[1], - } - ); - break; - case 'isNull': - queryBuilder.andWhere(`entity.${filter.field} IS NULL`); - break; - case 'isNotNull': - queryBuilder.andWhere(`entity.${filter.field} IS NOT NULL`); - break; - } - }); + /** + * 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;