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.
Files changed (82) hide show
  1. package/dist/assets/adapters/next/integrations/mailchimp/actions/mailchimp.ts +0 -1
  2. package/dist/assets/adapters/next/plugins/blog/schemas/menus.json +1 -1
  3. package/dist/assets/adapters/next/plugins/blog/schemas/posts.json +1 -1
  4. package/dist/assets/adapters/next/templates/init/admin-globals.css +1 -1
  5. package/dist/assets/adapters/next/templates/init/components/layouts/admin-nav-link.tsx +12 -2
  6. package/dist/assets/adapters/next/templates/init/components/layouts/admin-settings-sidebar.tsx +75 -0
  7. package/dist/assets/adapters/next/templates/init/components/layouts/admin-sidebar-nav-link.tsx +12 -2
  8. package/dist/assets/adapters/next/templates/init/components/layouts/admin-sidebar.tsx +2 -8
  9. package/dist/assets/adapters/next/templates/init/components/shared/data-table/data-grid.tsx +14 -5
  10. package/dist/assets/adapters/next/templates/init/components/shared/data-table/data-table.tsx +111 -112
  11. package/dist/assets/adapters/next/templates/init/components/shared/dev-mode/dev-mode-types.ts +4 -0
  12. package/dist/assets/adapters/next/templates/init/components/shared/dev-mode-integrate.tsx +1 -1
  13. package/dist/assets/adapters/next/templates/init/components/shared/entity-versions/entity-versions-drawer.tsx +1 -1
  14. package/dist/assets/adapters/next/templates/init/components/shared/media/media-url-importer.tsx +45 -47
  15. package/dist/assets/adapters/next/templates/init/components/shared/page-header.tsx +1 -1
  16. package/dist/assets/adapters/next/templates/init/data/webhook-events.ts +5 -0
  17. package/dist/assets/adapters/next/templates/init/hooks/use-table-utils.ts +27 -7
  18. package/dist/assets/adapters/next/templates/init/hooks/use-webhooks.ts +211 -0
  19. package/dist/assets/adapters/next/templates/init/lib/actions/audit/types.ts +2 -0
  20. package/dist/assets/adapters/next/templates/init/lib/actions/forms/index.ts +0 -1
  21. package/dist/assets/adapters/next/templates/init/lib/actions/forms/types.ts +0 -4
  22. package/dist/assets/adapters/next/templates/init/lib/actions/forms/upsert-form-settings.ts +0 -2
  23. package/dist/assets/adapters/next/templates/init/lib/actions/webhooks/create-webhook.ts +55 -0
  24. package/dist/assets/adapters/next/templates/init/lib/actions/webhooks/delete-webhook.ts +26 -0
  25. package/dist/assets/adapters/next/templates/init/lib/actions/webhooks/dispatch.ts +135 -0
  26. package/dist/assets/adapters/next/templates/init/lib/actions/webhooks/get-webhook-deliveries.ts +124 -0
  27. package/dist/assets/adapters/next/templates/init/lib/actions/webhooks/get-webhook-secret.ts +28 -0
  28. package/dist/assets/adapters/next/templates/init/lib/actions/webhooks/get-webhook-subscriptions.ts +32 -0
  29. package/dist/assets/adapters/next/templates/init/lib/actions/webhooks/get-webhooks.ts +154 -0
  30. package/dist/assets/adapters/next/templates/init/lib/actions/webhooks/index.ts +30 -0
  31. package/dist/assets/adapters/next/templates/init/lib/actions/webhooks/internal-get-active-endpoints.ts +20 -0
  32. package/dist/assets/adapters/next/templates/init/lib/actions/webhooks/regenerate-webhook-secret.ts +35 -0
  33. package/dist/assets/adapters/next/templates/init/lib/actions/webhooks/register.ts +70 -0
  34. package/dist/assets/adapters/next/templates/init/lib/actions/webhooks/send-test-webhook.ts +81 -0
  35. package/dist/assets/adapters/next/templates/init/lib/actions/webhooks/types.ts +104 -0
  36. package/dist/assets/adapters/next/templates/init/lib/actions/webhooks/update-webhook.ts +72 -0
  37. package/dist/assets/adapters/next/templates/init/lib/db/core/schema.ts +71 -2
  38. package/dist/assets/adapters/next/templates/init/lib/lifecycle-hooks/register.ts +2 -0
  39. package/dist/assets/adapters/next/templates/init/lib/lifecycle-hooks/runner.ts +3 -1
  40. package/dist/assets/adapters/next/templates/init/lib/lifecycle-hooks/types.ts +6 -1
  41. package/dist/assets/adapters/next/templates/init/pages/auth-gate-rsc.tsx +1 -1
  42. package/dist/assets/adapters/next/templates/init/pages/media/media-page-content.tsx +1 -1
  43. package/dist/assets/adapters/next/templates/init/pages/media/media-page-skeleton.tsx +1 -1
  44. package/dist/assets/adapters/next/templates/init/pages/settings/audit-log/audit-log-columns.tsx +150 -0
  45. package/dist/assets/adapters/next/templates/init/pages/settings/audit-log/audit-log-page-content.tsx +137 -0
  46. package/dist/assets/adapters/next/templates/init/pages/{audit-log → settings/audit-log}/audit-log-page-skeleton.tsx +1 -1
  47. package/dist/assets/adapters/next/templates/init/pages/settings/audit-log/audit-log-table.tsx +295 -0
  48. package/dist/assets/adapters/next/templates/init/pages/settings/forms/edit-form-notifications-dialog.tsx +128 -0
  49. package/dist/assets/adapters/next/templates/init/pages/settings/forms/forms-settings-page-content.tsx +111 -0
  50. package/dist/assets/adapters/next/templates/init/pages/settings/forms/forms-settings-page-skeleton.tsx +7 -0
  51. package/dist/assets/adapters/next/templates/init/pages/settings/forms/forms-settings-page.tsx +14 -0
  52. package/dist/assets/adapters/next/templates/init/pages/settings/settings-layout.tsx +10 -0
  53. package/dist/assets/adapters/next/templates/init/pages/settings/webhooks/delete-webhook-dialog.tsx +82 -0
  54. package/dist/assets/adapters/next/templates/init/pages/settings/webhooks/webhook-actions.tsx +53 -0
  55. package/dist/assets/adapters/next/templates/init/pages/settings/webhooks/webhook-enabled-switch.tsx +18 -0
  56. package/dist/assets/adapters/next/templates/init/pages/settings/webhooks/webhook-endpoint-dialog.tsx +370 -0
  57. package/dist/assets/adapters/next/templates/init/pages/settings/webhooks/webhooks-columns.tsx +258 -0
  58. package/dist/assets/adapters/next/templates/init/pages/settings/webhooks/webhooks-logs-columns.tsx +200 -0
  59. package/dist/assets/adapters/next/templates/init/pages/settings/webhooks/webhooks-logs-page-content.tsx +117 -0
  60. package/dist/assets/adapters/next/templates/init/pages/settings/webhooks/webhooks-logs-page.tsx +14 -0
  61. package/dist/assets/adapters/next/templates/init/pages/settings/webhooks/webhooks-logs-table.tsx +253 -0
  62. package/dist/assets/adapters/next/templates/init/pages/settings/webhooks/webhooks-page-content.tsx +161 -0
  63. package/dist/assets/adapters/next/templates/init/pages/settings/webhooks/webhooks-page-skeleton.tsx +7 -0
  64. package/dist/assets/adapters/next/templates/init/pages/settings/webhooks/webhooks-page.tsx +14 -0
  65. package/dist/assets/adapters/next/templates/init/pages/settings/webhooks/webhooks-table.tsx +264 -0
  66. package/dist/assets/adapters/next/templates/init/pages/users/users-page-skeleton.tsx +1 -1
  67. package/dist/assets/adapters/next/templates/init/pages/users/users-table.tsx +103 -109
  68. package/dist/assets/adapters/next/templates/init/types/webhooks.ts +7 -0
  69. package/dist/assets/adapters/next/templates/init/utils/webhook/signature.ts +13 -0
  70. package/dist/assets/shared-assets/react-admin/ui/card.tsx +1 -1
  71. package/dist/assets/shared-assets/react-admin/ui/drawer.tsx +2 -2
  72. package/dist/assets/shared-assets/react-admin/ui/input-group.tsx +3 -3
  73. package/dist/assets/shared-assets/react-admin/ui/sidebar.tsx +1 -1
  74. package/dist/cli.js +947 -620
  75. package/dist/cli.js.map +1 -1
  76. package/package.json +1 -1
  77. package/dist/assets/adapters/next/templates/init/lib/actions/forms/test-form-webhook.ts +0 -40
  78. package/dist/assets/adapters/next/templates/init/pages/audit-log/audit-log-page-content.tsx +0 -550
  79. package/dist/assets/adapters/next/templates/init/utils/webhook/webhook.ts +0 -28
  80. /package/dist/assets/adapters/next/templates/init/pages/{audit-log → settings/audit-log}/audit-log-detail-drawer.tsx +0 -0
  81. /package/dist/assets/adapters/next/templates/init/pages/{audit-log → settings/audit-log}/audit-log-detail-row.tsx +0 -0
  82. /package/dist/assets/adapters/next/templates/init/pages/{audit-log → settings/audit-log}/audit-log-page.tsx +0 -0
