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.
213 lines
5.5 KiB
JavaScript
213 lines
5.5 KiB
JavaScript
/*!
|
|
* Copyright (c) 2024 PLANKA Software GmbH
|
|
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
|
*/
|
|
|
|
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).
|
|
* @property {any[]} [projects] - Array of projects (optional).
|
|
* @property {any[]} [baseCustomFieldGroups] - Array of base custom field groups (optional).
|
|
* @property {any[]} [boards] - Array of boards (optional).
|
|
* @property {any[]} [boardMemberships] - Array of board memberships (optional).
|
|
* @property {any[]} [labels] - Array of labels (optional).
|
|
* @property {any[]} [lists] - Array of lists (optional).
|
|
* @property {any[]} [cards] - Array of cards (optional).
|
|
* @property {any[]} [cardMemberships] - Array of card memberships (optional).
|
|
* @property {any[]} [taskLists] - Array of task lists (optional).
|
|
* @property {any[]} [customFieldGroups] - Array of custom field groups (optional).
|
|
* @property {any[]} [customFields] - Array of custom fields (optional).
|
|
* @property {any[]} [comments] - Array of comments (optional).
|
|
* @property {any[]} [actions] - Array of actions (optional).
|
|
*/
|
|
|
|
/**
|
|
* @typedef {Object} Data
|
|
* @property {any} item - Actual event data.
|
|
* @property {Included} [included] - Optional included data.
|
|
*/
|
|
|
|
/**
|
|
* Sends a webhook notification to a configured URL.
|
|
*
|
|
* @param {*} webhook - Webhook configuration.
|
|
* @param {string} event - The event.
|
|
* @param {Data} data - The data object containing event data and optionally included data.
|
|
* @param {Data} [prevData] - The data object containing previous state of data (optional).
|
|
* @param {ref} user - User object associated with the event.
|
|
* @returns {Promise<void>}
|
|
*/
|
|
async function sendWebhook(webhook, event, data, prevData, user) {
|
|
const headers = {
|
|
'Content-Type': 'application/json',
|
|
'User-Agent': `planka (+${sails.config.custom.baseUrl})`,
|
|
};
|
|
|
|
if (webhook.accessToken) {
|
|
headers.Authorization = `Bearer ${webhook.accessToken}`;
|
|
}
|
|
|
|
const body = JSON.stringify({
|
|
event,
|
|
data,
|
|
prevData,
|
|
user,
|
|
});
|
|
|
|
try {
|
|
const response = await fetch(webhook.url, {
|
|
headers,
|
|
body,
|
|
method: 'POST',
|
|
dispatcher: sails.config.custom.outgoingProxy
|
|
? new ProxyAgent(sails.config.custom.outgoingProxy)
|
|
: undefined,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const message = await response.text();
|
|
|
|
sails.log.error(
|
|
`Webhook ${webhook.name} failed with status ${response.status} and message: ${message}`,
|
|
);
|
|
}
|
|
} catch (error) {
|
|
sails.log.error(`Webhook ${webhook.name} failed with error: ${error}`);
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
sync: true,
|
|
|
|
inputs: {
|
|
webhooks: {
|
|
type: 'ref',
|
|
required: true,
|
|
},
|
|
event: {
|
|
type: 'string',
|
|
required: true,
|
|
isIn: Object.values(Webhook.Events),
|
|
},
|
|
buildData: {
|
|
type: 'ref',
|
|
required: true,
|
|
},
|
|
buildPrevData: {
|
|
type: 'ref',
|
|
},
|
|
user: {
|
|
type: 'ref',
|
|
required: true,
|
|
},
|
|
scope: {
|
|
type: 'ref',
|
|
},
|
|
},
|
|
|
|
fn(inputs) {
|
|
const userId = inputs.user && inputs.user.id;
|
|
|
|
const eventFilteredWebhooks = inputs.webhooks.filter((webhook) => {
|
|
if (!webhook.url) {
|
|
return false;
|
|
}
|
|
|
|
if (webhook.excludedEvents && webhook.excludedEvents.includes(inputs.event)) {
|
|
return false;
|
|
}
|
|
|
|
if (webhook.events && !webhook.events.includes(inputs.event)) {
|
|
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;
|
|
});
|
|
|
|
if (webhooks.length === 0) {
|
|
return;
|
|
}
|
|
|
|
webhooks.forEach((webhook) => {
|
|
sendWebhook(
|
|
webhook,
|
|
inputs.event,
|
|
data,
|
|
prevData,
|
|
sails.helpers.users.presentOne(inputs.user),
|
|
);
|
|
});
|
|
},
|
|
};
|