@tiflux/mcp
Version:
TiFlux MCP Server - Model Context Protocol integration for Claude Code and other AI clients
466 lines (398 loc) • 14.8 kB
JavaScript
/**
* TicketValidator - Validações específicas para tickets
*
* Centraliza todas as regras de validação:
* - Dados de criação e atualização
* - Filtros de listagem
* - Business rules específicas
* - Formatos e tipos de dados
*/
class TicketValidator {
constructor(container) {
this.container = container;
this.logger = container.resolve('logger');
this.config = container.resolve('config');
}
/**
* Valida dados para criação de ticket
*/
async validateCreateData(ticketData) {
const errors = [];
this.logger.debug('Validating ticket create data', {
hasTitle: !!ticketData.title,
hasDescription: !!ticketData.description,
hasClientId: !!ticketData.client_id,
hasClientName: !!ticketData.client_name
});
// 1. Campos obrigatórios
if (!ticketData.title || ticketData.title.toString().trim() === '') {
errors.push('title é obrigatório');
}
if (!ticketData.description || ticketData.description.toString().trim() === '') {
errors.push('description é obrigatório');
}
// 2. Cliente é obrigatório (ID ou nome)
if (!ticketData.client_id && !ticketData.client_name) {
errors.push('client_id ou client_name é obrigatório');
}
// 3. Validações de formato e tamanho
if (ticketData.title) {
const title = ticketData.title.toString().trim();
if (title.length < 3) {
errors.push('title deve ter pelo menos 3 caracteres');
}
if (title.length > 255) {
errors.push('title deve ter no máximo 255 caracteres');
}
}
if (ticketData.description) {
const description = ticketData.description.toString().trim();
if (description.length < 10) {
errors.push('description deve ter pelo menos 10 caracteres');
}
if (description.length > 65535) {
errors.push('description deve ter no máximo 65535 caracteres');
}
}
// 4. Validações de IDs numéricos
const numericFields = [
'client_id',
'desk_id',
'priority_id',
'status_id',
'responsible_id',
'services_catalogs_item_id'
];
numericFields.forEach(field => {
if (ticketData[field] !== undefined && ticketData[field] !== null) {
const value = parseInt(ticketData[field]);
if (isNaN(value) || value <= 0) {
errors.push(`${field} deve ser um número inteiro positivo`);
}
}
});
// 5. Validações de strings
const stringFields = [
'client_name',
'desk_name',
'requestor_name',
'requestor_email',
'requestor_telephone',
'followers'
];
stringFields.forEach(field => {
if (ticketData[field] !== undefined && ticketData[field] !== null) {
if (typeof ticketData[field] !== 'string') {
errors.push(`${field} deve ser uma string`);
} else if (ticketData[field].trim() === '') {
errors.push(`${field} não pode estar vazio`);
}
}
});
// 6. Validações específicas
if (ticketData.requestor_email) {
if (!this._isValidEmail(ticketData.requestor_email)) {
errors.push('requestor_email deve ter um formato válido');
}
}
if (ticketData.requestor_telephone) {
if (!this._isValidPhone(ticketData.requestor_telephone)) {
errors.push('requestor_telephone deve ter um formato válido');
}
}
if (ticketData.followers) {
const emails = ticketData.followers.split(',').map(e => e.trim());
const invalidEmails = emails.filter(email => !this._isValidEmail(email));
if (invalidEmails.length > 0) {
errors.push(`followers contém emails inválidos: ${invalidEmails.join(', ')}`);
}
}
// 7. Validações de business rules
await this._validateBusinessRules(ticketData, errors, 'create');
if (errors.length > 0) {
this.logger.warn('Ticket create validation failed', { errors });
throw new ValidationError(`Dados de criação inválidos: ${errors.join(', ')}`);
}
this.logger.debug('Ticket create validation passed');
}
/**
* Valida dados para atualização de ticket
*/
async validateUpdateData(updateData) {
const errors = [];
this.logger.debug('Validating ticket update data', {
fields: Object.keys(updateData)
});
// 1. Pelo menos um campo deve ser fornecido
const allowedFields = [
'title', 'description', 'client_id', 'desk_id', 'priority_id',
'status_id', 'stage_id', 'responsible_id', 'followers',
'client_name', 'desk_name', 'stage_name', 'responsible_name',
'services_catalogs_item_id', 'catalog_item_name',
'requestor_name', 'requestor_email', 'requestor_telephone'
];
const providedFields = Object.keys(updateData).filter(key =>
allowedFields.includes(key) && updateData[key] !== undefined
);
if (providedFields.length === 0) {
errors.push('Pelo menos um campo deve ser fornecido para atualização');
}
// 2. Validações de formato (similares ao create, mas opcionais)
if (updateData.title !== undefined) {
if (updateData.title === null || updateData.title === '') {
errors.push('title não pode ser vazio');
} else {
const title = updateData.title.toString().trim();
if (title.length < 3) {
errors.push('title deve ter pelo menos 3 caracteres');
}
if (title.length > 255) {
errors.push('title deve ter no máximo 255 caracteres');
}
}
}
if (updateData.description !== undefined) {
if (updateData.description === null || updateData.description === '') {
errors.push('description não pode ser vazio');
} else {
const description = updateData.description.toString().trim();
if (description.length < 10) {
errors.push('description deve ter pelo menos 10 caracteres');
}
if (description.length > 65535) {
errors.push('description deve ter no máximo 65535 caracteres');
}
}
}
// 3. Validações de IDs numéricos
const numericFields = [
'client_id', 'desk_id', 'priority_id', 'status_id',
'stage_id', 'responsible_id', 'services_catalogs_item_id'
];
numericFields.forEach(field => {
if (updateData[field] !== undefined) {
if (updateData[field] === null) {
// null é válido para alguns campos (ex: responsible_id)
if (!['responsible_id'].includes(field)) {
errors.push(`${field} não pode ser nulo`);
}
} else {
const value = parseInt(updateData[field]);
if (isNaN(value) || value <= 0) {
errors.push(`${field} deve ser um número inteiro positivo`);
}
}
}
});
// 4. Validações específicas para atualização
if (updateData.followers !== undefined) {
if (updateData.followers !== null && updateData.followers !== '') {
const emails = updateData.followers.split(',').map(e => e.trim());
const invalidEmails = emails.filter(email => !this._isValidEmail(email));
if (invalidEmails.length > 0) {
errors.push(`followers contém emails inválidos: ${invalidEmails.join(', ')}`);
}
}
}
// 5. Validações de business rules
await this._validateBusinessRules(updateData, errors, 'update');
if (errors.length > 0) {
this.logger.warn('Ticket update validation failed', { errors });
throw new ValidationError(`Dados de atualização inválidos: ${errors.join(', ')}`);
}
this.logger.debug('Ticket update validation passed');
}
/**
* Valida filtros para listagem de tickets
*/
async validateListFilters(filters) {
const errors = [];
this.logger.debug('Validating ticket list filters', {
filters: Object.keys(filters)
});
// 1. Pelo menos um filtro obrigatório deve estar presente
const requiredFilters = ['desk_ids', 'desk_name', 'client_ids', 'stage_ids', 'stage_name', 'responsible_ids'];
const hasRequiredFilter = requiredFilters.some(filter => filters[filter]);
if (!hasRequiredFilter) {
errors.push('Pelo menos um dos filtros é obrigatório: desk_ids, desk_name, client_ids, stage_ids, stage_name ou responsible_ids');
}
// 2. Validações de formato de IDs
const idFields = ['desk_ids', 'client_ids', 'stage_ids', 'responsible_ids'];
idFields.forEach(field => {
if (filters[field]) {
const ids = filters[field].toString().split(',').map(id => id.trim());
// Máximo 15 IDs
if (ids.length > 15) {
errors.push(`${field} deve conter no máximo 15 IDs`);
}
// Todos devem ser números inteiros positivos
const invalidIds = ids.filter(id => {
const num = parseInt(id);
return isNaN(num) || num <= 0;
});
if (invalidIds.length > 0) {
errors.push(`${field} contém IDs inválidos: ${invalidIds.join(', ')}`);
}
}
});
// 3. Validações de nomes
if (filters.desk_name) {
if (typeof filters.desk_name !== 'string' || filters.desk_name.trim() === '') {
errors.push('desk_name deve ser uma string não vazia');
}
}
if (filters.stage_name) {
if (typeof filters.stage_name !== 'string' || filters.stage_name.trim() === '') {
errors.push('stage_name deve ser uma string não vazia');
}
// stage_name deve ser usado junto com desk_name
if (!filters.desk_name) {
errors.push('stage_name deve ser usado junto com desk_name');
}
}
// 4. Validações de paginação
if (filters.limit !== undefined) {
const limit = parseInt(filters.limit);
if (isNaN(limit) || limit < 1 || limit > 200) {
errors.push('limit deve ser um número entre 1 e 200');
}
}
if (filters.offset !== undefined) {
const offset = parseInt(filters.offset);
if (isNaN(offset) || offset < 1) {
errors.push('offset deve ser um número maior que 0');
}
}
// 5. Validação de is_closed
if (filters.is_closed !== undefined) {
if (typeof filters.is_closed !== 'boolean' &&
!['true', 'false', '1', '0'].includes(filters.is_closed.toString().toLowerCase())) {
errors.push('is_closed deve ser um valor booleano (true/false)');
}
}
if (errors.length > 0) {
this.logger.warn('Ticket list validation failed', { errors });
throw new ValidationError(`Filtros de listagem inválidos: ${errors.join(', ')}`);
}
this.logger.debug('Ticket list validation passed');
}
/**
* Validações de business rules específicas
*/
async _validateBusinessRules(data, errors, operation) {
// 1. Validar se client existe (se fornecido client_id)
if (data.client_id && this.container.has('clientService')) {
try {
// Será implementado quando tivermos ClientService
this.logger.debug('Client validation not implemented yet', { clientId: data.client_id });
} catch (error) {
this.logger.warn('Failed to validate client', { clientId: data.client_id, error: error.message });
// Não falha a validação por isso
}
}
// 2. Validar limites de configuração
const limits = this.config.get('validation.limits', {});
if (limits.maxTitleLength && data.title) {
const titleLength = data.title.toString().length;
if (titleLength > limits.maxTitleLength) {
errors.push(`title não pode exceder ${limits.maxTitleLength} caracteres`);
}
}
if (limits.maxDescriptionLength && data.description) {
const descriptionLength = data.description.toString().length;
if (descriptionLength > limits.maxDescriptionLength) {
errors.push(`description não pode exceder ${limits.maxDescriptionLength} caracteres`);
}
}
// 3. Validações específicas por operação
if (operation === 'create') {
// Validações específicas para criação
if (data.status_id && data.status_id !== this.config.get('defaults.initial_status_id')) {
this.logger.debug('Creating ticket with non-default status', { statusId: data.status_id });
}
}
if (operation === 'update') {
// Validações específicas para atualização
if (data.stage_id) {
// Poderiam haver validações de transição de estado aqui
this.logger.debug('Updating ticket stage', { stageId: data.stage_id });
}
}
}
/**
* Valida formato de email
*/
_isValidEmail(email) {
if (!email || typeof email !== 'string') {
return false;
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email.trim());
}
/**
* Valida formato de telefone (básico)
*/
_isValidPhone(phone) {
if (!phone || typeof phone !== 'string') {
return false;
}
// Remove espaços e caracteres especiais para validação
const cleanPhone = phone.replace(/[\s\-\(\)\.]/g, '');
// Deve ter pelo menos 8 dígitos e no máximo 15
const phoneRegex = /^[\+]?[0-9]{8,15}$/;
return phoneRegex.test(cleanPhone);
}
/**
* Sanitiza dados removendo campos não permitidos
*/
sanitizeCreateData(data) {
const allowedFields = [
'title', 'description', 'client_id', 'client_name', 'desk_id', 'desk_name',
'priority_id', 'status_id', 'responsible_id', 'services_catalogs_item_id',
'requestor_name', 'requestor_email', 'requestor_telephone', 'followers'
];
const sanitized = {};
allowedFields.forEach(field => {
if (data[field] !== undefined) {
sanitized[field] = data[field];
}
});
return sanitized;
}
/**
* Sanitiza dados de atualização
*/
sanitizeUpdateData(data) {
const allowedFields = [
'title', 'description', 'client_id', 'desk_id', 'priority_id',
'status_id', 'stage_id', 'responsible_id', 'followers',
'client_name', 'desk_name'
];
const sanitized = {};
allowedFields.forEach(field => {
if (data[field] !== undefined) {
sanitized[field] = data[field];
}
});
return sanitized;
}
/**
* Configurações de validação atuais
*/
getValidationConfig() {
return {
limits: this.config.get('validation.limits', {}),
required: {
create: ['title', 'description', 'client_id_or_name'],
update: ['at_least_one_field']
},
formats: {
email: 'RFC 5322 compliant',
phone: '8-15 digits, optional country code'
}
};
}
}
// Import das classes de erro
const { ValidationError } = require('../../utils/errors');
module.exports = TicketValidator;