fix(webhook): scope_filter Hibernate ordinal bug + UI toggle для active
Два бага в /webhooks (user report):
1. **500 при выборе scope в форме**
POST /admin/webhooks/subscriptions с scopeFilter:["PUBLIC"] возвращал 500.
Root cause: WebhookSubscription.scopeFilter был List<DataScope> с
@JdbcTypeCode(SqlTypes.ARRAY). Hibernate сериализовал enum в TEXT[] как
ordinal (е.g. {0} для PUBLIC), а DB CHECK constraint
chk_webhook_scope_filter ожидает строковые имена. Insert падал
DataIntegrityViolationException → 500.
Fix: field теперь List<String> (raw enum names). Getter/setter
конвертируют в/из DataScope — API surface (DTO, service signatures,
WebhookDispatcher) не меняется.
2. **Нет toggle active/inactive в UI**
useUpdateWebhook hook был, но не использовался в UI. Status показывался
только read-only badge'ом. Невозможно деактивировать webhook через UI.
Fix: добавлен toggle button рядом с Status badge на /webhooks/:id.
Sends PUT с inverted active flag (backend treats PUT как full replace,
поэтому пересобираем full payload).
i18n: 'webhooks.action.activate' / 'deactivate' default labels (RU).
This commit is contained in:
@@ -33,6 +33,7 @@ import {
|
|||||||
useRetryWebhookDelivery,
|
useRetryWebhookDelivery,
|
||||||
useRotateWebhookSecret,
|
useRotateWebhookSecret,
|
||||||
useTestWebhook,
|
useTestWebhook,
|
||||||
|
useUpdateWebhook,
|
||||||
} from '@/api/mutations'
|
} from '@/api/mutations'
|
||||||
import type { WebhookTestPingResult } from '@/api/client'
|
import type { WebhookTestPingResult } from '@/api/client'
|
||||||
import { SCOPE_DOT } from '@/lib/scope-style'
|
import { SCOPE_DOT } from '@/lib/scope-style'
|
||||||
@@ -63,6 +64,7 @@ function WebhookDetailPage() {
|
|||||||
}
|
}
|
||||||
const rotateMut = useRotateWebhookSecret()
|
const rotateMut = useRotateWebhookSecret()
|
||||||
const deleteMut = useDeleteWebhook()
|
const deleteMut = useDeleteWebhook()
|
||||||
|
const updateMut = useUpdateWebhook()
|
||||||
const retryMut = useRetryWebhookDelivery()
|
const retryMut = useRetryWebhookDelivery()
|
||||||
const testMut = useTestWebhook()
|
const testMut = useTestWebhook()
|
||||||
const [testResult, setTestResult] = useState<WebhookTestPingResult | null>(null)
|
const [testResult, setTestResult] = useState<WebhookTestPingResult | null>(null)
|
||||||
@@ -161,9 +163,38 @@ function WebhookDetailPage() {
|
|||||||
<code className="text-mono break-all">{data.url}</code>
|
<code className="text-mono break-all">{data.url}</code>
|
||||||
</InfoRow>
|
</InfoRow>
|
||||||
<InfoRow label={t('webhooks.col.status')}>
|
<InfoRow label={t('webhooks.col.status')}>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
<Badge variant={data.active ? 'success' : 'neutral'}>
|
<Badge variant={data.active ? 'success' : 'neutral'}>
|
||||||
{data.active ? t('webhooks.active') : t('webhooks.inactive')}
|
{data.active ? t('webhooks.active') : t('webhooks.inactive')}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
// Toggle active. PUT requires full payload — пересобираем
|
||||||
|
// из current state + переворачиваем active. Backend
|
||||||
|
// service treats PUT как full replace, поэтому все
|
||||||
|
// поля должны быть переданы.
|
||||||
|
updateMut.mutate({
|
||||||
|
id: data.id,
|
||||||
|
payload: {
|
||||||
|
name: data.name,
|
||||||
|
url: data.url,
|
||||||
|
scopeFilter: data.scopeFilter ?? undefined,
|
||||||
|
dictionaryFilter: data.dictionaryFilter ?? undefined,
|
||||||
|
eventTypeFilter: data.eventTypeFilter ?? undefined,
|
||||||
|
description: data.description ?? undefined,
|
||||||
|
active: !data.active,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
disabled={updateMut.isPending}
|
||||||
|
className="text-cap text-accent hover:underline disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{data.active
|
||||||
|
? t('webhooks.action.deactivate', { defaultValue: 'Деактивировать' })
|
||||||
|
: t('webhooks.action.activate', { defaultValue: 'Активировать' })}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</InfoRow>
|
</InfoRow>
|
||||||
<InfoRow label={t('webhooks.field.scopeFilter')}>
|
<InfoRow label={t('webhooks.field.scopeFilter')}>
|
||||||
{data.scopeFilter && data.scopeFilter.length > 0 ? (
|
{data.scopeFilter && data.scopeFilter.length > 0 ? (
|
||||||
|
|||||||
+23
-4
@@ -41,10 +41,23 @@ public class WebhookSubscription {
|
|||||||
@Column(name = "url", nullable = false, length = 2048)
|
@Column(name = "url", nullable = false, length = 2048)
|
||||||
private String url;
|
private String url;
|
||||||
|
|
||||||
/** TEXT[] postgres → List<String> via SqlTypes.ARRAY. */
|
/**
|
||||||
|
* TEXT[] postgres хранит enum names (PUBLIC/INTERNAL/RESTRICTED).
|
||||||
|
*
|
||||||
|
* <p><b>Bug history:</b> раньше field был {@code List<DataScope>} +
|
||||||
|
* {@code @JdbcTypeCode(SqlTypes.ARRAY)} — Hibernate сериализовал enum
|
||||||
|
* как ordinal (e.g. {@code {0}} для PUBLIC), а DB CHECK constraint
|
||||||
|
* ({@code chk_webhook_scope_filter}) требует string values из набора
|
||||||
|
* {@code 'PUBLIC','INTERNAL','RESTRICTED'}. Insert падал с
|
||||||
|
* {@code DataIntegrityViolationException} → 500 на POST с scopeFilter.
|
||||||
|
*
|
||||||
|
* <p><b>Fix:</b> field хранит {@code List<String>} (raw enum names).
|
||||||
|
* Getter/setter конвертируют в/из {@link DataScope} — внешний API
|
||||||
|
* (DTO + service signature + WebhookDispatcher) не меняется.
|
||||||
|
*/
|
||||||
@JdbcTypeCode(SqlTypes.ARRAY)
|
@JdbcTypeCode(SqlTypes.ARRAY)
|
||||||
@Column(name = "scope_filter", columnDefinition = "TEXT[]")
|
@Column(name = "scope_filter", columnDefinition = "TEXT[]")
|
||||||
private List<DataScope> scopeFilter;
|
private List<String> scopeFilter;
|
||||||
|
|
||||||
@JdbcTypeCode(SqlTypes.ARRAY)
|
@JdbcTypeCode(SqlTypes.ARRAY)
|
||||||
@Column(name = "dictionary_filter", columnDefinition = "TEXT[]")
|
@Column(name = "dictionary_filter", columnDefinition = "TEXT[]")
|
||||||
@@ -96,7 +109,10 @@ public class WebhookSubscription {
|
|||||||
public UUID getId() { return id; }
|
public UUID getId() { return id; }
|
||||||
public String getName() { return name; }
|
public String getName() { return name; }
|
||||||
public String getUrl() { return url; }
|
public String getUrl() { return url; }
|
||||||
public List<DataScope> getScopeFilter() { return scopeFilter; }
|
public List<DataScope> getScopeFilter() {
|
||||||
|
if (scopeFilter == null) return null;
|
||||||
|
return scopeFilter.stream().map(DataScope::valueOf).toList();
|
||||||
|
}
|
||||||
public List<String> getDictionaryFilter() { return dictionaryFilter; }
|
public List<String> getDictionaryFilter() { return dictionaryFilter; }
|
||||||
public List<String> getEventTypeFilter() { return eventTypeFilter; }
|
public List<String> getEventTypeFilter() { return eventTypeFilter; }
|
||||||
public String getHmacSecret() { return hmacSecret; }
|
public String getHmacSecret() { return hmacSecret; }
|
||||||
@@ -108,7 +124,10 @@ public class WebhookSubscription {
|
|||||||
|
|
||||||
public void setName(String v) { this.name = v; touch(); }
|
public void setName(String v) { this.name = v; touch(); }
|
||||||
public void setUrl(String v) { this.url = v; touch(); }
|
public void setUrl(String v) { this.url = v; touch(); }
|
||||||
public void setScopeFilter(List<DataScope> v) { this.scopeFilter = v; touch(); }
|
public void setScopeFilter(List<DataScope> v) {
|
||||||
|
this.scopeFilter = (v == null) ? null : v.stream().map(Enum::name).toList();
|
||||||
|
touch();
|
||||||
|
}
|
||||||
public void setDictionaryFilter(List<String> v) { this.dictionaryFilter = v; touch(); }
|
public void setDictionaryFilter(List<String> v) { this.dictionaryFilter = v; touch(); }
|
||||||
public void setEventTypeFilter(List<String> v) { this.eventTypeFilter = v; touch(); }
|
public void setEventTypeFilter(List<String> v) { this.eventTypeFilter = v; touch(); }
|
||||||
public void setActive(boolean v) { this.active = v; touch(); }
|
public void setActive(boolean v) { this.active = v; touch(); }
|
||||||
|
|||||||
Reference in New Issue
Block a user