feat(webhook): Phase 4 — admin-ui /webhooks page
Routes: - /webhooks (layout) → /webhooks/index (list) + /webhooks/$id (detail) - nav link добавлен в __root.tsx между Outbox и Language switch List page (webhooks.index.tsx): - Table: name | URL | scope filter (colored dots) | dictionaries | status - ScopeChips reuse SCOPE_DOT из lib/scope-style.ts (consistency с dictionaries scope coloring) - Create dialog: name, URL, scope filter (MultiSelect), dictionary & event type (CSV inputs), description. Client-side validation (URL scheme, name required) дублирует server-side @Pattern. - Secret reveal modal после create: warning + plaintext + copy button. Single-time view — после закрытия secret не получить, только rotate. Detail page (webhooks.$id.tsx): - Subscription metadata grid (URL, status, filters, masked secret, createdBy + timestamp) - Action buttons: rotate-secret + delete (с confirmation) - Delivery history table: outboxEventId | status badge (delivered/ retrying/pending/dlq) | attempts | last attempt | HTTP code | error - Pagination 50/page - Status badges: delivered=success, retrying=warning, pending=neutral, dlq=error API client (queries + mutations): - 4 queries: subscriptions list, subscription by id, deliveries page, DLQ page (last unused в UI пока, но готов для admin DLQ tab) - 4 mutations: create, update, delete, rotateSecret. Все invalidate правильные query keys. i18n: 50 ключей × 2 локали (ru-RU + en-US) под webhooks.*
This commit is contained in:
@@ -173,3 +173,56 @@ export type DlqPage = {
|
||||
number: number
|
||||
size: number
|
||||
}
|
||||
|
||||
// === Webhooks (v2) ===
|
||||
|
||||
export type WebhookSubscription = {
|
||||
id: string
|
||||
name: string
|
||||
url: string
|
||||
scopeFilter?: DataScope[] | null
|
||||
dictionaryFilter?: string[] | null
|
||||
eventTypeFilter?: string[] | null
|
||||
/** Masked `sk_****<last4>` или plaintext только в response создания/rotate. */
|
||||
hmacSecret: string
|
||||
active: boolean
|
||||
createdAt: string
|
||||
createdBy: string
|
||||
updatedAt: string
|
||||
description?: string | null
|
||||
}
|
||||
|
||||
export type CreateWebhookSubscriptionRequest = {
|
||||
name: string
|
||||
url: string
|
||||
scopeFilter?: DataScope[]
|
||||
dictionaryFilter?: string[]
|
||||
eventTypeFilter?: string[]
|
||||
active?: boolean
|
||||
description?: string
|
||||
}
|
||||
|
||||
export type WebhookDeliveryStatus = 'pending' | 'retrying' | 'delivered' | 'dlq'
|
||||
|
||||
export type WebhookDelivery = {
|
||||
id: number
|
||||
subscriptionId: string
|
||||
outboxEventId: number
|
||||
status: WebhookDeliveryStatus
|
||||
attemptCount: number
|
||||
createdAt: string
|
||||
nextAttemptAt: string
|
||||
lastAttemptAt?: string | null
|
||||
deliveredAt?: string | null
|
||||
lastError?: string | null
|
||||
lastStatusCode?: number | null
|
||||
dlqAt?: string | null
|
||||
}
|
||||
|
||||
export type WebhookDeliveryPage = {
|
||||
content: WebhookDelivery[]
|
||||
totalElements: number
|
||||
totalPages: number
|
||||
number: number
|
||||
size: number
|
||||
}
|
||||
|
||||
@@ -3,8 +3,10 @@ import {
|
||||
apiClient,
|
||||
type CreateDictionaryRequest,
|
||||
type CreateRecordRequest,
|
||||
type CreateWebhookSubscriptionRequest,
|
||||
type DictionaryDetail,
|
||||
type RecordResponse,
|
||||
type WebhookSubscription,
|
||||
} from './client'
|
||||
|
||||
const idempotencyKey = () =>
|
||||
@@ -96,3 +98,69 @@ export const useCloseRecord = (dictionaryName: string) => {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// === Webhooks (v2) ===
|
||||
|
||||
export const useCreateWebhook = () => {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async (req: CreateWebhookSubscriptionRequest): Promise<WebhookSubscription> => {
|
||||
const { data } = await apiClient.post<WebhookSubscription>(
|
||||
'/admin/webhooks/subscriptions',
|
||||
req,
|
||||
)
|
||||
return data
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['webhooks', 'subscriptions'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const useUpdateWebhook = () => {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async (params: {
|
||||
id: string
|
||||
payload: CreateWebhookSubscriptionRequest
|
||||
}): Promise<WebhookSubscription> => {
|
||||
const { data } = await apiClient.put<WebhookSubscription>(
|
||||
`/admin/webhooks/subscriptions/${encodeURIComponent(params.id)}`,
|
||||
params.payload,
|
||||
)
|
||||
return data
|
||||
},
|
||||
onSuccess: (_data, vars) => {
|
||||
qc.invalidateQueries({ queryKey: ['webhooks', 'subscriptions'] })
|
||||
qc.invalidateQueries({ queryKey: ['webhooks', 'subscriptions', vars.id] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const useDeleteWebhook = () => {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async (id: string): Promise<void> => {
|
||||
await apiClient.delete(`/admin/webhooks/subscriptions/${encodeURIComponent(id)}`)
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['webhooks', 'subscriptions'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const useRotateWebhookSecret = () => {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async (id: string): Promise<WebhookSubscription> => {
|
||||
const { data } = await apiClient.post<WebhookSubscription>(
|
||||
`/admin/webhooks/subscriptions/${encodeURIComponent(id)}/rotate-secret`,
|
||||
)
|
||||
return data
|
||||
},
|
||||
onSuccess: (_data, id) => {
|
||||
qc.invalidateQueries({ queryKey: ['webhooks', 'subscriptions'] })
|
||||
qc.invalidateQueries({ queryKey: ['webhooks', 'subscriptions', id] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
type FlattenedRecord,
|
||||
type OutboxStats,
|
||||
type RecordResponse,
|
||||
type WebhookDeliveryPage,
|
||||
type WebhookSubscription,
|
||||
} from './client'
|
||||
|
||||
export const dictionariesQuery = queryOptions({
|
||||
@@ -135,6 +137,70 @@ export const dlqQuery = (page: number, size: number) =>
|
||||
|
||||
export const useDlq = (page: number, size: number) => useQuery(dlqQuery(page, size))
|
||||
|
||||
// === Webhooks (v2) ===
|
||||
|
||||
export const webhookSubscriptionsQuery = queryOptions({
|
||||
queryKey: ['webhooks', 'subscriptions'] as const,
|
||||
queryFn: async (): Promise<WebhookSubscription[]> => {
|
||||
const { data } = await apiClient.get<WebhookSubscription[]>(
|
||||
'/admin/webhooks/subscriptions',
|
||||
)
|
||||
return data
|
||||
},
|
||||
})
|
||||
|
||||
export const useWebhookSubscriptions = () => useQuery(webhookSubscriptionsQuery)
|
||||
|
||||
export const webhookSubscriptionQuery = (id: string) =>
|
||||
queryOptions({
|
||||
queryKey: ['webhooks', 'subscriptions', id] as const,
|
||||
queryFn: async (): Promise<WebhookSubscription> => {
|
||||
const { data } = await apiClient.get<WebhookSubscription>(
|
||||
`/admin/webhooks/subscriptions/${encodeURIComponent(id)}`,
|
||||
)
|
||||
return data
|
||||
},
|
||||
})
|
||||
|
||||
export const useWebhookSubscription = (id: string) =>
|
||||
useQuery({
|
||||
...webhookSubscriptionQuery(id),
|
||||
enabled: Boolean(id),
|
||||
})
|
||||
|
||||
export const webhookDeliveriesQuery = (id: string, page: number, size: number) =>
|
||||
queryOptions({
|
||||
queryKey: ['webhooks', 'deliveries', id, page, size] as const,
|
||||
queryFn: async (): Promise<WebhookDeliveryPage> => {
|
||||
const { data } = await apiClient.get<WebhookDeliveryPage>(
|
||||
`/admin/webhooks/subscriptions/${encodeURIComponent(id)}/deliveries`,
|
||||
{ params: { page, size } },
|
||||
)
|
||||
return data
|
||||
},
|
||||
})
|
||||
|
||||
export const useWebhookDeliveries = (id: string, page: number, size: number) =>
|
||||
useQuery({
|
||||
...webhookDeliveriesQuery(id, page, size),
|
||||
enabled: Boolean(id),
|
||||
})
|
||||
|
||||
export const webhookDlqQuery = (page: number, size: number) =>
|
||||
queryOptions({
|
||||
queryKey: ['webhooks', 'dlq', page, size] as const,
|
||||
queryFn: async (): Promise<WebhookDeliveryPage> => {
|
||||
const { data } = await apiClient.get<WebhookDeliveryPage>(
|
||||
'/admin/webhooks/deliveries/dlq',
|
||||
{ params: { page, size } },
|
||||
)
|
||||
return data
|
||||
},
|
||||
})
|
||||
|
||||
export const useWebhookDlq = (page: number, size: number) =>
|
||||
useQuery(webhookDlqQuery(page, size))
|
||||
|
||||
export const useDictionaries = () => useQuery(dictionariesQuery)
|
||||
export const useDictionaryDetail = (name: string) => useQuery(dictionaryDetailQuery(name))
|
||||
export const useRecords = (dictionaryName: string, scopeCsv: string) =>
|
||||
|
||||
Reference in New Issue
Block a user