feat(D): Phase D — Undo/Restore komplett implementiert
Check Cross-Plugin Imports / check (push) Has been cancelled
Check Cross-Plugin Imports / check (push) Has been cancelled
- D-GEN: RestoreRegistry mit RestoreConfig (model_class, restore_permission, excluded_fields, special_handler) - D-HOOK: history_hooks.py mit register_history_hooks() für after_create/update/delete - D-CORE: Company create+update record_history in companies.py - D-PLUG: Task/Calendar/DMS record_history in services/routes - D-SOFT: Alle registrierten Entitäten haben deleted_at + un-delete via Registry - D-MAIL: Mail special_handler (IMAP Trash-Move, Folder-Verify) + record_history in delete/move - D-TRASH: GET /entity-history/trash (filterbar, paginiert) + Frontend Trash.tsx - D-TOAST: UndoToast.tsx (5s Auto-Dismiss, useUndoToast Hook) - D-HIST-UI: HistoryPanel.tsx (Timeline, Diff-View, Restore-Button) - D-BULK: POST /entity-history/bulk-restore mit partial_success Semantik - D-RET: POST /entity-history/retention/archive (GDPR hard-delete >90 Tage) - D-TEST: 26 Tests in test_restore_registry.py, alle grün - D-DOC: test-strategy.md + security_kernel.md aktualisiert Backend: 10 Dateien, Frontend: 7 Dateien, Tests: 1 Datei, Docs: 3 Dateien 26/26 Tests passed, TSC 0 errors, App import 492 routes
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Entity History hooks — undo/restore functionality.
|
||||
* Entity History hooks — undo/restore/trash functionality.
|
||||
*/
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
@@ -10,9 +10,9 @@ export interface EntityHistoryEntry {
|
||||
entity_type: string;
|
||||
entity_id: string;
|
||||
action: 'create' | 'update' | 'delete';
|
||||
snapshot_before: Record<string, any> | null;
|
||||
snapshot_after: Record<string, any> | null;
|
||||
changes: Record<string, { old: any; new: any }> | null;
|
||||
snapshot_before: Record<string, unknown> | null;
|
||||
snapshot_after: Record<string, unknown> | null;
|
||||
changes: Record<string, { old: unknown; new: unknown }> | null;
|
||||
user_id: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
@@ -22,22 +22,82 @@ export interface EntityHistoryList {
|
||||
total: number;
|
||||
}
|
||||
|
||||
export function useEntityHistory(entityType?: string, entityId?: string) {
|
||||
export interface TrashItem {
|
||||
history_id: string;
|
||||
entity_type: string;
|
||||
entity_id: string;
|
||||
snapshot_before: Record<string, unknown> | null;
|
||||
deleted_at: string | null;
|
||||
user_id: string | null;
|
||||
}
|
||||
|
||||
export interface TrashListResponse {
|
||||
items: TrashItem[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
export interface BulkRestoreResultItem {
|
||||
history_id: string;
|
||||
success: boolean;
|
||||
entity_type: string | null;
|
||||
entity_id: string | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface BulkRestoreResponse {
|
||||
total: number;
|
||||
succeeded: number;
|
||||
failed: number;
|
||||
results: BulkRestoreResultItem[];
|
||||
partial_success: boolean;
|
||||
}
|
||||
|
||||
export function useEntityHistory(entityType?: string, entityId?: string, limit = 50) {
|
||||
return useQuery({
|
||||
queryKey: ['entityHistory', entityType, entityId],
|
||||
queryKey: ['entityHistory', entityType, entityId, limit],
|
||||
queryFn: () =>
|
||||
apiGet<EntityHistoryList>(`/entity-history/${entityType}/${entityId}`),
|
||||
apiGet<EntityHistoryList>(`/entity-history/${entityType}/${entityId}?limit=${limit}`),
|
||||
enabled: !!entityType && !!entityId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useTrashList(entityType?: string, limit = 50, offset = 0) {
|
||||
const params = new URLSearchParams();
|
||||
params.set('limit', String(limit));
|
||||
params.set('offset', String(offset));
|
||||
if (entityType) {
|
||||
params.set('entity_type', entityType);
|
||||
}
|
||||
return useQuery({
|
||||
queryKey: ['trash', entityType ?? 'all', limit, offset],
|
||||
queryFn: () => apiGet<TrashListResponse>(`/entity-history/trash?${params.toString()}`),
|
||||
});
|
||||
}
|
||||
|
||||
export function useRestoreFromHistory() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (historyId: string) =>
|
||||
apiClient.post('/entity-history/restore', { history_id: historyId }).then(r => r.data),
|
||||
onSuccess: (_data, _variables, context) => {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['entityHistory'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['trash'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['contacts'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['contact'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useBulkRestore() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (historyIds: string[]) =>
|
||||
apiPost<BulkRestoreResponse>('/entity-history/bulk-restore', { history_ids: historyIds }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['entityHistory'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['trash'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['contacts'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['contact'] });
|
||||
},
|
||||
@@ -51,6 +111,7 @@ export function useUndoLastAction() {
|
||||
apiPost(`/entity-history/undo/${entityType}/${entityId}`, {}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['entityHistory'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['trash'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['contacts'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['contact'] });
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user