(
`/drafts/${encodeURIComponent(id)}`,
)
return data
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['review-queue'] })
qc.invalidateQueries({ queryKey: ['draft'] })
qc.invalidateQueries({ queryKey: ['drafts'] }) // /me + by-dict
},
})
}
// === Schema workflow mutations (Phase 1a, v2.2.0 backend) =============
/**
* Создаёт schema draft от current HEAD. Backend проверяет fromVersion
* против live schemaVersion (409 version_mismatch если расходится).
* Также 409 active_draft_exists если на dict'е уже есть non-terminal draft.
*
* onSuccess invalidates: active-draft query (banner picks up) +
* drafts list + reviewer pool queue.
*/
export const useCreateSchemaDraft = (dictionaryName: string) => {
const qc = useQueryClient()
return useMutation({
mutationFn: async (payload: CreateSchemaDraftRequest): Promise => {
const { data } = await apiClient.post(
`/dictionaries/${dictionaryName}/drafts`,
payload,
)
return data
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['schema-draft-active', dictionaryName] })
qc.invalidateQueries({ queryKey: ['schema-drafts', dictionaryName] })
qc.invalidateQueries({ queryKey: ['schema-review-queue'] })
},
})
}
/**
* Update content (proposedSchema / reason) в существующем draft'е без
* recreate. Status-gate: только DRAFT и CHANGES_REQUESTED. Backend
* автоматически переводит CHANGES_REQUESTED → DRAFT после update
* (maker должен ре-submit на ревью осознанно).
*/
export const useUpdateSchemaDraft = (dictionaryName: string) => {
const qc = useQueryClient()
return useMutation({
mutationFn: async (params: {
draftId: string
body: UpdateSchemaDraftRequest
}): Promise => {
const { data } = await apiClient.patch(
`/dictionaries/${dictionaryName}/drafts/${params.draftId}`,
params.body,
)
return data
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['schema-draft-active', dictionaryName] })
qc.invalidateQueries({ queryKey: ['schema-draft', dictionaryName] })
qc.invalidateQueries({ queryKey: ['schema-drafts', dictionaryName] })
qc.invalidateQueries({ queryKey: ['schema-review-queue'] })
qc.invalidateQueries({ queryKey: ['changelog', dictionaryName] })
},
})
}
/** Maker submits draft → review_pending. 202 Accepted. */
export const useSubmitSchemaDraftForReview = (dictionaryName: string) => {
const qc = useQueryClient()
return useMutation({
mutationFn: async (params: {
draftId: string
body?: SubmitForReviewRequest
}): Promise => {
const { data } = await apiClient.post(
`/dictionaries/${dictionaryName}/drafts/${params.draftId}/review`,
params.body ?? {},
)
return data
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['schema-draft-active', dictionaryName] })
qc.invalidateQueries({ queryKey: ['schema-draft', dictionaryName] })
qc.invalidateQueries({ queryKey: ['schema-drafts', dictionaryName] })
qc.invalidateQueries({ queryKey: ['schema-review-queue'] })
},
})
}
/**
* Reviewer decide: approve / request_changes / reject. Backend
* проверяет self_approve_forbidden (maker_id != actor) → 403.
*/
export const useDecideSchemaDraft = (dictionaryName: string) => {
const qc = useQueryClient()
return useMutation({
mutationFn: async (params: {
draftId: string
body: DecisionRequest
}): Promise => {
const { data } = await apiClient.post(
`/dictionaries/${dictionaryName}/drafts/${params.draftId}/decision`,
params.body,
)
return data
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['schema-draft-active', dictionaryName] })
qc.invalidateQueries({ queryKey: ['schema-draft', dictionaryName] })
qc.invalidateQueries({ queryKey: ['schema-drafts', dictionaryName] })
qc.invalidateQueries({ queryKey: ['schema-review-queue'] })
// Changelog gets new entry (draft_approve / draft_reject / draft_changes_requested)
qc.invalidateQueries({ queryKey: ['changelog', dictionaryName] })
},
})
}
/**
* Publish approved draft → bump live HEAD. Backend computes new version
* (minor default, major на breaking). Returns wrapped {draft, newVersion, publishedAt}.
*
* onSuccess invalidates dict detail (schema/version changed) + changelog +
* draft queries.
*/
export const usePublishSchemaDraft = (dictionaryName: string) => {
const qc = useQueryClient()
return useMutation({
mutationFn: async (params: {
draftId: string
body: PublishSchemaDraftRequest
}): Promise => {
const { data } = await apiClient.post(
`/dictionaries/${dictionaryName}/drafts/${params.draftId}/publish`,
params.body,
)
return data
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['schema-draft-active', dictionaryName] })
qc.invalidateQueries({ queryKey: ['schema-draft', dictionaryName] })
qc.invalidateQueries({ queryKey: ['schema-drafts', dictionaryName] })
qc.invalidateQueries({ queryKey: ['schema-review-queue'] })
qc.invalidateQueries({ queryKey: ['dictionary', dictionaryName] })
qc.invalidateQueries({ queryKey: ['changelog', dictionaryName] })
},
})
}
/** Maker withdraws non-terminal draft (terminal state). */
export const useWithdrawSchemaDraft = (dictionaryName: string) => {
const qc = useQueryClient()
return useMutation({
mutationFn: async (draftId: string): Promise => {
const { data } = await apiClient.delete(
`/dictionaries/${dictionaryName}/drafts/${draftId}`,
)
return data
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['schema-draft-active', dictionaryName] })
qc.invalidateQueries({ queryKey: ['schema-draft', dictionaryName] })
qc.invalidateQueries({ queryKey: ['schema-drafts', dictionaryName] })
qc.invalidateQueries({ queryKey: ['schema-review-queue'] })
qc.invalidateQueries({ queryKey: ['changelog', dictionaryName] })
},
})
}
export const useRetryWebhookDelivery = () => {
const qc = useQueryClient()
return useMutation({
mutationFn: async (deliveryId: number): Promise => {
const { data } = await apiClient.post(
`/admin/webhooks/deliveries/${deliveryId}/retry`,
)
return data
},
onSuccess: () => {
// Invalidate всё что показывает delivery rows: detail page deliveries,
// global DLQ list. Cheaper чем targeted invalidation т.к. delivery
// counts маленькие.
qc.invalidateQueries({ queryKey: ['webhooks', 'deliveries'] })
qc.invalidateQueries({ queryKey: ['webhooks', 'dlq'] })
},
})
}
// === Notifications mutations (TODO 7) ===
/**
* Mark одно notification как read. Optimistic update — UI обновляется
* мгновенно, query invalidates после server confirm. Если backend reject'нет,
* onError refetch'нет original state.
*/
export const useMarkNotificationRead = () => {
const qc = useQueryClient()
return useMutation({
mutationFn: async (notificationId: string): Promise => {
await apiClient.post(
`/me/notifications/${encodeURIComponent(notificationId)}/read`,
)
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['notifications', 'me'] })
},
})
}
/**
* Mark все unread notifications as read. Drawer footer button "Mark all read".
*/
export const useMarkAllNotificationsRead = () => {
const qc = useQueryClient()
return useMutation({
mutationFn: async (): Promise => {
await apiClient.post('/me/notifications/read-all')
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['notifications', 'me'] })
},
})
}
/**
* Phase B — update per-channel notification preferences. PUT upserts all 3
* каналов атомарно (backend @Transactional). Optimistic update — UI flips
* toggle instantly, onError refetch restores original state.
*
* Cache key 'notifications/me/preferences' — separate from feed query
* (которая использует 'notifications/me'). Invalidate prefs alone не
* triggers feed refetch.
*/
export const useUpdateNotificationPreferences = () => {
const qc = useQueryClient()
return useMutation({
mutationFn: async (prefs: NotificationPreferences): Promise => {
const { data } = await apiClient.put(
'/me/notifications/preferences',
prefs,
)
return data
},
onMutate: async (next) => {
await qc.cancelQueries({ queryKey: ['notifications', 'me', 'preferences'] })
const prev = qc.getQueryData([
'notifications',
'me',
'preferences',
])
qc.setQueryData(['notifications', 'me', 'preferences'], next)
return { prev }
},
onError: (_err, _next, ctx) => {
if (ctx?.prev) {
qc.setQueryData(['notifications', 'me', 'preferences'], ctx.prev)
}
},
onSettled: () => {
qc.invalidateQueries({ queryKey: ['notifications', 'me', 'preferences'] })
},
})
}
/**
* AI Schema Assist — per-field suggest. Returns parsed {fieldName, schema}.
* Каллер показывает diff preview, user accept/edit/reject.
*
* 404 → AI disabled на бэкенде — frontend hides button (handled в caller).
* 503/circuit_open → temporary unavailable, show banner.
* 422 → bad output from LLM — show error message, suggest retry.
* 429 → rate limit, show "try again in a minute".
*/
export const useAiSuggestField = () => {
return useMutation({
mutationFn: async (req: {
existingSchema: unknown
prompt: string
}): Promise => {
const { data } = await apiClient.post(
'/ai/suggest-field',
req,
{ timeout: 30_000 }, // override default 10s — LLM может быть slow
)
return data
},
})
}
/**
* Phase C Don't Disturb — update quiet hours settings.
* Optimistic update + invalidation на settled.
*/
export const useUpdateNotificationSettings = () => {
const qc = useQueryClient()
return useMutation({
mutationFn: async (
next: import('./client').NotificationSettings,
): Promise => {
const { data } = await apiClient.put(
'/me/notifications/settings',
next,
)
return data
},
onSettled: () => {
qc.invalidateQueries({ queryKey: ['notifications', 'me', 'settings'] })
},
})
}