From 05979a460fba7da98e67e0b5b3ca619383cef60e Mon Sep 17 00:00:00 2001 From: Symon Date: Sat, 2 May 2026 10:31:30 +0300 Subject: [PATCH] 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. --- .../AdministrationModal/WebhooksPane.jsx | 11 +- .../components/webhooks/Webhooks/Editor.jsx | 330 +++++++++++------- .../src/components/webhooks/Webhooks/Item.jsx | 17 +- .../components/webhooks/Webhooks/Webhooks.jsx | 22 +- client/src/models/Webhook.js | 10 + client/src/selectors/boards.js | 3 + client/src/selectors/projects.js | 3 + server/api/controllers/webhooks/create.js | 30 +- server/api/controllers/webhooks/update.js | 29 +- server/api/helpers/utils/send-webhooks.js | 77 +++- server/api/models/Webhook.js | 23 ++ ...0502000000_add_scope_columns_to_webhook.js | 22 ++ 12 files changed, 446 insertions(+), 131 deletions(-) create mode 100644 server/db/migrations/20260502000000_add_scope_columns_to_webhook.js diff --git a/client/src/components/common/AdministrationModal/WebhooksPane.jsx b/client/src/components/common/AdministrationModal/WebhooksPane.jsx index 13f2aaf7..81d2523c 100644 --- a/client/src/components/common/AdministrationModal/WebhooksPane.jsx +++ b/client/src/components/common/AdministrationModal/WebhooksPane.jsx @@ -15,6 +15,9 @@ import styles from './WebhooksPane.module.scss'; const WebhooksPane = React.memo(() => { const webhookIds = useSelector(selectors.selectWebhookIds); + const projects = useSelector(selectors.selectProjects); + const boards = useSelector(selectors.selectBoards); + const users = useSelector(selectors.selectUsers); const dispatch = useDispatch(); @@ -27,7 +30,13 @@ const WebhooksPane = React.memo(() => { return ( - + ); }); diff --git a/client/src/components/webhooks/Webhooks/Editor.jsx b/client/src/components/webhooks/Webhooks/Editor.jsx index 2dda9a87..c79fad76 100644 --- a/client/src/components/webhooks/Webhooks/Editor.jsx +++ b/client/src/components/webhooks/Webhooks/Editor.jsx @@ -3,7 +3,7 @@ * Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md */ -import React, { useCallback, useImperativeHandle } from 'react'; +import React, { useCallback, useImperativeHandle, useMemo } from 'react'; import PropTypes from 'prop-types'; import { useTranslation } from 'react-i18next'; import { Dropdown, Input } from 'semantic-ui-react'; @@ -13,134 +13,224 @@ import WEBHOOK_EVENTS from '../../../constants/WebhookEvents'; import styles from './Editor.module.scss'; -const Editor = React.forwardRef(({ data, isReadOnly, onFieldChange }, ref) => { - const [t] = useTranslation(); +const Editor = React.forwardRef( + ({ data, projects, boards, users, isReadOnly, onFieldChange }, ref) => { + const [t] = useTranslation(); - const [nameFieldRef, handleNameFieldRef] = useNestedRef('inputRef'); - const [urlFieldRef, handleUrlFieldRef] = useNestedRef('inputRef'); + const [nameFieldRef, handleNameFieldRef] = useNestedRef('inputRef'); + const [urlFieldRef, handleUrlFieldRef] = useNestedRef('inputRef'); - const focusNameField = useCallback(() => { - nameFieldRef.current.focus({ - preventScroll: true, - }); - }, [nameFieldRef]); + const focusNameField = useCallback(() => { + nameFieldRef.current.focus({ + preventScroll: true, + }); + }, [nameFieldRef]); - const selectNameField = useCallback(() => { - nameFieldRef.current.select(); - }, [nameFieldRef]); + const selectNameField = useCallback(() => { + nameFieldRef.current.select(); + }, [nameFieldRef]); - const selectUrlField = useCallback(() => { - urlFieldRef.current.select(); - }, [urlFieldRef]); + const selectUrlField = useCallback(() => { + urlFieldRef.current.select(); + }, [urlFieldRef]); - useImperativeHandle( - ref, - () => ({ - focusNameField, - selectNameField, - selectUrlField, - }), - [focusNameField, selectNameField, selectUrlField], - ); + useImperativeHandle( + ref, + () => ({ + focusNameField, + selectNameField, + selectUrlField, + }), + [focusNameField, selectNameField, selectUrlField], + ); - return ( - <> -
{t('common.title')}
- -
{t('common.url')}
- -
- {t('common.accessToken')} ( - {t('common.optional', { - context: 'inline', - })} - ) -
- - {data.excludedEvents.length === 0 && ( - <> -
- {t('common.events')} ( - {t('common.optional', { - context: 'inline', - })} - ) -
- ({ - text: event, - value: event, - }))} - value={data.events} - placeholder="All" - readOnly={isReadOnly} - className={styles.field} - onChange={onFieldChange} - /> - - )} - {data.events.length === 0 && ( - <> -
- {t('common.excludedEvents')} ( - {t('common.optional', { - context: 'inline', - })} - ) -
- ({ - text: event, - value: event, - }))} - value={data.excludedEvents} - placeholder="None" - readOnly={isReadOnly} - className={styles.field} - onChange={onFieldChange} - /> - - )} - - ); -}); + const handleScopeChange = useCallback( + (_, { name, value }) => { + onFieldChange(null, { name, value: value === '' ? null : value }); + }, + [onFieldChange], + ); + + const projectOptions = useMemo( + () => projects.map((project) => ({ text: project.name, value: project.id })), + [projects], + ); + + const boardOptions = useMemo(() => { + const filtered = data.projectId + ? boards.filter((board) => board.projectId === data.projectId) + : boards; + return filtered.map((board) => ({ text: board.name, value: board.id })); + }, [boards, data.projectId]); + + const userOptions = useMemo( + () => + users.map((user) => ({ + text: user.name || user.username || user.email, + value: user.id, + })), + [users], + ); + + return ( + <> +
{t('common.title')}
+ +
{t('common.url')}
+ +
+ {t('common.accessToken')} ( + {t('common.optional', { + context: 'inline', + })} + ) +
+ +
+ {t('common.project')} ( + {t('common.optional', { + context: 'inline', + })} + ) +
+ +
+ {t('common.board')} ( + {t('common.optional', { + context: 'inline', + })} + ) +
+ +
+ {t('common.account')} ( + {t('common.optional', { + context: 'inline', + })} + ) +
+ + {data.excludedEvents.length === 0 && ( + <> +
+ {t('common.events')} ( + {t('common.optional', { + context: 'inline', + })} + ) +
+ ({ + text: event, + value: event, + }))} + value={data.events} + placeholder="All" + readOnly={isReadOnly} + className={styles.field} + onChange={onFieldChange} + /> + + )} + {data.events.length === 0 && ( + <> +
+ {t('common.excludedEvents')} ( + {t('common.optional', { + context: 'inline', + })} + ) +
+ ({ + text: event, + value: event, + }))} + value={data.excludedEvents} + placeholder="None" + readOnly={isReadOnly} + className={styles.field} + onChange={onFieldChange} + /> + + )} + + ); + }, +); Editor.propTypes = { data: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types + projects: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types + boards: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types + users: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types isReadOnly: PropTypes.bool, onFieldChange: PropTypes.func.isRequired, }; diff --git a/client/src/components/webhooks/Webhooks/Item.jsx b/client/src/components/webhooks/Webhooks/Item.jsx index dc484912..ee2fffe8 100644 --- a/client/src/components/webhooks/Webhooks/Item.jsx +++ b/client/src/components/webhooks/Webhooks/Item.jsx @@ -20,7 +20,7 @@ import ConfirmationStep from '../../common/ConfirmationStep'; import styles from './Item.module.scss'; import { useToggle } from '../../../lib/hooks'; -const Item = React.memo(({ id }) => { +const Item = React.memo(({ id, projects, boards, users }) => { const selectWebhookById = useMemo(() => selectors.makeSelectWebhookById(), []); const webhook = useSelector((state) => selectWebhookById(state, id)); @@ -36,6 +36,9 @@ const Item = React.memo(({ id }) => { accessToken: webhook.accessToken, events: webhook.events, excludedEvents: webhook.excludedEvents, + projectId: webhook.projectId, + boardId: webhook.boardId, + userId: webhook.userId, }), [webhook], ); @@ -47,6 +50,9 @@ const Item = React.memo(({ id }) => { accessToken: defaultData.accessToken || '', events: defaultData.events || [], excludedEvents: defaultData.excludedEvents || [], + projectId: defaultData.projectId || null, + boardId: defaultData.boardId || null, + userId: defaultData.userId || null, })); const cleanData = useMemo( @@ -57,6 +63,9 @@ const Item = React.memo(({ id }) => { accessToken: data.accessToken.trim() || null, events: data.events.length === 0 ? null : data.events, excludedEvents: data.excludedEvents.length === 0 ? null : data.excludedEvents, + projectId: data.projectId || null, + boardId: data.boardId || null, + userId: data.userId || null, }), [data], ); @@ -99,6 +108,9 @@ const Item = React.memo(({ id }) => { @@ -132,6 +144,9 @@ const Item = React.memo(({ id }) => { Item.propTypes = { id: PropTypes.string.isRequired, + projects: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types + boards: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types + users: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types }; export default Item; diff --git a/client/src/components/webhooks/Webhooks/Webhooks.jsx b/client/src/components/webhooks/Webhooks/Webhooks.jsx index e953ed5c..a2f0d553 100644 --- a/client/src/components/webhooks/Webhooks/Webhooks.jsx +++ b/client/src/components/webhooks/Webhooks/Webhooks.jsx @@ -15,6 +15,9 @@ import Item from './Item'; import Editor from './Editor'; const DEFAULT_DATA = { + projectId: null, + boardId: null, + userId: null, name: '', url: '', accessToken: '', @@ -22,7 +25,7 @@ const DEFAULT_DATA = { excludedEvents: [], }; -const Webhooks = React.memo(({ ids, onCreate }) => { +const Webhooks = React.memo(({ ids, projects, boards, users, onCreate }) => { const [t] = useTranslation(); const [data, handleFieldChange, setData] = useForm(DEFAULT_DATA); @@ -38,6 +41,9 @@ const Webhooks = React.memo(({ ids, onCreate }) => { accessToken: data.accessToken.trim() || null, events: data.events.length === 0 ? null : data.events, excludedEvents: data.excludedEvents.length === 0 ? null : data.excludedEvents, + projectId: data.projectId || null, + boardId: data.boardId || null, + userId: data.userId || null, }; if (!cleanData.name) { @@ -72,14 +78,21 @@ const Webhooks = React.memo(({ ids, onCreate }) => { {ids.length > 0 && ( {ids.map((id) => ( - + ))} )} {ids.length < 10 && (
- +
@@ -90,6 +103,9 @@ const Webhooks = React.memo(({ ids, onCreate }) => { Webhooks.propTypes = { ids: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types + projects: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types + boards: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types + users: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types onCreate: PropTypes.func.isRequired, }; diff --git a/client/src/models/Webhook.js b/client/src/models/Webhook.js index 17ff5855..f8f283dc 100644 --- a/client/src/models/Webhook.js +++ b/client/src/models/Webhook.js @@ -18,11 +18,21 @@ export default class extends BaseModel { accessToken: attr(), events: attr(), excludedEvents: attr(), + projectId: fk({ + to: 'Project', + as: 'project', + relatedName: 'webhooks', + }), boardId: fk({ to: 'Board', as: 'board', relatedName: 'webhooks', }), + userId: fk({ + to: 'User', + as: 'user', + relatedName: 'webhooks', + }), }; static reducer({ type, payload }, Webhook) { diff --git a/client/src/selectors/boards.js b/client/src/selectors/boards.js index 4d038494..aed07aac 100644 --- a/client/src/selectors/boards.js +++ b/client/src/selectors/boards.js @@ -12,6 +12,8 @@ import { isLocalId } from '../utils/local-id'; import { isListArchiveOrTrash } from '../utils/record-helpers'; import { ListTypes } from '../constants/Enums'; +export const selectBoards = createSelector(orm, ({ Board }) => Board.all().toRefArray()); + export const makeSelectBoardById = () => createSelector( orm, @@ -466,6 +468,7 @@ export const selectIsBoardWithIdExists = createSelector( ); export default { + selectBoards, makeSelectBoardById, selectBoardById, makeSelectCurrentUserMembershipByBoardId, diff --git a/client/src/selectors/projects.js b/client/src/selectors/projects.js index 8dc0faf0..aaf3ddf7 100644 --- a/client/src/selectors/projects.js +++ b/client/src/selectors/projects.js @@ -10,6 +10,8 @@ import { selectPath } from './router'; import { selectCurrentUserId } from './users'; import { isLocalId } from '../utils/local-id'; +export const selectProjects = createSelector(orm, ({ Project }) => Project.all().toRefArray()); + export const makeSelectProjectById = () => createSelector( orm, @@ -311,6 +313,7 @@ export const selectIsCurrentUserManagerForCurrentProject = createSelector( ); export default { + selectProjects, makeSelectProjectById, selectProjectById, makeSelectBoardIdsByProjectId, diff --git a/server/api/controllers/webhooks/create.js b/server/api/controllers/webhooks/create.js index dc6ede26..184c907b 100644 --- a/server/api/controllers/webhooks/create.js +++ b/server/api/controllers/webhooks/create.js @@ -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(','); diff --git a/server/api/controllers/webhooks/update.js b/server/api/controllers/webhooks/update.js index ecee20b2..cb338cbe 100644 --- a/server/api/controllers/webhooks/update.js +++ b/server/api/controllers/webhooks/update.js @@ -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(','); diff --git a/server/api/helpers/utils/send-webhooks.js b/server/api/helpers/utils/send-webhooks.js index d3af93ed..d84c2b72 100644 --- a/server/api/helpers/utils/send-webhooks.js +++ b/server/api/helpers/utils/send-webhooks.js @@ -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, diff --git a/server/api/models/Webhook.js b/server/api/models/Webhook.js index 4e73b9a6..b72b5cd7 100644 --- a/server/api/models/Webhook.js +++ b/server/api/models/Webhook.js @@ -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', + }, }, }; diff --git a/server/db/migrations/20260502000000_add_scope_columns_to_webhook.js b/server/db/migrations/20260502000000_add_scope_columns_to_webhook.js new file mode 100644 index 00000000..aebf91da --- /dev/null +++ b/server/db/migrations/20260502000000_add_scope_columns_to_webhook.js @@ -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'); + });