diff --git a/src/shared/services/base.service.ts b/src/shared/services/base.service.ts new file mode 100644 index 0000000..e368b92 --- /dev/null +++ b/src/shared/services/base.service.ts @@ -0,0 +1,429 @@ +import { query, queryOne, getClient, PoolClient } from '../../config/database.js'; +import { NotFoundError, ValidationError } from '../errors/index.js'; +import { PaginationMeta } from '../types/index.js'; + +/** + * 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; + + /** + * 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, + filters: BasePaginationFilters & Record = {}, + options: QueryOptions = {} + ): Promise> { + const { + page = 1, + limit = 20, + sortBy = this.config.defaultSortField || 'created_at', + sortOrder = 'desc', + search, + ...customFilters + } = filters; + + const offset = (page - 1) * limit; + const params: any[] = [tenantId]; + let paramIndex = 2; + + // Construir WHERE clause + let whereClause = 'WHERE tenant_id = $1'; + + // 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), + }; + } + + /** + * Obtiene un registro por ID + */ + async findById( + 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 ${this.config.selectFields} + FROM ${this.fullTableName} + ${whereClause} + `; + + 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; + } + + /** + * Obtiene un registro por ID o lanza error si no existe + */ + 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; + } + + /** + * Verifica si existe un registro + */ + 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; + } + + /** + * 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;