[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:
parent
54bb752246
commit
f08e60b520
@ -1,415 +1,429 @@
|
||||
import {
|
||||
Repository,
|
||||
FindOptionsWhere,
|
||||
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>) {}
|
||||
import { query, queryOne, getClient, PoolClient } from '../../config/database.js';
|
||||
import { NotFoundError, ValidationError } from '../errors/index.js';
|
||||
import { PaginationMeta } from '../types/index.js';
|
||||
|
||||
/**
|
||||
* Find all entities for a tenant with optional pagination and filters
|
||||
* 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;
|
||||
|
||||
/**
|
||||
* 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<string, any> = {},
|
||||
options: QueryOptions = {}
|
||||
): Promise<PaginatedResult<T>> {
|
||||
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<string, string> = {};
|
||||
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,
|
||||
pagination: {
|
||||
page: pagination.page,
|
||||
limit: pagination.limit,
|
||||
data: dataResult.rows as T[],
|
||||
total,
|
||||
totalPages,
|
||||
hasNext: pagination.page < totalPages,
|
||||
hasPrev: pagination.page > 1,
|
||||
},
|
||||
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 entity by ID
|
||||
* Obtiene un registro por ID
|
||||
*/
|
||||
async findById(
|
||||
tenantId: string,
|
||||
id: string,
|
||||
relations?: string[]
|
||||
tenantId: string,
|
||||
options: QueryOptions = {}
|
||||
): Promise<T | null> {
|
||||
const where = { tenantId, id } as FindOptionsWhere<T>;
|
||||
const relationsOption = relations
|
||||
? (relations.reduce((acc, rel) => ({ ...acc, [rel]: true }), {}) as FindOptionsRelations<T>)
|
||||
: undefined;
|
||||
let whereClause = 'WHERE id = $1 AND tenant_id = $2';
|
||||
|
||||
return this.repository.findOne({
|
||||
where,
|
||||
relations: relationsOption,
|
||||
});
|
||||
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<T>(sql, [id, tenantId]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find entities by a specific field
|
||||
* Obtiene un registro por ID o lanza error si no existe
|
||||
*/
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create multiple entities
|
||||
*/
|
||||
async createMany(
|
||||
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);
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an entity
|
||||
*/
|
||||
async update(
|
||||
tenantId: string,
|
||||
async findByIdOrFail(
|
||||
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,
|
||||
ids: string[]
|
||||
): Promise<ServiceResult<number>> {
|
||||
try {
|
||||
const result = await this.repository.delete({
|
||||
tenantId,
|
||||
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,
|
||||
},
|
||||
};
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if entity exists
|
||||
* Verifica si existe un registro
|
||||
*/
|
||||
async exists(tenantId: string, id: string): Promise<boolean> {
|
||||
const count = await this.repository.count({
|
||||
where: { tenantId, id } as FindOptionsWhere<T>,
|
||||
});
|
||||
return count > 0;
|
||||
async exists(
|
||||
id: string,
|
||||
tenantId: string,
|
||||
options: QueryOptions = {}
|
||||
): 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(
|
||||
tenantId: string,
|
||||
filters?: FilterCondition[]
|
||||
filters: Record<string, any> = {},
|
||||
options: QueryOptions = {}
|
||||
): Promise<number> {
|
||||
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<T>,
|
||||
filters: FilterCondition[]
|
||||
): void {
|
||||
filters.forEach((filter, index) => {
|
||||
const paramName = `filter${index}`;
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
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],
|
||||
/**
|
||||
* 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';
|
||||
}
|
||||
);
|
||||
break;
|
||||
case 'isNull':
|
||||
queryBuilder.andWhere(`entity.${filter.field} IS NULL`);
|
||||
break;
|
||||
case 'isNotNull':
|
||||
queryBuilder.andWhere(`entity.${filter.field} IS NOT NULL`);
|
||||
break;
|
||||
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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user