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

@@ -275,6 +275,33 @@ export class SaveFilterInput {
criteria!: SavedFilterCriteriaInput;
}
@InputType()
export class CreateWebhookInput {
@Field(() => ID) @IsUUID() projectId!: string;
@Field() @IsUrl({ require_tld: false, require_protocol: true }) url!: string;
@Field(() => [String], { description: 'Event names, family.* or *' })
@IsArray()
@ArrayMaxSize(50)
@IsString({ each: true })
events!: string[];
}
@InputType()
export class UpdateWebhookInput {
@Field(() => ID) @IsUUID() webhookId!: string;
@Field({ nullable: true })
@IsOptional()
@IsUrl({ require_tld: false, require_protocol: true })
url?: string;
@Field(() => [String], { nullable: true })
@IsOptional()
@IsArray()
@ArrayMaxSize(50)
@IsString({ each: true })
events?: string[];
@Field({ nullable: true }) @IsOptional() @IsBoolean() active?: boolean;
}
@InputType()
export class RankIssueInput {
@Field(() => ID) @IsUUID() issueId!: string;

View File

@@ -170,6 +170,42 @@ export class GitIntegrationGql {
webhookPath!: string;
}
@ObjectType('WebhookDelivery')
export class WebhookDeliveryGql {
@Field() at!: string;
@Field() event!: string;
@Field(() => Int, { nullable: true, description: 'HTTP status' })
status!: number | null;
@Field() ok!: boolean;
@Field(() => String, { nullable: true }) error!: string | null;
}
@ObjectType('Webhook', {
description:
'Where the project tells another system what happened (signed POST, HMAC-SHA256)',
})
export class WebhookGql {
@Field(() => ID) id!: string;
@Field(() => ID) projectId!: string;
@Field() url!: string;
@Field(() => [String], { description: 'Event names, family.* or *' })
events!: string[];
@Field() active!: boolean;
@Field(() => UserRefGql) createdBy!: UserRefGql;
@Field() createdAt!: string;
@Field(() => WebhookDeliveryGql, { nullable: true })
lastDelivery!: WebhookDeliveryGql | null;
}
@ObjectType('WebhookCreated')
export class WebhookCreatedGql {
@Field(() => ID) id!: string;
@Field({
description: 'Signs every delivery. Shown only now: keep it.',
})
secret!: string;
}
@ObjectType('IssueParent', { description: 'The ticket another one hangs from' })
export class IssueParentGql {
@Field(() => ID) id!: string;

View File

@@ -0,0 +1,72 @@
import { Args, ID, Mutation, Query, Resolver } from '@nestjs/graphql';
import { User } from '@task-stream/api/identity/domain';
import { CurrentUser } from '@task-stream/api/identity/presenters';
import { WebhooksService } from '@task-stream/api/tracker/application';
import { CreateWebhookInput, UpdateWebhookInput } from './inputs';
import { WebhookCreatedGql, WebhookDeliveryGql, WebhookGql } from './types';
/** Outgoing webhooks of a project (owners and admins). */
@Resolver(() => WebhookGql)
export class WebhookResolver {
constructor(private readonly webhooksService: WebhooksService) {}
@Query(() => [WebhookGql])
webhooks(
@CurrentUser() me: User,
@Args('projectId', { type: () => ID }) projectId: string,
) {
return this.webhooksService.list(me.id, projectId);
}
@Query(() => [String], {
description: 'Event names a webhook can subscribe to (also family.* and *)',
})
webhookEvents() {
return this.webhooksService.events();
}
@Mutation(() => WebhookCreatedGql)
createWebhook(
@CurrentUser() me: User,
@Args('input') input: CreateWebhookInput,
) {
return this.webhooksService.create(
me.id,
input.projectId,
input.url,
input.events,
);
}
@Mutation(() => WebhookGql)
updateWebhook(
@CurrentUser() me: User,
@Args('input') input: UpdateWebhookInput,
) {
const { webhookId, url, events, active } = input;
return this.webhooksService.update(me.id, webhookId, {
...(url != null && { url }),
...(events != null && { events }),
...(active != null && { active }),
});
}
@Mutation(() => Boolean)
async deleteWebhook(
@CurrentUser() me: User,
@Args('webhookId', { type: () => ID }) webhookId: string,
) {
await this.webhooksService.delete(me.id, webhookId);
return true;
}
@Mutation(() => WebhookDeliveryGql, {
description: 'Send a ping now and tell how it went',
})
testWebhook(
@CurrentUser() me: User,
@Args('webhookId', { type: () => ID }) webhookId: string,
) {
return this.webhooksService.test(me.id, webhookId);
}
}