feat: filter webhooks per project, board, and account

Adds optional project, board, and acting-user scope to each webhook so
events from a specific board or project can be routed to a single
endpoint without an external automation tool in the middle (closes
#1457).

A webhook now fires for an event only when every set scope matches:
empty scope still means "fire for everything", keeping existing
webhooks working unchanged. Scope filtering is centralized in the
sendWebhooks helper, which auto-derives projectId/boardId from the
event payload, so existing call sites are untouched.
This commit is contained in:
Symon
2026-05-02 10:31:30 +03:00
parent a8dcd7cef3
commit 05979a460f
12 changed files with 446 additions and 131 deletions
+73 -4
View File
@@ -7,6 +7,48 @@ const { ProxyAgent } = require('undici');
const Webhook = require('../../models/Webhook');
const BOARD_ITEM_EVENTS = new Set([
Webhook.Events.BOARD_CREATE,
Webhook.Events.BOARD_UPDATE,
Webhook.Events.BOARD_DELETE,
]);
const PROJECT_ITEM_EVENTS = new Set([
Webhook.Events.PROJECT_CREATE,
Webhook.Events.PROJECT_UPDATE,
Webhook.Events.PROJECT_DELETE,
]);
function resolveScope(event, data, override) {
const result = { projectId: null, boardId: null, ...(override || {}) };
if (!data) return result;
const { item } = data;
const included = data.included || {};
if (!result.projectId) {
if (included.projects && included.projects[0]) {
result.projectId = included.projects[0].id;
} else if (item && PROJECT_ITEM_EVENTS.has(event)) {
result.projectId = item.id;
} else if (item && item.projectId) {
result.projectId = item.projectId;
}
}
if (!result.boardId) {
if (included.boards && included.boards[0]) {
result.boardId = included.boards[0].id;
} else if (item && BOARD_ITEM_EVENTS.has(event)) {
result.boardId = item.id;
} else if (item && item.boardId) {
result.boardId = item.boardId;
}
}
return result;
}
/**
* @typedef {Object} Included
* @property {any[]} [users] - Array of users (optional).
@@ -104,10 +146,15 @@ module.exports = {
type: 'ref',
required: true,
},
scope: {
type: 'ref',
},
},
fn(inputs) {
const webhooks = inputs.webhooks.filter((webhook) => {
const userId = inputs.user && inputs.user.id;
const eventFilteredWebhooks = inputs.webhooks.filter((webhook) => {
if (!webhook.url) {
return false;
}
@@ -120,6 +167,31 @@ module.exports = {
return false;
}
if (webhook.userId && webhook.userId !== userId) {
return false;
}
return true;
});
if (eventFilteredWebhooks.length === 0) {
return;
}
const data = inputs.buildData();
const prevData = inputs.buildPrevData && inputs.buildPrevData();
const scope = resolveScope(inputs.event, data, inputs.scope);
const webhooks = eventFilteredWebhooks.filter((webhook) => {
if (webhook.projectId && webhook.projectId !== scope.projectId) {
return false;
}
if (webhook.boardId && webhook.boardId !== scope.boardId) {
return false;
}
return true;
});
@@ -127,9 +199,6 @@ module.exports = {
return;
}
const data = inputs.buildData();
const prevData = inputs.buildPrevData && inputs.buildPrevData();
webhooks.forEach((webhook) => {
sendWebhook(
webhook,