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.
This commit is contained in:
jjbrunton committed 2026-08-28 14:42:11 +01:00
1 parent 01bb8225ae
commit 006761d3b0
294 files changed
+16259 -6138

No files matched your search

+5 -5
View File
@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback } from 'react';
import { supabase } from '@/config';
import { adminData } from '@/lib/adminApi';
export interface CouncilGenerator {
model: string;
@@ -74,7 +74,7 @@ export function useAiConfig(): UseAiConfigReturn {
setError(null);
try {
const { data, error: fetchError } = await supabase
const { data, error: fetchError } = await adminData
.from('ai_config')
.select('*')
.limit(1)
@@ -82,7 +82,7 @@ export function useAiConfig(): UseAiConfigReturn {
if (fetchError) {
// If user doesn't have access (not super admin), that's expected
if (fetchError.code === 'PGRST116' || fetchError.message.includes('permission')) {
if (fetchError.message.includes('permission')) {
setError('Access denied. Super admin privileges required.');
} else {
setError(fetchError.message);
@@ -110,7 +110,7 @@ export function useAiConfig(): UseAiConfigReturn {
}
try {
const { error: updateError } = await supabase
const { error: updateError } = await adminData
.from('ai_config')
.update(updates)
.eq('id', config.id);
@@ -160,7 +160,7 @@ export async function preloadAiConfig(): Promise<AiConfig | null> {
}
try {
const { data } = await supabase
const { data } = await adminData
.from('ai_config')
.select('*')
.limit(1)
+3 -3
View File
@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback } from 'react';
import { supabase } from '@/config';
import { adminData } from '@/lib/adminApi';
export interface AppConfig {
id: string;
@@ -40,7 +40,7 @@ export function useAppConfig(): UseAppConfigReturn {
setError(null);
try {
const { data, error: fetchError } = await supabase
const { data, error: fetchError } = await adminData
.from('app_config')
.select('*')
.limit(1)
@@ -71,7 +71,7 @@ export function useAppConfig(): UseAppConfigReturn {
}
try {
const { error: updateError } = await supabase
const { error: updateError } = await adminData
.from('app_config')
.update(updates)
.eq('id', config.id);
@@ -1,4 +1,4 @@
import { supabase } from '@/config';
import { adminData } from '@/lib/adminApi';
// Tables to audit
const AUDITED_TABLES = [
@@ -26,23 +26,10 @@ async function logAction(
oldValues?: Record<string, unknown> | null,
newValues?: Record<string, unknown> | null
): Promise<void> {
if (!AUDITED_TABLES.includes(tableName)) return;
try {
const { error } = await supabase.rpc('log_admin_action', {
p_table_name: tableName,
p_action: action,
p_record_id: recordId,
p_old_values: oldValues || null,
p_new_values: newValues || null,
});
if (error) {
console.error('Failed to log audit action:', error);
}
} catch (error) {
console.error('Failed to log audit action:', error);
// Don't throw - logging failure shouldn't block the operation
}
// The standalone admin API writes the mutation and audit record in one
// transaction. Keeping this no-op preserves the existing call sites while
// avoiding duplicate or client-forgeable audit entries.
void tableName; void action; void recordId; void oldValues; void newValues;
}
interface InsertResult<T> {
@@ -56,9 +43,9 @@ interface SingleResult<T> {
}
/**
* Audited Supabase operations - automatically logs INSERT/UPDATE/DELETE to audit_logs
* Admin API mutations. The server writes each mutation and audit record atomically.
*/
export const auditedSupabase = {
export const auditedAdminData = {
/**
* Insert records with audit logging
*/
@@ -68,7 +55,7 @@ export const auditedSupabase = {
): Promise<InsertResult<T>> {
const recordArray = Array.isArray(records) ? records : [records];
const { data, error } = await supabase
const { data, error } = await adminData
.from(table)
.insert(recordArray)
.select();
@@ -94,7 +81,7 @@ export const auditedSupabase = {
// Fetch old values before update
let oldValues: Record<string, unknown> | null = null;
if (AUDITED_TABLES.includes(table)) {
const { data: oldData } = await supabase
const { data: oldData } = await adminData
.from(table)
.select('*')
.eq('id', id)
@@ -102,7 +89,7 @@ export const auditedSupabase = {
oldValues = oldData;
}
const { data, error } = await supabase
const { data, error } = await adminData
.from(table)
.update(updates)
.eq('id', id)
@@ -127,14 +114,14 @@ export const auditedSupabase = {
// Fetch old values before update
let oldRecords: Record<string, unknown>[] = [];
if (AUDITED_TABLES.includes(table)) {
const { data: oldData } = await supabase
const { data: oldData } = await adminData
.from(table)
.select('*')
.eq(filter.column, filter.value);
oldRecords = oldData || [];
}
const { data, error } = await supabase
const { data, error } = await adminData
.from(table)
.update(updates)
.eq(filter.column, filter.value)
@@ -161,7 +148,7 @@ export const auditedSupabase = {
// Fetch old values before delete
let oldValues: Record<string, unknown> | null = null;
if (AUDITED_TABLES.includes(table)) {
const { data: oldData } = await supabase
const { data: oldData } = await adminData
.from(table)
.select('*')
.eq('id', id)
@@ -169,7 +156,7 @@ export const auditedSupabase = {
oldValues = oldData;
}
const { error } = await supabase
const { error } = await adminData
.from(table)
.delete()
.eq('id', id);
@@ -191,14 +178,14 @@ export const auditedSupabase = {
// Fetch old values before delete
let oldRecords: Record<string, unknown>[] = [];
if (AUDITED_TABLES.includes(table)) {
const { data: oldData } = await supabase
const { data: oldData } = await adminData
.from(table)
.select('*')
.in('id', ids);
oldRecords = oldData || [];
}
const { error } = await supabase
const { error } = await adminData
.from(table)
.delete()
.in('id', ids);
@@ -224,7 +211,7 @@ export const auditedSupabase = {
// Fetch old values before delete
let oldValues: Record<string, unknown> | null = null;
if (AUDITED_TABLES.includes(table)) {
const { data: oldData } = await supabase
const { data: oldData } = await adminData
.from(table)
.select('*')
.eq(field, value)
@@ -232,7 +219,7 @@ export const auditedSupabase = {
oldValues = oldData;
}
const { error } = await supabase
const { error } = await adminData
.from(table)
.delete()
.eq(field, value);
+12 -12
View File
@@ -1,14 +1,14 @@
import { useState, useCallback } from 'react';
import { supabase } from '@/config';
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
import { adminData } from '@/lib/adminApi';
import { auditedAdminData } from '@/hooks/useAuditedAdminData';
import { toast } from 'sonner';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type SupabaseFilterFn = (query: any) => any;
type AdminFilterFn = (query: any) => any;
interface UseCrudOperationsOptions<T> {
/**
* The Supabase table name
* The standalone admin API resource name
*/
table: string;
@@ -41,7 +41,7 @@ interface UseCrudOperationsOptions<T> {
}
/**
* Generic CRUD operations hook for Supabase tables
* Generic CRUD operations hook for standalone admin API resources
* Reduces boilerplate for data fetching and mutations across pages.
*
* @example
@@ -90,13 +90,13 @@ export function useCrudOperations<T extends { id: string }>({
* @param filter Optional filter function to modify the query
*/
const fetchAll = useCallback(async (
filter?: SupabaseFilterFn
filter?: AdminFilterFn
): Promise<T[]> => {
setLoading(true);
setError(null);
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let query: any = supabase.from(table).select(select);
let query: any = adminData.from(table).select(select);
if (filter) {
query = filter(query);
@@ -131,7 +131,7 @@ export function useCrudOperations<T extends { id: string }>({
*/
const fetchById = useCallback(async (id: string): Promise<T | null> => {
try {
const { data, error: fetchError } = await supabase
const { data, error: fetchError } = await adminData
.from(table)
.select(select)
.eq('id', id)
@@ -158,7 +158,7 @@ export function useCrudOperations<T extends { id: string }>({
const { refetch = true, silent = false } = options;
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { data: created, error: createError } = await auditedSupabase.insert(table, data as any);
const { data: created, error: createError } = await auditedAdminData.insert(table, data as any);
if (createError) throw createError;
@@ -170,7 +170,7 @@ export function useCrudOperations<T extends { id: string }>({
await fetchAll();
}
// auditedSupabase.insert returns T[] | null, get first element
// auditedAdminData.insert returns T[] | null, get first element
return created && created.length > 0 ? (created[0] as T) : null;
} catch (err) {
if (!silent) {
@@ -194,7 +194,7 @@ export function useCrudOperations<T extends { id: string }>({
): Promise<boolean> => {
const { refetch = true, silent = false } = options;
try {
const { error: updateError } = await auditedSupabase.update(table, id, data);
const { error: updateError } = await auditedAdminData.update(table, id, data);
if (updateError) throw updateError;
@@ -233,7 +233,7 @@ export function useCrudOperations<T extends { id: string }>({
}
try {
const { error: deleteError } = await auditedSupabase.delete(table, id);
const { error: deleteError } = await auditedAdminData.delete(table, id);
if (deleteError) throw deleteError;
+92 -243
View File
@@ -1,243 +1,92 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import { supabase } from '@/config';
import { toast } from 'sonner';
import type { RealtimeChannel } from '@supabase/supabase-js';
export type SubscriptionStatus = 'SUBSCRIBED' | 'SUBSCRIBING' | 'CHANNEL_ERROR' | 'TIMED_OUT' | 'CLOSED';
export interface UseRealtimeSubscriptionOptions<T> {
table: string;
schema?: string;
event?: 'INSERT' | 'UPDATE' | 'DELETE' | '*';
filter?: string;
onInsert?: (payload: T) => void;
onUpdate?: (payload: { old: T; new: T }) => void;
onDelete?: (payload: T) => void;
insertToast?: {
enabled: boolean;
message: string | ((payload: T) => string);
type?: 'success' | 'info' | 'warning';
};
updateToast?: {
enabled: boolean;
message: string | ((payload: { old: T; new: T }) => string);
type?: 'success' | 'info' | 'warning';
};
deleteToast?: {
enabled: boolean;
message: string | ((payload: T) => string);
type?: 'success' | 'info' | 'warning';
};
debounceMs?: number;
enabled?: boolean;
}
export interface UseRealtimeSubscriptionReturn {
status: SubscriptionStatus;
error: Error | null;
}
const MAX_RETRIES = 3;
export function useRealtimeSubscription<T>(
options: UseRealtimeSubscriptionOptions<T>
): UseRealtimeSubscriptionReturn {
const {
table,
schema = 'public',
event = '*',
filter,
onInsert,
onUpdate,
onDelete,
insertToast,
updateToast,
deleteToast,
debounceMs = 0,
enabled = true,
} = options;
const [status, setStatus] = useState<SubscriptionStatus>('CLOSED');
const [error, setError] = useState<Error | null>(null);
const channelRef = useRef<RealtimeChannel | null>(null);
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const retryCountRef = useRef(0);
const isMountedRef = useRef(true);
// Use refs for callbacks and toast options to avoid re-subscription on every render
const onInsertRef = useRef(onInsert);
const onUpdateRef = useRef(onUpdate);
const onDeleteRef = useRef(onDelete);
const insertToastRef = useRef(insertToast);
const updateToastRef = useRef(updateToast);
const deleteToastRef = useRef(deleteToast);
// Keep refs up to date
useEffect(() => {
onInsertRef.current = onInsert;
onUpdateRef.current = onUpdate;
onDeleteRef.current = onDelete;
insertToastRef.current = insertToast;
updateToastRef.current = updateToast;
deleteToastRef.current = deleteToast;
});
// Debounced callback wrapper
const debouncedCallback = useCallback(
(callback: () => void) => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
if (debounceMs > 0) {
debounceTimerRef.current = setTimeout(() => {
if (isMountedRef.current) {
callback();
}
}, debounceMs);
} else {
callback();
}
},
[debounceMs]
);
// Handle incoming changes - use refs to avoid dependency changes
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const handleChange = useCallback(
(payload: { eventType: string; new: any; old: any }) => {
if (!isMountedRef.current) return;
const eventType = payload.eventType;
if (eventType === 'INSERT') {
const newRecord = payload.new as T;
if (insertToastRef.current?.enabled) {
const message =
typeof insertToastRef.current.message === 'function'
? insertToastRef.current.message(newRecord)
: insertToastRef.current.message;
toast[insertToastRef.current.type || 'info'](message);
}
if (onInsertRef.current) {
debouncedCallback(() => onInsertRef.current!(newRecord));
}
}
if (eventType === 'UPDATE') {
const updatePayload = { old: payload.old as T, new: payload.new as T };
if (updateToastRef.current?.enabled) {
const message =
typeof updateToastRef.current.message === 'function'
? updateToastRef.current.message(updatePayload)
: updateToastRef.current.message;
toast[updateToastRef.current.type || 'info'](message);
}
if (onUpdateRef.current) {
debouncedCallback(() => onUpdateRef.current!(updatePayload));
}
}
if (eventType === 'DELETE') {
const deletedRecord = payload.old as T;
if (deleteToastRef.current?.enabled) {
const message =
typeof deleteToastRef.current.message === 'function'
? deleteToastRef.current.message(deletedRecord)
: deleteToastRef.current.message;
toast[deleteToastRef.current.type || 'info'](message);
}
if (onDeleteRef.current) {
debouncedCallback(() => onDeleteRef.current!(deletedRecord));
}
}
},
[debouncedCallback]
);
useEffect(() => {
isMountedRef.current = true;
if (!enabled) {
setStatus('CLOSED');
return;
}
const subscribe = () => {
// Create unique channel name
const channelName = `admin-${table}-${filter || 'all'}-${Date.now()}`;
// Build subscription config
const subscriptionConfig: {
event: 'INSERT' | 'UPDATE' | 'DELETE' | '*';
schema: string;
table: string;
filter?: string;
} = {
event,
schema,
table,
};
if (filter) {
subscriptionConfig.filter = filter;
}
setStatus('SUBSCRIBING');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const channel = (supabase.channel(channelName) as any)
.on('postgres_changes', subscriptionConfig, handleChange)
.subscribe((subscriptionStatus: string) => {
if (!isMountedRef.current) return;
if (subscriptionStatus === 'SUBSCRIBED') {
setStatus('SUBSCRIBED');
setError(null);
retryCountRef.current = 0;
} else if (subscriptionStatus === 'CHANNEL_ERROR') {
setStatus('CHANNEL_ERROR');
setError(new Error(`Failed to subscribe to ${table}`));
// Auto-retry with exponential backoff
if (retryCountRef.current < MAX_RETRIES) {
const timeout = Math.pow(2, retryCountRef.current) * 1000;
retryCountRef.current++;
setTimeout(() => {
if (isMountedRef.current && channelRef.current) {
supabase.removeChannel(channelRef.current);
subscribe();
}
}, timeout);
}
} else if (subscriptionStatus === 'TIMED_OUT') {
setStatus('TIMED_OUT');
setError(new Error(`Subscription to ${table} timed out`));
} else if (subscriptionStatus === 'CLOSED') {
setStatus('CLOSED');
}
});
channelRef.current = channel;
};
subscribe();
return () => {
isMountedRef.current = false;
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
if (channelRef.current) {
supabase.removeChannel(channelRef.current);
}
};
}, [enabled, table, schema, event, filter, handleChange]);
return { status, error };
}
import { useCallback, useEffect, useRef, useState } from 'react';
import { toast } from 'sonner';
import { queryAdminRows, type AdminFilter } from '@/lib/adminApi';
export type SubscriptionStatus = 'SUBSCRIBED' | 'SUBSCRIBING' | 'CHANNEL_ERROR' | 'TIMED_OUT' | 'CLOSED';
export interface UseRealtimeSubscriptionOptions<T> {
table: string; schema?: string; event?: 'INSERT' | 'UPDATE' | 'DELETE' | '*'; filter?: string;
onInsert?: (payload: T) => void; onUpdate?: (payload: { old: T; new: T }) => void; onDelete?: (payload: T) => void;
insertToast?: { enabled: boolean; message: string | ((payload: T) => string); type?: 'success' | 'info' | 'warning' };
updateToast?: { enabled: boolean; message: string | ((payload: { old: T; new: T }) => string); type?: 'success' | 'info' | 'warning' };
deleteToast?: { enabled: boolean; message: string | ((payload: T) => string); type?: 'success' | 'info' | 'warning' };
debounceMs?: number; enabled?: boolean; pollIntervalMs?: number;
}
export interface UseRealtimeSubscriptionReturn { status: SubscriptionStatus; error: Error | null }
const DEFAULT_POLL_INTERVAL_MS = 10_000;
const MIN_POLL_INTERVAL_MS = 5_000;
function parseFilter(filter?: string): AdminFilter[] | undefined {
if (!filter) return undefined;
const match = /^([a-z_][a-z0-9_]*)=(eq|neq|gte|lte)\.(.+)$/.exec(filter);
return match ? [{ column: match[1], op: match[2] as AdminFilter['op'], value: match[3] }] : undefined;
}
function rowKey(row: unknown, index: number): string {
if (row && typeof row === 'object') {
const record = row as Record<string, unknown>;
const key = record.id ?? `${record.pack_id ?? ''}:${record.topic_id ?? ''}`;
if (key) return String(key);
}
return String(index);
}
export function useRealtimeSubscription<T>(options: UseRealtimeSubscriptionOptions<T>): UseRealtimeSubscriptionReturn {
const { table, event = '*', filter, onInsert, onUpdate, onDelete, insertToast, updateToast, deleteToast,
debounceMs = 0, enabled = true, pollIntervalMs = DEFAULT_POLL_INTERVAL_MS } = options;
const [status, setStatus] = useState<SubscriptionStatus>('CLOSED');
const [error, setError] = useState<Error | null>(null);
const snapshotRef = useRef<Map<string, { value: T; json: string }> | null>(null);
const callbacksRef = useRef({ onInsert, onUpdate, onDelete, insertToast, updateToast, deleteToast });
callbacksRef.current = { onInsert, onUpdate, onDelete, insertToast, updateToast, deleteToast };
const emit = useCallback((callback: () => void) => {
if (debounceMs <= 0) callback(); else window.setTimeout(callback, debounceMs);
}, [debounceMs]);
useEffect(() => {
if (!enabled) { setStatus('CLOSED'); return; }
let cancelled = false;
let timer: number | undefined;
const interval = Math.max(MIN_POLL_INTERVAL_MS, pollIntervalMs);
const poll = async () => {
if (document.visibilityState === 'hidden') { timer = window.setTimeout(poll, interval); return; }
try {
setStatus((current) => current === 'SUBSCRIBED' ? current : 'SUBSCRIBING');
const { rows } = await queryAdminRows<T>(table, { filters: parseFilter(filter), limit: 500 });
if (cancelled) return;
const next = new Map(rows.map((row, index) => [rowKey(row, index), { value: row, json: JSON.stringify(row) }]));
const previous = snapshotRef.current;
if (previous) {
if (event === '*' || event === 'INSERT') for (const [key, entry] of next) if (!previous.has(key)) {
const config = callbacksRef.current;
if (config.insertToast?.enabled) toast[config.insertToast.type || 'info'](typeof config.insertToast.message === 'function' ? config.insertToast.message(entry.value) : config.insertToast.message);
if (config.onInsert) emit(() => config.onInsert!(entry.value));
}
if (event === '*' || event === 'UPDATE') for (const [key, entry] of next) {
const old = previous.get(key);
if (old && old.json !== entry.json) {
const payload = { old: old.value, new: entry.value };
const config = callbacksRef.current;
if (config.updateToast?.enabled) toast[config.updateToast.type || 'info'](typeof config.updateToast.message === 'function' ? config.updateToast.message(payload) : config.updateToast.message);
if (config.onUpdate) emit(() => config.onUpdate!(payload));
}
}
if (event === '*' || event === 'DELETE') for (const [key, entry] of previous) if (!next.has(key)) {
const config = callbacksRef.current;
if (config.deleteToast?.enabled) toast[config.deleteToast.type || 'info'](typeof config.deleteToast.message === 'function' ? config.deleteToast.message(entry.value) : config.deleteToast.message);
if (config.onDelete) emit(() => config.onDelete!(entry.value));
}
}
snapshotRef.current = next;
setStatus('SUBSCRIBED'); setError(null);
} catch (cause) {
if (!cancelled) { setStatus('CHANNEL_ERROR'); setError(cause instanceof Error ? cause : new Error(String(cause))); }
} finally {
if (!cancelled) timer = window.setTimeout(poll, interval);
}
};
void poll();
return () => { cancelled = true; if (timer !== undefined) window.clearTimeout(timer); snapshotRef.current = null; };
}, [emit, enabled, event, filter, pollIntervalMs, table]);
return { status, error };
}