Files
Sauci/apps/admin/src/hooks/useCrudOperations.ts
T
jjbrunton 006761d3b0 feat(backend): add standalone data plane
Replace the Supabase application data plane with the Node/PostgreSQL API, migrate mobile, admin, web, and MCP callers, add controlled cutover tooling, and define hardened Dokploy infrastructure.
2026-08-28 14:42:11 +01:00

304 lines
8.4 KiB
TypeScript

import { useState, useCallback } from 'react';
import { adminData } from '@/lib/adminApi';
import { auditedAdminData } from '@/hooks/useAuditedAdminData';
import { toast } from 'sonner';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type AdminFilterFn = (query: any) => any;
interface UseCrudOperationsOptions<T> {
/**
* The standalone admin API resource name
*/
table: string;
/**
* Default ordering for fetches
*/
orderBy?: {
column: keyof T | string;
ascending?: boolean;
};
/**
* Select expression for fetches
* @default '*'
*/
select?: string;
/**
* Success messages for operations
*/
messages?: {
createSuccess?: string;
updateSuccess?: string;
deleteSuccess?: string;
fetchError?: string;
createError?: string;
updateError?: string;
deleteError?: string;
};
}
/**
* Generic CRUD operations hook for standalone admin API resources
* Reduces boilerplate for data fetching and mutations across pages.
*
* @example
* ```tsx
* const {
* items,
* loading,
* fetchAll,
* create,
* update,
* remove,
* } = useCrudOperations<Category>({
* table: 'categories',
* orderBy: { column: 'sort_order', ascending: true },
* messages: {
* createSuccess: 'Category created',
* updateSuccess: 'Category updated',
* deleteSuccess: 'Category deleted',
* },
* });
* ```
*/
export function useCrudOperations<T extends { id: string }>({
table,
orderBy,
select = '*',
messages = {},
}: UseCrudOperationsOptions<T>) {
const [items, setItems] = useState<T[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const defaultMessages = {
createSuccess: 'Created successfully',
updateSuccess: 'Updated successfully',
deleteSuccess: 'Deleted successfully',
fetchError: 'Failed to load data',
createError: 'Failed to create',
updateError: 'Failed to update',
deleteError: 'Failed to delete',
...messages,
};
/**
* Fetch all items from the table
* @param filter Optional filter function to modify the query
*/
const fetchAll = useCallback(async (
filter?: AdminFilterFn
): Promise<T[]> => {
setLoading(true);
setError(null);
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let query: any = adminData.from(table).select(select);
if (filter) {
query = filter(query);
}
if (orderBy) {
query = query.order(orderBy.column as string, {
ascending: orderBy.ascending ?? true,
});
}
const { data, error: fetchError } = await query;
if (fetchError) throw fetchError;
const result = (data || []) as T[];
setItems(result);
return result;
} catch (err) {
const error = err as Error;
setError(error);
toast.error(defaultMessages.fetchError);
console.error(error);
return [];
} finally {
setLoading(false);
}
}, [table, select, orderBy, defaultMessages.fetchError]);
/**
* Fetch a single item by ID
*/
const fetchById = useCallback(async (id: string): Promise<T | null> => {
try {
const { data, error: fetchError } = await adminData
.from(table)
.select(select)
.eq('id', id)
.maybeSingle();
if (fetchError) throw fetchError;
return data as T | null;
} catch (err) {
console.error(err);
return null;
}
}, [table, select]);
/**
* Create a new item
* @param data The data to insert
* @param options.refetch Whether to refetch after creating (default: true)
* @returns The created item, or null on error
*/
const create = useCallback(async (
data: Omit<T, 'id' | 'created_at'> | Record<string, unknown>,
options: { refetch?: boolean; silent?: boolean } = {}
): Promise<T | null> => {
const { refetch = true, silent = false } = options;
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { data: created, error: createError } = await auditedAdminData.insert(table, data as any);
if (createError) throw createError;
if (!silent) {
toast.success(defaultMessages.createSuccess);
}
if (refetch) {
await fetchAll();
}
// auditedAdminData.insert returns T[] | null, get first element
return created && created.length > 0 ? (created[0] as T) : null;
} catch (err) {
if (!silent) {
toast.error(defaultMessages.createError);
}
console.error(err);
return null;
}
}, [table, fetchAll, defaultMessages.createSuccess, defaultMessages.createError]);
/**
* Update an existing item
* @param id The item ID
* @param data The data to update
* @param options.refetch Whether to refetch after updating (default: true)
*/
const update = useCallback(async (
id: string,
data: Partial<T> | Record<string, unknown>,
options: { refetch?: boolean; silent?: boolean } = {}
): Promise<boolean> => {
const { refetch = true, silent = false } = options;
try {
const { error: updateError } = await auditedAdminData.update(table, id, data);
if (updateError) throw updateError;
if (!silent) {
toast.success(defaultMessages.updateSuccess);
}
if (refetch) {
await fetchAll();
}
return true;
} catch (err) {
if (!silent) {
toast.error(defaultMessages.updateError);
}
console.error(err);
return false;
}
}, [table, fetchAll, defaultMessages.updateSuccess, defaultMessages.updateError]);
/**
* Delete an item
* @param id The item ID
* @param options.confirm Confirmation message (if provided, shows confirm dialog)
* @param options.refetch Whether to refetch after deleting (default: true)
*/
const remove = useCallback(async (
id: string,
options: { confirm?: string; refetch?: boolean; silent?: boolean } = {}
): Promise<boolean> => {
const { confirm: confirmMessage, refetch = true, silent = false } = options;
if (confirmMessage && !window.confirm(confirmMessage)) {
return false;
}
try {
const { error: deleteError } = await auditedAdminData.delete(table, id);
if (deleteError) throw deleteError;
if (!silent) {
toast.success(defaultMessages.deleteSuccess);
}
if (refetch) {
await fetchAll();
}
return true;
} catch (err) {
if (!silent) {
toast.error(defaultMessages.deleteError);
}
console.error(err);
return false;
}
}, [table, fetchAll, defaultMessages.deleteSuccess, defaultMessages.deleteError]);
/**
* Optimistically update an item in local state
* Useful for immediate UI feedback before server confirms
*/
const optimisticUpdate = useCallback((id: string, updates: Partial<T>) => {
setItems(prev => prev.map(item =>
item.id === id ? { ...item, ...updates } : item
));
}, []);
/**
* Optimistically add an item to local state
*/
const optimisticAdd = useCallback((item: T) => {
setItems(prev => [...prev, item]);
}, []);
/**
* Optimistically remove an item from local state
*/
const optimisticRemove = useCallback((id: string) => {
setItems(prev => prev.filter(item => item.id !== id));
}, []);
return {
// State
items,
setItems,
loading,
error,
// Read operations
fetchAll,
fetchById,
// Write operations
create,
update,
remove,
// Optimistic updates
optimisticUpdate,
optimisticAdd,
optimisticRemove,
};
}