- Add class-validator and class-transformer dependencies - Fix inventory entities index.ts exports - Add ConflictError to shared types - Fix ai.service.ts quota field names - Fix audit.service.ts field names and remove missing methods - Fix storage.service.ts bucket and file field names - Rewrite partners.service.ts/controller.ts to match entity - Fix product.entity.ts computed column syntax - Fix inventory-adjustment-line.entity.ts computed column - Fix webhooks.service.ts field names - Fix whatsapp.service.ts order field names - Fix swagger.config.ts import.meta.url issue Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
265 lines
7.8 KiB
TypeScript
265 lines
7.8 KiB
TypeScript
import { Repository, FindOptionsWhere, In, LessThan } from 'typeorm';
|
|
import { WebhookEventType, WebhookEndpoint, WebhookDelivery, WebhookEvent } from '../entities';
|
|
|
|
export class WebhooksService {
|
|
constructor(
|
|
private readonly eventTypeRepository: Repository<WebhookEventType>,
|
|
private readonly endpointRepository: Repository<WebhookEndpoint>,
|
|
private readonly deliveryRepository: Repository<WebhookDelivery>,
|
|
private readonly eventRepository: Repository<WebhookEvent>
|
|
) {}
|
|
|
|
// ============================================
|
|
// EVENT TYPES
|
|
// ============================================
|
|
|
|
async findAllEventTypes(): Promise<WebhookEventType[]> {
|
|
return this.eventTypeRepository.find({
|
|
where: { isActive: true },
|
|
order: { category: 'ASC', code: 'ASC' },
|
|
});
|
|
}
|
|
|
|
async findEventTypeByCode(code: string): Promise<WebhookEventType | null> {
|
|
return this.eventTypeRepository.findOne({ where: { code } });
|
|
}
|
|
|
|
async findEventTypesByCategory(category: string): Promise<WebhookEventType[]> {
|
|
return this.eventTypeRepository.find({
|
|
where: { category: category as any, isActive: true },
|
|
order: { code: 'ASC' },
|
|
});
|
|
}
|
|
|
|
// ============================================
|
|
// ENDPOINTS
|
|
// ============================================
|
|
|
|
async findAllEndpoints(tenantId: string): Promise<WebhookEndpoint[]> {
|
|
return this.endpointRepository.find({
|
|
where: { tenantId },
|
|
order: { name: 'ASC' },
|
|
});
|
|
}
|
|
|
|
async findActiveEndpoints(tenantId: string): Promise<WebhookEndpoint[]> {
|
|
return this.endpointRepository.find({
|
|
where: { tenantId, isActive: true },
|
|
order: { name: 'ASC' },
|
|
});
|
|
}
|
|
|
|
async findEndpoint(id: string): Promise<WebhookEndpoint | null> {
|
|
return this.endpointRepository.findOne({ where: { id } });
|
|
}
|
|
|
|
async findEndpointByUrl(tenantId: string, url: string): Promise<WebhookEndpoint | null> {
|
|
return this.endpointRepository.findOne({ where: { tenantId, url } });
|
|
}
|
|
|
|
async createEndpoint(
|
|
tenantId: string,
|
|
data: Partial<WebhookEndpoint>,
|
|
createdBy?: string
|
|
): Promise<WebhookEndpoint> {
|
|
const endpoint = this.endpointRepository.create({
|
|
...data,
|
|
tenantId,
|
|
createdBy,
|
|
});
|
|
return this.endpointRepository.save(endpoint);
|
|
}
|
|
|
|
async updateEndpoint(
|
|
id: string,
|
|
data: Partial<WebhookEndpoint>,
|
|
updatedBy?: string
|
|
): Promise<WebhookEndpoint | null> {
|
|
const endpoint = await this.findEndpoint(id);
|
|
if (!endpoint) return null;
|
|
|
|
Object.assign(endpoint, data, { updatedBy });
|
|
return this.endpointRepository.save(endpoint);
|
|
}
|
|
|
|
async deleteEndpoint(id: string): Promise<boolean> {
|
|
const result = await this.endpointRepository.softDelete(id);
|
|
return (result.affected ?? 0) > 0;
|
|
}
|
|
|
|
async toggleEndpoint(id: string, isActive: boolean): Promise<WebhookEndpoint | null> {
|
|
const endpoint = await this.findEndpoint(id);
|
|
if (!endpoint) return null;
|
|
|
|
endpoint.isActive = isActive;
|
|
return this.endpointRepository.save(endpoint);
|
|
}
|
|
|
|
async findEndpointsForEvent(tenantId: string, eventTypeCode: string): Promise<WebhookEndpoint[]> {
|
|
return this.endpointRepository
|
|
.createQueryBuilder('endpoint')
|
|
.where('endpoint.tenant_id = :tenantId', { tenantId })
|
|
.andWhere('endpoint.is_active = true')
|
|
.andWhere(':eventTypeCode = ANY(endpoint.subscribed_events)', { eventTypeCode })
|
|
.getMany();
|
|
}
|
|
|
|
async updateEndpointHealth(
|
|
id: string,
|
|
isHealthy: boolean,
|
|
consecutiveFailures: number
|
|
): Promise<void> {
|
|
// Update using available fields on WebhookEndpoint entity
|
|
// isHealthy maps to isActive, tracking failures via delivery stats
|
|
await this.endpointRepository.update(id, {
|
|
isActive: isHealthy,
|
|
failedDeliveries: consecutiveFailures > 0 ? consecutiveFailures : undefined,
|
|
});
|
|
}
|
|
|
|
// ============================================
|
|
// EVENTS
|
|
// ============================================
|
|
|
|
async createEvent(tenantId: string, data: Partial<WebhookEvent>): Promise<WebhookEvent> {
|
|
const event = this.eventRepository.create({
|
|
...data,
|
|
tenantId,
|
|
status: 'pending',
|
|
});
|
|
return this.eventRepository.save(event);
|
|
}
|
|
|
|
async findEvent(id: string): Promise<WebhookEvent | null> {
|
|
return this.eventRepository.findOne({
|
|
where: { id },
|
|
relations: ['deliveries'],
|
|
});
|
|
}
|
|
|
|
async findPendingEvents(limit: number = 100): Promise<WebhookEvent[]> {
|
|
return this.eventRepository.find({
|
|
where: { status: 'pending' },
|
|
order: { createdAt: 'ASC' },
|
|
take: limit,
|
|
});
|
|
}
|
|
|
|
async updateEventStatus(id: string, status: string): Promise<void> {
|
|
const updates: Partial<WebhookEvent> = { status: status as any };
|
|
if (status === 'processed') {
|
|
updates.processedAt = new Date();
|
|
}
|
|
await this.eventRepository.update(id, updates);
|
|
}
|
|
|
|
// ============================================
|
|
// DELIVERIES
|
|
// ============================================
|
|
|
|
async createDelivery(data: Partial<WebhookDelivery>): Promise<WebhookDelivery> {
|
|
const delivery = this.deliveryRepository.create({
|
|
...data,
|
|
status: 'pending',
|
|
});
|
|
return this.deliveryRepository.save(delivery);
|
|
}
|
|
|
|
async findDelivery(id: string): Promise<WebhookDelivery | null> {
|
|
return this.deliveryRepository.findOne({ where: { id } });
|
|
}
|
|
|
|
async findDeliveriesForEvent(eventId: string): Promise<WebhookDelivery[]> {
|
|
return this.deliveryRepository.find({
|
|
where: { eventId },
|
|
order: { attemptNumber: 'ASC' },
|
|
});
|
|
}
|
|
|
|
async findDeliveriesForEndpoint(
|
|
endpointId: string,
|
|
limit: number = 50
|
|
): Promise<WebhookDelivery[]> {
|
|
return this.deliveryRepository.find({
|
|
where: { endpointId },
|
|
order: { createdAt: 'DESC' },
|
|
take: limit,
|
|
relations: ['event'],
|
|
});
|
|
}
|
|
|
|
async updateDeliveryResult(
|
|
id: string,
|
|
status: string,
|
|
responseStatus?: number,
|
|
responseBody?: string,
|
|
duration?: number,
|
|
errorMessage?: string
|
|
): Promise<void> {
|
|
const updates: Partial<WebhookDelivery> = {
|
|
status: status as any,
|
|
responseStatus,
|
|
responseBody,
|
|
responseTimeMs: duration,
|
|
errorMessage,
|
|
completedAt: new Date(),
|
|
};
|
|
|
|
await this.deliveryRepository.update(id, updates);
|
|
}
|
|
|
|
async findPendingRetries(limit: number = 100): Promise<WebhookDelivery[]> {
|
|
const now = new Date();
|
|
return this.deliveryRepository.find({
|
|
where: {
|
|
status: 'pending',
|
|
nextRetryAt: LessThan(now),
|
|
},
|
|
order: { nextRetryAt: 'ASC' },
|
|
take: limit,
|
|
});
|
|
}
|
|
|
|
async scheduleRetry(id: string, nextRetryAt: Date, attemptNumber: number): Promise<void> {
|
|
await this.deliveryRepository.update(id, {
|
|
nextRetryAt,
|
|
attemptNumber,
|
|
status: 'pending',
|
|
});
|
|
}
|
|
|
|
// ============================================
|
|
// STATISTICS
|
|
// ============================================
|
|
|
|
async getEndpointStats(
|
|
endpointId: string,
|
|
startDate: Date,
|
|
endDate: Date
|
|
): Promise<{
|
|
total: number;
|
|
success: number;
|
|
failed: number;
|
|
avgDuration: number;
|
|
}> {
|
|
const result = await this.deliveryRepository
|
|
.createQueryBuilder('d')
|
|
.select('COUNT(*)', 'total')
|
|
.addSelect('SUM(CASE WHEN d.status = :success THEN 1 ELSE 0 END)', 'success')
|
|
.addSelect('SUM(CASE WHEN d.status = :failed THEN 1 ELSE 0 END)', 'failed')
|
|
.addSelect('AVG(d.duration_ms)', 'avgDuration')
|
|
.where('d.endpoint_id = :endpointId', { endpointId })
|
|
.andWhere('d.created_at BETWEEN :startDate AND :endDate', { startDate, endDate })
|
|
.setParameter('success', 'success')
|
|
.setParameter('failed', 'failed')
|
|
.getRawOne();
|
|
|
|
return {
|
|
total: parseInt(result.total) || 0,
|
|
success: parseInt(result.success) || 0,
|
|
failed: parseInt(result.failed) || 0,
|
|
avgDuration: parseFloat(result.avgDuration) || 0,
|
|
};
|
|
}
|
|
}
|