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');
+ });