betterstart-cli 0.0.27 → 0.0.29

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.
Files changed (84) hide show
  1. package/dist/assets/adapters/next/integrations/mailchimp/actions/mailchimp.ts +0 -1
  2. package/dist/assets/adapters/next/integrations/vercel-blob/actions/vercel-blob.ts +39 -0
  3. package/dist/assets/adapters/next/integrations/vercel-blob/integration.ts +36 -0
  4. package/dist/assets/adapters/next/plugins/blog/schemas/menus.json +1 -1
  5. package/dist/assets/adapters/next/plugins/blog/schemas/posts.json +1 -1
  6. package/dist/assets/adapters/next/templates/init/admin-globals.css +1 -1
  7. package/dist/assets/adapters/next/templates/init/components/layouts/admin-nav-link.tsx +12 -2
  8. package/dist/assets/adapters/next/templates/init/components/layouts/admin-settings-sidebar.tsx +75 -0
  9. package/dist/assets/adapters/next/templates/init/components/layouts/admin-sidebar-nav-link.tsx +12 -2
  10. package/dist/assets/adapters/next/templates/init/components/layouts/admin-sidebar.tsx +2 -8
  11. package/dist/assets/adapters/next/templates/init/components/shared/data-table/data-grid.tsx +14 -5
  12. package/dist/assets/adapters/next/templates/init/components/shared/data-table/data-table.tsx +111 -112
  13. package/dist/assets/adapters/next/templates/init/components/shared/dev-mode/dev-mode-types.ts +4 -0
  14. package/dist/assets/adapters/next/templates/init/components/shared/dev-mode-integrate.tsx +1 -1
  15. package/dist/assets/adapters/next/templates/init/components/shared/entity-versions/entity-versions-drawer.tsx +1 -1
  16. package/dist/assets/adapters/next/templates/init/components/shared/media/media-url-importer.tsx +45 -47
  17. package/dist/assets/adapters/next/templates/init/components/shared/page-header.tsx +1 -1
  18. package/dist/assets/adapters/next/templates/init/data/webhook-events.ts +5 -0
  19. package/dist/assets/adapters/next/templates/init/hooks/use-table-utils.ts +27 -7
  20. package/dist/assets/adapters/next/templates/init/hooks/use-webhooks.ts +211 -0
  21. package/dist/assets/adapters/next/templates/init/lib/actions/audit/types.ts +2 -0
  22. package/dist/assets/adapters/next/templates/init/lib/actions/forms/index.ts +0 -1
  23. package/dist/assets/adapters/next/templates/init/lib/actions/forms/types.ts +0 -4
  24. package/dist/assets/adapters/next/templates/init/lib/actions/forms/upsert-form-settings.ts +0 -2
  25. package/dist/assets/adapters/next/templates/init/lib/actions/webhooks/create-webhook.ts +55 -0
  26. package/dist/assets/adapters/next/templates/init/lib/actions/webhooks/delete-webhook.ts +26 -0
  27. package/dist/assets/adapters/next/templates/init/lib/actions/webhooks/dispatch.ts +135 -0
  28. package/dist/assets/adapters/next/templates/init/lib/actions/webhooks/get-webhook-deliveries.ts +124 -0
  29. package/dist/assets/adapters/next/templates/init/lib/actions/webhooks/get-webhook-secret.ts +28 -0
  30. package/dist/assets/adapters/next/templates/init/lib/actions/webhooks/get-webhook-subscriptions.ts +32 -0
  31. package/dist/assets/adapters/next/templates/init/lib/actions/webhooks/get-webhooks.ts +154 -0
  32. package/dist/assets/adapters/next/templates/init/lib/actions/webhooks/index.ts +30 -0
  33. package/dist/assets/adapters/next/templates/init/lib/actions/webhooks/internal-get-active-endpoints.ts +20 -0
  34. package/dist/assets/adapters/next/templates/init/lib/actions/webhooks/regenerate-webhook-secret.ts +35 -0
  35. package/dist/assets/adapters/next/templates/init/lib/actions/webhooks/register.ts +70 -0
  36. package/dist/assets/adapters/next/templates/init/lib/actions/webhooks/send-test-webhook.ts +81 -0
  37. package/dist/assets/adapters/next/templates/init/lib/actions/webhooks/types.ts +104 -0
  38. package/dist/assets/adapters/next/templates/init/lib/actions/webhooks/update-webhook.ts +72 -0
  39. package/dist/assets/adapters/next/templates/init/lib/db/core/schema.ts +71 -2
  40. package/dist/assets/adapters/next/templates/init/lib/lifecycle-hooks/register.ts +2 -0
  41. package/dist/assets/adapters/next/templates/init/lib/lifecycle-hooks/runner.ts +3 -1
  42. package/dist/assets/adapters/next/templates/init/lib/lifecycle-hooks/types.ts +6 -1
  43. package/dist/assets/adapters/next/templates/init/pages/auth-gate-rsc.tsx +1 -1
  44. package/dist/assets/adapters/next/templates/init/pages/media/media-page-content.tsx +1 -1
  45. package/dist/assets/adapters/next/templates/init/pages/media/media-page-skeleton.tsx +1 -1
  46. package/dist/assets/adapters/next/templates/init/pages/settings/audit-log/audit-log-columns.tsx +150 -0
  47. package/dist/assets/adapters/next/templates/init/pages/settings/audit-log/audit-log-page-content.tsx +137 -0
  48. package/dist/assets/adapters/next/templates/init/pages/{audit-log → settings/audit-log}/audit-log-page-skeleton.tsx +1 -1
  49. package/dist/assets/adapters/next/templates/init/pages/settings/audit-log/audit-log-table.tsx +295 -0
  50. package/dist/assets/adapters/next/templates/init/pages/settings/forms/edit-form-notifications-dialog.tsx +128 -0
  51. package/dist/assets/adapters/next/templates/init/pages/settings/forms/forms-settings-page-content.tsx +111 -0
  52. package/dist/assets/adapters/next/templates/init/pages/settings/forms/forms-settings-page-skeleton.tsx +7 -0
  53. package/dist/assets/adapters/next/templates/init/pages/settings/forms/forms-settings-page.tsx +14 -0
  54. package/dist/assets/adapters/next/templates/init/pages/settings/settings-layout.tsx +10 -0
  55. package/dist/assets/adapters/next/templates/init/pages/settings/webhooks/delete-webhook-dialog.tsx +82 -0
  56. package/dist/assets/adapters/next/templates/init/pages/settings/webhooks/webhook-actions.tsx +53 -0
  57. package/dist/assets/adapters/next/templates/init/pages/settings/webhooks/webhook-enabled-switch.tsx +18 -0
  58. package/dist/assets/adapters/next/templates/init/pages/settings/webhooks/webhook-endpoint-dialog.tsx +370 -0
  59. package/dist/assets/adapters/next/templates/init/pages/settings/webhooks/webhooks-columns.tsx +258 -0
  60. package/dist/assets/adapters/next/templates/init/pages/settings/webhooks/webhooks-logs-columns.tsx +200 -0
  61. package/dist/assets/adapters/next/templates/init/pages/settings/webhooks/webhooks-logs-page-content.tsx +117 -0
  62. package/dist/assets/adapters/next/templates/init/pages/settings/webhooks/webhooks-logs-page.tsx +14 -0
  63. package/dist/assets/adapters/next/templates/init/pages/settings/webhooks/webhooks-logs-table.tsx +253 -0
  64. package/dist/assets/adapters/next/templates/init/pages/settings/webhooks/webhooks-page-content.tsx +161 -0
  65. package/dist/assets/adapters/next/templates/init/pages/settings/webhooks/webhooks-page-skeleton.tsx +7 -0
  66. package/dist/assets/adapters/next/templates/init/pages/settings/webhooks/webhooks-page.tsx +14 -0
  67. package/dist/assets/adapters/next/templates/init/pages/settings/webhooks/webhooks-table.tsx +264 -0
  68. package/dist/assets/adapters/next/templates/init/pages/users/users-page-skeleton.tsx +1 -1
  69. package/dist/assets/adapters/next/templates/init/pages/users/users-table.tsx +103 -109
  70. package/dist/assets/adapters/next/templates/init/types/webhooks.ts +7 -0
  71. package/dist/assets/adapters/next/templates/init/utils/webhook/signature.ts +13 -0
  72. package/dist/assets/shared-assets/react-admin/ui/card.tsx +1 -1
  73. package/dist/assets/shared-assets/react-admin/ui/drawer.tsx +2 -2
  74. package/dist/assets/shared-assets/react-admin/ui/input-group.tsx +3 -3
  75. package/dist/assets/shared-assets/react-admin/ui/sidebar.tsx +1 -1
  76. package/dist/cli.js +2191 -1304
  77. package/dist/cli.js.map +1 -1
  78. package/package.json +1 -1
  79. package/dist/assets/adapters/next/templates/init/lib/actions/forms/test-form-webhook.ts +0 -40
  80. package/dist/assets/adapters/next/templates/init/pages/audit-log/audit-log-page-content.tsx +0 -550
  81. package/dist/assets/adapters/next/templates/init/utils/webhook/webhook.ts +0 -28
  82. /package/dist/assets/adapters/next/templates/init/pages/{audit-log → settings/audit-log}/audit-log-detail-drawer.tsx +0 -0
  83. /package/dist/assets/adapters/next/templates/init/pages/{audit-log → settings/audit-log}/audit-log-detail-row.tsx +0 -0
  84. /package/dist/assets/adapters/next/templates/init/pages/{audit-log → settings/audit-log}/audit-log-page.tsx +0 -0
