feat: outgoing webhooks

Owners and admins can point a project at other systems: a signed JSON
POST (HMAC-SHA256 of the body) when tickets, comments, releases or sprints
change, filtered by event or family. Deliveries are retried a couple of
times, the last one is shown, and a ping can be sent from the settings.
Private addresses are refused unless WEBHOOKS_ALLOW_PRIVATE is set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 15:36:19 +02:00
parent 046d77c813
commit d43fc620e9
37 changed files with 3660 additions and 28 deletions

View File

@@ -0,0 +1,62 @@
import { Injectable } from '@nestjs/common';
import { CommandBus, QueryBus } from '@nestjs/cqrs';
import { WebhookDelivery } from './ports/outgoing-webhooks.repository';
import { WEBHOOK_EVENT_NAMES } from './webhooks/webhook-events';
import {
CreateWebhookCommand,
DeleteWebhookCommand,
GetWebhooksQuery,
TestWebhookCommand,
UpdateWebhookCommand,
WebhookView,
} from './webhooks/webhooks';
/** The project's outgoing webhooks (owners and admins). */
@Injectable()
export class WebhooksService {
constructor(
private readonly commandBus: CommandBus,
private readonly queryBus: QueryBus,
) {}
/** Names other systems can subscribe to (plus `family.*` and `*`). */
events(): string[] {
return WEBHOOK_EVENT_NAMES;
}
list(viewerId: string, projectId: string): Promise<WebhookView[]> {
return this.queryBus.execute(new GetWebhooksQuery(viewerId, projectId));
}
/** The secret comes back only here. */
create(
actorId: string,
projectId: string,
url: string,
events: string[],
): Promise<{ id: string; secret: string }> {
return this.commandBus.execute(
new CreateWebhookCommand(actorId, projectId, url, events),
);
}
update(
actorId: string,
webhookId: string,
changes: { url?: string; events?: string[]; active?: boolean },
): Promise<WebhookView> {
return this.commandBus.execute(
new UpdateWebhookCommand(actorId, webhookId, changes),
);
}
delete(actorId: string, webhookId: string): Promise<void> {
return this.commandBus.execute(
new DeleteWebhookCommand(actorId, webhookId),
);
}
test(actorId: string, webhookId: string): Promise<WebhookDelivery> {
return this.commandBus.execute(new TestWebhookCommand(actorId, webhookId));
}
}