betterstart-cli 0.0.28 → 0.0.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/adapters/next/integrations/mailchimp/actions/mailchimp.ts +0 -1
- package/dist/assets/adapters/next/plugins/blog/schemas/menus.json +1 -1
- package/dist/assets/adapters/next/plugins/blog/schemas/posts.json +1 -1
- package/dist/assets/adapters/next/templates/init/admin-globals.css +1 -1
- package/dist/assets/adapters/next/templates/init/components/layouts/admin-nav-link.tsx +12 -2
- package/dist/assets/adapters/next/templates/init/components/layouts/admin-settings-sidebar.tsx +75 -0
- package/dist/assets/adapters/next/templates/init/components/layouts/admin-sidebar-nav-link.tsx +12 -2
- package/dist/assets/adapters/next/templates/init/components/layouts/admin-sidebar.tsx +2 -8
- package/dist/assets/adapters/next/templates/init/components/shared/data-table/data-grid.tsx +14 -5
- package/dist/assets/adapters/next/templates/init/components/shared/data-table/data-table.tsx +111 -112
- package/dist/assets/adapters/next/templates/init/components/shared/dev-mode/dev-mode-types.ts +4 -0
- package/dist/assets/adapters/next/templates/init/components/shared/dev-mode-integrate.tsx +1 -1
- package/dist/assets/adapters/next/templates/init/components/shared/entity-versions/entity-versions-drawer.tsx +1 -1
- package/dist/assets/adapters/next/templates/init/components/shared/media/media-url-importer.tsx +45 -47
- package/dist/assets/adapters/next/templates/init/components/shared/page-header.tsx +1 -1
- package/dist/assets/adapters/next/templates/init/data/webhook-events.ts +5 -0
- package/dist/assets/adapters/next/templates/init/hooks/use-table-utils.ts +27 -7
- package/dist/assets/adapters/next/templates/init/hooks/use-webhooks.ts +211 -0
- package/dist/assets/adapters/next/templates/init/lib/actions/audit/types.ts +2 -0
- package/dist/assets/adapters/next/templates/init/lib/actions/forms/index.ts +0 -1
- package/dist/assets/adapters/next/templates/init/lib/actions/forms/types.ts +0 -4
- package/dist/assets/adapters/next/templates/init/lib/actions/forms/upsert-form-settings.ts +0 -2
- package/dist/assets/adapters/next/templates/init/lib/actions/webhooks/create-webhook.ts +55 -0
- package/dist/assets/adapters/next/templates/init/lib/actions/webhooks/delete-webhook.ts +26 -0
- package/dist/assets/adapters/next/templates/init/lib/actions/webhooks/dispatch.ts +135 -0
- package/dist/assets/adapters/next/templates/init/lib/actions/webhooks/get-webhook-deliveries.ts +124 -0
- package/dist/assets/adapters/next/templates/init/lib/actions/webhooks/get-webhook-secret.ts +28 -0
- package/dist/assets/adapters/next/templates/init/lib/actions/webhooks/get-webhook-subscriptions.ts +32 -0
- package/dist/assets/adapters/next/templates/init/lib/actions/webhooks/get-webhooks.ts +154 -0
- package/dist/assets/adapters/next/templates/init/lib/actions/webhooks/index.ts +30 -0
- package/dist/assets/adapters/next/templates/init/lib/actions/webhooks/internal-get-active-endpoints.ts +20 -0
- package/dist/assets/adapters/next/templates/init/lib/actions/webhooks/regenerate-webhook-secret.ts +35 -0
- package/dist/assets/adapters/next/templates/init/lib/actions/webhooks/register.ts +70 -0
- package/dist/assets/adapters/next/templates/init/lib/actions/webhooks/send-test-webhook.ts +81 -0
- package/dist/assets/adapters/next/templates/init/lib/actions/webhooks/types.ts +104 -0
- package/dist/assets/adapters/next/templates/init/lib/actions/webhooks/update-webhook.ts +72 -0
- package/dist/assets/adapters/next/templates/init/lib/db/core/schema.ts +71 -2
- package/dist/assets/adapters/next/templates/init/lib/lifecycle-hooks/register.ts +2 -0
- package/dist/assets/adapters/next/templates/init/lib/lifecycle-hooks/runner.ts +3 -1
- package/dist/assets/adapters/next/templates/init/lib/lifecycle-hooks/types.ts +6 -1
- package/dist/assets/adapters/next/templates/init/pages/auth-gate-rsc.tsx +1 -1
- package/dist/assets/adapters/next/templates/init/pages/media/media-page-content.tsx +1 -1
- package/dist/assets/adapters/next/templates/init/pages/media/media-page-skeleton.tsx +1 -1
- package/dist/assets/adapters/next/templates/init/pages/settings/audit-log/audit-log-columns.tsx +150 -0
- package/dist/assets/adapters/next/templates/init/pages/settings/audit-log/audit-log-page-content.tsx +137 -0
- package/dist/assets/adapters/next/templates/init/pages/{audit-log → settings/audit-log}/audit-log-page-skeleton.tsx +1 -1
- package/dist/assets/adapters/next/templates/init/pages/settings/audit-log/audit-log-table.tsx +295 -0
- package/dist/assets/adapters/next/templates/init/pages/settings/forms/edit-form-notifications-dialog.tsx +128 -0
- package/dist/assets/adapters/next/templates/init/pages/settings/forms/forms-settings-page-content.tsx +111 -0
- package/dist/assets/adapters/next/templates/init/pages/settings/forms/forms-settings-page-skeleton.tsx +7 -0
- package/dist/assets/adapters/next/templates/init/pages/settings/forms/forms-settings-page.tsx +14 -0
- package/dist/assets/adapters/next/templates/init/pages/settings/settings-layout.tsx +10 -0
- package/dist/assets/adapters/next/templates/init/pages/settings/webhooks/delete-webhook-dialog.tsx +82 -0
- package/dist/assets/adapters/next/templates/init/pages/settings/webhooks/webhook-actions.tsx +53 -0
- package/dist/assets/adapters/next/templates/init/pages/settings/webhooks/webhook-enabled-switch.tsx +18 -0
- package/dist/assets/adapters/next/templates/init/pages/settings/webhooks/webhook-endpoint-dialog.tsx +370 -0
- package/dist/assets/adapters/next/templates/init/pages/settings/webhooks/webhooks-columns.tsx +258 -0
- package/dist/assets/adapters/next/templates/init/pages/settings/webhooks/webhooks-logs-columns.tsx +200 -0
- package/dist/assets/adapters/next/templates/init/pages/settings/webhooks/webhooks-logs-page-content.tsx +117 -0
- package/dist/assets/adapters/next/templates/init/pages/settings/webhooks/webhooks-logs-page.tsx +14 -0
- package/dist/assets/adapters/next/templates/init/pages/settings/webhooks/webhooks-logs-table.tsx +253 -0
- package/dist/assets/adapters/next/templates/init/pages/settings/webhooks/webhooks-page-content.tsx +161 -0
- package/dist/assets/adapters/next/templates/init/pages/settings/webhooks/webhooks-page-skeleton.tsx +7 -0
- package/dist/assets/adapters/next/templates/init/pages/settings/webhooks/webhooks-page.tsx +14 -0
- package/dist/assets/adapters/next/templates/init/pages/settings/webhooks/webhooks-table.tsx +264 -0
- package/dist/assets/adapters/next/templates/init/pages/users/users-page-skeleton.tsx +1 -1
- package/dist/assets/adapters/next/templates/init/pages/users/users-table.tsx +103 -109
- package/dist/assets/adapters/next/templates/init/types/webhooks.ts +7 -0
- package/dist/assets/adapters/next/templates/init/utils/webhook/signature.ts +13 -0
- package/dist/assets/shared-assets/react-admin/ui/card.tsx +1 -1
- package/dist/assets/shared-assets/react-admin/ui/drawer.tsx +2 -2
- package/dist/assets/shared-assets/react-admin/ui/input-group.tsx +3 -3
- package/dist/assets/shared-assets/react-admin/ui/sidebar.tsx +1 -1
- package/dist/cli.js +947 -620
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
- package/dist/assets/adapters/next/templates/init/lib/actions/forms/test-form-webhook.ts +0 -40
- package/dist/assets/adapters/next/templates/init/pages/audit-log/audit-log-page-content.tsx +0 -550
- package/dist/assets/adapters/next/templates/init/utils/webhook/webhook.ts +0 -28
- /package/dist/assets/adapters/next/templates/init/pages/{audit-log → settings/audit-log}/audit-log-detail-drawer.tsx +0 -0
- /package/dist/assets/adapters/next/templates/init/pages/{audit-log → settings/audit-log}/audit-log-detail-row.tsx +0 -0
- /package/dist/assets/adapters/next/templates/init/pages/{audit-log → settings/audit-log}/audit-log-page.tsx +0 -0
package/dist/assets/adapters/next/templates/init/lib/actions/webhooks/get-webhook-subscriptions.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
'use server'
|
|
2
|
+
|
|
3
|
+
import { requireRole, UserRole } from '@admin/auth/middleware'
|
|
4
|
+
import db from '@admin/db'
|
|
5
|
+
import { webhookSubscriptions } from '@admin/db/schema'
|
|
6
|
+
import { cacheLife, cacheTag } from 'next/cache'
|
|
7
|
+
import type { WebhookSubscriptionEntry } from './types'
|
|
8
|
+
import { webhooksCacheTags } from './types'
|
|
9
|
+
|
|
10
|
+
async function readWebhookSubscriptions(): Promise<WebhookSubscriptionEntry[]> {
|
|
11
|
+
'use cache'
|
|
12
|
+
cacheLife('max')
|
|
13
|
+
cacheTag(webhooksCacheTags.subscriptions)
|
|
14
|
+
|
|
15
|
+
return db
|
|
16
|
+
.select({
|
|
17
|
+
webhookId: webhookSubscriptions.webhookId,
|
|
18
|
+
event: webhookSubscriptions.event
|
|
19
|
+
})
|
|
20
|
+
.from(webhookSubscriptions)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function getWebhookSubscriptions(): Promise<WebhookSubscriptionEntry[]> {
|
|
24
|
+
await requireRole([UserRole.ADMIN])
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
return await readWebhookSubscriptions()
|
|
28
|
+
} catch (error) {
|
|
29
|
+
console.error('Error fetching webhook subscriptions:', error)
|
|
30
|
+
return []
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
'use server'
|
|
2
|
+
|
|
3
|
+
import { requireRole, UserRole } from '@admin/auth/middleware'
|
|
4
|
+
import db from '@admin/db'
|
|
5
|
+
import { webhookDeliveries, webhookSubscriptions, webhooks } from '@admin/db/schema'
|
|
6
|
+
import { and, asc, count, desc, eq, gte, ilike, isNull, lt, or, sql } from 'drizzle-orm'
|
|
7
|
+
import type { WebhooksPage, WebhooksQuery } from './types'
|
|
8
|
+
|
|
9
|
+
function getExclusiveDateUpperBound(value?: string): string | null {
|
|
10
|
+
if (!value) return null
|
|
11
|
+
|
|
12
|
+
const date = new Date(`${value}T00:00:00.000Z`)
|
|
13
|
+
if (Number.isNaN(date.getTime())) return null
|
|
14
|
+
|
|
15
|
+
date.setUTCDate(date.getUTCDate() + 1)
|
|
16
|
+
return date.toISOString().slice(0, 10)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Deliberately uncached: filters and sorting depend on delivery rows written
|
|
20
|
+
// inside after(), where cache invalidation is unavailable, so cached reads
|
|
21
|
+
// could never be refreshed.
|
|
22
|
+
export async function getWebhooks(query?: WebhooksQuery): Promise<WebhooksPage> {
|
|
23
|
+
await requireRole([UserRole.ADMIN])
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
const latestDeliveries = db
|
|
27
|
+
.selectDistinctOn([webhookDeliveries.webhookId], {
|
|
28
|
+
webhookId: webhookDeliveries.webhookId,
|
|
29
|
+
success: webhookDeliveries.success,
|
|
30
|
+
createdAt: webhookDeliveries.createdAt
|
|
31
|
+
})
|
|
32
|
+
.from(webhookDeliveries)
|
|
33
|
+
.orderBy(webhookDeliveries.webhookId, desc(webhookDeliveries.createdAt))
|
|
34
|
+
.as('latest_deliveries')
|
|
35
|
+
const subscriptionCounts = db
|
|
36
|
+
.select({
|
|
37
|
+
webhookId: webhookSubscriptions.webhookId,
|
|
38
|
+
eventCount: count().as('event_count')
|
|
39
|
+
})
|
|
40
|
+
.from(webhookSubscriptions)
|
|
41
|
+
.groupBy(webhookSubscriptions.webhookId)
|
|
42
|
+
.as('subscription_counts')
|
|
43
|
+
const eventCount = sql<number>`coalesce(${subscriptionCounts.eventCount}, 0)`
|
|
44
|
+
|
|
45
|
+
const conditions = []
|
|
46
|
+
const search = query?.search
|
|
47
|
+
if (search && typeof search === 'string' && search.trim()) {
|
|
48
|
+
const searchTerm = `%${search.trim().toLowerCase()}%`
|
|
49
|
+
conditions.push(or(ilike(webhooks.name, searchTerm), ilike(webhooks.url, searchTerm)))
|
|
50
|
+
}
|
|
51
|
+
if (query?.enabled !== undefined) {
|
|
52
|
+
conditions.push(eq(webhooks.enabled, query.enabled))
|
|
53
|
+
}
|
|
54
|
+
if (query?.lastDelivery === 'delivered') {
|
|
55
|
+
conditions.push(eq(latestDeliveries.success, true))
|
|
56
|
+
}
|
|
57
|
+
if (query?.lastDelivery === 'failed') {
|
|
58
|
+
conditions.push(eq(latestDeliveries.success, false))
|
|
59
|
+
}
|
|
60
|
+
if (query?.lastDelivery === 'never') {
|
|
61
|
+
conditions.push(isNull(latestDeliveries.webhookId))
|
|
62
|
+
}
|
|
63
|
+
if (query?.createdAtFrom) {
|
|
64
|
+
conditions.push(gte(webhooks.createdAt, query.createdAtFrom))
|
|
65
|
+
}
|
|
66
|
+
const createdAtExclusiveTo = getExclusiveDateUpperBound(query?.createdAtTo)
|
|
67
|
+
if (createdAtExclusiveTo) {
|
|
68
|
+
conditions.push(lt(webhooks.createdAt, createdAtExclusiveTo))
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const baseQuery = db
|
|
72
|
+
.select({
|
|
73
|
+
id: webhooks.id,
|
|
74
|
+
name: webhooks.name,
|
|
75
|
+
url: webhooks.url,
|
|
76
|
+
enabled: webhooks.enabled,
|
|
77
|
+
createdAt: webhooks.createdAt,
|
|
78
|
+
updatedAt: webhooks.updatedAt,
|
|
79
|
+
eventCount,
|
|
80
|
+
lastDeliverySuccess: latestDeliveries.success,
|
|
81
|
+
lastDeliveryAt: latestDeliveries.createdAt
|
|
82
|
+
})
|
|
83
|
+
.from(webhooks)
|
|
84
|
+
.leftJoin(subscriptionCounts, eq(subscriptionCounts.webhookId, webhooks.id))
|
|
85
|
+
.leftJoin(latestDeliveries, eq(latestDeliveries.webhookId, webhooks.id))
|
|
86
|
+
|
|
87
|
+
const sortableColumns = {
|
|
88
|
+
name: webhooks.name,
|
|
89
|
+
url: webhooks.url,
|
|
90
|
+
enabled: webhooks.enabled,
|
|
91
|
+
createdAt: webhooks.createdAt,
|
|
92
|
+
updatedAt: webhooks.updatedAt,
|
|
93
|
+
eventCount,
|
|
94
|
+
lastDeliveryAt: latestDeliveries.createdAt
|
|
95
|
+
} as const
|
|
96
|
+
const sortColumn =
|
|
97
|
+
query?.orderBy && query.orderBy in sortableColumns
|
|
98
|
+
? sortableColumns[query.orderBy as keyof typeof sortableColumns]
|
|
99
|
+
: webhooks.createdAt
|
|
100
|
+
const sortFn = query?.orderDirection === 'desc' ? desc : asc
|
|
101
|
+
const orderClause = sortFn(sortColumn)
|
|
102
|
+
|
|
103
|
+
const orderedQuery =
|
|
104
|
+
conditions.length > 0
|
|
105
|
+
? baseQuery.where(and(...conditions)).orderBy(orderClause)
|
|
106
|
+
: baseQuery.orderBy(orderClause)
|
|
107
|
+
|
|
108
|
+
const rawLimit = query?.limit
|
|
109
|
+
const limit =
|
|
110
|
+
typeof rawLimit === 'number'
|
|
111
|
+
? Number.isFinite(rawLimit)
|
|
112
|
+
? Math.max(1, rawLimit)
|
|
113
|
+
: 1
|
|
114
|
+
: undefined
|
|
115
|
+
const rawOffset = query?.offset
|
|
116
|
+
const offset =
|
|
117
|
+
typeof rawOffset === 'number' && Number.isFinite(rawOffset) ? Math.max(0, rawOffset) : 0
|
|
118
|
+
const isPaginated = typeof limit === 'number'
|
|
119
|
+
const results = isPaginated
|
|
120
|
+
? await (offset > 0 ? orderedQuery.limit(limit).offset(offset) : orderedQuery.limit(limit))
|
|
121
|
+
: await orderedQuery
|
|
122
|
+
|
|
123
|
+
const total = isPaginated
|
|
124
|
+
? Number(
|
|
125
|
+
(
|
|
126
|
+
await db
|
|
127
|
+
.select({ count: sql`count(*)` })
|
|
128
|
+
.from(webhooks)
|
|
129
|
+
.leftJoin(subscriptionCounts, eq(subscriptionCounts.webhookId, webhooks.id))
|
|
130
|
+
.leftJoin(latestDeliveries, eq(latestDeliveries.webhookId, webhooks.id))
|
|
131
|
+
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
|
132
|
+
)[0]?.count ?? 0
|
|
133
|
+
)
|
|
134
|
+
: results.length
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
webhooks: results.map((row) => ({
|
|
138
|
+
id: row.id,
|
|
139
|
+
name: row.name,
|
|
140
|
+
url: row.url,
|
|
141
|
+
enabled: row.enabled,
|
|
142
|
+
createdAt: row.createdAt,
|
|
143
|
+
updatedAt: row.updatedAt,
|
|
144
|
+
eventCount: Number(row.eventCount ?? 0),
|
|
145
|
+
lastDeliverySuccess: row.lastDeliverySuccess ?? null,
|
|
146
|
+
lastDeliveryAt: row.lastDeliveryAt ?? null
|
|
147
|
+
})),
|
|
148
|
+
total
|
|
149
|
+
}
|
|
150
|
+
} catch (error) {
|
|
151
|
+
console.error('Error fetching webhooks:', error)
|
|
152
|
+
throw new Error('Failed to fetch webhooks')
|
|
153
|
+
}
|
|
154
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Client-safe barrel: only types and server actions. Server-only modules are
|
|
2
|
+
// imported by path — emitWebhookEvent from './dispatch' and
|
|
3
|
+
// registerWebhookHooks from './register' — so client components can import
|
|
4
|
+
// this barrel without pulling node:crypto into the bundle.
|
|
5
|
+
|
|
6
|
+
export { createWebhook } from './create-webhook'
|
|
7
|
+
export { deleteWebhook } from './delete-webhook'
|
|
8
|
+
export { getWebhookDeliveries } from './get-webhook-deliveries'
|
|
9
|
+
export { getWebhookSecret } from './get-webhook-secret'
|
|
10
|
+
export { getWebhookSubscriptions } from './get-webhook-subscriptions'
|
|
11
|
+
export { getWebhooks } from './get-webhooks'
|
|
12
|
+
export { regenerateWebhookSecret } from './regenerate-webhook-secret'
|
|
13
|
+
export { sendTestWebhook } from './send-test-webhook'
|
|
14
|
+
export type {
|
|
15
|
+
CreateWebhookInput,
|
|
16
|
+
SendTestWebhookResult,
|
|
17
|
+
UpdateWebhookInput,
|
|
18
|
+
WebhookDeliveriesPage,
|
|
19
|
+
WebhookDeliveriesQuery,
|
|
20
|
+
WebhookDeliveryEntry,
|
|
21
|
+
WebhookEndpointData,
|
|
22
|
+
WebhookEndpointListItem,
|
|
23
|
+
WebhookResult,
|
|
24
|
+
WebhookSecretResult,
|
|
25
|
+
WebhookSubscriptionEntry,
|
|
26
|
+
WebhooksPage,
|
|
27
|
+
WebhooksQuery
|
|
28
|
+
} from './types'
|
|
29
|
+
export { webhooksCacheTags } from './types'
|
|
30
|
+
export { updateWebhook } from './update-webhook'
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Dispatcher-only read. Deliberately unguarded and uncached: it runs inside
|
|
2
|
+
// `after()` where no session or cache scope is guaranteed. Never export this
|
|
3
|
+
// from the actions barrel.
|
|
4
|
+
import db from '@admin/db'
|
|
5
|
+
import { webhookSubscriptions, webhooks } from '@admin/db/schema'
|
|
6
|
+
import { and, eq } from 'drizzle-orm'
|
|
7
|
+
|
|
8
|
+
export interface ActiveWebhookEndpoint {
|
|
9
|
+
id: string
|
|
10
|
+
url: string
|
|
11
|
+
secret: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function getActiveEndpointsForEvent(event: string): Promise<ActiveWebhookEndpoint[]> {
|
|
15
|
+
return db
|
|
16
|
+
.select({ id: webhooks.id, url: webhooks.url, secret: webhooks.secret })
|
|
17
|
+
.from(webhookSubscriptions)
|
|
18
|
+
.innerJoin(webhooks, eq(webhookSubscriptions.webhookId, webhooks.id))
|
|
19
|
+
.where(and(eq(webhookSubscriptions.event, event), eq(webhooks.enabled, true)))
|
|
20
|
+
}
|
package/dist/assets/adapters/next/templates/init/lib/actions/webhooks/regenerate-webhook-secret.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
'use server'
|
|
2
|
+
|
|
3
|
+
import { requireRole, UserRole } from '@admin/auth/middleware'
|
|
4
|
+
import db from '@admin/db'
|
|
5
|
+
import { webhooks } from '@admin/db/schema'
|
|
6
|
+
import { generateWebhookSecret } from '@admin/utils/webhook/signature'
|
|
7
|
+
import { eq } from 'drizzle-orm'
|
|
8
|
+
import { updateTag } from 'next/cache'
|
|
9
|
+
import type { WebhookSecretResult } from './types'
|
|
10
|
+
import { webhooksCacheTags } from './types'
|
|
11
|
+
|
|
12
|
+
export async function regenerateWebhookSecret(id: string): Promise<WebhookSecretResult> {
|
|
13
|
+
await requireRole([UserRole.ADMIN])
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
const [row] = await db
|
|
17
|
+
.update(webhooks)
|
|
18
|
+
.set({
|
|
19
|
+
secret: generateWebhookSecret(),
|
|
20
|
+
updatedAt: new Date().toISOString()
|
|
21
|
+
})
|
|
22
|
+
.where(eq(webhooks.id, id))
|
|
23
|
+
.returning({ secret: webhooks.secret })
|
|
24
|
+
|
|
25
|
+
if (!row) return { success: false, error: 'Webhook not found' }
|
|
26
|
+
updateTag(webhooksCacheTags.endpoints)
|
|
27
|
+
return { success: true, secret: row.secret }
|
|
28
|
+
} catch (error) {
|
|
29
|
+
console.error('Error regenerating webhook secret:', error)
|
|
30
|
+
return {
|
|
31
|
+
success: false,
|
|
32
|
+
error: error instanceof Error ? error.message : 'Failed to regenerate webhook secret'
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { webhookEventSources } from '@admin/data/webhook-events'
|
|
2
|
+
import { registerHook } from '@admin/lib/lifecycle-hooks/registry'
|
|
3
|
+
import type {
|
|
4
|
+
AfterLifecycleHookEvent,
|
|
5
|
+
LifecycleHookContext
|
|
6
|
+
} from '@admin/lib/lifecycle-hooks/types'
|
|
7
|
+
import { emitWebhookEvent } from './dispatch'
|
|
8
|
+
|
|
9
|
+
let webhookHooksRegistered = false
|
|
10
|
+
|
|
11
|
+
const ENTITY_EVENT_HOOKS: Array<{
|
|
12
|
+
hook: AfterLifecycleHookEvent
|
|
13
|
+
suffix: string
|
|
14
|
+
}> = [
|
|
15
|
+
{ hook: 'afterCreate', suffix: 'created' },
|
|
16
|
+
{ hook: 'afterUpdate', suffix: 'updated' },
|
|
17
|
+
{ hook: 'afterDelete', suffix: 'deleted' },
|
|
18
|
+
{ hook: 'afterPublish', suffix: 'published' },
|
|
19
|
+
{ hook: 'afterUnpublish', suffix: 'unpublished' },
|
|
20
|
+
{ hook: 'afterRestore', suffix: 'restored' },
|
|
21
|
+
{ hook: 'afterReorder', suffix: 'reordered' }
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
function entityEventPayload(hook: AfterLifecycleHookEvent, context: LifecycleHookContext): unknown {
|
|
25
|
+
if (hook === 'afterRestore') {
|
|
26
|
+
return {
|
|
27
|
+
record: context.data ?? null,
|
|
28
|
+
restoredFromVersion: context.metadata?.restoredFromVersion ?? null
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (hook === 'afterDelete') {
|
|
32
|
+
return context.data ?? { id: context.entityId ?? null }
|
|
33
|
+
}
|
|
34
|
+
return context.data ?? null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function registerWebhookHooks(): void {
|
|
38
|
+
if (webhookHooksRegistered) return
|
|
39
|
+
webhookHooksRegistered = true
|
|
40
|
+
|
|
41
|
+
for (const source of webhookEventSources) {
|
|
42
|
+
if (source.kind === 'entity') {
|
|
43
|
+
for (const { hook, suffix } of ENTITY_EVENT_HOOKS) {
|
|
44
|
+
const event = `${source.schema}.${suffix}`
|
|
45
|
+
if (!source.events.includes(event)) continue
|
|
46
|
+
registerHook(source.schema, hook, (context) => {
|
|
47
|
+
emitWebhookEvent(event, entityEventPayload(hook, context))
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
continue
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (source.kind === 'single') {
|
|
54
|
+
const event = `${source.schema}.updated`
|
|
55
|
+
if (!source.events.includes(event)) continue
|
|
56
|
+
const emitUpdated = (context: LifecycleHookContext) => {
|
|
57
|
+
emitWebhookEvent(event, context.data ?? null)
|
|
58
|
+
}
|
|
59
|
+
registerHook(source.schema, 'afterCreate', emitUpdated)
|
|
60
|
+
registerHook(source.schema, 'afterUpdate', emitUpdated)
|
|
61
|
+
continue
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const event = source.events[0]
|
|
65
|
+
if (!event) continue
|
|
66
|
+
registerHook(source.schema, 'afterCreate', (context) => {
|
|
67
|
+
emitWebhookEvent(event, context.data ?? null)
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
'use server'
|
|
2
|
+
|
|
3
|
+
import { requireRole, UserRole } from '@admin/auth/middleware'
|
|
4
|
+
import db from '@admin/db'
|
|
5
|
+
import { webhooks } from '@admin/db/schema'
|
|
6
|
+
import { createWebhookEventId, signWebhookPayload } from '@admin/utils/webhook/signature'
|
|
7
|
+
import { eq } from 'drizzle-orm'
|
|
8
|
+
import { recordWebhookDelivery } from './dispatch'
|
|
9
|
+
import type { SendTestWebhookResult } from './types'
|
|
10
|
+
|
|
11
|
+
const TEST_REQUEST_TIMEOUT_MS = 10_000
|
|
12
|
+
|
|
13
|
+
export async function sendTestWebhook(id: string): Promise<SendTestWebhookResult> {
|
|
14
|
+
await requireRole([UserRole.ADMIN])
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
const [endpoint] = await db
|
|
18
|
+
.select({ id: webhooks.id, url: webhooks.url, secret: webhooks.secret })
|
|
19
|
+
.from(webhooks)
|
|
20
|
+
.where(eq(webhooks.id, id))
|
|
21
|
+
.limit(1)
|
|
22
|
+
|
|
23
|
+
if (!endpoint) return { success: false, error: 'Webhook not found' }
|
|
24
|
+
|
|
25
|
+
const eventId = createWebhookEventId()
|
|
26
|
+
const body = JSON.stringify({
|
|
27
|
+
id: eventId,
|
|
28
|
+
event: 'webhook.test',
|
|
29
|
+
timestamp: new Date().toISOString(),
|
|
30
|
+
data: { message: 'This is a test webhook from BetterStart' }
|
|
31
|
+
})
|
|
32
|
+
const timestamp = Math.floor(Date.now() / 1000).toString()
|
|
33
|
+
const startedAt = Date.now()
|
|
34
|
+
let statusCode: number | null = null
|
|
35
|
+
let errorMessage: string | null = null
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
const response = await fetch(endpoint.url, {
|
|
39
|
+
method: 'POST',
|
|
40
|
+
headers: {
|
|
41
|
+
'content-type': 'application/json',
|
|
42
|
+
'x-webhook-id': eventId,
|
|
43
|
+
'x-webhook-timestamp': timestamp,
|
|
44
|
+
'x-webhook-signature': signWebhookPayload(endpoint.secret, timestamp, body)
|
|
45
|
+
},
|
|
46
|
+
body,
|
|
47
|
+
signal: AbortSignal.timeout(TEST_REQUEST_TIMEOUT_MS)
|
|
48
|
+
})
|
|
49
|
+
statusCode = response.status
|
|
50
|
+
if (!response.ok) errorMessage = `Webhook returned status ${response.status}`
|
|
51
|
+
} catch (error) {
|
|
52
|
+
errorMessage = error instanceof Error ? error.message : 'Webhook request failed'
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
await recordWebhookDelivery({
|
|
56
|
+
webhookId: endpoint.id,
|
|
57
|
+
event: 'webhook.test',
|
|
58
|
+
eventId,
|
|
59
|
+
attempt: 1,
|
|
60
|
+
success: errorMessage === null,
|
|
61
|
+
statusCode,
|
|
62
|
+
durationMs: Date.now() - startedAt,
|
|
63
|
+
error: errorMessage
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
if (errorMessage !== null) {
|
|
67
|
+
return {
|
|
68
|
+
success: false,
|
|
69
|
+
...(statusCode !== null ? { statusCode } : {}),
|
|
70
|
+
error: errorMessage
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return { success: true, ...(statusCode !== null ? { statusCode } : {}) }
|
|
74
|
+
} catch (error) {
|
|
75
|
+
console.error('Error sending test webhook:', error)
|
|
76
|
+
return {
|
|
77
|
+
success: false,
|
|
78
|
+
error: error instanceof Error ? error.message : 'Failed to send test webhook'
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
export interface WebhookEndpointData {
|
|
2
|
+
id: string
|
|
3
|
+
name: string
|
|
4
|
+
url: string
|
|
5
|
+
enabled: boolean
|
|
6
|
+
createdAt: string
|
|
7
|
+
updatedAt: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface WebhookEndpointListItem extends WebhookEndpointData {
|
|
11
|
+
eventCount: number
|
|
12
|
+
lastDeliverySuccess: boolean | null
|
|
13
|
+
lastDeliveryAt: string | null
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface WebhooksQuery {
|
|
17
|
+
search?: string
|
|
18
|
+
enabled?: boolean
|
|
19
|
+
lastDelivery?: 'delivered' | 'failed' | 'never'
|
|
20
|
+
createdAtFrom?: string
|
|
21
|
+
createdAtTo?: string
|
|
22
|
+
limit?: number
|
|
23
|
+
offset?: number
|
|
24
|
+
orderBy?: string
|
|
25
|
+
orderDirection?: 'asc' | 'desc'
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface WebhooksPage {
|
|
29
|
+
webhooks: WebhookEndpointListItem[]
|
|
30
|
+
total: number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface WebhookDeliveriesQuery {
|
|
34
|
+
search?: string
|
|
35
|
+
success?: boolean
|
|
36
|
+
webhookId?: string
|
|
37
|
+
createdAtFrom?: string
|
|
38
|
+
createdAtTo?: string
|
|
39
|
+
limit?: number
|
|
40
|
+
offset?: number
|
|
41
|
+
orderBy?: string
|
|
42
|
+
orderDirection?: 'asc' | 'desc'
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface WebhookDeliveriesPage {
|
|
46
|
+
deliveries: WebhookDeliveryEntry[]
|
|
47
|
+
total: number
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface CreateWebhookInput {
|
|
51
|
+
name: string
|
|
52
|
+
url: string
|
|
53
|
+
enabled: boolean
|
|
54
|
+
events: string[]
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface UpdateWebhookInput {
|
|
58
|
+
name?: string
|
|
59
|
+
url?: string
|
|
60
|
+
enabled?: boolean
|
|
61
|
+
events?: string[]
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface WebhookResult {
|
|
65
|
+
success: boolean
|
|
66
|
+
error?: string
|
|
67
|
+
webhook?: WebhookEndpointData
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface WebhookSecretResult {
|
|
71
|
+
success: boolean
|
|
72
|
+
error?: string
|
|
73
|
+
secret?: string
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface WebhookSubscriptionEntry {
|
|
77
|
+
webhookId: string
|
|
78
|
+
event: string
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface WebhookDeliveryEntry {
|
|
82
|
+
id: string
|
|
83
|
+
webhookId: string
|
|
84
|
+
webhookName: string
|
|
85
|
+
event: string
|
|
86
|
+
eventId: string
|
|
87
|
+
attempt: number
|
|
88
|
+
success: boolean
|
|
89
|
+
statusCode: number | null
|
|
90
|
+
durationMs: number
|
|
91
|
+
error: string | null
|
|
92
|
+
createdAt: string
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface SendTestWebhookResult {
|
|
96
|
+
success: boolean
|
|
97
|
+
statusCode?: number
|
|
98
|
+
error?: string
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export const webhooksCacheTags = {
|
|
102
|
+
endpoints: 'webhooks:endpoints',
|
|
103
|
+
subscriptions: 'webhooks:subscriptions'
|
|
104
|
+
} as const
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
'use server'
|
|
2
|
+
|
|
3
|
+
import { requireRole, UserRole } from '@admin/auth/middleware'
|
|
4
|
+
import db from '@admin/db'
|
|
5
|
+
import { webhookSubscriptions, webhooks } from '@admin/db/schema'
|
|
6
|
+
import { and, eq, notInArray } from 'drizzle-orm'
|
|
7
|
+
import { updateTag } from 'next/cache'
|
|
8
|
+
import type { UpdateWebhookInput, WebhookResult } from './types'
|
|
9
|
+
import { webhooksCacheTags } from './types'
|
|
10
|
+
|
|
11
|
+
export async function updateWebhook(id: string, input: UpdateWebhookInput): Promise<WebhookResult> {
|
|
12
|
+
await requireRole([UserRole.ADMIN])
|
|
13
|
+
|
|
14
|
+
try {
|
|
15
|
+
const updated = await db.transaction(async (tx) => {
|
|
16
|
+
const [row] = await tx
|
|
17
|
+
.update(webhooks)
|
|
18
|
+
.set({
|
|
19
|
+
...(input.name !== undefined ? { name: input.name } : {}),
|
|
20
|
+
...(input.url !== undefined ? { url: input.url } : {}),
|
|
21
|
+
...(input.enabled !== undefined ? { enabled: input.enabled } : {}),
|
|
22
|
+
updatedAt: new Date().toISOString()
|
|
23
|
+
})
|
|
24
|
+
.where(eq(webhooks.id, id))
|
|
25
|
+
.returning()
|
|
26
|
+
|
|
27
|
+
if (!row) throw new Error('Webhook not found')
|
|
28
|
+
|
|
29
|
+
if (input.events !== undefined) {
|
|
30
|
+
const events = input.events
|
|
31
|
+
if (events.length > 0) {
|
|
32
|
+
await tx
|
|
33
|
+
.delete(webhookSubscriptions)
|
|
34
|
+
.where(
|
|
35
|
+
and(
|
|
36
|
+
eq(webhookSubscriptions.webhookId, id),
|
|
37
|
+
notInArray(webhookSubscriptions.event, events)
|
|
38
|
+
)
|
|
39
|
+
)
|
|
40
|
+
await tx
|
|
41
|
+
.insert(webhookSubscriptions)
|
|
42
|
+
.values(events.map((event) => ({ webhookId: id, event })))
|
|
43
|
+
.onConflictDoNothing()
|
|
44
|
+
} else {
|
|
45
|
+
await tx.delete(webhookSubscriptions).where(eq(webhookSubscriptions.webhookId, id))
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return row
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
updateTag(webhooksCacheTags.endpoints)
|
|
53
|
+
updateTag(webhooksCacheTags.subscriptions)
|
|
54
|
+
return {
|
|
55
|
+
success: true,
|
|
56
|
+
webhook: {
|
|
57
|
+
id: updated.id,
|
|
58
|
+
name: updated.name,
|
|
59
|
+
url: updated.url,
|
|
60
|
+
enabled: updated.enabled,
|
|
61
|
+
createdAt: updated.createdAt,
|
|
62
|
+
updatedAt: updated.updatedAt
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
} catch (error) {
|
|
66
|
+
console.error('Error updating webhook:', error)
|
|
67
|
+
return {
|
|
68
|
+
success: false,
|
|
69
|
+
error: error instanceof Error ? error.message : 'Failed to update webhook'
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -80,8 +80,6 @@ export const formSettings = pgTable(
|
|
|
80
80
|
{
|
|
81
81
|
id: uuid().defaultRandom().primaryKey().notNull(),
|
|
82
82
|
formName: varchar('formName', { length: 100 }).notNull(),
|
|
83
|
-
webhookUrl: text('webhookUrl'),
|
|
84
|
-
webhookEnabled: boolean('webhookEnabled').default(false).notNull(),
|
|
85
83
|
notificationEmails: text('notificationEmails'),
|
|
86
84
|
createdAt: timestamp('createdAt', { precision: 3, mode: 'string' })
|
|
87
85
|
.default(sql`CURRENT_TIMESTAMP`)
|
|
@@ -98,6 +96,77 @@ export const formSettings = pgTable(
|
|
|
98
96
|
]
|
|
99
97
|
)
|
|
100
98
|
|
|
99
|
+
// ============================================================================
|
|
100
|
+
// Webhooks (endpoint registry, event subscriptions, delivery log)
|
|
101
|
+
// ============================================================================
|
|
102
|
+
|
|
103
|
+
export const webhooks = pgTable('Webhooks', {
|
|
104
|
+
id: uuid().defaultRandom().primaryKey().notNull(),
|
|
105
|
+
name: varchar('name', { length: 200 }).notNull(),
|
|
106
|
+
url: text('url').notNull(),
|
|
107
|
+
secret: text('secret').notNull(),
|
|
108
|
+
enabled: boolean('enabled').default(true).notNull(),
|
|
109
|
+
createdAt: timestamp('createdAt', { precision: 3, mode: 'string' })
|
|
110
|
+
.default(sql`CURRENT_TIMESTAMP`)
|
|
111
|
+
.notNull(),
|
|
112
|
+
updatedAt: timestamp('updatedAt', { precision: 3, mode: 'string' })
|
|
113
|
+
.default(sql`CURRENT_TIMESTAMP`)
|
|
114
|
+
.notNull()
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
export const webhookSubscriptions = pgTable(
|
|
118
|
+
'WebhookSubscriptions',
|
|
119
|
+
{
|
|
120
|
+
id: uuid().defaultRandom().primaryKey().notNull(),
|
|
121
|
+
webhookId: uuid('webhookId')
|
|
122
|
+
.notNull()
|
|
123
|
+
.references(() => webhooks.id, { onDelete: 'cascade' }),
|
|
124
|
+
event: varchar('event', { length: 200 }).notNull(),
|
|
125
|
+
createdAt: timestamp('createdAt', { precision: 3, mode: 'string' })
|
|
126
|
+
.default(sql`CURRENT_TIMESTAMP`)
|
|
127
|
+
.notNull()
|
|
128
|
+
},
|
|
129
|
+
(table) => [
|
|
130
|
+
uniqueIndex('WebhookSubscriptions_webhookId_event_key').using(
|
|
131
|
+
'btree',
|
|
132
|
+
table.webhookId.asc().nullsLast().op('uuid_ops'),
|
|
133
|
+
table.event.asc().nullsLast().op('text_ops')
|
|
134
|
+
),
|
|
135
|
+
index('WebhookSubscriptions_event_idx').using(
|
|
136
|
+
'btree',
|
|
137
|
+
table.event.asc().nullsLast().op('text_ops')
|
|
138
|
+
)
|
|
139
|
+
]
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
export const webhookDeliveries = pgTable(
|
|
143
|
+
'WebhookDeliveries',
|
|
144
|
+
{
|
|
145
|
+
id: uuid().defaultRandom().primaryKey().notNull(),
|
|
146
|
+
webhookId: uuid('webhookId')
|
|
147
|
+
.notNull()
|
|
148
|
+
.references(() => webhooks.id, { onDelete: 'cascade' }),
|
|
149
|
+
event: varchar('event', { length: 200 }).notNull(),
|
|
150
|
+
eventId: varchar('eventId', { length: 64 }).notNull(),
|
|
151
|
+
attempt: integer('attempt').notNull(),
|
|
152
|
+
success: boolean('success').notNull(),
|
|
153
|
+
statusCode: integer('statusCode'),
|
|
154
|
+
durationMs: integer('durationMs').notNull(),
|
|
155
|
+
error: text('error'),
|
|
156
|
+
createdAt: timestamp('createdAt', { precision: 3, mode: 'string' })
|
|
157
|
+
.default(sql`CURRENT_TIMESTAMP`)
|
|
158
|
+
.notNull()
|
|
159
|
+
},
|
|
160
|
+
(table) => [
|
|
161
|
+
index('WebhookDeliveries_webhookId_createdAt_idx').using(
|
|
162
|
+
'btree',
|
|
163
|
+
table.webhookId.asc().nullsLast().op('uuid_ops'),
|
|
164
|
+
table.createdAt.desc().nullsLast()
|
|
165
|
+
),
|
|
166
|
+
index('WebhookDeliveries_createdAt_idx').using('btree', table.createdAt.desc().nullsLast())
|
|
167
|
+
]
|
|
168
|
+
)
|
|
169
|
+
|
|
101
170
|
// ============================================================================
|
|
102
171
|
// Entity Versions
|
|
103
172
|
// ============================================================================
|