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 { InjectModel } from '@nestjs/mongoose';
import {
OutgoingWebhook,
OutgoingWebhooksRepository,
WebhookDelivery,
} from '@task-stream/api/tracker/application';
import { Model } from 'mongoose';
import { OutgoingWebhookDoc, ReadModels, fromDoc } from './read-model.schemas';
@Injectable()
export class MongoOutgoingWebhooksRepository extends OutgoingWebhooksRepository {
constructor(
@InjectModel(ReadModels.OutgoingWebhook)
private readonly webhooks: Model<OutgoingWebhookDoc>,
) {
super();
}
async findByProject(projectId: string): Promise<OutgoingWebhook[]> {
const docs = await this.webhooks
.find({ projectId })
.sort({ createdAt: 1 })
.lean<OutgoingWebhookDoc[]>();
return docs.map((doc) => fromDoc(doc) as OutgoingWebhook);
}
async findActive(projectId: string): Promise<OutgoingWebhook[]> {
const docs = await this.webhooks
.find({ projectId, active: true })
.lean<OutgoingWebhookDoc[]>();
return docs.map((doc) => fromDoc(doc) as OutgoingWebhook);
}
async findById(id: string): Promise<OutgoingWebhook | null> {
const doc = await this.webhooks.findById(id).lean<OutgoingWebhookDoc>();
return doc ? (fromDoc(doc) as OutgoingWebhook) : null;
}
async insert({ id, ...rest }: OutgoingWebhook): Promise<void> {
await this.webhooks.create({ _id: id, ...rest });
}
async update(
id: string,
changes: Partial<Pick<OutgoingWebhook, 'url' | 'events' | 'active'>>,
): Promise<void> {
if (Object.keys(changes).length)
await this.webhooks.updateOne({ _id: id }, { $set: changes });
}
async delete(id: string): Promise<void> {
await this.webhooks.deleteOne({ _id: id });
}
async recordDelivery(id: string, delivery: WebhookDelivery): Promise<void> {
await this.webhooks.updateOne(
{ _id: id },
{ $set: { lastDelivery: delivery } },
);
}
}

View File

@@ -1,3 +1,4 @@
import { OutgoingWebhook } from '@task-stream/api/tracker/application';
import {
ActivityReadModel,
CommentReadModel,
@@ -30,6 +31,7 @@ export type ActivityDoc = Doc<ActivityReadModel>;
export type TimelineDoc = Doc<IssueTimelineEntry>;
export type NotificationDoc = Doc<NotificationReadModel>;
export type SavedFilterDoc = Doc<SavedFilterReadModel>;
export type OutgoingWebhookDoc = Omit<OutgoingWebhook, 'id'> & { _id: string };
export interface GitIntegrationDoc {
_id: string;
secret: string;
@@ -52,6 +54,7 @@ export const ReadModels = {
SavedFilter: 'SavedFilter',
Sprint: 'SprintReadModel',
GitIntegration: 'GitIntegration',
OutgoingWebhook: 'OutgoingWebhook',
} as const;
const userRef = { _id: false, id: String, name: String };
@@ -203,6 +206,18 @@ export const GitIntegrationSchema = new Schema<GitIntegrationDoc>(
{ ...loose, collection: 'git_integrations' },
);
export const OutgoingWebhookSchema = new Schema<OutgoingWebhookDoc>(
{
_id: String,
projectId: { type: String, index: true },
active: Boolean,
events: [String],
createdBy: userRef,
createdAt: String,
},
{ ...loose, collection: 'outgoing_webhooks' },
);
export const READ_MODEL_SCHEMAS = [
{ name: ReadModels.Project, schema: ProjectSchema },
{ name: ReadModels.Issue, schema: IssueSchema },
@@ -210,6 +225,7 @@ export const READ_MODEL_SCHEMAS = [
{ name: ReadModels.Release, schema: ReleaseSchema },
{ name: ReadModels.Sprint, schema: SprintSchema },
{ name: ReadModels.GitIntegration, schema: GitIntegrationSchema },
{ name: ReadModels.OutgoingWebhook, schema: OutgoingWebhookSchema },
{ name: ReadModels.Activity, schema: ActivitySchema },
{ name: ReadModels.Timeline, schema: TimelineSchema },
{ name: ReadModels.Notification, schema: NotificationSchema },

View File

@@ -33,6 +33,7 @@ import {
ReleaseDoc,
SavedFilterDoc,
GitIntegrationDoc,
OutgoingWebhookDoc,
TimelineDoc,
} from './read-model.schemas';
import { upsertIfNewer } from './upsert-if-newer';
@@ -52,6 +53,8 @@ export class MongoUpsertMaterializedProjectRepository extends UpsertMaterialized
private readonly savedFilters: Model<SavedFilterDoc>,
@InjectModel(ReadModels.GitIntegration)
private readonly gitIntegrations: Model<GitIntegrationDoc>,
@InjectModel(ReadModels.OutgoingWebhook)
private readonly outgoingWebhooks: Model<OutgoingWebhookDoc>,
) {
super();
}
@@ -64,6 +67,7 @@ export class MongoUpsertMaterializedProjectRepository extends UpsertMaterialized
// Views of the board of a project that is gone.
this.savedFilters.deleteMany({ projectId }),
this.gitIntegrations.deleteOne({ _id: projectId }),
this.outgoingWebhooks.deleteMany({ projectId }),
]);
}