Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .changeset/webhook-form-pickers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
"@objectstack/plugin-webhooks": minor
---

Webhook form: pick, don't type. The `sys_webhook` create/edit form made admins
hand-type machine data in three fields; they're now proper controls (extends the
`sys_sharing_rule` pass):

- `method` — free text → **select** (`GET`/`POST`/`PUT`/`PATCH`/`DELETE`). Option
values are lowercased by `Field.select`; the auto-enqueuer now upper-cases the
resolved method before delivery, so legacy `'POST'` rows and the new lowercase
values both normalise to a canonical HTTP method.
- `triggers` — hand-typed comma-separated string → **multi-select**
(`create`/`update`/`delete`/`undelete`/`api`). Stored as an array; the
auto-enqueuer's `parseRow` now accepts array, JSON-encoded-array-string, and
the legacy comma-separated forms, so existing subscriptions keep firing.
- `object_name` — free text → the **`object-ref`** object picker (same widget as
`sys_sharing_rule`; degrades to a text input where the widget isn't loaded).

Backward compatible: no data migration required. Added tests covering the array
and JSON-string trigger shapes.
36 changes: 36 additions & 0 deletions packages/plugins/plugin-webhooks/src/auto-enqueuer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,42 @@ describe('AutoEnqueuer', () => {
await ae.stop();
});

it('parses triggers authored as a multi-select array', async () => {
// The `triggers` field is now a multi-select stored as an array; the
// parser must treat it identically to the legacy CSV form.
const engine = new FakeEngine({ sys_webhook: [webhook({ triggers: ['create'] })] });
const realtime = new FakeRealtime();
const { enqueue, calls } = makeRecorder();
const ae = new AutoEnqueuer(engine, realtime, enqueue, { refreshIntervalMs: 0 });
await ae.start();

await realtime.publish(event('created', 'contact', { id: 'c-1' }));
await realtime.publish(event('updated', 'contact', { id: 'c-1' }, '2026-05-24T00:00:01.000Z'));
await flush();

expect(calls).toHaveLength(1);
expect(calls[0].label).toBe('data.record.created');
await ae.stop();
});

it('parses triggers stored as a JSON-encoded array string', async () => {
// Some drivers hand a JSON array column back as a string — accept it.
const engine = new FakeEngine({ sys_webhook: [webhook({ triggers: '["create","update"]' })] });
const realtime = new FakeRealtime();
const { enqueue, calls } = makeRecorder();
const ae = new AutoEnqueuer(engine, realtime, enqueue, { refreshIntervalMs: 0 });
await ae.start();

await realtime.publish(event('created', 'contact', { id: 'c-1' }));
await realtime.publish(event('deleted', 'contact', { id: 'c-1' }, '2026-05-24T00:00:02.000Z'));
await flush();

// create + update are subscribed; delete is not.
expect(calls).toHaveLength(1);
expect(calls[0].label).toBe('data.record.created');
await ae.stop();
});

it('fans out to multiple matching webhooks', async () => {
const engine = new FakeEngine({
sys_webhook: [
Expand Down
33 changes: 28 additions & 5 deletions packages/plugins/plugin-webhooks/src/auto-enqueuer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,11 +205,30 @@ export class AutoEnqueuer {

private parseRow(row: any): CachedSubscription | null {
if (!row?.id || !row?.url) return null;
const triggersField = (row.triggers ?? '') as string;
// `triggers` is now authored as a multi-select (stored as an array), but
// legacy rows stored a comma-separated string (and some drivers hand a
// JSON-encoded array back as a string). Accept all three shapes so a
// schema change never silently drops a subscription's events.
const rawTriggers = row.triggers;
let triggerList: string[];
if (Array.isArray(rawTriggers)) {
triggerList = rawTriggers.map((t) => String(t));
} else {
const s = String(rawTriggers ?? '').trim();
if (s.startsWith('[')) {
try {
const parsed = JSON.parse(s);
triggerList = Array.isArray(parsed) ? parsed.map((t) => String(t)) : [s];
} catch {
triggerList = s.split(',');
}
} else {
triggerList = s.split(',');
}
}
const triggers = new Set(
triggersField
.split(',')
.map((s: string) => s.trim().toLowerCase())
triggerList
.map((t) => t.trim().toLowerCase())
.filter(Boolean) as Array<'create' | 'update' | 'delete' | 'undelete'>,
);
if (triggers.size === 0) {
Expand All @@ -235,7 +254,11 @@ export class AutoEnqueuer {
objectName: row.object_name ? String(row.object_name) : undefined,
triggers,
url: String(row.url),
method: row.method ?? defn.method ?? 'POST',
// Method is authored via a select whose option values are lowercased
// (get/post/…); upper-case here so delivery uses a canonical HTTP
// method regardless of whether the row was authored before or after
// the select change (legacy rows stored 'POST').
method: String(row.method ?? defn.method ?? 'POST').toUpperCase(),
headers: defn.headers,
secret: defn.secret,
timeoutMs: defn.timeoutMs,
Expand Down
45 changes: 30 additions & 15 deletions packages/plugins/plugin-webhooks/src/sys-webhook.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,17 +113,26 @@ export const SysWebhook = ObjectSchema.create({
label: 'Object',
required: false,
maxLength: 100,
// Object picker (same widget as sys_sharing_rule) instead of a free-text
// machine name. Falls back to a text input when the widget is unavailable.
widget: 'object-ref',
description: 'Short object name whose events fire this webhook (blank = manual / API-triggered)',
group: 'Definition',
}),

triggers: Field.text({
label: 'Triggers',
required: false,
maxLength: 200,
description: 'Comma-separated event list: create,update,delete,undelete,api',
group: 'Definition',
}),
triggers: Field.select(
['create', 'update', 'delete', 'undelete', 'api'],
{
label: 'Triggers',
required: false,
// Multi-select instead of a hand-typed comma-separated string. Stored as
// an array; the auto-enqueuer parser also tolerates the legacy
// comma-separated / JSON-string forms so existing rows keep working.
multiple: true,
description: 'Record events that fire this webhook (empty = manual / API-triggered)',
group: 'Definition',
},
),

url: Field.text({
label: 'Target URL',
Expand All @@ -133,14 +142,20 @@ export const SysWebhook = ObjectSchema.create({
group: 'Definition',
}),

method: Field.text({
label: 'HTTP Method',
required: true,
defaultValue: 'POST',
maxLength: 10,
description: 'GET / POST / PUT / PATCH / DELETE',
group: 'Definition',
}),
method: Field.select(
['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
{
label: 'HTTP Method',
required: true,
// Select instead of free text. Option values are lowercased by the
// Field.select helper (get/post/…); the auto-enqueuer upper-cases the
// resolved method before delivery, so existing 'POST' rows and the
// lowercase option values both normalise correctly.
defaultValue: 'post',
description: 'HTTP method used for the callback request',
group: 'Definition',
},
),

description: Field.textarea({ label: 'Description', required: false, group: 'Definition' }),

Expand Down