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
@@ -15,6 +15,9 @@ import styles from './WebhooksPane.module.scss';
const WebhooksPane = React.memo(() => { const WebhooksPane = React.memo(() => {
const webhookIds = useSelector(selectors.selectWebhookIds); const webhookIds = useSelector(selectors.selectWebhookIds);
const projects = useSelector(selectors.selectProjects);
const boards = useSelector(selectors.selectBoards);
const users = useSelector(selectors.selectUsers);
const dispatch = useDispatch(); const dispatch = useDispatch();
@@ -27,7 +30,13 @@ const WebhooksPane = React.memo(() => {
return ( return (
<Tab.Pane attached={false} className={styles.wrapper}> <Tab.Pane attached={false} className={styles.wrapper}>
<Webhooks ids={webhookIds} onCreate={handleCreate} /> <Webhooks
ids={webhookIds}
projects={projects}
boards={boards}
users={users}
onCreate={handleCreate}
/>
</Tab.Pane> </Tab.Pane>
); );
}); });
+210 -120
View File
@@ -3,7 +3,7 @@
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md * 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 PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Dropdown, Input } from 'semantic-ui-react'; import { Dropdown, Input } from 'semantic-ui-react';
@@ -13,134 +13,224 @@ import WEBHOOK_EVENTS from '../../../constants/WebhookEvents';
import styles from './Editor.module.scss'; import styles from './Editor.module.scss';
const Editor = React.forwardRef(({ data, isReadOnly, onFieldChange }, ref) => { const Editor = React.forwardRef(
const [t] = useTranslation(); ({ data, projects, boards, users, isReadOnly, onFieldChange }, ref) => {
const [t] = useTranslation();
const [nameFieldRef, handleNameFieldRef] = useNestedRef('inputRef'); const [nameFieldRef, handleNameFieldRef] = useNestedRef('inputRef');
const [urlFieldRef, handleUrlFieldRef] = useNestedRef('inputRef'); const [urlFieldRef, handleUrlFieldRef] = useNestedRef('inputRef');
const focusNameField = useCallback(() => { const focusNameField = useCallback(() => {
nameFieldRef.current.focus({ nameFieldRef.current.focus({
preventScroll: true, preventScroll: true,
}); });
}, [nameFieldRef]); }, [nameFieldRef]);
const selectNameField = useCallback(() => { const selectNameField = useCallback(() => {
nameFieldRef.current.select(); nameFieldRef.current.select();
}, [nameFieldRef]); }, [nameFieldRef]);
const selectUrlField = useCallback(() => { const selectUrlField = useCallback(() => {
urlFieldRef.current.select(); urlFieldRef.current.select();
}, [urlFieldRef]); }, [urlFieldRef]);
useImperativeHandle( useImperativeHandle(
ref, ref,
() => ({ () => ({
focusNameField, focusNameField,
selectNameField, selectNameField,
selectUrlField, selectUrlField,
}), }),
[focusNameField, selectNameField, selectUrlField], [focusNameField, selectNameField, selectUrlField],
); );
return ( const handleScopeChange = useCallback(
<> (_, { name, value }) => {
<div className={styles.text}>{t('common.title')}</div> onFieldChange(null, { name, value: value === '' ? null : value });
<Input },
fluid [onFieldChange],
ref={handleNameFieldRef} );
name="name"
value={data.name} const projectOptions = useMemo(
maxLength={128} () => projects.map((project) => ({ text: project.name, value: project.id })),
readOnly={isReadOnly} [projects],
className={styles.field} );
onChange={onFieldChange}
/> const boardOptions = useMemo(() => {
<div className={styles.text}>{t('common.url')}</div> const filtered = data.projectId
<Input ? boards.filter((board) => board.projectId === data.projectId)
fluid : boards;
ref={handleUrlFieldRef} return filtered.map((board) => ({ text: board.name, value: board.id }));
name="url" }, [boards, data.projectId]);
value={data.url}
maxLength={2048} const userOptions = useMemo(
readOnly={isReadOnly} () =>
className={styles.field} users.map((user) => ({
onChange={onFieldChange} text: user.name || user.username || user.email,
/> value: user.id,
<div className={styles.text}> })),
{t('common.accessToken')} ( [users],
{t('common.optional', { );
context: 'inline',
})} return (
) <>
</div> <div className={styles.text}>{t('common.title')}</div>
<Input <Input
fluid fluid
name="accessToken" ref={handleNameFieldRef}
value={data.accessToken} name="name"
maxLength={512} value={data.name}
readOnly={isReadOnly} maxLength={128}
className={styles.field} readOnly={isReadOnly}
onChange={onFieldChange} className={styles.field}
/> onChange={onFieldChange}
{data.excludedEvents.length === 0 && ( />
<> <div className={styles.text}>{t('common.url')}</div>
<div className={styles.text}> <Input
{t('common.events')} ( fluid
{t('common.optional', { ref={handleUrlFieldRef}
context: 'inline', name="url"
})} value={data.url}
) maxLength={2048}
</div> readOnly={isReadOnly}
<Dropdown className={styles.field}
selection onChange={onFieldChange}
multiple />
fluid <div className={styles.text}>
name="events" {t('common.accessToken')} (
options={WEBHOOK_EVENTS.map((event) => ({ {t('common.optional', {
text: event, context: 'inline',
value: event, })}
}))} )
value={data.events} </div>
placeholder="All" <Input
readOnly={isReadOnly} fluid
className={styles.field} name="accessToken"
onChange={onFieldChange} value={data.accessToken}
/> maxLength={512}
</> readOnly={isReadOnly}
)} className={styles.field}
{data.events.length === 0 && ( onChange={onFieldChange}
<> />
<div className={styles.text}> <div className={styles.text}>
{t('common.excludedEvents')} ( {t('common.project')} (
{t('common.optional', { {t('common.optional', {
context: 'inline', context: 'inline',
})} })}
) )
</div> </div>
<Dropdown <Dropdown
selection selection
multiple clearable
fluid fluid
name="excludedEvents" name="projectId"
options={WEBHOOK_EVENTS.map((event) => ({ options={projectOptions}
text: event, value={data.projectId || ''}
value: event, placeholder={t('common.all')}
}))} disabled={isReadOnly}
value={data.excludedEvents} className={styles.field}
placeholder="None" onChange={handleScopeChange}
readOnly={isReadOnly} />
className={styles.field} <div className={styles.text}>
onChange={onFieldChange} {t('common.board')} (
/> {t('common.optional', {
</> context: 'inline',
)} })}
</> )
); </div>
}); <Dropdown
selection
clearable
fluid
name="boardId"
options={boardOptions}
value={data.boardId || ''}
placeholder={t('common.all')}
disabled={isReadOnly}
className={styles.field}
onChange={handleScopeChange}
/>
<div className={styles.text}>
{t('common.account')} (
{t('common.optional', {
context: 'inline',
})}
)
</div>
<Dropdown
selection
clearable
fluid
name="userId"
options={userOptions}
value={data.userId || ''}
placeholder={t('common.all')}
disabled={isReadOnly}
className={styles.field}
onChange={handleScopeChange}
/>
{data.excludedEvents.length === 0 && (
<>
<div className={styles.text}>
{t('common.events')} (
{t('common.optional', {
context: 'inline',
})}
)
</div>
<Dropdown
selection
multiple
fluid
name="events"
options={WEBHOOK_EVENTS.map((event) => ({
text: event,
value: event,
}))}
value={data.events}
placeholder="All"
readOnly={isReadOnly}
className={styles.field}
onChange={onFieldChange}
/>
</>
)}
{data.events.length === 0 && (
<>
<div className={styles.text}>
{t('common.excludedEvents')} (
{t('common.optional', {
context: 'inline',
})}
)
</div>
<Dropdown
selection
multiple
fluid
name="excludedEvents"
options={WEBHOOK_EVENTS.map((event) => ({
text: event,
value: event,
}))}
value={data.excludedEvents}
placeholder="None"
readOnly={isReadOnly}
className={styles.field}
onChange={onFieldChange}
/>
</>
)}
</>
);
},
);
Editor.propTypes = { Editor.propTypes = {
data: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types 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, isReadOnly: PropTypes.bool,
onFieldChange: PropTypes.func.isRequired, onFieldChange: PropTypes.func.isRequired,
}; };
@@ -20,7 +20,7 @@ import ConfirmationStep from '../../common/ConfirmationStep';
import styles from './Item.module.scss'; import styles from './Item.module.scss';
import { useToggle } from '../../../lib/hooks'; import { useToggle } from '../../../lib/hooks';
const Item = React.memo(({ id }) => { const Item = React.memo(({ id, projects, boards, users }) => {
const selectWebhookById = useMemo(() => selectors.makeSelectWebhookById(), []); const selectWebhookById = useMemo(() => selectors.makeSelectWebhookById(), []);
const webhook = useSelector((state) => selectWebhookById(state, id)); const webhook = useSelector((state) => selectWebhookById(state, id));
@@ -36,6 +36,9 @@ const Item = React.memo(({ id }) => {
accessToken: webhook.accessToken, accessToken: webhook.accessToken,
events: webhook.events, events: webhook.events,
excludedEvents: webhook.excludedEvents, excludedEvents: webhook.excludedEvents,
projectId: webhook.projectId,
boardId: webhook.boardId,
userId: webhook.userId,
}), }),
[webhook], [webhook],
); );
@@ -47,6 +50,9 @@ const Item = React.memo(({ id }) => {
accessToken: defaultData.accessToken || '', accessToken: defaultData.accessToken || '',
events: defaultData.events || [], events: defaultData.events || [],
excludedEvents: defaultData.excludedEvents || [], excludedEvents: defaultData.excludedEvents || [],
projectId: defaultData.projectId || null,
boardId: defaultData.boardId || null,
userId: defaultData.userId || null,
})); }));
const cleanData = useMemo( const cleanData = useMemo(
@@ -57,6 +63,9 @@ const Item = React.memo(({ id }) => {
accessToken: data.accessToken.trim() || null, accessToken: data.accessToken.trim() || null,
events: data.events.length === 0 ? null : data.events, events: data.events.length === 0 ? null : data.events,
excludedEvents: data.excludedEvents.length === 0 ? null : data.excludedEvents, excludedEvents: data.excludedEvents.length === 0 ? null : data.excludedEvents,
projectId: data.projectId || null,
boardId: data.boardId || null,
userId: data.userId || null,
}), }),
[data], [data],
); );
@@ -99,6 +108,9 @@ const Item = React.memo(({ id }) => {
<Editor <Editor
ref={editorRef} ref={editorRef}
data={data} data={data}
projects={projects}
boards={boards}
users={users}
isReadOnly={!webhook.isPersisted} isReadOnly={!webhook.isPersisted}
onFieldChange={handleFieldChange} onFieldChange={handleFieldChange}
/> />
@@ -132,6 +144,9 @@ const Item = React.memo(({ id }) => {
Item.propTypes = { Item.propTypes = {
id: PropTypes.string.isRequired, 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; export default Item;
@@ -15,6 +15,9 @@ import Item from './Item';
import Editor from './Editor'; import Editor from './Editor';
const DEFAULT_DATA = { const DEFAULT_DATA = {
projectId: null,
boardId: null,
userId: null,
name: '', name: '',
url: '', url: '',
accessToken: '', accessToken: '',
@@ -22,7 +25,7 @@ const DEFAULT_DATA = {
excludedEvents: [], excludedEvents: [],
}; };
const Webhooks = React.memo(({ ids, onCreate }) => { const Webhooks = React.memo(({ ids, projects, boards, users, onCreate }) => {
const [t] = useTranslation(); const [t] = useTranslation();
const [data, handleFieldChange, setData] = useForm(DEFAULT_DATA); const [data, handleFieldChange, setData] = useForm(DEFAULT_DATA);
@@ -38,6 +41,9 @@ const Webhooks = React.memo(({ ids, onCreate }) => {
accessToken: data.accessToken.trim() || null, accessToken: data.accessToken.trim() || null,
events: data.events.length === 0 ? null : data.events, events: data.events.length === 0 ? null : data.events,
excludedEvents: data.excludedEvents.length === 0 ? null : data.excludedEvents, excludedEvents: data.excludedEvents.length === 0 ? null : data.excludedEvents,
projectId: data.projectId || null,
boardId: data.boardId || null,
userId: data.userId || null,
}; };
if (!cleanData.name) { if (!cleanData.name) {
@@ -72,14 +78,21 @@ const Webhooks = React.memo(({ ids, onCreate }) => {
{ids.length > 0 && ( {ids.length > 0 && (
<Accordion styled fluid> <Accordion styled fluid>
{ids.map((id) => ( {ids.map((id) => (
<Item key={id} id={id} /> <Item key={id} id={id} projects={projects} boards={boards} users={users} />
))} ))}
</Accordion> </Accordion>
)} )}
{ids.length < 10 && ( {ids.length < 10 && (
<Segment> <Segment>
<Form onSubmit={handleCreateSubmit}> <Form onSubmit={handleCreateSubmit}>
<Editor ref={editorRef} data={data} onFieldChange={handleFieldChange} /> <Editor
ref={editorRef}
data={data}
projects={projects}
boards={boards}
users={users}
onFieldChange={handleFieldChange}
/>
<Button positive>{t('action.addWebhook')}</Button> <Button positive>{t('action.addWebhook')}</Button>
</Form> </Form>
</Segment> </Segment>
@@ -90,6 +103,9 @@ const Webhooks = React.memo(({ ids, onCreate }) => {
Webhooks.propTypes = { Webhooks.propTypes = {
ids: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types 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, onCreate: PropTypes.func.isRequired,
}; };
+10
View File
@@ -18,11 +18,21 @@ export default class extends BaseModel {
accessToken: attr(), accessToken: attr(),
events: attr(), events: attr(),
excludedEvents: attr(), excludedEvents: attr(),
projectId: fk({
to: 'Project',
as: 'project',
relatedName: 'webhooks',
}),
boardId: fk({ boardId: fk({
to: 'Board', to: 'Board',
as: 'board', as: 'board',
relatedName: 'webhooks', relatedName: 'webhooks',
}), }),
userId: fk({
to: 'User',
as: 'user',
relatedName: 'webhooks',
}),
}; };
static reducer({ type, payload }, Webhook) { static reducer({ type, payload }, Webhook) {
+3
View File
@@ -12,6 +12,8 @@ import { isLocalId } from '../utils/local-id';
import { isListArchiveOrTrash } from '../utils/record-helpers'; import { isListArchiveOrTrash } from '../utils/record-helpers';
import { ListTypes } from '../constants/Enums'; import { ListTypes } from '../constants/Enums';
export const selectBoards = createSelector(orm, ({ Board }) => Board.all().toRefArray());
export const makeSelectBoardById = () => export const makeSelectBoardById = () =>
createSelector( createSelector(
orm, orm,
@@ -466,6 +468,7 @@ export const selectIsBoardWithIdExists = createSelector(
); );
export default { export default {
selectBoards,
makeSelectBoardById, makeSelectBoardById,
selectBoardById, selectBoardById,
makeSelectCurrentUserMembershipByBoardId, makeSelectCurrentUserMembershipByBoardId,
+3
View File
@@ -10,6 +10,8 @@ import { selectPath } from './router';
import { selectCurrentUserId } from './users'; import { selectCurrentUserId } from './users';
import { isLocalId } from '../utils/local-id'; import { isLocalId } from '../utils/local-id';
export const selectProjects = createSelector(orm, ({ Project }) => Project.all().toRefArray());
export const makeSelectProjectById = () => export const makeSelectProjectById = () =>
createSelector( createSelector(
orm, orm,
@@ -311,6 +313,7 @@ export const selectIsCurrentUserManagerForCurrentProject = createSelector(
); );
export default { export default {
selectProjects,
makeSelectProjectById, makeSelectProjectById,
selectProjectById, selectProjectById,
makeSelectBoardIdsByProjectId, makeSelectBoardIdsByProjectId,
+29 -1
View File
@@ -22,6 +22,21 @@
* - name * - name
* - url * - url
* properties: * 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: * name:
* type: string * type: string
* maxLength: 128 * maxLength: 128
@@ -72,6 +87,7 @@
*/ */
const { isUrl } = require('../../../utils/validators'); const { isUrl } = require('../../../utils/validators');
const { idInput } = require('../../../utils/inputs');
const Errors = { const Errors = {
LIMIT_REACHED: { LIMIT_REACHED: {
@@ -81,6 +97,18 @@ const Errors = {
module.exports = { module.exports = {
inputs: { inputs: {
projectId: {
...idInput,
allowNull: true,
},
boardId: {
...idInput,
allowNull: true,
},
userId: {
...idInput,
allowNull: true,
},
name: { name: {
type: 'string', type: 'string',
maxLength: 128, maxLength: 128,
@@ -121,7 +149,7 @@ module.exports = {
async fn(inputs) { async fn(inputs) {
const { currentUser } = this.req; 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 events = inputs.events && inputs.events.split(',');
const excludedEvents = inputs.excludedEvents && inputs.excludedEvents.split(','); const excludedEvents = inputs.excludedEvents && inputs.excludedEvents.split(',');
+28 -1
View File
@@ -27,6 +27,21 @@
* schema: * schema:
* type: object * type: object
* properties: * 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: * name:
* type: string * type: string
* maxLength: 128 * maxLength: 128
@@ -91,6 +106,18 @@ module.exports = {
...idInput, ...idInput,
required: true, required: true,
}, },
projectId: {
...idInput,
allowNull: true,
},
boardId: {
...idInput,
allowNull: true,
},
userId: {
...idInput,
allowNull: true,
},
name: { name: {
type: 'string', type: 'string',
isNotEmptyString: true, isNotEmptyString: true,
@@ -136,7 +163,7 @@ module.exports = {
throw Errors.WEBHOOK_NOT_FOUND; 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 events = inputs.events && inputs.events.split(',');
const excludedEvents = inputs.excludedEvents && inputs.excludedEvents.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 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 * @typedef {Object} Included
* @property {any[]} [users] - Array of users (optional). * @property {any[]} [users] - Array of users (optional).
@@ -104,10 +146,15 @@ module.exports = {
type: 'ref', type: 'ref',
required: true, required: true,
}, },
scope: {
type: 'ref',
},
}, },
fn(inputs) { fn(inputs) {
const webhooks = inputs.webhooks.filter((webhook) => { const userId = inputs.user && inputs.user.id;
const eventFilteredWebhooks = inputs.webhooks.filter((webhook) => {
if (!webhook.url) { if (!webhook.url) {
return false; return false;
} }
@@ -120,6 +167,31 @@ module.exports = {
return false; 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; return true;
}); });
@@ -127,9 +199,6 @@ module.exports = {
return; return;
} }
const data = inputs.buildData();
const prevData = inputs.buildPrevData && inputs.buildPrevData();
webhooks.forEach((webhook) => { webhooks.forEach((webhook) => {
sendWebhook( sendWebhook(
webhook, webhook,
+23
View File
@@ -30,6 +30,21 @@
* type: string * type: string
* description: Unique identifier for the webhook * description: Unique identifier for the webhook
* example: "1357158568008091264" * 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: * name:
* type: string * type: string
* description: Name/title of the webhook * description: Name/title of the webhook
@@ -200,9 +215,17 @@ module.exports = {
// ╠═╣╚═╗╚═╗║ ║║ ║╠═╣ ║ ║║ ║║║║╚═╗ // ╠═╣╚═╗╚═╗║ ║║ ║╠═╣ ║ ║║ ║║║║╚═╗
// ╩ ╩╚═╝╚═╝╚═╝╚═╝╩╩ ╩ ╩ ╩╚═╝╝╚╝╚═╝ // ╩ ╩╚═╝╚═╝╚═╝╚═╝╩╩ ╩ ╩ ╩╚═╝╝╚╝╚═╝
projectId: {
model: 'Project',
columnName: 'project_id',
},
boardId: { boardId: {
model: 'Board', model: 'Board',
columnName: 'board_id', 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');
});