Skip to content

Commit 4d5533f

Browse files
authored
feat(webhooks): delivery list and view (#5231)
1 parent 95b7d5a commit 4d5533f

83 files changed

Lines changed: 5009 additions & 374 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/superpowers/plans/2026-05-20-webhook-deliveries-page.md

Lines changed: 1870 additions & 0 deletions
Large diffs are not rendered by default.

docs/superpowers/plans/2026-05-21-webhook-delivery-retention-setting.md

Lines changed: 1337 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
# Webhook Deliveries Page
2+
3+
**Date:** 2026-05-20
4+
**Branch:** bruno/fix/webhooks-models-list
5+
**Status:** Approved (rev 2 — post code-review fixes)
6+
7+
## Goal
8+
9+
Add a global webhook deliveries page to the admin UI where users can browse all deliveries across all webhooks, filter by app/entity/event/status, and inspect each delivery's full detail inline via an accordion. Delivery detail components must be reusable across accordion, drawer, and dialog contexts.
10+
11+
## Architecture Overview
12+
13+
Three layers of change:
14+
15+
1. **API** — remove the required `webhookId` top-level arg from `listWebhookDeliveries`; expose a `where` GraphQL input that maps directly to the existing `IListWebhookDeliveriesInputWhere` use-case type; add `responseHeaders` to the GraphQL delivery type
16+
2. **SDK** — update method signature and query to match; add `responseHeaders` to the TypeScript type and selection set
17+
3. **Admin UI** — extract reusable `DeliveryDetailContent`, build new deliveries page with presenter and filter bar
18+
19+
### Reusable delivery detail component tree
20+
21+
```
22+
DeliveryDetailContent ← pure: takes WebhookDelivery, renders all fields
23+
24+
used by:
25+
├── DeliveryAccordionRow ← inline expand/collapse wrapper (new page)
26+
├── DeliveryDetail ← existing drawer wrapper (refactored to delegate here)
27+
└── DeliveryDialog ← future modal wrapper (not built now)
28+
```
29+
30+
The resend button and close button stay in each wrapper — not in `DeliveryDetailContent`.
31+
32+
## API Changes
33+
34+
### `listWebhookDeliveries` — GraphQL schema
35+
36+
Replace the current signature:
37+
38+
```graphql
39+
# before
40+
listWebhookDeliveries(webhookId: ID!, limit: Int, after: String): WebhookDeliveryListResponse!
41+
```
42+
43+
with:
44+
45+
```graphql
46+
# after
47+
listWebhookDeliveries(
48+
where: WebhookDeliveryListWhereInput
49+
limit: Int
50+
after: String
51+
): WebhookDeliveryListResponse!
52+
53+
input WebhookDeliveryListWhereInput {
54+
webhookId_eq: ID
55+
eventType_in: [String!]
56+
status_in: [String!]
57+
}
58+
```
59+
60+
These three operators map directly to the existing `IListWebhookDeliveriesInputWhere` type (which already extends `IdInterfaceGenerator<"webhookId">`, `TextInterfaceGenerator<"eventType">`, and `TextInterfaceGenerator<WebhookDeliveryStatus>`). The resolver passes `args.where` straight to the use caseno translation layer needed.
61+
62+
The existing drawer calls `listWebhookDeliveries(webhookId: ...)` — update its gateway to pass `where: { webhookId_eq: id }` instead.
63+
64+
### `WebhookDelivery` GraphQL type
65+
66+
Add the missing field:
67+
68+
```graphql
69+
type WebhookDelivery {
70+
# ... existing fields ...
71+
responseHeaders: JSON # was missing; stored compressed, decompressed by transformer
72+
}
73+
```
74+
75+
### SDK `listWebhookDeliveries`
76+
77+
Three changes:
78+
79+
1. `ListWebhookDeliveriesParams`: replace `webhookId: string` with `where?: { webhookId_eq?: string; eventType_in?: string[]; status_in?: string[] }`
80+
2. `WebhookDelivery` type: add `responseHeaders: unknown | null`
81+
3. GraphQL selection set: add `responseHeaders`; same change needed in `getWebhookDelivery` if it shares the type
82+
83+
## Admin UI
84+
85+
### New reusable component: `DeliveryDetailContent`
86+
87+
Location: `packages/webhooks/src/admin/presentation/WebhookDeliveries/components/DeliveryDetailContent.tsx`
88+
89+
Props: `delivery: WebhookDelivery`
90+
91+
Renders a summary row (always visible) plus these independently collapsible sections:
92+
93+
| Section | Content | Default state |
94+
|---|---|---|
95+
| Summary | HTTP status code, status badge, response time (ms), created date | always visible |
96+
| Payload | JSON block | expanded |
97+
| Request headers | JSON block | collapsed |
98+
| Response headers | JSON block | collapsed |
99+
| Response body | text block | collapsed |
100+
101+
Uses `Accordion` from `@webiny/admin-ui` for the collapsible sections internally.
102+
103+
### Refactor: `DeliveryDetail.tsx`
104+
105+
Replace its inline field rendering with `<DeliveryDetailContent delivery={delivery} />`. The close `IconButton` and resend `Button` stay in this wrapper unchanged.
106+
107+
### New component: `DeliveryAccordionRow`
108+
109+
Location: `packages/webhooks/src/admin/presentation/WebhookDeliveries/components/DeliveryAccordionRow.tsx`
110+
111+
A thin wrapper around `Accordion.Item` from `@webiny/admin-ui`:
112+
113+
- `title`: event type string
114+
- `subtitle`: status `Tag` + HTTP code + response time
115+
- `actions`: resend `Accordion.Item.Action` (icon button)
116+
- `children`: `<DeliveryDetailContent delivery={delivery} />`
117+
- `open` / `onOpenChange`: controlled externally by the presenter
118+
119+
`Accordion` does not have a built-in "single open" modethe presenter enforces it by tracking `expandedDeliveryId` and passing `open={expandedDeliveryId === delivery.id}` to each row. Clicking an already-open row passes `false` to `onOpenChange`, collapsing it.
120+
121+
The existing `WebhookDeliveriesDrawer` continues to use `DataTable` — `DeliveryAccordionRow` is only used on the new deliveries page.
122+
123+
### New page: `WebhookDeliveriesPage`
124+
125+
Location: `packages/webhooks/src/admin/presentation/WebhookDeliveriesPage/`
126+
127+
Files:
128+
129+
```
130+
WebhookDeliveriesPagePresenter.ts ← filter state, available events, accordion, pagination
131+
feature.ts ← DI wiring
132+
components/
133+
WebhookDeliveriesPage.tsx ← page root
134+
DeliveryFilters.tsx ← filter bar
135+
```
136+
137+
**Route:** `/webhooks/deliveries`
138+
139+
Added to `packages/webhooks/src/admin/routes.ts` as `Routes.Deliveries`, and registered in `WebhookRoutes.tsx` **before** `Routes.Form` to prevent the `/webhooks/:id` pattern from capturing it. The existing `/webhooks/settings` route already uses this ordering — follow the same pattern.
140+
141+
**Filter bar (`DeliveryFilters.tsx`):**
142+
143+
| Filter | Type | Data source |
144+
|---|---|---|
145+
| App | single-select dropdown | distinct `app` values from `listAvailableWebhookEvents` |
146+
| Entity | single-select dropdown | `entity` values for selected app; cleared when app changes |
147+
| Event | single-select dropdown | `eventName` values for selected app+entity; cleared when entity changes |
148+
| Status | multi-select | hardcoded: `pending`, `delivering`, `delivered`, `failed` |
149+
150+
**Partial filter semantics:**
151+
152+
The presenter translates filter selections into the `eventType_in` array by matching against the loaded `availableEvents` list:
153+
154+
- App selected, no entity/event → include all `eventName` values where `event.app === selectedApp`
155+
- App + entity, no event → include all `eventName` values where `app` and `entity` both match
156+
- App + entity + event → include the single matching `eventName`
157+
- No app selected → omit `eventType_in` entirely (backend returns all deliveries)
158+
- Status selected → pass `status_in: [...]`; omit when empty
159+
160+
**On filter change:** reset cursor to `null` (fresh first page), collapse any open accordion item (`expandedDeliveryId = null`), replace the delivery list with the new results.
161+
162+
**Pagination:** "Load more" button below the accordion. Uses `WebhookDeliveriesDataSource` cursor-based pagination. The button is hidden when `hasMoreItems` is `false`.
163+
164+
**Delivery list:** An `Accordion` (from `@webiny/admin-ui`) where each item is a `DeliveryAccordionRow`.
165+
166+
**Loading / empty / error states:**
167+
168+
- Initial load: show a spinner in place of the accordion
169+
- Empty result (no deliveries or no matches for current filter): show an empty-state message ("No deliveries found")
170+
- API error: show an inline error message with a retry button
171+
- "Load more" in-flight: disable the button and show a spinner on it
172+
- Resend in-flight: disable the resend button on the affected row
173+
174+
### Presenter: `WebhookDeliveriesPagePresenter`
175+
176+
State managed (MobX observable):
177+
178+
- `availableEvents: WebhookEvent[]` — loaded once on `init()`
179+
- `filters: { app: string | null; entity: string | null; eventName: string | null; status: string[] }` — current selections
180+
- `expandedDeliveryId: string | null` — which accordion row is open
181+
- delegates list/pagination to `ListPresenter` (existing abstraction) with the translated `where` input
182+
183+
Actions:
184+
185+
- `init()` — loads available events and first page of deliveries in parallel
186+
- `setAppFilter(app)` — sets app, clears entity, event; triggers fresh fetch
187+
- `setEntityFilter(entity)` — sets entity, clears event; triggers fresh fetch
188+
- `setEventFilter(eventName)` — triggers fresh fetch
189+
- `setStatusFilter(status[])` — triggers fresh fetch
190+
- `expandDelivery(id)` — toggles accordion row (set to `null` if already open)
191+
- `loadMore()` — appends next page
192+
- `resend(id)` — calls resend use case, then refreshes the current page (same `where` + `after: null`)
193+
194+
### Feature wiring
195+
196+
`WebhookDeliveriesPageFeature` registers:
197+
198+
- `ListWebhookDeliveriesFeature`
199+
- `ListAvailableEventsFeature` ← admin-side name; file: `packages/webhooks/src/admin/features/listAvailableEvents/feature.ts`
200+
- `ResendWebhookDeliveryFeature`
201+
- `WebhookDeliveriesPagePresenterFeature`
202+
203+
## Data Flow
204+
205+
```
206+
mount → init()
207+
→ listAvailableWebhookEvents() → populate filter dropdowns
208+
→ listWebhookDeliveries(where: {}) → populate accordion (all deliveries)
209+
210+
filter change → setXxxFilter()
211+
→ translate filters → eventType_in[], status_in[]
212+
→ reset cursor, collapse expanded row
213+
→ listWebhookDeliveries(where: { eventType_in, status_in })
214+
→ replace accordion items
215+
216+
row click → expandDelivery(id)
217+
→ set expandedDeliveryId (or null if same row)
218+
→ Accordion.Item open/close via controlled props
219+
220+
load more → loadMore()
221+
→ listWebhookDeliveries(where: ..., after: cursor)
222+
→ append items to accordion
223+
224+
resend(id) → resend use case
225+
→ refresh: listWebhookDeliveries(where: ..., after: null)
226+
→ replace accordion items (preserves expanded row)
227+
228+
drawer (existing, unchanged behaviour) → WebhookDeliveriesDrawer
229+
→ gateway updated: listWebhookDeliveries(where: { webhookId_eq: id })
230+
```
231+
232+
## What is NOT in scope
233+
234+
- `DeliveryDialog` wrapper (future — `DeliveryDetailContent` is ready for it)
235+
- Bulk resend
236+
- Export / download
237+
- Search by payload content
238+
- Delivery analytics / metrics
239+
- The existing per-webhook drawer (stays as-is; only the gateway call changes)
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
# Webhook Delivery Retention Setting
2+
3+
## Overview
4+
5+
Add a user-configurable `deliveryRetentionDays` field to the webhook settings model. This replaces the hardcoded `WEBHOOK_DELIVERY_RETENTION_DAYS = 90` constant and allows users to control how long delivery records are retained before DynamoDB TTL removes them.
6+
7+
- **Min:** 0 (delete immediately)
8+
- **Max:** `WEBHOOK_DELIVERY_MAX_RETENTION_DAYS` (currently 3650 — 10 years)
9+
- **Default (unset):** `WEBHOOK_DELIVERY_MAX_RETENTION_DAYS`
10+
- **DynamoDB TTL wiring:** handled by the CMS storage layer, out of scope here
11+
12+
---
13+
14+
## Constants (`packages/webhooks/src/api/domain/constants.ts`)
15+
16+
Add:
17+
```ts
18+
export const WEBHOOK_DELIVERY_MAX_RETENTION_DAYS = 3650;
19+
```
20+
21+
Remove `WEBHOOK_DELIVERY_RETENTION_DAYS = 90`.
22+
23+
---
24+
25+
## API Layer
26+
27+
### CMS Model (`WebhookSettingsModel.ts`)
28+
29+
Add a `number` field `deliveryRetentionDays`:
30+
- Not required, not encrypted
31+
- Validation: min 0, max `WEBHOOK_DELIVERY_MAX_RETENTION_DAYS`
32+
33+
### Domain type (`WebhookSettings.ts`)
34+
35+
```ts
36+
export interface IWebhookSettings {
37+
signingSecret: string | undefined;
38+
deliveryRetentionDays: number | undefined;
39+
}
40+
```
41+
42+
### GraphQL schema (`WebhookSettingsSchema.ts`)
43+
44+
Add to `WebhookSettings` type:
45+
```graphql
46+
deliveryRetentionDays: Int
47+
```
48+
49+
Add to `UpdateWebhookSettingsInput`:
50+
```graphql
51+
deliveryRetentionDays: Int
52+
```
53+
54+
### Zod validation (`UpdateWebhookSettingsUseCase`)
55+
56+
Add to input schema:
57+
```ts
58+
deliveryRetentionDays: z.number().int().min(0).max(WEBHOOK_DELIVERY_MAX_RETENTION_DAYS).optional()
59+
```
60+
61+
### Use cases — `expiresAt` computation
62+
63+
`WebhookDispatcher`, `ResendWebhookDeliveryUseCase`, and `TriggerWebhookUseCase` each:
64+
65+
1. Inject `GetWebhookSettingsRepository` as a new dependency
66+
2. Call `getWebhookSettingsRepository.execute()` before creating the delivery
67+
3. Compute:
68+
```ts
69+
const retentionDays = settings.deliveryRetentionDays ?? WEBHOOK_DELIVERY_MAX_RETENTION_DAYS;
70+
const expiresAt = new Date(Date.now() + retentionDays * 24 * 60 * 60 * 1000).toISOString();
71+
```
72+
73+
---
74+
75+
## Admin Layer
76+
77+
### Shared type (`packages/webhooks/src/admin/shared/types.ts`)
78+
79+
```ts
80+
export interface WebhookSettings {
81+
signingSecret: string | undefined;
82+
deliveryRetentionDays: number | undefined;
83+
}
84+
```
85+
86+
### Gateways
87+
88+
- `GetWebhookSettingsGateway` — add `deliveryRetentionDays` to the GraphQL query fields
89+
- `UpdateWebhookSettingsGateway` — add `deliveryRetentionDays` to the mutation input and response fields
90+
91+
### Presenter (`WebhookSettingsPresenter.ts`)
92+
93+
Add a number field to the `FormModel`:
94+
- Label: `Delivery Retention (days)`
95+
- Description: `How long to keep delivery logs. Set to 0 to delete immediately. Maximum ${WEBHOOK_DELIVERY_MAX_RETENTION_DAYS} days.`
96+
- Placeholder: `${WEBHOOK_DELIVERY_MAX_RETENTION_DAYS}`
97+
- Validation: min 0, max `WEBHOOK_DELIVERY_MAX_RETENTION_DAYS`
98+
99+
---
100+
101+
## Out of Scope
102+
103+
- DynamoDB TTL attribute wiring (handled by CMS storage layer)
104+
- Migration of existing delivery records

packages/api-core/src/features/webhooks/Webhook/abstractions/WebhookFactory.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export interface IWebhookFactoryDefinition {
44
app: string;
55
appLabel: string;
66
entity: string;
7+
entityLabel: string;
78
eventName: string;
89
label: string;
910
}

packages/api-headless-cms-ddb-es/src/definitions/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,10 @@ export interface IEntryEntityAttributesData {
6666
system?: ICmsEntrySystem;
6767
live: ICmsEntryLive | null;
6868
revisionDescription: string | undefined;
69+
/**
70+
* A timestamp of when the entry should be automatically deleted from the database.
71+
*/
72+
expiresAt: number | null;
6973
}
7074

7175
export type IEntryEntityAttributes = IStandardEntityAttributes<IEntryEntityAttributesData>;

packages/api-headless-cms-ddb/src/definitions/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,10 @@ export interface IEntryEntityAttributesData {
6666
system?: ICmsEntrySystem;
6767
live: ICmsEntryLive | null;
6868
revisionDescription: string | undefined;
69+
/**
70+
* A timestamp of when the entry should be automatically deleted from the database.
71+
*/
72+
expiresAt: number | null;
6973
}
7074

7175
export type IEntryEntityAttributes = IStandardEntityAttributes<IEntryEntityAttributesData>;

0 commit comments

Comments
 (0)