@@ -23,7 +23,7 @@ export function PageHeader({
23
23
 
24
24
  return (
25
25
  <>
26
- <header className="fixed top-0 border-t right-0 left-(--admin-shell-content-left) z-20 grid h-14 grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center border-b border-border/80 px-4 bg-sidebar transition-[left] duration-200 ease-linear">
26
+ <header className="fixed z-200 top-0 border-t right-0 left-(--admin-shell-content-left) grid h-14 grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center border-b border-border/80 px-4 bg-sidebar transition-[left] duration-200 ease-linear">
27
27
  <div className="flex w-full items-center justify-start gap-2">
28
28
  {hasBackButton && (
29
29
  <Button variant="ghost" size="icon" onClick={() => router.back()}>
@@ -0,0 +1,5 @@
1
+ import type { WebhookEventSource } from '@admin/types/webhooks'
2
+
3
+ export type { WebhookEventSource } from '@admin/types/webhooks'
4
+
5
+ export const webhookEventSources: WebhookEventSource[] = []
@@ -174,15 +174,35 @@ export function useTableUtils<TData extends RowData>({
174
174
  setColumnSizing((current) => {
175
175
  const next: ColumnSizingState = {}
176
176
  let changed = Object.keys(current).length !== baseColumns.length
177
+ const targetTotal = Math.round(Math.max(containerWidth, fixedTotal + flexibleMinTotal))
178
+ const flexibleSizing = flexibleColumns.map((column) => {
179
+ const columnShrinkRoom = Math.max(column.size - column.minSize, 0)
180
+ const shrink = shrinkRoom > 0 ? shrinkNeeded * (columnShrinkRoom / shrinkRoom) : 0
181
+ const grow = extraPerColumn
182
+ return {
183
+ id: column.id,
184
+ size: Math.round(column.size + grow - shrink)
185
+ }
186
+ })
187
+ const flexibleSizingById = new Map(flexibleSizing.map((column) => [column.id, column.size]))
188
+ const roundedFixedTotal = baseColumns
189
+ .filter((column) => !flexibleColumnIds.has(column.id))
190
+ .reduce((total, column) => total + Math.round(column.size), 0)
191
+ const roundedFlexibleTotal = flexibleSizing.reduce((total, column) => total + column.size, 0)
192
+ const roundingDelta = targetTotal - roundedFixedTotal - roundedFlexibleTotal
193
+ const lastFlexibleColumn = flexibleSizing.at(-1)
194
+
195
+ if (lastFlexibleColumn) {
196
+ flexibleSizingById.set(
197
+ lastFlexibleColumn.id,
198
+ Math.max(lastFlexibleColumn.size + roundingDelta, 0)
199
+ )
200
+ }
177
201
 
178
202
  for (const column of baseColumns) {
179
- const columnShrinkRoom = Math.max(column.size - column.minSize, 0)
180
- const shrink =
181
- flexibleColumnIds.has(column.id) && shrinkRoom > 0
182
- ? shrinkNeeded * (columnShrinkRoom / shrinkRoom)
183
- : 0
184
- const grow = flexibleColumnIds.has(column.id) ? extraPerColumn : 0
185
- const size = Math.round(column.size + grow - shrink)
203
+ const size = flexibleColumnIds.has(column.id)
204
+ ? (flexibleSizingById.get(column.id) ?? Math.round(column.size))
205
+ : Math.round(column.size)
186
206
  next[column.id] = size
187
207
  if (current[column.id] !== size) {
188
208
  changed = true
@@ -0,0 +1,211 @@
1
+ 'use client'
2
+
3
+ import type { FormSettingsData } from '@admin/actions/forms'
4
+ import { getAllFormSettings } from '@admin/actions/forms'
5
+ import type {
6
+ WebhookDeliveriesPage,
7
+ WebhookDeliveriesQuery,
8
+ WebhookSubscriptionEntry,
9
+ WebhooksPage,
10
+ WebhooksQuery
11
+ } from '@admin/actions/webhooks'
12
+ import {
13
+ getWebhookDeliveries,
14
+ getWebhookSubscriptions,
15
+ getWebhooks,
16
+ sendTestWebhook,
17
+ updateWebhook
18
+ } from '@admin/actions/webhooks'
19
+ import { type UseQueryResult, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
20
+ import { toast } from 'sonner'
21
+
22
+ export function useWebhooks(
23
+ search?: string,
24
+ enabled?: string,
25
+ lastDelivery?: string,
26
+ createdAtFrom?: string,
27
+ createdAtTo?: string,
28
+ page?: number,
29
+ pageSize?: number,
30
+ orderBy?: string,
31
+ orderDirection?: 'asc' | 'desc',
32
+ options?: { enabled?: boolean }
33
+ ): UseQueryResult<WebhooksPage, Error> {
34
+ const safePage = Number.isFinite(page ?? 0) && (page ?? 0) > 0 ? (page ?? 0) : 0
35
+ const safePageSize =
36
+ pageSize === -1 || (Number.isFinite(pageSize ?? 20) && (pageSize ?? 20) > 0)
37
+ ? (pageSize ?? 20)
38
+ : 20
39
+
40
+ return useQuery({
41
+ queryKey: [
42
+ 'webhooks',
43
+ search ?? '',
44
+ enabled ?? '',
45
+ lastDelivery ?? '',
46
+ createdAtFrom ?? '',
47
+ createdAtTo ?? '',
48
+ safePage,
49
+ safePageSize,
50
+ orderBy ?? '',
51
+ orderDirection ?? ''
52
+ ],
53
+ queryFn: () => {
54
+ const filters: WebhooksQuery = {}
55
+ if (search) filters.search = search
56
+ if (enabled === 'enabled') filters.enabled = true
57
+ if (enabled === 'disabled') filters.enabled = false
58
+ if (lastDelivery === 'delivered' || lastDelivery === 'failed' || lastDelivery === 'never') {
59
+ filters.lastDelivery = lastDelivery
60
+ }
61
+ if (createdAtFrom) filters.createdAtFrom = createdAtFrom
62
+ if (createdAtTo) filters.createdAtTo = createdAtTo
63
+ if (safePageSize !== -1) {
64
+ filters.limit = safePageSize
65
+ filters.offset = safePage * safePageSize
66
+ }
67
+ if (orderBy) filters.orderBy = orderBy
68
+ if (orderDirection) filters.orderDirection = orderDirection
69
+ return getWebhooks(Object.keys(filters).length > 0 ? filters : undefined)
70
+ },
71
+ enabled: options?.enabled ?? true
72
+ })
73
+ }
74
+
75
+ export function useWebhookOptions(): UseQueryResult<WebhooksPage, Error> {
76
+ return useQuery({
77
+ queryKey: ['webhooks', 'options'],
78
+ queryFn: () => getWebhooks()
79
+ })
80
+ }
81
+
82
+ export function useToggleWebhookEnabled() {
83
+ const queryClient = useQueryClient()
84
+
85
+ return useMutation({
86
+ mutationFn: async (input: { id: string; enabled: boolean }) => {
87
+ const result = await updateWebhook(input.id, { enabled: input.enabled })
88
+ if (!result.success) {
89
+ throw new Error(result.error || 'Failed to update webhook')
90
+ }
91
+
92
+ return result
93
+ },
94
+ onMutate: async (input) => {
95
+ await queryClient.cancelQueries({ queryKey: ['webhooks'] })
96
+ const previous = queryClient.getQueriesData<WebhooksPage>({
97
+ queryKey: ['webhooks']
98
+ })
99
+ queryClient.setQueriesData<WebhooksPage>({ queryKey: ['webhooks'] }, (page) =>
100
+ page
101
+ ? {
102
+ ...page,
103
+ webhooks: page.webhooks.map((webhook) =>
104
+ webhook.id === input.id ? { ...webhook, enabled: input.enabled } : webhook
105
+ )
106
+ }
107
+ : page
108
+ )
109
+ return { previous }
110
+ },
111
+ onError: (error: Error, _input, context) => {
112
+ for (const [queryKey, data] of context?.previous ?? []) {
113
+ queryClient.setQueryData(queryKey, data)
114
+ }
115
+ toast.error(error.message || 'Failed to update webhook')
116
+ },
117
+ onSettled: async () => {
118
+ await queryClient.invalidateQueries({ queryKey: ['webhooks'] })
119
+ }
120
+ })
121
+ }
122
+
123
+ export function useSendTestWebhook() {
124
+ const queryClient = useQueryClient()
125
+
126
+ return useMutation({
127
+ mutationFn: async (id: string) => {
128
+ const result = await sendTestWebhook(id)
129
+ if (!result.success) {
130
+ throw new Error(result.error || 'Test webhook failed')
131
+ }
132
+
133
+ return result
134
+ },
135
+ onSuccess: async (result) => {
136
+ toast.success(
137
+ result.statusCode ? `Test delivered (status ${result.statusCode})` : 'Test delivered'
138
+ )
139
+ await queryClient.invalidateQueries({ queryKey: ['webhook-deliveries'] })
140
+ await queryClient.invalidateQueries({ queryKey: ['webhooks'] })
141
+ },
142
+ onError: async (error: Error) => {
143
+ toast.error(error.message || 'Test webhook failed')
144
+ await queryClient.invalidateQueries({ queryKey: ['webhook-deliveries'] })
145
+ await queryClient.invalidateQueries({ queryKey: ['webhooks'] })
146
+ }
147
+ })
148
+ }
149
+
150
+ export function useWebhookSubscriptions() {
151
+ return useQuery<WebhookSubscriptionEntry[]>({
152
+ queryKey: ['webhook-subscriptions'],
153
+ queryFn: () => getWebhookSubscriptions()
154
+ })
155
+ }
156
+
157
+ export function useWebhookDeliveries(
158
+ search?: string,
159
+ status?: string,
160
+ endpoint?: string,
161
+ createdAtFrom?: string,
162
+ createdAtTo?: string,
163
+ page?: number,
164
+ pageSize?: number,
165
+ orderBy?: string,
166
+ orderDirection?: 'asc' | 'desc'
167
+ ): UseQueryResult<WebhookDeliveriesPage, Error> {
168
+ const safePage = Number.isFinite(page ?? 0) && (page ?? 0) > 0 ? (page ?? 0) : 0
169
+ const safePageSize =
170
+ pageSize === -1 || (Number.isFinite(pageSize ?? 20) && (pageSize ?? 20) > 0)
171
+ ? (pageSize ?? 20)
172
+ : 20
173
+
174
+ return useQuery({
175
+ queryKey: [
176
+ 'webhook-deliveries',
177
+ search ?? '',
178
+ status ?? '',
179
+ endpoint ?? '',
180
+ createdAtFrom ?? '',
181
+ createdAtTo ?? '',
182
+ safePage,
183
+ safePageSize,
184
+ orderBy ?? '',
185
+ orderDirection ?? ''
186
+ ],
187
+ queryFn: () => {
188
+ const filters: WebhookDeliveriesQuery = {}
189
+ if (search) filters.search = search
190
+ if (status === 'success') filters.success = true
191
+ if (status === 'failed') filters.success = false
192
+ if (endpoint) filters.webhookId = endpoint
193
+ if (createdAtFrom) filters.createdAtFrom = createdAtFrom
194
+ if (createdAtTo) filters.createdAtTo = createdAtTo
195
+ if (safePageSize !== -1) {
196
+ filters.limit = safePageSize
197
+ filters.offset = safePage * safePageSize
198
+ }
199
+ if (orderBy) filters.orderBy = orderBy
200
+ if (orderDirection) filters.orderDirection = orderDirection
201
+ return getWebhookDeliveries(Object.keys(filters).length > 0 ? filters : undefined)
202
+ }
203
+ })
204
+ }
205
+
206
+ export function useFormSettingsList() {
207
+ return useQuery<FormSettingsData[]>({
208
+ queryKey: ['form-settings'],
209
+ queryFn: () => getAllFormSettings()
210
+ })
211
+ }
@@ -13,6 +13,8 @@ export const AUDIT_LOG_ACTIONS = [
13
13
  'delete',
14
14
  'publish',
15
15
  'unpublish',
16
+ 'restore',
17
+ 'reorder',
16
18
  'cleanup'
17
19
  ] as const
18
20
  export const AUDIT_LOG_SOURCE_TYPES = ['admin_ui', 'server_action', 'system_job'] as const
@@ -1,6 +1,5 @@
1
1
  export { getAllFormSettings } from './get-all-form-settings'
2
2
  export { getFormSettings } from './get-form-settings'
3
- export { testFormWebhook } from './test-form-webhook'
4
3
  export type {
5
4
  FormSettingsData,
6
5
  FormSettingsResult,
@@ -1,16 +1,12 @@
1
1
  export interface FormSettingsData {
2
2
  id: string
3
3
  formName: string
4
- webhookUrl: string | null
5
- webhookEnabled: boolean
6
4
  notificationEmails: string | null
7
5
  createdAt: string
8
6
  updatedAt: string
9
7
  }
10
8
 
11
9
  export interface UpsertFormSettingsInput {
12
- webhookUrl?: string | null
13
- webhookEnabled?: boolean
14
10
  notificationEmails?: string | null
15
11
  }
16
12
 
@@ -18,8 +18,6 @@ export async function upsertFormSettings(
18
18
  .insert(formSettings)
19
19
  .values({
20
20
  formName,
21
- webhookUrl: data.webhookUrl ?? null,
22
- webhookEnabled: data.webhookEnabled ?? false,
23
21
  notificationEmails: data.notificationEmails ?? null
24
22
  })
25
23
  .onConflictDoUpdate({
@@ -0,0 +1,55 @@
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 { generateWebhookSecret } from '@admin/utils/webhook/signature'
7
+ import { updateTag } from 'next/cache'
8
+ import type { CreateWebhookInput, WebhookResult } from './types'
9
+ import { webhooksCacheTags } from './types'
10
+
11
+ export async function createWebhook(input: CreateWebhookInput): Promise<WebhookResult> {
12
+ await requireRole([UserRole.ADMIN])
13
+
14
+ try {
15
+ const created = await db.transaction(async (tx) => {
16
+ const [row] = await tx
17
+ .insert(webhooks)
18
+ .values({
19
+ name: input.name,
20
+ url: input.url,
21
+ secret: generateWebhookSecret(),
22
+ enabled: input.enabled
23
+ })
24
+ .returning()
25
+
26
+ if (input.events.length > 0) {
27
+ await tx
28
+ .insert(webhookSubscriptions)
29
+ .values(input.events.map((event) => ({ webhookId: row.id, event })))
30
+ }
31
+
32
+ return row
33
+ })
34
+
35
+ updateTag(webhooksCacheTags.endpoints)
36
+ updateTag(webhooksCacheTags.subscriptions)
37
+ return {
38
+ success: true,
39
+ webhook: {
40
+ id: created.id,
41
+ name: created.name,
42
+ url: created.url,
43
+ enabled: created.enabled,
44
+ createdAt: created.createdAt,
45
+ updatedAt: created.updatedAt
46
+ }
47
+ }
48
+ } catch (error) {
49
+ console.error('Error creating webhook:', error)
50
+ return {
51
+ success: false,
52
+ error: error instanceof Error ? error.message : 'Failed to create webhook'
53
+ }
54
+ }
55
+ }
@@ -0,0 +1,26 @@
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 { updateTag } from 'next/cache'
8
+ import type { WebhookResult } from './types'
9
+ import { webhooksCacheTags } from './types'
10
+
11
+ export async function deleteWebhook(id: string): Promise<WebhookResult> {
12
+ await requireRole([UserRole.ADMIN])
13
+
14
+ try {
15
+ await db.delete(webhooks).where(eq(webhooks.id, id))
16
+ updateTag(webhooksCacheTags.endpoints)
17
+ updateTag(webhooksCacheTags.subscriptions)
18
+ return { success: true }
19
+ } catch (error) {
20
+ console.error('Error deleting webhook:', error)
21
+ return {
22
+ success: false,
23
+ error: error instanceof Error ? error.message : 'Failed to delete webhook'
24
+ }
25
+ }
26
+ }
@@ -0,0 +1,135 @@
1
+ import db from '@admin/db'
2
+ import { webhookDeliveries } from '@admin/db/schema'
3
+ import { createWebhookEventId, signWebhookPayload } from '@admin/utils/webhook/signature'
4
+ import { and, desc, eq, notInArray } from 'drizzle-orm'
5
+ import { after } from 'next/server'
6
+ import type { ActiveWebhookEndpoint } from './internal-get-active-endpoints'
7
+ import { getActiveEndpointsForEvent } from './internal-get-active-endpoints'
8
+
9
+ const MAX_ATTEMPTS = 3
10
+ const RETRY_DELAYS_MS = [1_000, 5_000]
11
+ const REQUEST_TIMEOUT_MS = 10_000
12
+ const DELIVERY_LOG_LIMIT = 100
13
+
14
+ export function emitWebhookEvent(event: string, data: unknown): void {
15
+ const eventId = createWebhookEventId()
16
+ const body = JSON.stringify({
17
+ id: eventId,
18
+ event,
19
+ timestamp: new Date().toISOString(),
20
+ data
21
+ })
22
+
23
+ // A missing request scope must never crash the mutation that emitted the event.
24
+ try {
25
+ after(() => deliverWebhookEvent(eventId, event, body))
26
+ } catch (error) {
27
+ console.error('Webhook dispatch skipped (no request scope):', {
28
+ event,
29
+ error
30
+ })
31
+ }
32
+ }
33
+
34
+ async function deliverWebhookEvent(eventId: string, event: string, body: string): Promise<void> {
35
+ try {
36
+ const endpoints = await getActiveEndpointsForEvent(event)
37
+ if (endpoints.length === 0) return
38
+
39
+ await Promise.allSettled(
40
+ endpoints.map((endpoint) => deliverToEndpoint(endpoint, eventId, event, body))
41
+ )
42
+ } catch (error) {
43
+ console.error('Webhook delivery failed:', { event, error })
44
+ }
45
+ }
46
+
47
+ async function deliverToEndpoint(
48
+ endpoint: ActiveWebhookEndpoint,
49
+ eventId: string,
50
+ event: string,
51
+ body: string
52
+ ): Promise<void> {
53
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
54
+ const startedAt = Date.now()
55
+ let statusCode: number | null = null
56
+ let errorMessage: string | null = null
57
+
58
+ try {
59
+ const timestamp = Math.floor(Date.now() / 1000).toString()
60
+ const response = await fetch(endpoint.url, {
61
+ method: 'POST',
62
+ headers: {
63
+ 'content-type': 'application/json',
64
+ 'x-webhook-id': eventId,
65
+ 'x-webhook-timestamp': timestamp,
66
+ 'x-webhook-signature': signWebhookPayload(endpoint.secret, timestamp, body)
67
+ },
68
+ body,
69
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
70
+ })
71
+ statusCode = response.status
72
+ if (!response.ok) errorMessage = `Webhook returned status ${response.status}`
73
+ } catch (error) {
74
+ errorMessage = error instanceof Error ? error.message : 'Webhook request failed'
75
+ }
76
+
77
+ await recordWebhookDelivery({
78
+ webhookId: endpoint.id,
79
+ event,
80
+ eventId,
81
+ attempt,
82
+ success: errorMessage === null,
83
+ statusCode,
84
+ durationMs: Date.now() - startedAt,
85
+ error: errorMessage
86
+ })
87
+
88
+ if (errorMessage === null) return
89
+ if (attempt < MAX_ATTEMPTS) {
90
+ await new Promise((resolve) => setTimeout(resolve, RETRY_DELAYS_MS[attempt - 1]))
91
+ }
92
+ }
93
+ }
94
+
95
+ export async function recordWebhookDelivery(entry: {
96
+ webhookId: string
97
+ event: string
98
+ eventId: string
99
+ attempt: number
100
+ success: boolean
101
+ statusCode: number | null
102
+ durationMs: number
103
+ error: string | null
104
+ }): Promise<void> {
105
+ try {
106
+ await db.insert(webhookDeliveries).values(entry)
107
+ await pruneWebhookDeliveries(entry.webhookId)
108
+ } catch (error) {
109
+ console.error('Failed to record webhook delivery:', {
110
+ event: entry.event,
111
+ error
112
+ })
113
+ }
114
+ }
115
+
116
+ async function pruneWebhookDeliveries(webhookId: string): Promise<void> {
117
+ const keepRows = await db
118
+ .select({ id: webhookDeliveries.id })
119
+ .from(webhookDeliveries)
120
+ .where(eq(webhookDeliveries.webhookId, webhookId))
121
+ .orderBy(desc(webhookDeliveries.createdAt))
122
+ .limit(DELIVERY_LOG_LIMIT)
123
+
124
+ if (keepRows.length < DELIVERY_LOG_LIMIT) return
125
+
126
+ await db.delete(webhookDeliveries).where(
127
+ and(
128
+ eq(webhookDeliveries.webhookId, webhookId),
129
+ notInArray(
130
+ webhookDeliveries.id,
131
+ keepRows.map((row) => row.id)
132
+ )
133
+ )
134
+ )
135
+ }
@@ -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
+ }