Merge pull request #1650 from symonbaikov/feature/webhook-scope-filter

feat: filter webhooks per project, board, and account
This commit is contained in:
Daniel Hiller
2026-09-17 02:00:30 +02:00
committed by GitHub
12 changed files with 446 additions and 131 deletions
+29 -1
View File
@@ -22,6 +22,21 @@
* - name
* - url
* properties:
* projectId:
* type: string
* nullable: true
* description: Optional project scope
* example: "1357158568008091264"
* boardId:
* type: string
* nullable: true
* description: Optional board scope
* example: "1357158568008091264"
* userId:
* type: string
* nullable: true
* description: Optional acting-user scope
* example: "1357158568008091264"
* name:
* type: string
* maxLength: 128
@@ -72,6 +87,7 @@
*/
const { isUrl } = require('../../../utils/validators');
const { idInput } = require('../../../utils/inputs');
const Errors = {
LIMIT_REACHED: {
@@ -81,6 +97,18 @@ const Errors = {
module.exports = {
inputs: {
projectId: {
...idInput,
allowNull: true,
},
boardId: {
...idInput,
allowNull: true,
},
userId: {
...idInput,
allowNull: true,
},
name: {
type: 'string',
maxLength: 128,
@@ -121,7 +149,7 @@ module.exports = {
async fn(inputs) {
const { currentUser } = this.req;
const values = _.pick(inputs, ['name', 'url', 'accessToken']);
const values = _.pick(inputs, ['projectId', 'boardId', 'userId', 'name', 'url', 'accessToken']);
const events = inputs.events && inputs.events.split(',');
const excludedEvents = inputs.excludedEvents && inputs.excludedEvents.split(',');
+28 -1
View File
@@ -27,6 +27,21 @@
* schema:
* type: object
* properties:
* projectId:
* type: string
* nullable: true
* description: Optional project scope
* example: "1357158568008091264"
* boardId:
* type: string
* nullable: true
* description: Optional board scope
* example: "1357158568008091264"
* userId:
* type: string
* nullable: true
* description: Optional acting-user scope
* example: "1357158568008091264"
* name:
* type: string
* maxLength: 128
@@ -91,6 +106,18 @@ module.exports = {
...idInput,
required: true,
},
projectId: {
...idInput,
allowNull: true,
},
boardId: {
...idInput,
allowNull: true,
},
userId: {
...idInput,
allowNull: true,
},
name: {
type: 'string',
isNotEmptyString: true,
@@ -136,7 +163,7 @@ module.exports = {
throw Errors.WEBHOOK_NOT_FOUND;
}
const values = _.pick(inputs, ['name', 'url', 'accessToken']);
const values = _.pick(inputs, ['projectId', 'boardId', 'userId', 'name', 'url', 'accessToken']);
const events = inputs.events && inputs.events.split(',');
const excludedEvents = inputs.excludedEvents && inputs.excludedEvents.split(',');
+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,
+23
View File
@@ -30,6 +30,21 @@
* type: string
* description: Unique identifier for the webhook
* example: "1357158568008091264"
* projectId:
* type: string
* nullable: true
* description: Optional project scope; webhook fires only for events within this project
* example: "1357158568008091264"
* boardId:
* type: string
* nullable: true
* description: Optional board scope; webhook fires only for events within this board
* example: "1357158568008091264"
* userId:
* type: string
* nullable: true
* description: Optional acting-user scope; webhook fires only for events triggered by this user
* example: "1357158568008091264"
* name:
* type: string
* description: Name/title of the webhook
@@ -200,9 +215,17 @@ module.exports = {
// ╠═╣╚═╗╚═╗║ ║║ ║╠═╣ ║ ║║ ║║║║╚═╗
// ╩ ╩╚═╝╚═╝╚═╝╚═╝╩╩ ╩ ╩ ╩╚═╝╝╚╝╚═╝
projectId: {
model: 'Project',
columnName: 'project_id',
},
boardId: {
model: 'Board',
columnName: 'board_id',
},
userId: {
model: 'User',
columnName: 'user_id',
},
},
};
@@ -0,0 +1,22 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
exports.up = (knex) =>
knex.schema.alterTable('webhook', (table) => {
table.bigInteger('project_id');
table.bigInteger('user_id');
table.index('project_id');
table.index('user_id');
});
exports.down = (knex) =>
knex.schema.alterTable('webhook', (table) => {
table.dropIndex('user_id');
table.dropIndex('project_id');
table.dropColumn('user_id');
table.dropColumn('project_id');
});