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,64 @@
import { inject, Injectable } from '@angular/core';
import {
Webhook,
WebhookDelivery,
WebhooksGateway,
} from '@task-stream/web/tracker/domain';
import { map, Observable } from 'rxjs';
import {
CreateWebhookDocument,
DeleteWebhookDocument,
TestWebhookDocument,
UpdateWebhookDocument,
WebhookEventsDocument,
WebhooksDocument,
} from '../graphql/generated';
import { GraphqlOperations } from './graphql-operations';
@Injectable()
export class GraphqlWebhooksGateway extends WebhooksGateway {
private readonly gql = inject(GraphqlOperations);
list(projectId: string): Observable<Webhook[]> {
return this.gql
.query(WebhooksDocument, { projectId })
.pipe(map((d) => d.webhooks as Webhook[]));
}
events(): Observable<string[]> {
return this.gql
.query(WebhookEventsDocument, {})
.pipe(map((d) => d.webhookEvents));
}
create(
projectId: string,
url: string,
events: string[],
): Observable<{ id: string; secret: string }> {
return this.gql
.mutate(CreateWebhookDocument, { input: { projectId, url, events } })
.pipe(map((d) => d.createWebhook));
}
update(
webhookId: string,
changes: { url?: string; events?: string[]; active?: boolean },
): Observable<Webhook> {
return this.gql
.mutate(UpdateWebhookDocument, { input: { webhookId, ...changes } })
.pipe(map((d) => d.updateWebhook as Webhook));
}
remove(webhookId: string): Observable<void> {
return this.gql
.mutate(DeleteWebhookDocument, { webhookId })
.pipe(map(() => undefined));
}
test(webhookId: string): Observable<WebhookDelivery> {
return this.gql
.mutate(TestWebhookDocument, { webhookId })
.pipe(map((d) => d.testWebhook));
}
}