File

src/webhooks/webhook.service.ts

Index

Methods

Constructor

constructor(prisma: PrismaService)
Parameters :
Name Type Optional
prisma PrismaService No

Methods

Async createWebhook
createWebhook(orgId: string, url: string, events: string[], secret?: string)

Create a new webhook subscription.

Parameters :
Name Type Optional
orgId string No
url string No
events string[] No
secret string Yes
Returns : unknown
Async deleteWebhook
deleteWebhook(id: string, orgId: string)

Delete a webhook.

Parameters :
Name Type Optional
id string No
orgId string No
Returns : unknown
Async fireEvent
fireEvent(orgId: string, event: string, payload: any)

Fire an event to all matching webhooks for an organization. POSTs the payload with HMAC-SHA256 signature in X-Webhook-Signature header.

Parameters :
Name Type Optional
orgId string No
event string No
payload any No
Returns : Promise<void>
Async listWebhooks
listWebhooks(orgId: string)

List all webhooks for an organization.

Parameters :
Name Type Optional
orgId string No
Returns : unknown
Async testWebhook
testWebhook(id: string, orgId: string)

Send a test ping to a webhook.

Parameters :
Name Type Optional
id string No
orgId string No
Returns : unknown
import { Injectable, NotFoundException, Logger } from '@nestjs/common';
import * as crypto from 'crypto';
import { PrismaService } from '../prisma/prisma.service';

@Injectable()
export class WebhookService {
  private readonly logger = new Logger(WebhookService.name);

  constructor(private prisma: PrismaService) {}

  /**
   * Fire an event to all matching webhooks for an organization.
   * POSTs the payload with HMAC-SHA256 signature in X-Webhook-Signature header.
   */
  async fireEvent(
    orgId: string,
    event: string,
    payload: any,
  ): Promise<void> {
    const webhooks = await this.prisma.webhook.findMany({
      where: {
        organizationId: orgId,
        isActive: true,
        events: { has: event },
      },
    });

    for (const webhook of webhooks) {
      this.deliverWebhook(webhook, event, payload).catch((err) => {
        this.logger.error(
          `Failed to deliver webhook ${webhook.id} for event ${event}: ${err.message}`,
        );
      });
    }
  }

  private async deliverWebhook(
    webhook: any,
    event: string,
    payload: any,
  ): Promise<void> {
    const body = JSON.stringify({
      event,
      timestamp: new Date().toISOString(),
      data: payload,
    });

    const signature = crypto
      .createHmac('sha256', webhook.secret)
      .update(body)
      .digest('hex');

    try {
      const response = await fetch(webhook.url, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'X-Webhook-Signature': signature,
          'X-Webhook-Event': event,
        },
        body,
        signal: AbortSignal.timeout(10000),
      });

      if (!response.ok) {
        throw new Error(`HTTP ${response.status} ${response.statusText}`);
      }

      await this.prisma.webhook.update({
        where: { id: webhook.id },
        data: { lastTriggeredAt: new Date(), failCount: 0 },
      });
    } catch (error) {
      const newFailCount = webhook.failCount + 1;
      const updateData: any = { failCount: { increment: 1 } };

      // Disable after 10 consecutive failures
      if (newFailCount >= 10) {
        updateData.isActive = false;
        this.logger.warn(
          `Webhook ${webhook.id} disabled after ${newFailCount} consecutive failures`,
        );
      }

      await this.prisma.webhook.update({
        where: { id: webhook.id },
        data: updateData,
      });
    }
  }

  /**
   * Create a new webhook subscription.
   */
  async createWebhook(
    orgId: string,
    url: string,
    events: string[],
    secret?: string,
  ) {
    const webhookSecret =
      secret || crypto.randomBytes(32).toString('hex');

    return this.prisma.webhook.create({
      data: {
        organizationId: orgId,
        url,
        secret: webhookSecret,
        events,
      },
    });
  }

  /**
   * List all webhooks for an organization.
   */
  async listWebhooks(orgId: string) {
    return this.prisma.webhook.findMany({
      where: { organizationId: orgId },
      select: {
        id: true,
        url: true,
        events: true,
        isActive: true,
        failCount: true,
        lastTriggeredAt: true,
        createdAt: true,
      },
      orderBy: { createdAt: 'desc' },
    });
  }

  /**
   * Delete a webhook.
   */
  async deleteWebhook(id: string, orgId: string) {
    const webhook = await this.prisma.webhook.findFirst({
      where: { id, organizationId: orgId },
    });

    if (!webhook) {
      throw new NotFoundException('Webhook not found');
    }

    return this.prisma.webhook.delete({ where: { id } });
  }

  /**
   * Send a test ping to a webhook.
   */
  async testWebhook(id: string, orgId: string) {
    const webhook = await this.prisma.webhook.findFirst({
      where: { id, organizationId: orgId },
    });

    if (!webhook) {
      throw new NotFoundException('Webhook not found');
    }

    const body = JSON.stringify({
      event: 'webhook.test',
      timestamp: new Date().toISOString(),
      data: { message: 'This is a test ping from FleetCommand TMS' },
    });

    const signature = crypto
      .createHmac('sha256', webhook.secret)
      .update(body)
      .digest('hex');

    try {
      const response = await fetch(webhook.url, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'X-Webhook-Signature': signature,
          'X-Webhook-Event': 'webhook.test',
        },
        body,
        signal: AbortSignal.timeout(10000),
      });

      return {
        success: response.ok,
        statusCode: response.status,
        statusText: response.statusText,
      };
    } catch (error: any) {
      return {
        success: false,
        error: error.message,
      };
    }
  }
}

results matching ""

    No results matching ""