import { Repository, FindOptionsWhere, In, LessThan } from 'typeorm'; import { WebhookEventType, WebhookEndpoint, WebhookDelivery, WebhookEvent } from '../entities'; export class WebhooksService { constructor( private readonly eventTypeRepository: Repository, private readonly endpointRepository: Repository, private readonly deliveryRepository: Repository, private readonly eventRepository: Repository ) {} // ============================================ // EVENT TYPES // ============================================ async findAllEventTypes(): Promise { return this.eventTypeRepository.find({ where: { isActive: true }, order: { category: 'ASC', code: 'ASC' }, }); } async findEventTypeByCode(code: string): Promise { return this.eventTypeRepository.findOne({ where: { code } }); } async findEventTypesByCategory(category: string): Promise { return this.eventTypeRepository.find({ where: { category: category as any, isActive: true }, order: { code: 'ASC' }, }); } // ============================================ // ENDPOINTS // ============================================ async findAllEndpoints(tenantId: string): Promise { return this.endpointRepository.find({ where: { tenantId }, order: { name: 'ASC' }, }); } async findActiveEndpoints(tenantId: string): Promise { return this.endpointRepository.find({ where: { tenantId, isActive: true }, order: { name: 'ASC' }, }); } async findEndpoint(id: string): Promise { return this.endpointRepository.findOne({ where: { id } }); } async findEndpointByUrl(tenantId: string, url: string): Promise { return this.endpointRepository.findOne({ where: { tenantId, url } }); } async createEndpoint( tenantId: string, data: Partial, createdBy?: string ): Promise { const endpoint = this.endpointRepository.create({ ...data, tenantId, createdBy, }); return this.endpointRepository.save(endpoint); } async updateEndpoint( id: string, data: Partial, updatedBy?: string ): Promise { 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 { const result = await this.endpointRepository.softDelete(id); return (result.affected ?? 0) > 0; } async toggleEndpoint(id: string, isActive: boolean): Promise { 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 { 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 { // 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): Promise { const event = this.eventRepository.create({ ...data, tenantId, status: 'pending', }); return this.eventRepository.save(event); } async findEvent(id: string): Promise { return this.eventRepository.findOne({ where: { id }, relations: ['deliveries'], }); } async findPendingEvents(limit: number = 100): Promise { return this.eventRepository.find({ where: { status: 'pending' }, order: { createdAt: 'ASC' }, take: limit, }); } async updateEventStatus(id: string, status: string): Promise { const updates: Partial = { status: status as any }; if (status === 'processed') { updates.processedAt = new Date(); } await this.eventRepository.update(id, updates); } // ============================================ // DELIVERIES // ============================================ async createDelivery(data: Partial): Promise { const delivery = this.deliveryRepository.create({ ...data, status: 'pending', }); return this.deliveryRepository.save(delivery); } async findDelivery(id: string): Promise { return this.deliveryRepository.findOne({ where: { id } }); } async findDeliveriesForEvent(eventId: string): Promise { return this.deliveryRepository.find({ where: { eventId }, order: { attemptNumber: 'ASC' }, }); } async findDeliveriesForEndpoint( endpointId: string, limit: number = 50 ): Promise { 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 { const updates: Partial = { status: status as any, responseStatus, responseBody, responseTimeMs: duration, errorMessage, completedAt: new Date(), }; await this.deliveryRepository.update(id, updates); } async findPendingRetries(limit: number = 100): Promise { 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 { 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, }; } }