Skip to content

Commit 16639f8

Browse files
parth0025claude
andauthored
feat(webhooks): add Slack and Discord delivery format presets (#243)
A webhook can now set format=slack or format=discord (default json) so it can target a Slack or Discord incoming webhook directly — the dispatcher renders each task event into Slack Block Kit or a Discord embed instead of raw JSON, no transform service needed. format is validated on create/update, declared on the webhooks schema, and the transformer is a pure function covered by unit tests. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 63a7205 commit 16639f8

5 files changed

Lines changed: 131 additions & 7 deletions

File tree

Modules/Webhooks/controller.js

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ const maskHook = (hook) => ({
1414
url: hook.url,
1515
events: hook.events,
1616
active: hook.active !== false,
17+
format: hook.format || 'json',
1718
createdBy: hook.createdBy || '',
1819
lastStatus: hook.lastStatus,
1920
lastDeliveredAt: hook.lastDeliveredAt,
@@ -29,11 +30,11 @@ exports.listEvents = (req, res) => {
2930
exports.createWebhook = async (req, res) => {
3031
try {
3132
const companyId = req.headers['companyid'] || '';
32-
const { name, url, events, userData } = req.body || {};
33+
const { name, url, events, format, userData } = req.body || {};
3334
if (!companyId) {
3435
return res.send({ status: false, statusText: 'companyId is required.' });
3536
}
36-
const check = validateWebhookInput({ name, url, events });
37+
const check = validateWebhookInput({ name, url, events, format });
3738
if (!check.valid) {
3839
return res.send({ status: false, statusText: check.reason });
3940
}
@@ -47,6 +48,7 @@ exports.createWebhook = async (req, res) => {
4748
events,
4849
secret,
4950
active: true,
51+
format: format || 'json',
5052
createdBy: userData && (userData.id || userData._id) ? String(userData.id || userData._id) : '',
5153
},
5254
}, 'save');
@@ -86,20 +88,22 @@ exports.updateWebhook = async (req, res) => {
8688
if (!companyId || !isObjectIdString(id)) {
8789
return res.send({ status: false, statusText: 'companyId and a valid webhook id are required.' });
8890
}
89-
const { name, url, events, active } = req.body || {};
91+
const { name, url, events, active, format } = req.body || {};
9092
const update = {};
91-
if (name !== undefined || url !== undefined || events !== undefined) {
93+
if (name !== undefined || url !== undefined || events !== undefined || format !== undefined) {
9294
const check = validateWebhookInput({
9395
name: name !== undefined ? name : 'placeholder',
9496
url: url !== undefined ? url : 'https://placeholder.invalid',
9597
events: events !== undefined ? events : ['*'],
98+
format,
9699
});
97100
if (!check.valid) {
98101
return res.send({ status: false, statusText: check.reason });
99102
}
100103
if (name !== undefined) update.name = String(name).trim();
101104
if (url !== undefined) update.url = String(url).trim();
102105
if (events !== undefined) update.events = events;
106+
if (format !== undefined) update.format = format;
103107
}
104108
if (active !== undefined) update.active = active === true;
105109
if (!Object.keys(update).length) {

Modules/Webhooks/dispatcher.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ const { SCHEMA_TYPE } = require('../../Config/schemaType');
33
const { MongoDbCrudOpration } = require('../../utils/mongo-handler/mongoQueries');
44
const logger = require('../../Config/loggerConfig');
55
const socketEmitter = require('../../event/socketEventEmitter');
6-
const { subscribesTo, classifyTaskEvent, trimTaskForDelivery, signPayload } = require('./helpers/webhookRules');
6+
const { subscribesTo, classifyTaskEvent, trimTaskForDelivery, signPayload, formatForTarget } = require('./helpers/webhookRules');
77

88
// Webhook dispatcher. Piggybacks on the namespaced socketEmitter events that
99
// every task mutation already fires, so no write path needed changes:
@@ -66,7 +66,9 @@ async function logDelivery(companyId, webhookId, entry) {
6666
}
6767

6868
async function deliverToHook(companyId, hook, body, attempt) {
69-
const bodyString = JSON.stringify(body);
69+
// Shape the payload for the hook's target (raw json / Slack / Discord).
70+
// The signature covers exactly what we send; body.event is kept for logging.
71+
const bodyString = JSON.stringify(formatForTarget(hook.format, body));
7072
const startedAt = Date.now();
7173
try {
7274
const response = await axios.post(hook.url, bodyString, {

Modules/Webhooks/helpers/webhookRules.js

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,18 @@ const WILDCARD = '*';
1616
const OBJECT_ID_PATTERN = /^[0-9a-fA-F]{24}$/;
1717
const MAX_NAME_LENGTH = 80;
1818

19+
// Outbound payload shapes. 'json' ships the raw envelope; 'slack' and
20+
// 'discord' render the event into that platform's incoming-webhook format.
21+
const WEBHOOK_FORMATS = Object.freeze(['json', 'slack', 'discord']);
22+
23+
const EVENT_LABELS = Object.freeze({
24+
'task.created': 'Task created',
25+
'task.updated': 'Task updated',
26+
'task.deleted': 'Task deleted',
27+
'task.archived': 'Task archived',
28+
'task.restored': 'Task restored',
29+
});
30+
1931
const isObjectIdString = (id) => OBJECT_ID_PATTERN.test(String(id || ''));
2032

2133
const isValidUrl = (value) => {
@@ -28,7 +40,7 @@ const isValidUrl = (value) => {
2840
};
2941

3042
/* Validate create/update input. Returns { valid, reason }. */
31-
const validateWebhookInput = ({ name, url, events }) => {
43+
const validateWebhookInput = ({ name, url, events, format }) => {
3244
if (!name || !String(name).trim() || String(name).length > MAX_NAME_LENGTH) {
3345
return { valid: false, reason: `A name up to ${MAX_NAME_LENGTH} characters is required.` };
3446
}
@@ -42,9 +54,52 @@ const validateWebhookInput = ({ name, url, events }) => {
4254
if (unknown.length) {
4355
return { valid: false, reason: `Unknown events: ${unknown.join(', ')}.` };
4456
}
57+
if (format !== undefined && !WEBHOOK_FORMATS.includes(String(format))) {
58+
return { valid: false, reason: `format must be one of: ${WEBHOOK_FORMATS.join(', ')}.` };
59+
}
4560
return { valid: true, reason: '' };
4661
};
4762

63+
const normalizeFormat = (format) => (WEBHOOK_FORMATS.includes(String(format)) ? String(format) : 'json');
64+
65+
// Best-effort human status label from the trimmed task (status may be an
66+
// object, a string, or absent).
67+
const statusLabel = (task) => {
68+
const s = task.status;
69+
return String((s && (s.text || s.statusName || s.name)) || task.statusType || '—');
70+
};
71+
72+
/* Render the canonical delivery body into the target platform's shape.
73+
* Slack expects { text, blocks }; Discord expects { embeds }. 'json' (and
74+
* anything unknown) ships the raw envelope unchanged. Pure — no I/O. */
75+
const formatForTarget = (format, body) => {
76+
const fmt = normalizeFormat(format);
77+
if (fmt === 'json') return body;
78+
79+
const task = body.data || {};
80+
const label = EVENT_LABELS[body.event] || body.event;
81+
const title = `${task.TaskKey ? task.TaskKey + ' — ' : ''}${task.TaskName || 'Task'}`;
82+
const meta = `Status: ${statusLabel(task)} · Priority: ${task.Task_Priority || '—'}${task.DueDate ? ' · Due: ' + task.DueDate : ''}`;
83+
84+
if (fmt === 'slack') {
85+
return {
86+
text: `${label}: ${title}`,
87+
blocks: [
88+
{ type: 'section', text: { type: 'mrkdwn', text: `*${label}*\n*${task.TaskKey || ''}* ${task.TaskName || ''}`.trim() } },
89+
{ type: 'context', elements: [{ type: 'mrkdwn', text: meta }] },
90+
],
91+
};
92+
}
93+
94+
// discord
95+
const fields = [
96+
{ name: 'Status', value: statusLabel(task), inline: true },
97+
{ name: 'Priority', value: String(task.Task_Priority || '—'), inline: true },
98+
];
99+
if (task.DueDate) fields.push({ name: 'Due', value: String(task.DueDate), inline: true });
100+
return { embeds: [{ title, description: label, fields }] };
101+
};
102+
48103
/* Does a webhook's subscription cover this event? */
49104
const subscribesTo = (webhook, event) => {
50105
const events = webhook?.events || [];
@@ -96,6 +151,7 @@ const generateSecret = () => crypto.randomBytes(24).toString('hex');
96151
module.exports = {
97152
EVENT_TYPES,
98153
WILDCARD,
154+
WEBHOOK_FORMATS,
99155
isObjectIdString,
100156
isValidUrl,
101157
validateWebhookInput,
@@ -104,4 +160,6 @@ module.exports = {
104160
trimTaskForDelivery,
105161
signPayload,
106162
generateSecret,
163+
normalizeFormat,
164+
formatForTarget,
107165
};

tests/webhook-rules.test.js

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,67 @@
99
const {
1010
EVENT_TYPES,
1111
WILDCARD,
12+
WEBHOOK_FORMATS,
1213
isValidUrl,
1314
validateWebhookInput,
1415
subscribesTo,
1516
classifyTaskEvent,
1617
trimTaskForDelivery,
1718
signPayload,
1819
generateSecret,
20+
normalizeFormat,
21+
formatForTarget,
1922
} = require('../Modules/Webhooks/helpers/webhookRules');
2023

24+
describe('webhook format presets', () => {
25+
const body = {
26+
event: 'task.updated',
27+
companyId: 'c1',
28+
data: { TaskKey: 'AHE-1', TaskName: 'Fix login', status: { text: 'In Progress' }, Task_Priority: 'HIGH', DueDate: '2026-07-01' },
29+
};
30+
31+
test('normalizeFormat falls back to json for unknown/empty', () => {
32+
expect(normalizeFormat('slack')).toBe('slack');
33+
expect(normalizeFormat('discord')).toBe('discord');
34+
expect(normalizeFormat('teams')).toBe('json');
35+
expect(normalizeFormat(undefined)).toBe('json');
36+
});
37+
38+
test('json format returns the raw envelope unchanged', () => {
39+
expect(formatForTarget('json', body)).toBe(body);
40+
expect(formatForTarget(undefined, body)).toBe(body);
41+
});
42+
43+
test('slack format produces text + blocks with the task details', () => {
44+
const out = formatForTarget('slack', body);
45+
expect(out.text).toContain('AHE-1');
46+
expect(Array.isArray(out.blocks)).toBe(true);
47+
const rendered = JSON.stringify(out);
48+
expect(rendered).toContain('Task updated');
49+
expect(rendered).toContain('In Progress');
50+
expect(rendered).toContain('HIGH');
51+
});
52+
53+
test('discord format produces an embed with status/priority/due fields', () => {
54+
const out = formatForTarget('discord', body);
55+
expect(Array.isArray(out.embeds)).toBe(true);
56+
expect(out.embeds[0].title).toContain('Fix login');
57+
const names = out.embeds[0].fields.map((f) => f.name);
58+
expect(names).toEqual(expect.arrayContaining(['Status', 'Priority', 'Due']));
59+
});
60+
61+
test('validateWebhookInput rejects an unknown format but accepts a valid one', () => {
62+
const base = { name: 'h', url: 'https://x.test', events: ['*'] };
63+
expect(validateWebhookInput({ ...base, format: 'teams' }).valid).toBe(false);
64+
expect(validateWebhookInput({ ...base, format: 'slack' }).valid).toBe(true);
65+
expect(validateWebhookInput(base).valid).toBe(true); // format optional
66+
});
67+
68+
test('WEBHOOK_FORMATS lists the three supported shapes', () => {
69+
expect(WEBHOOK_FORMATS).toEqual(['json', 'slack', 'discord']);
70+
});
71+
});
72+
2173
describe('🪝 WEBHOOKS - Rules', () => {
2274

2375
describe('validateWebhookInput', () => {

utils/mongo-handler/schema.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -436,6 +436,14 @@ const schema = {
436436
default: true,
437437
required: false,
438438
},
439+
// Outbound payload shape: 'json' (raw, default), 'slack' (Block Kit),
440+
// or 'discord' (embed) — lets a hook target a Slack/Discord incoming
441+
// webhook directly without a transform service.
442+
format: {
443+
type: String,
444+
default: "json",
445+
required: false,
446+
},
439447
createdBy: {
440448
type: String,
441449
required: false,

0 commit comments

Comments
 (0)