@@ -0,0 +1,124 @@
1
+ 'use server'
2
+
3
+ import { requireRole, UserRole } from '@admin/auth/middleware'
4
+ import db from '@admin/db'
5
+ import { webhookDeliveries, webhooks } from '@admin/db/schema'
6
+ import { and, asc, desc, eq, gte, ilike, lt, or, sql } from 'drizzle-orm'
7
+ import type { WebhookDeliveriesPage, WebhookDeliveriesQuery } 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
+ const SORTABLE_COLUMNS = {
20
+ createdAt: webhookDeliveries.createdAt,
21
+ event: webhookDeliveries.event,
22
+ webhookName: webhooks.name,
23
+ attempt: webhookDeliveries.attempt,
24
+ durationMs: webhookDeliveries.durationMs,
25
+ statusCode: webhookDeliveries.statusCode,
26
+ success: webhookDeliveries.success
27
+ } as const
28
+
29
+ // Deliberately uncached: delivery rows are written inside after(), where cache
30
+ // invalidation is unavailable, so cached reads could never be refreshed.
31
+ export async function getWebhookDeliveries(
32
+ query?: WebhookDeliveriesQuery
33
+ ): Promise<WebhookDeliveriesPage> {
34
+ await requireRole([UserRole.ADMIN])
35
+
36
+ try {
37
+ const conditions = []
38
+ const search = query?.search
39
+ if (search && typeof search === 'string' && search.trim()) {
40
+ const searchTerm = `%${search.trim().toLowerCase()}%`
41
+ conditions.push(
42
+ or(
43
+ ilike(webhookDeliveries.event, searchTerm),
44
+ ilike(webhookDeliveries.eventId, searchTerm),
45
+ ilike(webhooks.name, searchTerm)
46
+ )
47
+ )
48
+ }
49
+ if (query?.success !== undefined) {
50
+ conditions.push(eq(webhookDeliveries.success, query.success))
51
+ }
52
+ if (query?.webhookId) {
53
+ conditions.push(eq(webhookDeliveries.webhookId, query.webhookId))
54
+ }
55
+ if (query?.createdAtFrom) {
56
+ conditions.push(gte(webhookDeliveries.createdAt, query.createdAtFrom))
57
+ }
58
+ const createdAtExclusiveTo = getExclusiveDateUpperBound(query?.createdAtTo)
59
+ if (createdAtExclusiveTo) {
60
+ conditions.push(lt(webhookDeliveries.createdAt, createdAtExclusiveTo))
61
+ }
62
+
63
+ const baseQuery = db
64
+ .select({
65
+ id: webhookDeliveries.id,
66
+ webhookId: webhookDeliveries.webhookId,
67
+ webhookName: webhooks.name,
68
+ event: webhookDeliveries.event,
69
+ eventId: webhookDeliveries.eventId,
70
+ attempt: webhookDeliveries.attempt,
71
+ success: webhookDeliveries.success,
72
+ statusCode: webhookDeliveries.statusCode,
73
+ durationMs: webhookDeliveries.durationMs,
74
+ error: webhookDeliveries.error,
75
+ createdAt: webhookDeliveries.createdAt
76
+ })
77
+ .from(webhookDeliveries)
78
+ .innerJoin(webhooks, eq(webhookDeliveries.webhookId, webhooks.id))
79
+
80
+ const sortColumn =
81
+ query?.orderBy && query.orderBy in SORTABLE_COLUMNS
82
+ ? SORTABLE_COLUMNS[query.orderBy as keyof typeof SORTABLE_COLUMNS]
83
+ : webhookDeliveries.createdAt
84
+ const sortFn = query?.orderDirection === 'asc' ? asc : desc
85
+ const orderClause = sortFn(sortColumn)
86
+
87
+ const orderedQuery =
88
+ conditions.length > 0
89
+ ? baseQuery.where(and(...conditions)).orderBy(orderClause)
90
+ : baseQuery.orderBy(orderClause)
91
+
92
+ const rawLimit = query?.limit
93
+ const limit =
94
+ typeof rawLimit === 'number'
95
+ ? Number.isFinite(rawLimit)
96
+ ? Math.max(1, rawLimit)
97
+ : 1
98
+ : undefined
99
+ const rawOffset = query?.offset
100
+ const offset =
101
+ typeof rawOffset === 'number' && Number.isFinite(rawOffset) ? Math.max(0, rawOffset) : 0
102
+ const isPaginated = typeof limit === 'number'
103
+ const deliveries = isPaginated
104
+ ? await (offset > 0 ? orderedQuery.limit(limit).offset(offset) : orderedQuery.limit(limit))
105
+ : await orderedQuery
106
+
107
+ const total = isPaginated
108
+ ? Number(
109
+ (
110
+ await db
111
+ .select({ count: sql`count(*)` })
112
+ .from(webhookDeliveries)
113
+ .innerJoin(webhooks, eq(webhookDeliveries.webhookId, webhooks.id))
114
+ .where(conditions.length > 0 ? and(...conditions) : undefined)
115
+ )[0]?.count ?? 0
116
+ )
117
+ : deliveries.length
118
+
119
+ return { deliveries, total }
120
+ } catch (error) {
121
+ console.error('Error fetching webhook deliveries:', error)
122
+ throw new Error('Failed to fetch webhook deliveries')
123
+ }
124
+ }
@@ -0,0 +1,28 @@
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 { eq } from 'drizzle-orm'
7
+ import type { WebhookSecretResult } from './types'
8
+
9
+ export async function getWebhookSecret(id: string): Promise<WebhookSecretResult> {
10
+ await requireRole([UserRole.ADMIN])
11
+
12
+ try {
13
+ const [row] = await db
14
+ .select({ secret: webhooks.secret })
15
+ .from(webhooks)
16
+ .where(eq(webhooks.id, id))
17
+ .limit(1)
18
+
19
+ if (!row) return { success: false, error: 'Webhook not found' }
20
+ return { success: true, secret: row.secret }
21
+ } catch (error) {
22
+ console.error('Error fetching webhook secret:', error)
23
+ return {
24
+ success: false,
25
+ error: error instanceof Error ? error.message : 'Failed to fetch webhook secret'
26
+ }
27
+ }
28
+ }
@@ -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
+ }
@@ -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