feat: Backup & Restore - Manual backup tested complete.

This commit is contained in:
Eric Gullickson
2025-12-25 10:50:09 -06:00
parent 8ef6b3d853
commit 0357ce391f
38 changed files with 5734 additions and 1415447 deletions

View File

@@ -24,9 +24,6 @@ import {
UpdateCatalogTrimRequest,
CreateCatalogEngineRequest,
UpdateCatalogEngineRequest,
StationOverview,
CreateStationRequest,
UpdateStationRequest,
CatalogSearchResponse,
ImportPreviewResult,
ImportApplyResult,
@@ -41,6 +38,15 @@ import {
DeactivateUserRequest,
UpdateUserProfileRequest,
PromoteToAdminRequest,
// Backup types
BackupHistory,
BackupSchedule,
BackupSettings,
ListBackupsResponse,
CreateBackupRequest,
CreateScheduleRequest,
UpdateScheduleRequest,
RestorePreviewResponse,
} from '../types/admin.types';
export interface AuditLogsResponse {
@@ -189,26 +195,6 @@ export const adminApi = {
await apiClient.delete(`/admin/catalog/engines/${id}`);
},
// Stations
listStations: async (): Promise<StationOverview[]> => {
const response = await apiClient.get<StationOverview[]>('/admin/stations');
return response.data;
},
createStation: async (data: CreateStationRequest): Promise<StationOverview> => {
const response = await apiClient.post<StationOverview>('/admin/stations', data);
return response.data;
},
updateStation: async (id: string, data: UpdateStationRequest): Promise<StationOverview> => {
const response = await apiClient.put<StationOverview>(`/admin/stations/${id}`, data);
return response.data;
},
deleteStation: async (id: string): Promise<void> => {
await apiClient.delete(`/admin/stations/${id}`);
},
// Catalog Search
searchCatalog: async (
query: string,
@@ -368,4 +354,114 @@ export const adminApi = {
return response.data;
},
},
// Backup & Restore
backups: {
// List backups with pagination
list: async (params: { page?: number; pageSize?: number } = {}): Promise<ListBackupsResponse> => {
const response = await apiClient.get<ListBackupsResponse>('/admin/backups', { params });
return response.data;
},
// Get backup details
get: async (id: string): Promise<BackupHistory> => {
const response = await apiClient.get<BackupHistory>(`/admin/backups/${id}`);
return response.data;
},
// Create manual backup
create: async (data: CreateBackupRequest = {}): Promise<BackupHistory> => {
const response = await apiClient.post<BackupHistory>('/admin/backups', data);
return response.data;
},
// Download backup file
download: async (id: string): Promise<Blob> => {
const response = await apiClient.get(`/admin/backups/${id}/download`, {
responseType: 'blob',
});
return response.data;
},
// Delete backup
delete: async (id: string): Promise<void> => {
await apiClient.delete(`/admin/backups/${id}`);
},
// Upload backup file
upload: async (file: File): Promise<BackupHistory> => {
const formData = new FormData();
formData.append('file', file);
const response = await apiClient.post<BackupHistory>('/admin/backups/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
return response.data;
},
// Preview restore
previewRestore: async (id: string): Promise<RestorePreviewResponse> => {
const response = await apiClient.post<RestorePreviewResponse>(
`/admin/backups/${id}/restore/preview`
);
return response.data;
},
// Execute restore
restore: async (id: string): Promise<{ message: string }> => {
const response = await apiClient.post<{ message: string }>(
`/admin/backups/${id}/restore`
);
return response.data;
},
// Schedules
schedules: {
list: async (): Promise<BackupSchedule[]> => {
const response = await apiClient.get<BackupSchedule[]>('/admin/backups/schedules');
return response.data;
},
get: async (id: string): Promise<BackupSchedule> => {
const response = await apiClient.get<BackupSchedule>(`/admin/backups/schedules/${id}`);
return response.data;
},
create: async (data: CreateScheduleRequest): Promise<BackupSchedule> => {
const response = await apiClient.post<BackupSchedule>('/admin/backups/schedules', data);
return response.data;
},
update: async (id: string, data: UpdateScheduleRequest): Promise<BackupSchedule> => {
const response = await apiClient.put<BackupSchedule>(
`/admin/backups/schedules/${id}`,
data
);
return response.data;
},
delete: async (id: string): Promise<void> => {
await apiClient.delete(`/admin/backups/schedules/${id}`);
},
toggle: async (id: string): Promise<BackupSchedule> => {
const response = await apiClient.patch<BackupSchedule>(
`/admin/backups/schedules/${id}/toggle`
);
return response.data;
},
},
// Settings
settings: {
get: async (): Promise<BackupSettings> => {
const response = await apiClient.get<BackupSettings>('/admin/backups/settings');
return response.data;
},
update: async (data: Partial<BackupSettings>): Promise<BackupSettings> => {
const response = await apiClient.put<BackupSettings>('/admin/backups/settings', data);
return response.data;
},
},
},
};

View File

@@ -0,0 +1,239 @@
/**
* @ai-summary React Query hooks for Backup & Restore admin feature
* @ai-context Provides hooks for managing backups, schedules, and settings
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { adminApi } from '../api/admin.api';
import {
BackupSettings,
CreateBackupRequest,
CreateScheduleRequest,
UpdateScheduleRequest,
} from '../types/admin.types';
import toast from 'react-hot-toast';
// Query keys
export const backupKeys = {
all: ['admin', 'backups'] as const,
lists: () => [...backupKeys.all, 'list'] as const,
list: (params: { page?: number; pageSize?: number }) =>
[...backupKeys.lists(), params] as const,
details: () => [...backupKeys.all, 'detail'] as const,
detail: (id: string) => [...backupKeys.details(), id] as const,
schedules: () => [...backupKeys.all, 'schedules'] as const,
settings: () => [...backupKeys.all, 'settings'] as const,
};
// List backups with pagination
export const useBackups = (params: { page?: number; pageSize?: number } = {}) => {
return useQuery({
queryKey: backupKeys.list(params),
queryFn: () => adminApi.backups.list(params),
});
};
// Get backup details
export const useBackupDetails = (id: string) => {
return useQuery({
queryKey: backupKeys.detail(id),
queryFn: () => adminApi.backups.get(id),
enabled: !!id,
});
};
// List backup schedules
export const useBackupSchedules = () => {
return useQuery({
queryKey: backupKeys.schedules(),
queryFn: () => adminApi.backups.schedules.list(),
});
};
// Get backup settings
export const useBackupSettings = () => {
return useQuery({
queryKey: backupKeys.settings(),
queryFn: () => adminApi.backups.settings.get(),
});
};
// Create manual backup
export const useCreateBackup = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateBackupRequest) => adminApi.backups.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: backupKeys.lists() });
toast.success('Backup created successfully');
},
onError: () => {
toast.error('Failed to create backup');
},
});
};
// Delete backup
export const useDeleteBackup = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => adminApi.backups.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: backupKeys.lists() });
toast.success('Backup deleted successfully');
},
onError: () => {
toast.error('Failed to delete backup');
},
});
};
// Download backup
export const useDownloadBackup = () => {
return useMutation({
mutationFn: async ({ id, filename }: { id: string; filename: string }) => {
const blob = await adminApi.backups.download(id);
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
},
onSuccess: () => {
toast.success('Backup download started');
},
onError: () => {
toast.error('Failed to download backup');
},
});
};
// Upload backup
export const useUploadBackup = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (file: File) => adminApi.backups.upload(file),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: backupKeys.lists() });
toast.success('Backup uploaded successfully');
},
onError: () => {
toast.error('Failed to upload backup');
},
});
};
// Preview restore
export const useRestorePreview = () => {
return useMutation({
mutationFn: (id: string) => adminApi.backups.previewRestore(id),
onError: () => {
toast.error('Failed to generate restore preview');
},
});
};
// Execute restore
export const useExecuteRestore = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => adminApi.backups.restore(id),
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: backupKeys.all });
toast.success(data.message || 'Restore completed successfully');
},
onError: () => {
toast.error('Failed to restore backup');
},
});
};
// Create schedule
export const useCreateSchedule = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateScheduleRequest) => adminApi.backups.schedules.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: backupKeys.schedules() });
toast.success('Schedule created successfully');
},
onError: () => {
toast.error('Failed to create schedule');
},
});
};
// Update schedule
export const useUpdateSchedule = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdateScheduleRequest }) =>
adminApi.backups.schedules.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: backupKeys.schedules() });
toast.success('Schedule updated successfully');
},
onError: () => {
toast.error('Failed to update schedule');
},
});
};
// Delete schedule
export const useDeleteSchedule = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => adminApi.backups.schedules.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: backupKeys.schedules() });
toast.success('Schedule deleted successfully');
},
onError: () => {
toast.error('Failed to delete schedule');
},
});
};
// Toggle schedule enabled/disabled
export const useToggleSchedule = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => adminApi.backups.schedules.toggle(id),
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: backupKeys.schedules() });
toast.success(
data.isEnabled ? 'Schedule enabled successfully' : 'Schedule disabled successfully'
);
},
onError: () => {
toast.error('Failed to toggle schedule');
},
});
};
// Update settings
export const useUpdateSettings = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: Partial<BackupSettings>) => adminApi.backups.settings.update(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: backupKeys.settings() });
toast.success('Settings updated successfully');
},
onError: () => {
toast.error('Failed to update settings');
},
});
};

View File

@@ -1,79 +0,0 @@
/**
* @ai-summary React Query hooks for station overview (admin)
* @ai-context CRUD operations for global station management
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useAuth0 } from '@auth0/auth0-react';
import { adminApi } from '../api/admin.api';
import { CreateStationRequest, UpdateStationRequest } from '../types/admin.types';
import toast from 'react-hot-toast';
interface ApiError {
response?: {
data?: {
error?: string;
};
};
message?: string;
}
export const useStationOverview = () => {
const { isAuthenticated, isLoading } = useAuth0();
return useQuery({
queryKey: ['adminStations'],
queryFn: () => adminApi.listStations(),
enabled: isAuthenticated && !isLoading,
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 10 * 60 * 1000,
retry: 1,
refetchOnWindowFocus: false,
});
};
export const useCreateStation = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateStationRequest) => adminApi.createStation(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['adminStations'] });
toast.success('Station created successfully');
},
onError: (error: ApiError) => {
toast.error(error.response?.data?.error || 'Failed to create station');
},
});
};
export const useUpdateStation = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdateStationRequest }) =>
adminApi.updateStation(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['adminStations'] });
toast.success('Station updated successfully');
},
onError: (error: ApiError) => {
toast.error(error.response?.data?.error || 'Failed to update station');
},
});
};
export const useDeleteStation = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => adminApi.deleteStation(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['adminStations'] });
toast.success('Station deleted successfully');
},
onError: (error: ApiError) => {
toast.error(error.response?.data?.error || 'Failed to delete station');
},
});
};

File diff suppressed because it is too large Load Diff

View File

@@ -1,61 +0,0 @@
/**
* @ai-summary Mobile admin screen for gas station management
* @ai-context CRUD operations for global station data with mobile UI
*/
import React from 'react';
import { Navigate } from 'react-router-dom';
import { GlassCard } from '../../../shared-minimal/components/mobile/GlassCard';
import { MobileContainer } from '../../../shared-minimal/components/mobile/MobileContainer';
import { useAdminAccess } from '../../../core/auth/useAdminAccess';
export const AdminStationsMobileScreen: React.FC = () => {
const { isAdmin, loading } = useAdminAccess();
if (loading) {
return (
<MobileContainer>
<div className="flex items-center justify-center min-h-[400px]">
<div className="text-center">
<div className="text-slate-500 mb-2">Loading admin access...</div>
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
</div>
</div>
</MobileContainer>
);
}
if (!isAdmin) {
return <Navigate to="/garage/settings" replace />;
}
return (
<MobileContainer>
<div className="space-y-4 pb-20 p-4">
<div className="text-center mb-6">
<h1 className="text-2xl font-bold text-slate-800">Station Management</h1>
<p className="text-slate-500 mt-2">Manage gas station data</p>
</div>
<GlassCard padding="md">
<div>
<h2 className="text-lg font-semibold text-slate-800 mb-4">Gas Stations</h2>
<p className="text-sm text-slate-600 mb-4">
Station management interface coming soon.
</p>
<div className="space-y-2 text-sm text-slate-600">
<p className="font-semibold">Features:</p>
<ul className="list-disc pl-5 space-y-1">
<li>View all gas stations</li>
<li>Create new stations</li>
<li>Update station information</li>
<li>Delete stations</li>
<li>View station usage statistics</li>
</ul>
</div>
</div>
</GlassCard>
</div>
</MobileContainer>
);
};

View File

@@ -125,34 +125,6 @@ export interface UpdateCatalogEngineRequest {
fuelType?: string;
}
// Station types for admin
export interface StationOverview {
id: string;
name: string;
placeId: string;
address: string;
latitude: number;
longitude: number;
createdBy: string;
createdAt: string;
updatedAt: string;
}
export interface CreateStationRequest {
name: string;
placeId: string;
address: string;
latitude: number;
longitude: number;
}
export interface UpdateStationRequest {
name?: string;
address?: string;
latitude?: number;
longitude?: number;
}
// Admin access verification
export interface AdminAccessResponse {
isAdmin: boolean;
@@ -300,3 +272,92 @@ export interface UpdateUserProfileRequest {
export interface PromoteToAdminRequest {
role?: 'admin' | 'super_admin';
}
// ============================================
// Backup & Restore types
// ============================================
// Backup types
export type BackupFrequency = 'hourly' | 'daily' | 'weekly' | 'monthly';
export type BackupType = 'scheduled' | 'manual';
export type BackupStatus = 'in_progress' | 'completed' | 'failed';
export interface BackupHistory {
id: string;
scheduleId: string | null;
backupType: BackupType;
filename: string;
filePath: string;
fileSizeBytes: number;
databaseTablesCount: number | null;
documentsCount: number | null;
status: BackupStatus;
errorMessage: string | null;
startedAt: string;
completedAt: string | null;
createdBy: string | null;
metadata: Record<string, unknown>;
}
export interface BackupSchedule {
id: string;
name: string;
frequency: BackupFrequency;
cronExpression: string;
retentionCount: number;
isEnabled: boolean;
lastRunAt: string | null;
nextRunAt: string | null;
createdAt: string;
updatedAt: string;
lastBackup?: BackupHistory | null;
}
export interface BackupSettings {
emailOnSuccess: boolean;
emailOnFailure: boolean;
adminEmail: string;
maxBackupSizeMb: number;
compressionEnabled: boolean;
}
export interface ListBackupsResponse {
items: BackupHistory[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}
export interface CreateBackupRequest {
name?: string;
includeDocuments?: boolean;
}
export interface CreateScheduleRequest {
name: string;
frequency: BackupFrequency;
retentionCount?: number;
isEnabled?: boolean;
}
export interface UpdateScheduleRequest {
name?: string;
frequency?: BackupFrequency;
retentionCount?: number;
isEnabled?: boolean;
}
export interface RestorePreviewResponse {
backupId: string;
manifest: {
version: string;
createdAt: string;
contents: {
database: { tablesCount: number; sizeBytes: number };
documents: { totalFiles: number; totalSizeBytes: number };
};
};
warnings: string[];
estimatedDuration: string;
}

View File

@@ -421,14 +421,6 @@ export const MobileSettingsScreen: React.FC = () => {
<div className="font-semibold">Vehicle Catalog</div>
<div className="text-sm text-blue-600 mt-1">Manage makes, models, and engines</div>
</button>
<button
onClick={() => navigateToScreen('AdminStations')}
className="w-full text-left p-4 bg-blue-50 text-blue-700 rounded-lg font-medium hover:bg-blue-100 transition-colors active:bg-blue-200"
style={{ minHeight: '44px' }}
>
<div className="font-semibold">Station Management</div>
<div className="text-sm text-blue-600 mt-1">Manage gas station data and locations</div>
</button>
<button
onClick={() => navigateToScreen('AdminEmailTemplates')}
className="w-full text-left p-4 bg-blue-50 text-blue-700 rounded-lg font-medium hover:bg-blue-100 transition-colors active:bg-blue-200"
@@ -437,6 +429,14 @@ export const MobileSettingsScreen: React.FC = () => {
<div className="font-semibold">Email Templates</div>
<div className="text-sm text-blue-600 mt-1">Manage notification email templates</div>
</button>
<button
onClick={() => navigateToScreen('AdminBackup')}
className="w-full text-left p-4 bg-blue-50 text-blue-700 rounded-lg font-medium hover:bg-blue-100 transition-colors active:bg-blue-200"
style={{ minHeight: '44px' }}
>
<div className="font-semibold">Backup & Restore</div>
<div className="text-sm text-blue-600 mt-1">Create backups and restore data</div>
</button>
</div>
</div>
</GlassCard>