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

+351 -351
View File
@@ -1,146 +1,146 @@
import { useState, useEffect, useCallback } from 'react';
import { supabase } from '@/config';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { useState, useEffect, useCallback } from 'react';
import { adminData } from '@/lib/adminApi';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { PaginationControls } from '@/components/ui/pagination';
import { Textarea } from '@/components/ui/textarea';
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Label } from "@/components/ui/label";
import { Search, Bug, Lightbulb, MessageSquare, HelpCircle, ExternalLink, Smartphone, User } from 'lucide-react';
import { format } from 'date-fns';
import { toast } from 'sonner';
import { Link } from 'react-router-dom';
type FeedbackType = 'bug' | 'feature_request' | 'general' | 'question';
type FeedbackStatus = 'new' | 'reviewed' | 'in_progress' | 'resolved' | 'closed';
interface Feedback {
id: string;
user_id: string;
type: FeedbackType;
title: string;
description: string;
screenshot_url: string | null;
device_info: Record<string, unknown>;
status: FeedbackStatus;
admin_notes: string | null;
question_id: string | null;
created_at: string;
updated_at: string;
profile?: {
id: string;
name: string | null;
email: string | null;
};
question?: {
id: string;
text: string;
pack?: {
name: string;
};
};
}
const feedbackTypeConfig: Record<FeedbackType, { label: string; icon: React.ReactNode; variant: 'default' | 'secondary' | 'destructive' | 'outline' }> = {
bug: { label: 'Bug', icon: <Bug className="h-3 w-3" />, variant: 'destructive' },
feature_request: { label: 'Feature', icon: <Lightbulb className="h-3 w-3" />, variant: 'default' },
general: { label: 'General', icon: <MessageSquare className="h-3 w-3" />, variant: 'secondary' },
question: { label: 'Question', icon: <HelpCircle className="h-3 w-3" />, variant: 'outline' },
};
const statusConfig: Record<FeedbackStatus, { label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline' }> = {
new: { label: 'New', variant: 'destructive' },
reviewed: { label: 'Reviewed', variant: 'secondary' },
in_progress: { label: 'In Progress', variant: 'default' },
resolved: { label: 'Resolved', variant: 'outline' },
closed: { label: 'Closed', variant: 'outline' },
};
export function FeedbackPage() {
const [feedback, setFeedback] = useState<Feedback[]>([]);
const [loading, setLoading] = useState(true);
import { Textarea } from '@/components/ui/textarea';
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Label } from "@/components/ui/label";
import { Search, Bug, Lightbulb, MessageSquare, HelpCircle, ExternalLink, Smartphone, User } from 'lucide-react';
import { format } from 'date-fns';
import { toast } from 'sonner';
import { Link } from 'react-router-dom';
type FeedbackType = 'bug' | 'feature_request' | 'general' | 'question';
type FeedbackStatus = 'new' | 'reviewed' | 'in_progress' | 'resolved' | 'closed';
interface Feedback {
id: string;
user_id: string;
type: FeedbackType;
title: string;
description: string;
screenshot_url: string | null;
device_info: Record<string, unknown>;
status: FeedbackStatus;
admin_notes: string | null;
question_id: string | null;
created_at: string;
updated_at: string;
profile?: {
id: string;
name: string | null;
email: string | null;
};
question?: {
id: string;
text: string;
pack?: {
name: string;
};
};
}
const feedbackTypeConfig: Record<FeedbackType, { label: string; icon: React.ReactNode; variant: 'default' | 'secondary' | 'destructive' | 'outline' }> = {
bug: { label: 'Bug', icon: <Bug className="h-3 w-3" />, variant: 'destructive' },
feature_request: { label: 'Feature', icon: <Lightbulb className="h-3 w-3" />, variant: 'default' },
general: { label: 'General', icon: <MessageSquare className="h-3 w-3" />, variant: 'secondary' },
question: { label: 'Question', icon: <HelpCircle className="h-3 w-3" />, variant: 'outline' },
};
const statusConfig: Record<FeedbackStatus, { label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline' }> = {
new: { label: 'New', variant: 'destructive' },
reviewed: { label: 'Reviewed', variant: 'secondary' },
in_progress: { label: 'In Progress', variant: 'default' },
resolved: { label: 'Resolved', variant: 'outline' },
closed: { label: 'Closed', variant: 'outline' },
};
export function FeedbackPage() {
const [feedback, setFeedback] = useState<Feedback[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [typeFilter, setTypeFilter] = useState<string>('all');
const [statusFilter, setStatusFilter] = useState<string>('all');
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(25);
// Detail Dialog State
const [selectedFeedback, setSelectedFeedback] = useState<Feedback | null>(null);
const [isUpdating, setIsUpdating] = useState(false);
const [editStatus, setEditStatus] = useState<FeedbackStatus>('new');
const [editNotes, setEditNotes] = useState('');
const fetchFeedback = useCallback(async () => {
setLoading(true);
try {
const { data, error } = await supabase
.from('feedback')
.select(`
*,
profile:profiles!feedback_user_id_fkey(id, name, email),
question:questions!feedback_question_id_fkey(id, text, pack:question_packs(name))
`)
.order('created_at', { ascending: false });
if (error) throw error;
setFeedback(data || []);
} catch (error: unknown) {
console.error('Failed to load feedback:', error);
toast.error("Failed to load feedback");
} finally {
setLoading(false);
}
}, []);
// Real-time subscription for feedback table
const { status: realtimeStatus } = useRealtimeSubscription<Feedback>({
table: 'feedback',
onInsert: useCallback(() => {
// Refetch to get the full data with profile/question relations
fetchFeedback();
}, [fetchFeedback]),
onUpdate: useCallback(({ new: updated }: { old: Feedback; new: Feedback }) => {
setFeedback(prev => prev.map(f => f.id === updated.id ? { ...f, ...updated } : f));
}, []),
onDelete: useCallback((deleted: Feedback) => {
setFeedback(prev => prev.filter(f => f.id !== deleted.id));
}, []),
insertToast: {
enabled: true,
message: (payload) => `New ${payload.type.replace('_', ' ')} feedback: "${payload.title}"`,
type: 'info',
},
});
// Detail Dialog State
const [selectedFeedback, setSelectedFeedback] = useState<Feedback | null>(null);
const [isUpdating, setIsUpdating] = useState(false);
const [editStatus, setEditStatus] = useState<FeedbackStatus>('new');
const [editNotes, setEditNotes] = useState('');
const fetchFeedback = useCallback(async () => {
setLoading(true);
try {
const { data, error } = await adminData
.from('feedback')
.select(`
*,
profile:profiles!feedback_user_id_fkey(id, name, email),
question:questions!feedback_question_id_fkey(id, text, pack:question_packs(name))
`)
.order('created_at', { ascending: false });
if (error) throw error;
setFeedback(data || []);
} catch (error: unknown) {
console.error('Failed to load feedback:', error);
toast.error("Failed to load feedback");
} finally {
setLoading(false);
}
}, []);
// Real-time subscription for feedback table
const { status: realtimeStatus } = useRealtimeSubscription<Feedback>({
table: 'feedback',
onInsert: useCallback(() => {
// Refetch to get the full data with profile/question relations
fetchFeedback();
}, [fetchFeedback]),
onUpdate: useCallback(({ new: updated }: { old: Feedback; new: Feedback }) => {
setFeedback(prev => prev.map(f => f.id === updated.id ? { ...f, ...updated } : f));
}, []),
onDelete: useCallback((deleted: Feedback) => {
setFeedback(prev => prev.filter(f => f.id !== deleted.id));
}, []),
insertToast: {
enabled: true,
message: (payload) => `New ${payload.type.replace('_', ' ')} feedback: "${payload.title}"`,
type: 'info',
},
});
useEffect(() => {
fetchFeedback();
}, [fetchFeedback]);
@@ -149,40 +149,40 @@ export function FeedbackPage() {
setPage(1);
}, [search, typeFilter, statusFilter]);
const handleOpenDetail = (item: Feedback) => {
setSelectedFeedback(item);
setEditStatus(item.status);
setEditNotes(item.admin_notes || '');
};
const handleUpdateFeedback = async () => {
if (!selectedFeedback) return;
setIsUpdating(true);
try {
const { error } = await supabase
.from('feedback')
.update({
status: editStatus,
admin_notes: editNotes.trim() || null,
updated_at: new Date().toISOString(),
})
.eq('id', selectedFeedback.id);
if (error) throw error;
toast.success("Feedback updated");
setSelectedFeedback(null);
fetchFeedback();
} catch (error: any) {
console.error('Failed to update feedback:', error);
toast.error("Failed to update feedback");
} finally {
setIsUpdating(false);
}
};
const handleOpenDetail = (item: Feedback) => {
setSelectedFeedback(item);
setEditStatus(item.status);
setEditNotes(item.admin_notes || '');
};
const handleUpdateFeedback = async () => {
if (!selectedFeedback) return;
setIsUpdating(true);
try {
const { error } = await adminData
.from('feedback')
.update({
status: editStatus,
admin_notes: editNotes.trim() || null,
updated_at: new Date().toISOString(),
})
.eq('id', selectedFeedback.id);
if (error) throw error;
toast.success("Feedback updated");
setSelectedFeedback(null);
fetchFeedback();
} catch (error: any) {
console.error('Failed to update feedback:', error);
toast.error("Failed to update feedback");
} finally {
setIsUpdating(false);
}
};
const filteredFeedback = feedback.filter(item => {
if (typeFilter !== 'all' && item.type !== typeFilter) return false;
if (statusFilter !== 'all' && item.status !== statusFilter) return false;
@@ -210,35 +210,35 @@ export function FeedbackPage() {
}
}, [page, pageSize, totalCount]);
const getTypeBadge = (type: FeedbackType) => {
const config = feedbackTypeConfig[type];
return (
<Badge variant={config.variant} className="gap-1">
{config.icon}
{config.label}
</Badge>
);
};
const getStatusBadge = (status: FeedbackStatus) => {
const config = statusConfig[status];
return <Badge variant={config.variant}>{config.label}</Badge>;
};
const formatDeviceInfo = (info: Record<string, unknown>) => {
if (!info || Object.keys(info).length === 0) return null;
const lines: string[] = [];
if (info.platform) lines.push(`Platform: ${info.platform}`);
if (info.osVersion) lines.push(`OS Version: ${info.osVersion}`);
if (info.appVersion) lines.push(`App Version: ${info.appVersion}`);
if (info.deviceModel) lines.push(`Device: ${info.deviceModel}`);
return lines.length > 0 ? lines.join('\n') : JSON.stringify(info, null, 2);
};
return (
const getTypeBadge = (type: FeedbackType) => {
const config = feedbackTypeConfig[type];
return (
<Badge variant={config.variant} className="gap-1">
{config.icon}
{config.label}
</Badge>
);
};
const getStatusBadge = (status: FeedbackStatus) => {
const config = statusConfig[status];
return <Badge variant={config.variant}>{config.label}</Badge>;
};
const formatDeviceInfo = (info: Record<string, unknown>) => {
if (!info || Object.keys(info).length === 0) return null;
const lines: string[] = [];
if (info.platform) lines.push(`Platform: ${info.platform}`);
if (info.osVersion) lines.push(`OS Version: ${info.osVersion}`);
if (info.appVersion) lines.push(`App Version: ${info.appVersion}`);
if (info.deviceModel) lines.push(`Device: ${info.deviceModel}`);
return lines.length > 0 ? lines.join('\n') : JSON.stringify(info, null, 2);
};
return (
<div className="space-y-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
@@ -246,13 +246,13 @@ export function FeedbackPage() {
<h1 className="text-3xl font-bold tracking-tight">User Feedback</h1>
<RealtimeStatusIndicator status={realtimeStatus} showLabel />
</div>
<p className="text-muted-foreground">
Review and respond to user feedback submissions
</p>
</div>
</div>
{/* Filters */}
<p className="text-muted-foreground">
Review and respond to user feedback submissions
</p>
</div>
</div>
{/* Filters */}
<div className="flex flex-col gap-3 md:flex-row md:items-center">
<div className="relative w-full md:flex-1 md:max-w-sm">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
@@ -267,30 +267,30 @@ export function FeedbackPage() {
<SelectTrigger className="w-full md:w-[150px]">
<SelectValue placeholder="Type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Types</SelectItem>
<SelectItem value="bug">Bug</SelectItem>
<SelectItem value="feature_request">Feature Request</SelectItem>
<SelectItem value="general">General</SelectItem>
<SelectItem value="question">Question</SelectItem>
</SelectContent>
<SelectContent>
<SelectItem value="all">All Types</SelectItem>
<SelectItem value="bug">Bug</SelectItem>
<SelectItem value="feature_request">Feature Request</SelectItem>
<SelectItem value="general">General</SelectItem>
<SelectItem value="question">Question</SelectItem>
</SelectContent>
</Select>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-full md:w-[150px]">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Status</SelectItem>
<SelectItem value="new">New</SelectItem>
<SelectItem value="reviewed">Reviewed</SelectItem>
<SelectItem value="in_progress">In Progress</SelectItem>
<SelectItem value="resolved">Resolved</SelectItem>
<SelectItem value="closed">Closed</SelectItem>
</SelectContent>
</Select>
</div>
{/* Table */}
<SelectContent>
<SelectItem value="all">All Status</SelectItem>
<SelectItem value="new">New</SelectItem>
<SelectItem value="reviewed">Reviewed</SelectItem>
<SelectItem value="in_progress">In Progress</SelectItem>
<SelectItem value="resolved">Resolved</SelectItem>
<SelectItem value="closed">Closed</SelectItem>
</SelectContent>
</Select>
</div>
{/* Table */}
<div className="rounded-md border">
<Table>
<TableHeader>
@@ -373,131 +373,131 @@ export function FeedbackPage() {
}}
/>
)}
{/* Detail Dialog */}
<Dialog open={!!selectedFeedback} onOpenChange={(open) => !open && setSelectedFeedback(null)}>
<DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{selectedFeedback && getTypeBadge(selectedFeedback.type)}
<span className="truncate">{selectedFeedback?.title}</span>
</DialogTitle>
<DialogDescription>
Submitted {selectedFeedback && format(new Date(selectedFeedback.created_at), 'MMMM d, yyyy \'at\' h:mm a')}
</DialogDescription>
</DialogHeader>
{selectedFeedback && (
<div className="space-y-6">
{/* User Info */}
<div className="flex items-start gap-3 p-3 bg-muted/50 rounded-lg">
<User className="h-5 w-5 text-muted-foreground mt-0.5" />
<div className="flex-1">
<div className="font-medium">{selectedFeedback.profile?.name || 'Unknown User'}</div>
<div className="text-sm text-muted-foreground">{selectedFeedback.profile?.email}</div>
{selectedFeedback.profile?.id && (
<Link
to={`/users/${selectedFeedback.profile.id}`}
className="text-sm text-primary hover:underline inline-flex items-center gap-1 mt-1"
onClick={(e) => e.stopPropagation()}
>
View user profile <ExternalLink className="h-3 w-3" />
</Link>
)}
</div>
</div>
{/* Description */}
<div>
<Label className="text-sm font-medium">Description</Label>
<p className="mt-1 text-sm whitespace-pre-wrap">{selectedFeedback.description}</p>
</div>
{/* Related Question */}
{selectedFeedback.question && (
<div className="p-3 bg-muted/50 rounded-lg">
<Label className="text-sm font-medium">Related Question</Label>
<p className="mt-1 text-sm">{selectedFeedback.question.text}</p>
{selectedFeedback.question.pack && (
<p className="text-xs text-muted-foreground mt-1">
From pack: {selectedFeedback.question.pack.name}
</p>
)}
</div>
)}
{/* Screenshot */}
{selectedFeedback.screenshot_url && (
<div>
<Label className="text-sm font-medium">Screenshot</Label>
<a
href={selectedFeedback.screenshot_url}
target="_blank"
rel="noopener noreferrer"
className="mt-1 block"
>
<img
src={selectedFeedback.screenshot_url}
alt="Screenshot"
className="max-w-full max-h-[200px] rounded border object-contain"
/>
</a>
</div>
)}
{/* Device Info */}
{selectedFeedback.device_info && Object.keys(selectedFeedback.device_info).length > 0 && (
<div className="flex items-start gap-3 p-3 bg-muted/50 rounded-lg">
<Smartphone className="h-5 w-5 text-muted-foreground mt-0.5" />
<div className="flex-1">
<Label className="text-sm font-medium">Device Info</Label>
<pre className="mt-1 text-xs text-muted-foreground whitespace-pre-wrap font-mono">
{formatDeviceInfo(selectedFeedback.device_info)}
</pre>
</div>
</div>
)}
{/* Status Update */}
<div className="space-y-4 pt-4 border-t">
<div className="grid gap-2">
<Label>Status</Label>
<Select value={editStatus} onValueChange={(v) => setEditStatus(v as FeedbackStatus)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="new">New</SelectItem>
<SelectItem value="reviewed">Reviewed</SelectItem>
<SelectItem value="in_progress">In Progress</SelectItem>
<SelectItem value="resolved">Resolved</SelectItem>
<SelectItem value="closed">Closed</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label>Admin Notes</Label>
<Textarea
value={editNotes}
onChange={(e) => setEditNotes(e.target.value)}
placeholder="Add internal notes about this feedback..."
rows={3}
/>
</div>
</div>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setSelectedFeedback(null)}>
Cancel
</Button>
<Button onClick={handleUpdateFeedback} disabled={isUpdating}>
{isUpdating ? 'Saving...' : 'Save Changes'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
{/* Detail Dialog */}
<Dialog open={!!selectedFeedback} onOpenChange={(open) => !open && setSelectedFeedback(null)}>
<DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{selectedFeedback && getTypeBadge(selectedFeedback.type)}
<span className="truncate">{selectedFeedback?.title}</span>
</DialogTitle>
<DialogDescription>
Submitted {selectedFeedback && format(new Date(selectedFeedback.created_at), 'MMMM d, yyyy \'at\' h:mm a')}
</DialogDescription>
</DialogHeader>
{selectedFeedback && (
<div className="space-y-6">
{/* User Info */}
<div className="flex items-start gap-3 p-3 bg-muted/50 rounded-lg">
<User className="h-5 w-5 text-muted-foreground mt-0.5" />
<div className="flex-1">
<div className="font-medium">{selectedFeedback.profile?.name || 'Unknown User'}</div>
<div className="text-sm text-muted-foreground">{selectedFeedback.profile?.email}</div>
{selectedFeedback.profile?.id && (
<Link
to={`/users/${selectedFeedback.profile.id}`}
className="text-sm text-primary hover:underline inline-flex items-center gap-1 mt-1"
onClick={(e) => e.stopPropagation()}
>
View user profile <ExternalLink className="h-3 w-3" />
</Link>
)}
</div>
</div>
{/* Description */}
<div>
<Label className="text-sm font-medium">Description</Label>
<p className="mt-1 text-sm whitespace-pre-wrap">{selectedFeedback.description}</p>
</div>
{/* Related Question */}
{selectedFeedback.question && (
<div className="p-3 bg-muted/50 rounded-lg">
<Label className="text-sm font-medium">Related Question</Label>
<p className="mt-1 text-sm">{selectedFeedback.question.text}</p>
{selectedFeedback.question.pack && (
<p className="text-xs text-muted-foreground mt-1">
From pack: {selectedFeedback.question.pack.name}
</p>
)}
</div>
)}
{/* Screenshot */}
{selectedFeedback.screenshot_url && (
<div>
<Label className="text-sm font-medium">Screenshot</Label>
<a
href={selectedFeedback.screenshot_url}
target="_blank"
rel="noopener noreferrer"
className="mt-1 block"
>
<img
src={selectedFeedback.screenshot_url}
alt="Screenshot"
className="max-w-full max-h-[200px] rounded border object-contain"
/>
</a>
</div>
)}
{/* Device Info */}
{selectedFeedback.device_info && Object.keys(selectedFeedback.device_info).length > 0 && (
<div className="flex items-start gap-3 p-3 bg-muted/50 rounded-lg">
<Smartphone className="h-5 w-5 text-muted-foreground mt-0.5" />
<div className="flex-1">
<Label className="text-sm font-medium">Device Info</Label>
<pre className="mt-1 text-xs text-muted-foreground whitespace-pre-wrap font-mono">
{formatDeviceInfo(selectedFeedback.device_info)}
</pre>
</div>
</div>
)}
{/* Status Update */}
<div className="space-y-4 pt-4 border-t">
<div className="grid gap-2">
<Label>Status</Label>
<Select value={editStatus} onValueChange={(v) => setEditStatus(v as FeedbackStatus)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="new">New</SelectItem>
<SelectItem value="reviewed">Reviewed</SelectItem>
<SelectItem value="in_progress">In Progress</SelectItem>
<SelectItem value="resolved">Resolved</SelectItem>
<SelectItem value="closed">Closed</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label>Admin Notes</Label>
<Textarea
value={editNotes}
onChange={(e) => setEditNotes(e.target.value)}
placeholder="Add internal notes about this feedback..."
rows={3}
/>
</div>
</div>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setSelectedFeedback(null)}>
Cancel
</Button>
<Button onClick={handleUpdateFeedback} disabled={isUpdating}>
{isUpdating ? 'Saving...' : 'Save Changes'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}