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 -2
View File
@@ -1,5 +1,8 @@
VITE_SUPABASE_URL=your_supabase_url
VITE_SUPABASE_ANON_KEY=your_supabase_anon_key
# Hosted Supabase Auth public settings only. Admin product data and mutations go
# through VITE_API_URL; never expose a service-role or admin service token here.
VITE_SUPABASE_URL=your_nonproduction_supabase_auth_url
VITE_SUPABASE_ANON_KEY=your_nonproduction_supabase_anon_key
VITE_API_URL=http://127.0.0.1:3003
VITE_OPENAI_API_KEY=your_openai_api_key
VITE_OPENROUTER_API_KEY=your_openrouter_api_key
+4 -2
View File
@@ -18,7 +18,8 @@ RUN pnpm exec turbo run build --filter=@sauci/shared
# Build admin app with env vars
ARG VITE_SUPABASE_URL
ARG VITE_SUPABASE_ANON_KEY
ARG VITE_SUPABASE_ANON_KEY
ARG VITE_API_URL
ARG VITE_OPENAI_API_KEY
ARG VITE_OPENROUTER_API_KEY
ARG VITE_AI_MODEL
@@ -30,7 +31,8 @@ ARG VITE_COUNCIL_GENERATOR_MODEL
ARG VITE_COUNCIL_REVIEWER_MODEL
ENV VITE_SUPABASE_URL=$VITE_SUPABASE_URL
ENV VITE_SUPABASE_ANON_KEY=$VITE_SUPABASE_ANON_KEY
ENV VITE_SUPABASE_ANON_KEY=$VITE_SUPABASE_ANON_KEY
ENV VITE_API_URL=$VITE_API_URL
ENV VITE_OPENAI_API_KEY=$VITE_OPENAI_API_KEY
ENV VITE_OPENROUTER_API_KEY=$VITE_OPENROUTER_API_KEY
ENV VITE_AI_MODEL=$VITE_AI_MODEL
+2 -1
View File
@@ -5,7 +5,8 @@ services:
dockerfile: apps/admin/Dockerfile
args:
- VITE_SUPABASE_URL=${VITE_SUPABASE_URL}
- VITE_SUPABASE_ANON_KEY=${VITE_SUPABASE_ANON_KEY}
- VITE_SUPABASE_ANON_KEY=${VITE_SUPABASE_ANON_KEY}
- VITE_API_URL=${VITE_API_URL}
- VITE_OPENAI_API_KEY=${VITE_OPENAI_API_KEY}
- VITE_OPENROUTER_API_KEY=${VITE_OPENROUTER_API_KEY}
- VITE_AI_MODEL=${VITE_AI_MODEL}
+1
View File
@@ -8,6 +8,7 @@ services:
environment:
- VITE_SUPABASE_URL=${VITE_SUPABASE_URL}
- VITE_SUPABASE_ANON_KEY=${VITE_SUPABASE_ANON_KEY}
- VITE_API_URL=${VITE_API_URL:-http://127.0.0.1:3003}
- VITE_OPENAI_API_KEY=${VITE_OPENAI_API_KEY}
- VITE_OPENROUTER_API_KEY=${VITE_OPENROUTER_API_KEY}
- VITE_AI_MODEL=${VITE_AI_MODEL}
+1 -1
View File
@@ -195,7 +195,7 @@ function App() {
<Route
path="/flagged-messages"
element={
<ProtectedRoute requiredPermission={PERMISSION_KEYS.MANAGE_AI_CONFIG}>
<ProtectedRoute requiredPermission={PERMISSION_KEYS.VIEW_CHATS}>
<FlaggedMessagesPage />
</ProtectedRoute>
}
@@ -1,281 +1,281 @@
import { useState } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Loader2, Tags, Check, Plus, X } from 'lucide-react';
import { extractTopicsFromPack, ExtractedTopic } from '@/lib/openai';
import { supabase } from '@/config';
import { toast } from 'sonner';
interface ExtractTopicsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
pack: {
id: string;
name: string;
description: string | null;
};
onUpdated: () => void;
}
export function ExtractTopicsDialog({ open, onOpenChange, pack, onUpdated }: ExtractTopicsDialogProps) {
const [analyzing, setAnalyzing] = useState(false);
const [topics, setTopics] = useState<ExtractedTopic[]>([]);
const [reasoning, setReasoning] = useState('');
const [selectedTopics, setSelectedTopics] = useState<Set<string>>(new Set());
const [step, setStep] = useState<'initial' | 'review'>('initial');
const [applying, setApplying] = useState(false);
const handleAnalyze = async () => {
setAnalyzing(true);
try {
// Fetch questions for this pack
const { data: questions, error: questionsError } = await supabase
.from('questions')
.select('text')
.eq('pack_id', pack.id);
if (questionsError) throw questionsError;
if (!questions || questions.length === 0) {
toast.error('This pack has no questions to analyze');
return;
}
// Fetch existing topics
const { data: existingTopics, error: topicsError } = await supabase
.from('topics')
.select('id, name')
.order('name');
if (topicsError) throw topicsError;
const result = await extractTopicsFromPack(
pack.name,
pack.description,
questions.map(q => q.text),
existingTopics || []
);
setTopics(result.topics);
setReasoning(result.reasoning);
// Default select all
setSelectedTopics(new Set(result.topics.map(t => t.name)));
setStep('review');
} catch (error) {
console.error(error);
toast.error('Failed to analyze pack');
} finally {
setAnalyzing(false);
}
};
const handleApply = async () => {
setApplying(true);
try {
const selectedList = topics.filter(t => selectedTopics.has(t.name));
if (selectedList.length === 0) {
onOpenChange(false);
return;
}
// First, create any new topics
const newTopics = selectedList.filter(t => t.isNew);
const existingTopicIds: string[] = selectedList
.filter(t => !t.isNew && t.existingTopicId)
.map(t => t.existingTopicId!);
if (newTopics.length > 0) {
const { data: createdTopics, error: createError } = await supabase
.from('topics')
.insert(newTopics.map(t => ({ name: t.name })))
.select('id');
if (createError) throw createError;
if (createdTopics) {
existingTopicIds.push(...createdTopics.map(t => t.id));
}
}
// Remove existing pack_topics for this pack
await supabase
.from('pack_topics')
.delete()
.eq('pack_id', pack.id);
// Insert new pack_topics relationships
if (existingTopicIds.length > 0) {
const { error: linkError } = await supabase
.from('pack_topics')
.insert(existingTopicIds.map(topicId => ({
pack_id: pack.id,
topic_id: topicId,
})));
if (linkError) throw linkError;
}
toast.success(`Added ${selectedList.length} topic${selectedList.length !== 1 ? 's' : ''} to pack`);
onUpdated();
handleClose();
} catch (error) {
console.error(error);
toast.error('Failed to apply topics');
} finally {
setApplying(false);
}
};
const handleClose = () => {
onOpenChange(false);
// Reset state after transition
setTimeout(() => {
setStep('initial');
setTopics([]);
setReasoning('');
setSelectedTopics(new Set());
}, 300);
};
const toggleSelection = (name: string) => {
const next = new Set(selectedTopics);
if (next.has(name)) next.delete(name);
else next.add(name);
setSelectedTopics(next);
};
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-2xl max-h-[90vh] flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Tags className="h-5 w-5 text-blue-500" />
Extract Topics
</DialogTitle>
<DialogDescription>
Use AI to identify topics and kinks for "{pack.name}"
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto py-4">
{step === 'initial' ? (
<div className="flex flex-col items-center justify-center py-12 text-center space-y-4">
<div className="bg-blue-500/20 p-4 rounded-full">
<Tags className="h-12 w-12 text-blue-400" />
</div>
<div className="space-y-2">
<h3 className="text-lg font-medium">Analyze pack for topics</h3>
<p className="text-muted-foreground max-w-md mx-auto">
The AI will analyze the questions in this pack and suggest relevant topics
like bondage, exhibitionism, romance, etc. It will prefer matching existing
topics when possible.
</p>
</div>
<Button onClick={handleAnalyze} disabled={analyzing} size="lg" className="bg-blue-600 hover:bg-blue-700">
{analyzing ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Analyzing...
</>
) : (
'Start Analysis'
)}
</Button>
</div>
) : (
<div className="space-y-6">
{topics.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<X className="h-8 w-8 mx-auto mb-2 text-yellow-500" />
No topics could be identified for this pack.
</div>
) : (
<>
<div className="space-y-3">
<h4 className="text-sm font-medium">Suggested Topics</h4>
<div className="flex flex-wrap gap-2">
{topics.map((topic) => (
<Badge
key={topic.name}
variant={selectedTopics.has(topic.name) ? 'default' : 'outline'}
className={`cursor-pointer text-sm py-1.5 px-3 ${
selectedTopics.has(topic.name)
? topic.isNew
? 'bg-green-600 hover:bg-green-700'
: 'bg-blue-600 hover:bg-blue-700'
: 'hover:bg-muted'
}`}
onClick={() => toggleSelection(topic.name)}
>
{selectedTopics.has(topic.name) ? (
<Check className="h-3 w-3 mr-1" />
) : null}
{topic.name}
{topic.isNew && (
<Plus className="h-3 w-3 ml-1" />
)}
</Badge>
))}
</div>
<div className="flex gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<div className="w-3 h-3 rounded bg-blue-600" />
Existing topic
</span>
<span className="flex items-center gap-1">
<div className="w-3 h-3 rounded bg-green-600" />
<Plus className="h-3 w-3" />
New topic
</span>
</div>
</div>
{reasoning && (
<div className="space-y-2">
<h4 className="text-sm font-medium">AI Reasoning</h4>
<p className="text-sm text-muted-foreground bg-muted/50 p-3 rounded-md">
{reasoning}
</p>
</div>
)}
</>
)}
</div>
)}
</div>
<DialogFooter>
{step === 'review' && (
<>
<Button variant="outline" onClick={() => setStep('initial')}>
Back
</Button>
<Button
onClick={handleApply}
disabled={applying || topics.length === 0 || selectedTopics.size === 0}
className="bg-blue-600 hover:bg-blue-700"
>
{applying ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Applying...
</>
) : (
`Apply ${selectedTopics.size} Topic${selectedTopics.size !== 1 ? 's' : ''}`
)}
</Button>
</>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}
import { useState } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Loader2, Tags, Check, Plus, X } from 'lucide-react';
import { extractTopicsFromPack, ExtractedTopic } from '@/lib/openai';
import { adminData } from '@/lib/adminApi';
import { toast } from 'sonner';
interface ExtractTopicsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
pack: {
id: string;
name: string;
description: string | null;
};
onUpdated: () => void;
}
export function ExtractTopicsDialog({ open, onOpenChange, pack, onUpdated }: ExtractTopicsDialogProps) {
const [analyzing, setAnalyzing] = useState(false);
const [topics, setTopics] = useState<ExtractedTopic[]>([]);
const [reasoning, setReasoning] = useState('');
const [selectedTopics, setSelectedTopics] = useState<Set<string>>(new Set());
const [step, setStep] = useState<'initial' | 'review'>('initial');
const [applying, setApplying] = useState(false);
const handleAnalyze = async () => {
setAnalyzing(true);
try {
// Fetch questions for this pack
const { data: questions, error: questionsError } = await adminData
.from('questions')
.select('text')
.eq('pack_id', pack.id);
if (questionsError) throw questionsError;
if (!questions || questions.length === 0) {
toast.error('This pack has no questions to analyze');
return;
}
// Fetch existing topics
const { data: existingTopics, error: topicsError } = await adminData
.from('topics')
.select('id, name')
.order('name');
if (topicsError) throw topicsError;
const result = await extractTopicsFromPack(
pack.name,
pack.description,
questions.map(q => q.text),
existingTopics || []
);
setTopics(result.topics);
setReasoning(result.reasoning);
// Default select all
setSelectedTopics(new Set(result.topics.map(t => t.name)));
setStep('review');
} catch (error) {
console.error(error);
toast.error('Failed to analyze pack');
} finally {
setAnalyzing(false);
}
};
const handleApply = async () => {
setApplying(true);
try {
const selectedList = topics.filter(t => selectedTopics.has(t.name));
if (selectedList.length === 0) {
onOpenChange(false);
return;
}
// First, create any new topics
const newTopics = selectedList.filter(t => t.isNew);
const existingTopicIds: string[] = selectedList
.filter(t => !t.isNew && t.existingTopicId)
.map(t => t.existingTopicId!);
if (newTopics.length > 0) {
const { data: createdTopics, error: createError } = await adminData
.from('topics')
.insert(newTopics.map(t => ({ name: t.name })))
.select('id');
if (createError) throw createError;
if (createdTopics) {
existingTopicIds.push(...createdTopics.map(t => t.id));
}
}
// Remove existing pack_topics for this pack
await adminData
.from('pack_topics')
.delete()
.eq('pack_id', pack.id);
// Insert new pack_topics relationships
if (existingTopicIds.length > 0) {
const { error: linkError } = await adminData
.from('pack_topics')
.insert(existingTopicIds.map(topicId => ({
pack_id: pack.id,
topic_id: topicId,
})));
if (linkError) throw linkError;
}
toast.success(`Added ${selectedList.length} topic${selectedList.length !== 1 ? 's' : ''} to pack`);
onUpdated();
handleClose();
} catch (error) {
console.error(error);
toast.error('Failed to apply topics');
} finally {
setApplying(false);
}
};
const handleClose = () => {
onOpenChange(false);
// Reset state after transition
setTimeout(() => {
setStep('initial');
setTopics([]);
setReasoning('');
setSelectedTopics(new Set());
}, 300);
};
const toggleSelection = (name: string) => {
const next = new Set(selectedTopics);
if (next.has(name)) next.delete(name);
else next.add(name);
setSelectedTopics(next);
};
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-2xl max-h-[90vh] flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Tags className="h-5 w-5 text-blue-500" />
Extract Topics
</DialogTitle>
<DialogDescription>
Use AI to identify topics and kinks for "{pack.name}"
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto py-4">
{step === 'initial' ? (
<div className="flex flex-col items-center justify-center py-12 text-center space-y-4">
<div className="bg-blue-500/20 p-4 rounded-full">
<Tags className="h-12 w-12 text-blue-400" />
</div>
<div className="space-y-2">
<h3 className="text-lg font-medium">Analyze pack for topics</h3>
<p className="text-muted-foreground max-w-md mx-auto">
The AI will analyze the questions in this pack and suggest relevant topics
like bondage, exhibitionism, romance, etc. It will prefer matching existing
topics when possible.
</p>
</div>
<Button onClick={handleAnalyze} disabled={analyzing} size="lg" className="bg-blue-600 hover:bg-blue-700">
{analyzing ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Analyzing...
</>
) : (
'Start Analysis'
)}
</Button>
</div>
) : (
<div className="space-y-6">
{topics.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<X className="h-8 w-8 mx-auto mb-2 text-yellow-500" />
No topics could be identified for this pack.
</div>
) : (
<>
<div className="space-y-3">
<h4 className="text-sm font-medium">Suggested Topics</h4>
<div className="flex flex-wrap gap-2">
{topics.map((topic) => (
<Badge
key={topic.name}
variant={selectedTopics.has(topic.name) ? 'default' : 'outline'}
className={`cursor-pointer text-sm py-1.5 px-3 ${
selectedTopics.has(topic.name)
? topic.isNew
? 'bg-green-600 hover:bg-green-700'
: 'bg-blue-600 hover:bg-blue-700'
: 'hover:bg-muted'
}`}
onClick={() => toggleSelection(topic.name)}
>
{selectedTopics.has(topic.name) ? (
<Check className="h-3 w-3 mr-1" />
) : null}
{topic.name}
{topic.isNew && (
<Plus className="h-3 w-3 ml-1" />
)}
</Badge>
))}
</div>
<div className="flex gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<div className="w-3 h-3 rounded bg-blue-600" />
Existing topic
</span>
<span className="flex items-center gap-1">
<div className="w-3 h-3 rounded bg-green-600" />
<Plus className="h-3 w-3" />
New topic
</span>
</div>
</div>
{reasoning && (
<div className="space-y-2">
<h4 className="text-sm font-medium">AI Reasoning</h4>
<p className="text-sm text-muted-foreground bg-muted/50 p-3 rounded-md">
{reasoning}
</p>
</div>
)}
</>
)}
</div>
)}
</div>
<DialogFooter>
{step === 'review' && (
<>
<Button variant="outline" onClick={() => setStep('initial')}>
Back
</Button>
<Button
onClick={handleApply}
disabled={applying || topics.length === 0 || selectedTopics.size === 0}
className="bg-blue-600 hover:bg-blue-700"
>
{applying ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Applying...
</>
) : (
`Apply ${selectedTopics.size} Topic${selectedTopics.size !== 1 ? 's' : ''}`
)}
</Button>
</>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -12,7 +12,7 @@ import { Badge } from '@/components/ui/badge';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Loader2, ShieldCheck, ArrowRight, Check } from 'lucide-react';
import { analyzeQuestionTargets, TargetAnalysis } from '@/lib/openai';
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
import { auditedAdminData } from '@/hooks/useAuditedAdminData';
import { toast } from 'sonner';
// Define local compatible interface
@@ -111,7 +111,7 @@ export function FixTargetsDialog({ open, onOpenChange, questions, onUpdated }: F
// Apply updates in parallel with audit logging
await Promise.all(updates.map(update =>
auditedSupabase.update('questions', update.id, {
auditedAdminData.update('questions', update.id, {
allowed_couple_genders: update.suggested_targets && update.suggested_targets.length > 0 ? update.suggested_targets : null,
target_user_genders: update.suggested_initiator && update.suggested_initiator.length > 0 ? update.suggested_initiator : null,
})
@@ -11,7 +11,7 @@ import { Button } from '@/components/ui/button';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Loader2, Wand2, ArrowRight, Check } from 'lucide-react';
import { analyzeQuestionText, TextAnalysis } from '@/lib/openai';
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
import { auditedAdminData } from '@/hooks/useAuditedAdminData';
import { toast } from 'sonner';
interface Question {
@@ -62,7 +62,7 @@ export function FixTextDialog({ open, onOpenChange, questions, isExplicit = fals
// Apply updates in parallel with audit logging
await Promise.all(updates.map(update =>
auditedSupabase.update('questions', update.id, {
auditedAdminData.update('questions', update.id, {
text: update.suggested_text,
partner_text: update.suggested_partner_text,
})
@@ -22,8 +22,8 @@ import {
DeletionAnalysis,
PropsAnalysis,
} from '@/lib/openai';
import { supabase } from '@/config';
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
import { adminData } from '@/lib/adminApi';
import { auditedAdminData } from '@/hooks/useAuditedAdminData';
import { toast } from 'sonner';
type QuestionType = 'swipe' | 'text_answer' | 'audio' | 'photo' | 'who_likely';
@@ -101,7 +101,7 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, onUpdated
if (existingProps.length > 0) return existingProps;
try {
const { data, error } = await supabase
const { data, error } = await adminData
.from('questions')
.select('required_props')
.not('required_props', 'is', null)
@@ -252,12 +252,12 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, onUpdated
// Apply updates/deletions in parallel with audit logging
const operations: Promise<unknown>[] = updates.map(update =>
auditedSupabase.update('questions', update.id, update.data)
auditedAdminData.update('questions', update.id, update.data)
);
if (deleteIds.length > 0) {
operations.push(
auditedSupabase.deleteMany('questions', deleteIds).then(result => {
auditedAdminData.deleteMany('questions', deleteIds).then(result => {
if (result.error) throw result.error;
})
);
@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react';
import { Link, useLocation, useParams } from 'react-router-dom';
import { ChevronRight, Home } from 'lucide-react';
import { supabase } from '@/config';
import { adminData } from '@/lib/adminApi';
interface BreadcrumbItem {
label: string;
@@ -32,7 +32,7 @@ function useBreadcrumbs(): { breadcrumbs: BreadcrumbItem[]; loading: boolean } {
if (params.packId && !params.categoryId) {
setLoading(true);
try {
const { data } = await supabase
const { data } = await adminData
.from('question_packs')
.select('category_id, categories(name)')
.eq('id', params.packId)
@@ -63,7 +63,7 @@ function useBreadcrumbs(): { breadcrumbs: BreadcrumbItem[]; loading: boolean } {
if (params.userId) {
setLoading(true);
try {
const { data } = await supabase
const { data } = await adminData
.from('profiles')
.select('id, name')
.eq('id', params.userId)
+1 -1
View File
@@ -108,7 +108,7 @@ const systemNav: NavItem[] = [
label: 'Flagged Messages',
href: '/flagged-messages',
icon: <AlertTriangle className="h-5 w-5" />,
requiredPermission: PERMISSION_KEYS.MANAGE_AI_CONFIG,
requiredPermission: PERMISSION_KEYS.VIEW_CHATS,
},
{
label: 'AI Settings',
+15 -7
View File
@@ -1,11 +1,19 @@
import { createClient } from '@supabase/supabase-js';
export const supabaseConfig = {
export const supabaseConfig = {
url: import.meta.env.VITE_SUPABASE_URL,
anonKey: import.meta.env.VITE_SUPABASE_ANON_KEY,
};
export const supabase = createClient(
supabaseConfig.url,
supabaseConfig.anonKey
);
};
export const authClient = createClient(
supabaseConfig.url,
supabaseConfig.anonKey
);
const configuredApiUrl = import.meta.env.VITE_API_URL;
if (!configuredApiUrl) {
throw new Error('Missing required environment variable: VITE_API_URL');
}
export const apiUrl = configuredApiUrl.replace(/\/$/, '');
+22 -21
View File
@@ -1,5 +1,6 @@
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { supabase } from '../config';
import { authClient } from '../config';
import { adminRequest } from '@/lib/adminApi';
import type { User, Session } from '@supabase/supabase-js';
import { preloadAiConfig, clearAiConfigCache } from '@/hooks/useAiConfig';
@@ -80,23 +81,26 @@ export function AuthProvider({ children }: { children: ReactNode }) {
useEffect(() => {
// Get initial session
supabase.auth.getSession().then(({ data: { session } }) => {
authClient.auth.getSession().then(async ({ data: { session } }) => {
setSession(session);
setUser(session?.user ?? null);
// Restore permissions from localStorage
const storedPermissions = localStorage.getItem(PERMISSIONS_KEY);
if (storedPermissions) {
setPermissions(JSON.parse(storedPermissions));
// Preload AI config if user has an existing session (non-blocking)
preloadAiConfig().catch(console.error);
if (session) {
try {
const currentPermissions = await adminRequest<AdminPermissions>('/v1/admin/me');
setPermissions(currentPermissions);
localStorage.setItem(PERMISSIONS_KEY, JSON.stringify(currentPermissions));
preloadAiConfig().catch(console.error);
} catch {
setPermissions(null);
localStorage.removeItem(PERMISSIONS_KEY);
}
}
setLoading(false);
});
// Listen for auth changes
const { data: { subscription } } = supabase.auth.onAuthStateChange(
const { data: { subscription } } = authClient.auth.onAuthStateChange(
(_event, session) => {
setSession(session);
setUser(session?.user ?? null);
@@ -112,7 +116,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}, []);
const login = async (email: string, password: string) => {
const { data, error } = await supabase.auth.signInWithPassword({
const { error } = await authClient.auth.signInWithPassword({
email,
password,
});
@@ -122,20 +126,17 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}
// Fetch admin role and permissions
const { data: adminData, error: adminError } = await supabase
.from('admin_users')
.select('role, permissions')
.eq('user_id', data.user.id)
.single();
if (adminError || !adminData) {
await supabase.auth.signOut();
let adminData: AdminPermissions;
try {
adminData = await adminRequest<AdminPermissions>('/v1/admin/me');
} catch {
await authClient.auth.signOut();
throw new Error('Access denied. You are not an administrator.');
}
// Store permissions from database
const perms: AdminPermissions = {
role: adminData.role as AdminRole,
role: adminData.role,
permissions: (adminData.permissions as string[]) || [],
};
@@ -150,7 +151,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
localStorage.removeItem(PERMISSIONS_KEY);
setPermissions(null);
clearAiConfigCache();
await supabase.auth.signOut();
await authClient.auth.signOut();
};
const isSuperAdmin = permissions?.role === 'super_admin';
+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 };
}
+41
View File
@@ -0,0 +1,41 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { getSession } = vi.hoisted(() => ({ getSession: vi.fn() }));
vi.mock('@/config', () => ({
apiUrl: 'http://127.0.0.1:3003',
authClient: { auth: { getSession } },
}));
import { AdminApiError, adminRequest, queryAdminRows } from './adminApi';
describe('admin API transport', () => {
beforeEach(() => {
vi.restoreAllMocks();
getSession.mockResolvedValue({ data: { session: { access_token: 'hosted-auth-token' } }, error: null });
});
it('sends the hosted Auth bearer token to the standalone API', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({ rows: [], count: 0 }), {
status: 200, headers: { 'content-type': 'application/json' },
}));
await queryAdminRows('profiles', { limit: 25 });
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('http://127.0.0.1:3003/v1/admin/query/profiles');
expect(new Headers(init?.headers).get('Authorization')).toBe('Bearer hosted-auth-token');
expect(init?.body).toBe(JSON.stringify({ limit: 25 }));
});
it('fails closed when there is no authenticated session', async () => {
getSession.mockResolvedValue({ data: { session: null }, error: null });
await expect(adminRequest('/v1/admin/me')).rejects.toMatchObject({ status: 401 } satisfies Partial<AdminApiError>);
});
it('surfaces the API error message', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({ error: { message: 'Administrator access denied' } }), {
status: 403, headers: { 'content-type': 'application/json' },
}));
await expect(adminRequest('/v1/admin/me')).rejects.toThrow('Administrator access denied');
});
});
+254
View File
@@ -0,0 +1,254 @@
import { apiUrl, authClient } from '@/config';
export class AdminApiError extends Error {
constructor(message: string, readonly status: number, readonly details?: unknown) {
super(message);
this.name = 'AdminApiError';
}
}
type RequestOptions = Omit<RequestInit, 'body'> & { body?: unknown };
async function accessToken(): Promise<string> {
const { data, error } = await authClient.auth.getSession();
const token = data.session?.access_token;
if (error || !token) throw new AdminApiError('An authenticated admin session is required', 401, error);
return token;
}
export async function adminRequest<T>(path: string, options: RequestOptions = {}): Promise<T> {
const token = await accessToken();
const headers = new Headers(options.headers);
headers.set('Accept', 'application/json');
headers.set('Authorization', `Bearer ${token}`);
const body = options.body === undefined ? undefined : JSON.stringify(options.body);
if (body !== undefined) headers.set('Content-Type', 'application/json');
const response = await fetch(`${apiUrl}${path.startsWith('/') ? path : `/${path}`}`, { ...options, headers, body });
const text = await response.text();
let parsed: unknown;
try { parsed = text ? JSON.parse(text) : undefined; } catch { parsed = text; }
if (!response.ok) {
const nested = parsed && typeof parsed === 'object' && 'error' in parsed ? (parsed as { error: unknown }).error : undefined;
const message = nested && typeof nested === 'object' && 'message' in nested
? String((nested as { message: unknown }).message)
: `Admin API request failed with status ${response.status}`;
throw new AdminApiError(message, response.status, parsed);
}
return parsed as T;
}
export async function adminBinaryRequest(path: string, options: RequestOptions = {}): Promise<Blob> {
const token = await accessToken();
const headers = new Headers(options.headers);
headers.set('Authorization', `Bearer ${token}`);
const body = options.body === undefined ? undefined : JSON.stringify(options.body);
if (body !== undefined) headers.set('Content-Type', 'application/json');
const response = await fetch(`${apiUrl}${path.startsWith('/') ? path : `/${path}`}`, { ...options, headers, body });
if (!response.ok) throw new AdminApiError(`Admin media request failed with status ${response.status}`, response.status);
return response.blob();
}
export type AdminFilter = { column: string; op: 'eq' | 'neq' | 'in' | 'is' | 'gte' | 'lte' | 'ilike'; value: unknown };
export type AdminQuery = {
filters?: AdminFilter[];
order?: { column: string; ascending?: boolean };
limit?: number;
offset?: number;
};
export async function queryAdminRows<T>(resource: string, query: AdminQuery = {}): Promise<{ rows: T[]; count: number }> {
return adminRequest(`/v1/admin/query/${encodeURIComponent(resource)}`, { method: 'POST', body: query });
}
function uniqueValues(rows: Record<string, unknown>[], key: string): unknown[] {
return [...new Set(rows.map((row) => row[key]).filter((value) => value !== null && value !== undefined))];
}
async function relatedRows(resource: string, column: string, values: unknown[]): Promise<Record<string, unknown>[]> {
if (values.length === 0) return [];
return (await queryAdminRows<Record<string, unknown>>(resource, {
filters: [{ column, op: 'in', value: values }], limit: 500,
})).rows;
}
async function hydrateAdminRows(resource: string, selection: string, input: any[]): Promise<any[]> {
const rows = input as Record<string, unknown>[];
if (rows.length === 0) return input;
if (resource === 'question_packs' && selection.includes('categor')) {
const categories = await relatedRows('categories', 'id', uniqueValues(rows, 'category_id'));
const byId = new Map(categories.map((row) => [row.id, row]));
rows.forEach((row) => { row.category = byId.get(row.category_id) ?? null; });
}
if (resource === 'pack_topics' && selection.includes('topics')) {
const topics = await relatedRows('topics', 'id', uniqueValues(rows, 'topic_id'));
const byId = new Map(topics.map((row) => [row.id, row]));
rows.forEach((row) => { row.topics = byId.get(row.topic_id) ?? null; });
}
if (resource === 'feedback' && selection.includes('profile')) {
const profiles = await relatedRows('profiles', 'id', uniqueValues(rows, 'user_id'));
const byId = new Map(profiles.map((row) => [row.id, row]));
rows.forEach((row) => { row.profile = byId.get(row.user_id) ?? null; });
}
if (resource === 'message_reports') {
if (selection.includes('message:')) {
const messages = await relatedRows('messages', 'id', uniqueValues(rows, 'message_id'));
const byId = new Map(messages.map((row) => [row.id, row]));
rows.forEach((row) => { row.message = byId.get(row.message_id) ?? null; });
}
if (selection.includes('reporter_profile')) {
const profiles = await relatedRows('profiles', 'id', uniqueValues(rows, 'reporter_id'));
const byId = new Map(profiles.map((row) => [row.id, row]));
rows.forEach((row) => { row.reporter_profile = byId.get(row.reporter_id) ?? null; });
}
}
if (resource === 'responses') {
if (selection.includes('profile:')) {
const profiles = await relatedRows('profiles', 'id', uniqueValues(rows, 'user_id'));
const byId = new Map(profiles.map((row) => [row.id, row]));
rows.forEach((row) => { row.profile = byId.get(row.user_id) ?? null; });
}
if (selection.includes('question:')) {
const questions = await relatedRows('questions', 'id', uniqueValues(rows, 'question_id'));
if (selection.includes('pack:question_packs')) {
const packs = await relatedRows('question_packs', 'id', uniqueValues(questions, 'pack_id'));
const packsById = new Map(packs.map((row) => [row.id, row]));
questions.forEach((row) => { row.pack = packsById.get(row.pack_id) ?? null; });
}
const byId = new Map(questions.map((row) => [row.id, row]));
rows.forEach((row) => { row.question = byId.get(row.question_id) ?? null; });
}
}
if (resource === 'matches' && selection.includes('question:')) {
const questions = await relatedRows('questions', 'id', uniqueValues(rows, 'question_id'));
const byId = new Map(questions.map((row) => [row.id, row]));
rows.forEach((row) => { row.question = byId.get(row.question_id) ?? null; });
}
if (resource === 'messages' && selection.includes('match:')) {
const matches = await relatedRows('matches', 'id', uniqueValues(rows, 'match_id'));
const questions = await relatedRows('questions', 'id', uniqueValues(matches, 'question_id'));
const questionsById = new Map(questions.map((row) => [row.id, row]));
matches.forEach((row) => { row.question = questionsById.get(row.question_id) ?? null; });
const byId = new Map(matches.map((row) => [row.id, row]));
rows.forEach((row) => { row.match = byId.get(row.match_id) ?? null; });
}
return input;
}
type QueryResult<T> = { data: T | null; error: Error | null; count: number | null };
type QueryMode = 'many' | 'single' | 'maybeSingle';
class AdminQueryBuilder<T = any> implements PromiseLike<QueryResult<T[]>> {
private readonly filters: AdminFilter[] = [];
private ordering?: { column: string; ascending?: boolean };
private maxRows?: number;
private offset?: number;
private head = false;
private selection = '*';
private orFilters?: AdminFilter[];
private mode: QueryMode = 'many';
private mutation?: { type: 'insert'; records: Record<string, unknown>[] } | { type: 'update'; values: Record<string, unknown> } | { type: 'delete' };
constructor(private readonly resource: string) {}
select(columns = '*', options?: { count?: 'exact'; head?: boolean }): this {
this.selection = columns;
this.head = options?.head === true;
return this;
}
eq(column: string, value: unknown): this { this.filters.push({ column, op: 'eq', value }); return this; }
neq(column: string, value: unknown): this { this.filters.push({ column, op: 'neq', value }); return this; }
in(column: string, value: unknown[]): this { this.filters.push({ column, op: 'in', value }); return this; }
is(column: string, value: unknown): this { this.filters.push({ column, op: 'is', value }); return this; }
gte(column: string, value: unknown): this { this.filters.push({ column, op: 'gte', value }); return this; }
lte(column: string, value: unknown): this { this.filters.push({ column, op: 'lte', value }); return this; }
ilike(column: string, value: string): this { this.filters.push({ column, op: 'ilike', value }); return this; }
or(expression: string): this {
this.orFilters = expression.split(',').map((part) => {
const match = /^([a-z_][a-z0-9_]*)\.ilike\.(.+)$/.exec(part);
if (!match) throw new Error('Unsupported admin OR filter');
return { column: match[1], op: 'ilike' as const, value: match[2] };
});
return this;
}
not(column: string, operator: string, value: unknown): this {
if (operator !== 'is' || value !== null) throw new Error(`Unsupported admin filter: not ${operator}`);
this.filters.push({ column, op: 'neq', value: null });
return this;
}
order(column: string, options?: { ascending?: boolean }): this { this.ordering = { column, ascending: options?.ascending }; return this; }
limit(value: number): this { this.maxRows = value; return this; }
range(from: number, to: number): this { this.offset = from; this.maxRows = to - from + 1; return this; }
single<TResult = T>(): PromiseLike<QueryResult<TResult>> { this.mode = 'single'; return this as unknown as PromiseLike<QueryResult<TResult>>; }
maybeSingle<TResult = T>(): PromiseLike<QueryResult<TResult>> { this.mode = 'maybeSingle'; return this as unknown as PromiseLike<QueryResult<TResult>>; }
insert(records: Record<string, unknown> | Record<string, unknown>[]): this {
this.mutation = { type: 'insert', records: Array.isArray(records) ? records : [records] };
return this;
}
update(values: Record<string, unknown>): this { this.mutation = { type: 'update', values }; return this; }
delete(): this { this.mutation = { type: 'delete' }; return this; }
then<TResult1 = QueryResult<T[]>, TResult2 = never>(
onfulfilled?: ((value: QueryResult<T[]>) => TResult1 | PromiseLike<TResult1>) | null,
onrejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null,
): PromiseLike<TResult1 | TResult2> {
return this.execute().then(onfulfilled, onrejected);
}
private async execute(): Promise<QueryResult<T[]>> {
try {
if (this.mutation?.type === 'insert') {
const result = await adminRequest<{ rows: T[] }>(`/v1/admin/data/${this.resource}`, { method: 'POST', body: { records: this.mutation.records } });
return this.shape(result.rows, result.rows.length);
}
if (this.mutation) {
const matching = await queryAdminRows<Record<string, unknown>>(this.resource, { filters: this.filters, limit: 500 });
const ids = [...new Set(matching.rows.map((row) => String(row.id ?? row.pack_id ?? '')).filter(Boolean))];
if (this.mutation.type === 'update') {
const rows = await Promise.all(ids.map((id) => adminRequest<{ row: T }>(`/v1/admin/data/${this.resource}/${encodeURIComponent(id)}`, { method: 'PATCH', body: { values: this.mutation!.type === 'update' ? this.mutation!.values : {} } }).then((value) => value.row)));
return this.shape(rows, rows.length);
}
await Promise.all(ids.map((id) => adminRequest(`/v1/admin/data/${this.resource}/${encodeURIComponent(id)}`, { method: 'DELETE' })));
return this.shape([], ids.length);
}
const result = this.orFilters?.length
? await this.executeOrQuery()
: await queryAdminRows<T>(this.resource, { filters: this.filters, order: this.ordering, limit: this.maxRows, offset: this.offset });
if (!this.head) result.rows = await hydrateAdminRows(this.resource, this.selection, result.rows);
return this.shape(result.rows, result.count);
} catch (cause) {
return { data: null, error: cause instanceof Error ? cause : new Error(String(cause)), count: null };
}
}
private async executeOrQuery(): Promise<{ rows: T[]; count: number }> {
const results = await Promise.all(this.orFilters!.map((filter) => queryAdminRows<T>(this.resource, {
filters: [...this.filters, filter], order: this.ordering, limit: 500,
})));
const unique = new Map<string, T>();
for (const result of results) result.rows.forEach((row, index) => {
const record = row as Record<string, unknown>;
unique.set(String(record.id ?? `${record.pack_id ?? ''}:${record.topic_id ?? ''}:${index}`), row);
});
let rows = [...unique.values()];
if (this.offset) rows = rows.slice(this.offset);
if (this.maxRows) rows = rows.slice(0, this.maxRows);
return { rows, count: unique.size };
}
private shape(rows: T[], count: number): QueryResult<T[]> {
if (this.head) return { data: null, error: null, count };
if (this.mode === 'single') {
if (rows.length !== 1) return { data: null, error: new Error(`Expected one ${this.resource} record, received ${rows.length}`), count };
return { data: rows[0] as unknown as T[], error: null, count };
}
if (this.mode === 'maybeSingle') {
if (rows.length > 1) return { data: null, error: new Error(`Expected at most one ${this.resource} record, received ${rows.length}`), count };
return { data: (rows[0] ?? null) as unknown as T[], error: null, count };
}
return { data: rows, error: null, count };
}
}
export const adminData = {
from<T = any>(resource: string): AdminQueryBuilder<T> { return new AdminQueryBuilder<T>(resource); },
};
+1 -1
View File
@@ -47,7 +47,7 @@ export function getCouncilConfig(): CouncilConfig {
}
/**
* Initialize AI config by preloading from Supabase
* Initialize AI config by preloading from the standalone admin API
* Call this on app startup to ensure config is available
*/
export async function initializeAiConfig(): Promise<AiConfig | null> {
+2 -2
View File
@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback } from 'react';
import { supabase } from '@/config';
import { adminData } from '@/lib/adminApi';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { PaginationControls } from '@/components/ui/pagination';
@@ -45,7 +45,7 @@ export function AuditLogsPage() {
const from = (page - 1) * pageSize;
const to = from + pageSize - 1;
const { data, error, count } = await supabase
const { data, error, count } = await adminData
.from('audit_logs')
.select('*', { count: 'exact' })
.order('created_at', { ascending: false })
+25 -27
View File
@@ -1,7 +1,7 @@
import { useState, useEffect, useCallback } from 'react';
import { Link } from 'react-router-dom';
import { useAuth, PERMISSION_KEYS } from '@/contexts/AuthContext';
import { supabase } from '@/config';
import { adminData, adminRequest } from '@/lib/adminApi';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { LayoutGrid, Users, ClipboardList, ArrowRight, Activity, Target, TrendingUp, Heart, MessageCircle, Bell } from 'lucide-react';
import { Skeleton } from '@/components/ui/skeleton';
@@ -43,6 +43,7 @@ function formatFeatureName(featureName: string) {
export function DashboardPage() {
const { hasPermission } = useAuth();
const canViewUsers = hasPermission(PERMISSION_KEYS.VIEW_USERS);
const canViewChats = hasPermission(PERMISSION_KEYS.VIEW_CHATS);
const [stats, setStats] = useState({
categories: 0,
packs: 0,
@@ -64,10 +65,12 @@ export function DashboardPage() {
{ count: questionsCount },
{ count: usersCount },
] = await Promise.all([
supabase.from('categories').select('*', { count: 'exact', head: true }),
supabase.from('question_packs').select('*', { count: 'exact', head: true }),
supabase.from('questions').select('*', { count: 'exact', head: true }),
supabase.from('profiles').select('*', { count: 'exact', head: true }),
adminData.from('categories').select('*', { count: 'exact', head: true }),
adminData.from('question_packs').select('*', { count: 'exact', head: true }),
adminData.from('questions').select('*', { count: 'exact', head: true }),
canViewUsers
? adminData.from('profiles').select('*', { count: 'exact', head: true })
: Promise.resolve({ count: 0 }),
]);
setStats({
@@ -85,31 +88,24 @@ export function DashboardPage() {
{ data: featureInterests },
{ data: recentInterests },
] = await Promise.all([
supabase
.from('messages')
.select('created_at')
.gte('created_at', startDate.toISOString())
.lte('created_at', endDate.toISOString()),
supabase
.from('feature_interests')
.select('created_at, user_id')
.gte('created_at', startDate.toISOString())
.lte('created_at', endDate.toISOString()),
supabase
.from('feature_interests')
.select('id, user_id, feature_name, created_at')
.order('created_at', { ascending: false })
.limit(10),
canViewChats
? adminData.from('messages').select('created_at').gte('created_at', startDate.toISOString()).lte('created_at', endDate.toISOString())
: Promise.resolve({ data: [] }),
canViewUsers
? adminData.from('feature_interests').select('created_at, user_id').gte('created_at', startDate.toISOString()).lte('created_at', endDate.toISOString())
: Promise.resolve({ data: [] }),
canViewUsers
? adminData.from('feature_interests').select('id, user_id, feature_name, created_at').order('created_at', { ascending: false }).limit(10)
: Promise.resolve({ data: [] }),
]);
if (canViewUsers) {
const { data: countsData, error: countsError } = await supabase.rpc('get_feature_interest_counts');
if (countsError) {
try {
const { counts } = await adminRequest<{ counts: FeatureInterestCountsRow[] }>('/v1/admin/feature-interest-counts');
setFeatureInterestCounts(counts);
} catch (countsError) {
console.error('Failed to fetch feature interest counts:', countsError);
setFeatureInterestCounts([]);
} else {
setFeatureInterestCounts((countsData ?? []) as FeatureInterestCountsRow[]);
}
} else {
setFeatureInterestCounts([]);
@@ -172,7 +168,7 @@ export function DashboardPage() {
if (canViewUsers && recentActivity.length > 0) {
const userIds = Array.from(new Set(recentActivity.map(item => item.user_id)));
const { data: profiles } = await supabase
const { data: profiles } = await adminData
.from('profiles')
.select('id, name, email, avatar_url')
.in('id', userIds);
@@ -193,11 +189,12 @@ export function DashboardPage() {
} finally {
setLoading(false);
}
}, [canViewUsers]);
}, [canViewChats, canViewUsers]);
// Real-time subscriptions for dashboard stats (with debouncing)
const { status: profilesStatus } = useRealtimeSubscription({
table: 'profiles',
enabled: canViewUsers,
onInsert: fetchStats,
onDelete: fetchStats,
debounceMs: 2000,
@@ -205,6 +202,7 @@ export function DashboardPage() {
const { status: messagesStatus } = useRealtimeSubscription({
table: 'messages',
enabled: canViewChats,
onInsert: fetchStats,
debounceMs: 2000,
});
+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>
);
}
+16 -47
View File
@@ -1,5 +1,5 @@
import React, { useState, useEffect, useCallback } from 'react';
import { supabase } from '@/config';
import { adminBinaryRequest, adminData, adminRequest } from '@/lib/adminApi';
import { useNavigate } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
@@ -95,7 +95,7 @@ export function FlaggedMessagesPage() {
const from = (aiPage - 1) * aiPageSize;
const to = from + aiPageSize - 1;
const { data, error, count } = await supabase
const { data, error, count } = await adminData
.from('messages')
.select('id, created_at, user_id, match_id, moderation_status, flag_reason, content, media_path, media_type', { count: 'exact' })
.eq('moderation_status', 'flagged')
@@ -120,7 +120,7 @@ export function FlaggedMessagesPage() {
const from = (reportsPage - 1) * reportsPageSize;
const to = from + reportsPageSize - 1;
const { data, error, count } = await supabase
const { data, error, count } = await adminData
.from('message_reports')
.select(`
id,
@@ -166,24 +166,11 @@ export function FlaggedMessagesPage() {
}
}, [reportsPage, reportsPageSize, reportsTotal]);
// Custom media fetcher because supabase.functions.invoke is JSON-centric
const fetchDecryptedMedia = async (messageId: string) => {
try {
const { data: { session } } = await supabase.auth.getSession();
if (!session) return null;
const response = await fetch(`https://ckjcrkjpmhqhiucifukx.supabase.co/functions/v1/admin-decrypt-media`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${session.access_token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ messageId })
const blob = await adminBinaryRequest('/v1/admin/actions/decrypt-media', {
method: 'POST', body: { messageId },
});
if (!response.ok) throw new Error('Failed to fetch media');
const blob = await response.blob();
return URL.createObjectURL(blob);
} catch (e) {
console.error(e);
@@ -199,8 +186,8 @@ export function FlaggedMessagesPage() {
setImageDescription(null);
try {
const { data: textData, error: textError } = await supabase.functions.invoke('admin-decrypt-message', {
body: { messageId: message.id }
const textData = await adminRequest<{ content: string | null }>('/v1/admin/actions/decrypt-message', {
method: 'POST', body: { messageId: message.id },
});
let mediaUrl = null;
@@ -209,7 +196,7 @@ export function FlaggedMessagesPage() {
}
setDecryptedContent({
text: textData?.content || (textError ? 'Error decrypting text' : null),
text: textData.content,
mediaUrl
});
@@ -233,8 +220,8 @@ export function FlaggedMessagesPage() {
setImageDescription(null);
try {
const { data: textData, error: textError } = await supabase.functions.invoke('admin-decrypt-message', {
body: { messageId: report.message.id }
const textData = await adminRequest<{ content: string | null }>('/v1/admin/actions/decrypt-message', {
method: 'POST', body: { messageId: report.message.id },
});
let mediaUrl = null;
@@ -243,7 +230,7 @@ export function FlaggedMessagesPage() {
}
setDecryptedContent({
text: textData?.content || (textError ? 'Error decrypting text' : null),
text: textData.content,
mediaUrl
});
@@ -274,27 +261,9 @@ export function FlaggedMessagesPage() {
setDescribingInline(message.id);
try {
const { data: { session } } = await supabase.auth.getSession();
if (!session) {
toast.error('Not authenticated');
return;
}
const response = await fetch(`https://ckjcrkjpmhqhiucifukx.supabase.co/functions/v1/admin-decrypt-media`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${session.access_token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ messageId: message.id })
const blob = await adminBinaryRequest('/v1/admin/actions/decrypt-media', {
method: 'POST', body: { messageId: message.id },
});
if (!response.ok) {
toast.error('Failed to decrypt media');
return;
}
const blob = await response.blob();
const base64 = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => {
@@ -319,7 +288,7 @@ export function FlaggedMessagesPage() {
const handleMarkSafe = async (messageId: string) => {
setProcessing(messageId);
try {
const { error } = await supabase
const { error } = await adminData
.from('messages')
.update({ moderation_status: 'safe' })
.eq('id', messageId);
@@ -339,7 +308,7 @@ export function FlaggedMessagesPage() {
const handleDismissReport = async (reportId: string) => {
setProcessing(reportId);
try {
const { error } = await supabase
const { error } = await adminData
.from('message_reports')
.update({ status: 'dismissed', reviewed_at: new Date().toISOString() })
.eq('id', reportId);
@@ -359,7 +328,7 @@ export function FlaggedMessagesPage() {
const handleReviewReport = async (reportId: string) => {
setProcessing(reportId);
try {
const { error } = await supabase
const { error } = await adminData
.from('message_reports')
.update({ status: 'reviewed', reviewed_at: new Date().toISOString() })
.eq('id', reportId);
+264 -264
View File
@@ -1,69 +1,69 @@
import { useState, useEffect, useCallback } from 'react';
import { supabase } from '@/config';
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { adminData } from '@/lib/adminApi';
import { auditedAdminData } from '@/hooks/useAuditedAdminData';
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 { Switch } from '@/components/ui/switch';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Search, Ticket, Trash2, Plus, Copy, Users } from 'lucide-react';
import { format } from 'date-fns';
import { toast } from 'sonner';
interface RedemptionCode {
id: string;
code: string;
description: string | null;
max_uses: number;
current_uses: number;
expires_at: string | null;
is_active: boolean;
created_by: string | null;
created_at: string;
updated_at: string;
}
interface CodeRedemption {
id: string;
user_id: string;
redeemed_at: string;
profile?: {
name: string | null;
email: string | null;
};
}
function generateCode(length: number = 8): string {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // Removed confusing chars like 0, O, I, 1
let result = '';
for (let i = 0; i < length; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}
export function RedemptionCodesPage() {
import { Switch } from '@/components/ui/switch';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Search, Ticket, Trash2, Plus, Copy, Users } from 'lucide-react';
import { format } from 'date-fns';
import { toast } from 'sonner';
interface RedemptionCode {
id: string;
code: string;
description: string | null;
max_uses: number;
current_uses: number;
expires_at: string | null;
is_active: boolean;
created_by: string | null;
created_at: string;
updated_at: string;
}
interface CodeRedemption {
id: string;
user_id: string;
redeemed_at: string;
profile?: {
name: string | null;
email: string | null;
};
}
function generateCode(length: number = 8): string {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // Removed confusing chars like 0, O, I, 1
let result = '';
for (let i = 0; i < length; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}
export function RedemptionCodesPage() {
const [codes, setCodes] = useState<RedemptionCode[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
@@ -71,18 +71,18 @@ export function RedemptionCodesPage() {
const [pageSize, setPageSize] = useState(25);
const [totalCount, setTotalCount] = useState(0);
// Add Code State
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [formData, setFormData] = useState({
code: '',
description: '',
max_uses: 1,
expires_at: '',
});
// View Redemptions State
// Add Code State
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [formData, setFormData] = useState({
code: '',
description: '',
max_uses: 1,
expires_at: '',
});
// View Redemptions State
const [viewingCode, setViewingCode] = useState<RedemptionCode | null>(null);
const [redemptions, setRedemptions] = useState<CodeRedemption[]>([]);
const [loadingRedemptions, setLoadingRedemptions] = useState(false);
@@ -90,9 +90,9 @@ export function RedemptionCodesPage() {
const [redemptionsPageSize, setRedemptionsPageSize] = useState(10);
const [redemptionsTotal, setRedemptionsTotal] = useState(0);
const fetchCodes = useCallback(async () => {
setLoading(true);
try {
@@ -100,7 +100,7 @@ export function RedemptionCodesPage() {
const to = from + pageSize - 1;
const trimmedSearch = search.trim();
let query = supabase
let query = adminData
.from('redemption_codes')
.select('*', { count: 'exact' })
.order('created_at', { ascending: false });
@@ -122,14 +122,14 @@ export function RedemptionCodesPage() {
}
}, [page, pageSize, search]);
const fetchRedemptions = useCallback(async (codeId: string) => {
setLoadingRedemptions(true);
try {
const from = (redemptionsPage - 1) * redemptionsPageSize;
const to = from + redemptionsPageSize - 1;
const { data: redemptionData, error: redemptionError, count } = await supabase
const { data: redemptionData, error: redemptionError, count } = await adminData
.from('code_redemptions')
.select('id, user_id, redeemed_at', { count: 'exact' })
.eq('code_id', codeId)
@@ -142,7 +142,7 @@ export function RedemptionCodesPage() {
if (redemptionData && redemptionData.length > 0) {
const userIds = redemptionData.map(r => r.user_id);
const { data: profileData } = await supabase
const { data: profileData } = await adminData
.from('profiles')
.select('id, name, email')
.in('id', userIds);
@@ -191,117 +191,117 @@ export function RedemptionCodesPage() {
}
}, [redemptionsPage, redemptionsPageSize, redemptionsTotal]);
const handleAddCode = async () => {
if (!formData.code.trim()) {
toast.error("Please enter a code");
return;
}
setIsSubmitting(true);
try {
const { error } = await auditedSupabase.insert('redemption_codes', {
code: formData.code.toUpperCase().trim(),
description: formData.description.trim() || null,
max_uses: formData.max_uses,
current_uses: 0,
expires_at: formData.expires_at || null,
is_active: true,
});
if (error) throw error;
toast.success("Redemption code created");
setIsAddDialogOpen(false);
resetForm();
fetchCodes();
} catch (error: any) {
console.error('Failed to create code:', error);
if (error.message?.includes('duplicate') || error.code === '23505') {
toast.error("This code already exists");
} else {
toast.error(error.message || "Failed to create code");
}
} finally {
setIsSubmitting(false);
}
};
const handleToggleActive = async (code: RedemptionCode) => {
try {
const { error } = await auditedSupabase.update('redemption_codes', code.id, {
is_active: !code.is_active,
});
if (error) throw error;
toast.success(code.is_active ? "Code deactivated" : "Code activated");
fetchCodes();
} catch (error: any) {
toast.error("Failed to update code");
}
};
const handleDeleteCode = async (code: RedemptionCode) => {
if (!confirm(`Are you sure you want to delete the code "${code.code}"? This cannot be undone.`)) {
return;
}
try {
const { error } = await auditedSupabase.delete('redemption_codes', code.id);
if (error) throw error;
toast.success("Code deleted");
fetchCodes();
} catch (error: any) {
toast.error("Failed to delete code");
}
};
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
toast.success("Copied to clipboard");
};
const resetForm = () => {
setFormData({
code: '',
description: '',
max_uses: 1,
expires_at: '',
});
};
const handleOpenAddDialog = () => {
resetForm();
setFormData(prev => ({ ...prev, code: generateCode() }));
setIsAddDialogOpen(true);
};
const handleAddCode = async () => {
if (!formData.code.trim()) {
toast.error("Please enter a code");
return;
}
setIsSubmitting(true);
try {
const { error } = await auditedAdminData.insert('redemption_codes', {
code: formData.code.toUpperCase().trim(),
description: formData.description.trim() || null,
max_uses: formData.max_uses,
current_uses: 0,
expires_at: formData.expires_at || null,
is_active: true,
});
if (error) throw error;
toast.success("Redemption code created");
setIsAddDialogOpen(false);
resetForm();
fetchCodes();
} catch (error: any) {
console.error('Failed to create code:', error);
if (error.message?.includes('duplicate') || error.code === '23505') {
toast.error("This code already exists");
} else {
toast.error(error.message || "Failed to create code");
}
} finally {
setIsSubmitting(false);
}
};
const handleToggleActive = async (code: RedemptionCode) => {
try {
const { error } = await auditedAdminData.update('redemption_codes', code.id, {
is_active: !code.is_active,
});
if (error) throw error;
toast.success(code.is_active ? "Code deactivated" : "Code activated");
fetchCodes();
} catch (error: any) {
toast.error("Failed to update code");
}
};
const handleDeleteCode = async (code: RedemptionCode) => {
if (!confirm(`Are you sure you want to delete the code "${code.code}"? This cannot be undone.`)) {
return;
}
try {
const { error } = await auditedAdminData.delete('redemption_codes', code.id);
if (error) throw error;
toast.success("Code deleted");
fetchCodes();
} catch (error: any) {
toast.error("Failed to delete code");
}
};
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
toast.success("Copied to clipboard");
};
const resetForm = () => {
setFormData({
code: '',
description: '',
max_uses: 1,
expires_at: '',
});
};
const handleOpenAddDialog = () => {
resetForm();
setFormData(prev => ({ ...prev, code: generateCode() }));
setIsAddDialogOpen(true);
};
const handleViewRedemptions = (code: RedemptionCode) => {
setViewingCode(code);
setRedemptionsPage(1);
};
const filteredCodes = codes;
const getStatusBadge = (code: RedemptionCode) => {
if (!code.is_active) {
return <Badge variant="secondary">Inactive</Badge>;
}
if (code.expires_at && new Date(code.expires_at) < new Date()) {
return <Badge variant="destructive">Expired</Badge>;
}
if (code.current_uses >= code.max_uses) {
return <Badge variant="outline">Exhausted</Badge>;
}
return <Badge variant="default">Active</Badge>;
};
return (
const getStatusBadge = (code: RedemptionCode) => {
if (!code.is_active) {
return <Badge variant="secondary">Inactive</Badge>;
}
if (code.expires_at && new Date(code.expires_at) < new Date()) {
return <Badge variant="destructive">Expired</Badge>;
}
if (code.current_uses >= code.max_uses) {
return <Badge variant="outline">Exhausted</Badge>;
}
return <Badge variant="default">Active</Badge>;
};
return (
<div className="space-y-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
@@ -317,9 +317,9 @@ export function RedemptionCodesPage() {
placeholder="Search codes..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
className="pl-9"
/>
</div>
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
<DialogTrigger asChild>
<Button onClick={handleOpenAddDialog} className="w-full sm:w-auto">
@@ -327,14 +327,14 @@ export function RedemptionCodesPage() {
Generate Code
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Generate Redemption Code</DialogTitle>
<DialogDescription>
Create a new code that users can redeem for premium access.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Generate Redemption Code</DialogTitle>
<DialogDescription>
Create a new code that users can redeem for premium access.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label>Code</Label>
<div className="flex flex-col gap-2 sm:flex-row">
@@ -343,57 +343,57 @@ export function RedemptionCodesPage() {
onChange={(e) => setFormData(prev => ({ ...prev, code: e.target.value.toUpperCase() }))}
placeholder="e.g., PREMIUM2024"
className="font-mono"
/>
<Button
variant="outline"
size="icon"
onClick={() => setFormData(prev => ({ ...prev, code: generateCode() }))}
title="Generate random code"
>
<Ticket className="h-4 w-4" />
</Button>
</div>
</div>
<div className="grid gap-2">
<Label>Description (optional)</Label>
<Input
value={formData.description}
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
placeholder="e.g., Early adopter reward"
/>
</div>
<div className="grid gap-2">
<Label>Max Uses</Label>
<Input
type="number"
min={1}
value={formData.max_uses}
onChange={(e) => setFormData(prev => ({ ...prev, max_uses: parseInt(e.target.value) || 1 }))}
/>
<p className="text-xs text-muted-foreground">
How many times this code can be redeemed
</p>
</div>
<div className="grid gap-2">
<Label>Expires At (optional)</Label>
<Input
type="datetime-local"
value={formData.expires_at}
onChange={(e) => setFormData(prev => ({ ...prev, expires_at: e.target.value }))}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsAddDialogOpen(false)}>Cancel</Button>
<Button onClick={handleAddCode} disabled={!formData.code.trim() || isSubmitting}>
{isSubmitting ? 'Creating...' : 'Create Code'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</div>
/>
<Button
variant="outline"
size="icon"
onClick={() => setFormData(prev => ({ ...prev, code: generateCode() }))}
title="Generate random code"
>
<Ticket className="h-4 w-4" />
</Button>
</div>
</div>
<div className="grid gap-2">
<Label>Description (optional)</Label>
<Input
value={formData.description}
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
placeholder="e.g., Early adopter reward"
/>
</div>
<div className="grid gap-2">
<Label>Max Uses</Label>
<Input
type="number"
min={1}
value={formData.max_uses}
onChange={(e) => setFormData(prev => ({ ...prev, max_uses: parseInt(e.target.value) || 1 }))}
/>
<p className="text-xs text-muted-foreground">
How many times this code can be redeemed
</p>
</div>
<div className="grid gap-2">
<Label>Expires At (optional)</Label>
<Input
type="datetime-local"
value={formData.expires_at}
onChange={(e) => setFormData(prev => ({ ...prev, expires_at: e.target.value }))}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsAddDialogOpen(false)}>Cancel</Button>
<Button onClick={handleAddCode} disabled={!formData.code.trim() || isSubmitting}>
{isSubmitting ? 'Creating...' : 'Create Code'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</div>
<div className="rounded-md border">
<Table>
<TableHeader>
@@ -545,18 +545,18 @@ export function RedemptionCodesPage() {
/>
)}
{/* View Redemptions Dialog */}
<Dialog open={!!viewingCode} onOpenChange={(open) => !open && setViewingCode(null)}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>
Redemptions for <code className="font-mono bg-muted px-2 py-0.5 rounded">{viewingCode?.code}</code>
</DialogTitle>
<DialogDescription>
{viewingCode?.current_uses || 0} of {viewingCode?.max_uses} uses
</DialogDescription>
</DialogHeader>
{/* View Redemptions Dialog */}
<Dialog open={!!viewingCode} onOpenChange={(open) => !open && setViewingCode(null)}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>
Redemptions for <code className="font-mono bg-muted px-2 py-0.5 rounded">{viewingCode?.code}</code>
</DialogTitle>
<DialogDescription>
{viewingCode?.current_uses || 0} of {viewingCode?.max_uses} uses
</DialogDescription>
</DialogHeader>
<div className="max-h-[300px] overflow-y-auto">
{loadingRedemptions ? (
<div className="flex justify-center py-8">
@@ -607,10 +607,10 @@ export function RedemptionCodesPage() {
)}
<DialogFooter>
<Button variant="outline" onClick={() => setViewingCode(null)}>Close</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
<Button variant="outline" onClick={() => setViewingCode(null)}>Close</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+386 -386
View File
@@ -1,386 +1,386 @@
import { useState, useEffect } from 'react';
import { supabase } from '@/config';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { Target, Users, MessageSquare, Flame, Heart, Smile, Shield, TrendingUp, PieChart } from 'lucide-react';
import { Bar, BarChart, ResponsiveContainer, XAxis, YAxis, Tooltip, Cell, Pie, PieChart as RechartsPieChart } from 'recharts';
type UsageReason = 'improve_communication' | 'spice_up_intimacy' | 'deeper_connection' | 'have_fun' | 'strengthen_relationship';
interface UsageReasonStat {
reason: UsageReason | null;
count: number;
percentage: number;
}
interface GenderStat {
gender: string | null;
count: number;
percentage: number;
}
const usageReasonConfig: Record<UsageReason, { label: string; icon: React.ReactNode; color: string }> = {
improve_communication: {
label: 'Improve communication',
icon: <MessageSquare className="h-5 w-5" />,
color: '#3b82f6',
},
spice_up_intimacy: {
label: 'Spice up intimacy',
icon: <Flame className="h-5 w-5" />,
color: '#ef4444',
},
deeper_connection: {
label: 'Build deeper connection',
icon: <Heart className="h-5 w-5" />,
color: '#ec4899',
},
have_fun: {
label: 'Have fun together',
icon: <Smile className="h-5 w-5" />,
color: '#f59e0b',
},
strengthen_relationship: {
label: 'Strengthen relationship',
icon: <Shield className="h-5 w-5" />,
color: '#10b981',
},
};
const genderConfig: Record<string, { label: string; color: string }> = {
male: { label: 'Male', color: '#3b82f6' },
female: { label: 'Female', color: '#ec4899' },
'non-binary': { label: 'Non-binary', color: '#8b5cf6' },
'prefer-not-to-say': { label: 'Prefer not to say', color: '#6b7280' },
};
export function UsageInsightsPage() {
const [usageStats, setUsageStats] = useState<UsageReasonStat[]>([]);
const [genderStats, setGenderStats] = useState<GenderStat[]>([]);
const [totalUsers, setTotalUsers] = useState(0);
const [onboardedUsers, setOnboardedUsers] = useState(0);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchStats = async () => {
try {
// Get total users count
const { count: total } = await supabase
.from('profiles')
.select('*', { count: 'exact', head: true });
setTotalUsers(total || 0);
// Get onboarded users count
const { count: onboarded } = await supabase
.from('profiles')
.select('*', { count: 'exact', head: true })
.eq('onboarding_completed', true);
setOnboardedUsers(onboarded || 0);
// Get usage reason breakdown
const { data: usageData } = await supabase
.from('profiles')
.select('usage_reason')
.not('usage_reason', 'is', null);
const usageCounts: Record<string, number> = {};
usageData?.forEach(profile => {
const reason = profile.usage_reason as string;
usageCounts[reason] = (usageCounts[reason] || 0) + 1;
});
const totalWithReason = Object.values(usageCounts).reduce((a, b) => a + b, 0);
const usageStatsArray: UsageReasonStat[] = Object.entries(usageCounts)
.map(([reason, count]) => ({
reason: reason as UsageReason,
count,
percentage: totalWithReason > 0 ? Math.round((count / totalWithReason) * 100) : 0,
}))
.sort((a, b) => b.count - a.count);
setUsageStats(usageStatsArray);
// Get gender breakdown
const { data: genderData } = await supabase
.from('profiles')
.select('gender')
.not('gender', 'is', null);
const genderCounts: Record<string, number> = {};
genderData?.forEach(profile => {
const gender = profile.gender as string;
genderCounts[gender] = (genderCounts[gender] || 0) + 1;
});
const totalWithGender = Object.values(genderCounts).reduce((a, b) => a + b, 0);
const genderStatsArray: GenderStat[] = Object.entries(genderCounts)
.map(([gender, count]) => ({
gender,
count,
percentage: totalWithGender > 0 ? Math.round((count / totalWithGender) * 100) : 0,
}))
.sort((a, b) => b.count - a.count);
setGenderStats(genderStatsArray);
} catch (error) {
console.error('Failed to fetch usage stats:', error);
} finally {
setLoading(false);
}
};
fetchStats();
}, []);
const chartData = usageStats.map(stat => ({
name: stat.reason ? usageReasonConfig[stat.reason]?.label || stat.reason : 'Unknown',
value: stat.count,
color: stat.reason ? usageReasonConfig[stat.reason]?.color : '#6b7280',
}));
const pieData = genderStats.map(stat => ({
name: stat.gender ? genderConfig[stat.gender]?.label || stat.gender : 'Unknown',
value: stat.count,
color: stat.gender ? genderConfig[stat.gender]?.color : '#6b7280',
}));
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
<Target className="h-8 w-8 text-primary" />
Usage Insights
</h1>
<p className="text-muted-foreground">
Understand why users are using Sauci based on onboarding responses
</p>
</div>
{/* Quick Stats */}
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Users</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
{loading ? (
<Skeleton className="h-8 w-16" />
) : (
<div className="text-2xl font-bold">{totalUsers.toLocaleString()}</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Completed Onboarding</CardTitle>
<TrendingUp className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
{loading ? (
<Skeleton className="h-8 w-16" />
) : (
<>
<div className="text-2xl font-bold">{onboardedUsers.toLocaleString()}</div>
<p className="text-xs text-muted-foreground">
{totalUsers > 0 ? Math.round((onboardedUsers / totalUsers) * 100) : 0}% of total users
</p>
</>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Response Rate</CardTitle>
<PieChart className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
{loading ? (
<Skeleton className="h-8 w-16" />
) : (
<>
<div className="text-2xl font-bold">
{usageStats.reduce((sum, s) => sum + s.count, 0).toLocaleString()}
</div>
<p className="text-xs text-muted-foreground">
users provided usage reason
</p>
</>
)}
</CardContent>
</Card>
</div>
<div className="grid gap-6 md:grid-cols-2">
{/* Usage Reasons Chart */}
<Card className="col-span-1">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Target className="h-5 w-5 text-primary" />
Why Users Join Sauci
</CardTitle>
<CardDescription>
Distribution of user motivations from onboarding
</CardDescription>
</CardHeader>
<CardContent>
{loading ? (
<Skeleton className="h-[300px] w-full" />
) : usageStats.length === 0 ? (
<div className="flex items-center justify-center h-[300px] text-muted-foreground">
No usage data available yet
</div>
) : (
<div className="h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData} layout="vertical" margin={{ left: 20 }}>
<XAxis type="number" />
<YAxis
dataKey="name"
type="category"
width={150}
tick={{ fontSize: 12 }}
/>
<Tooltip
contentStyle={{
borderRadius: '8px',
border: '1px solid #e2e8f0',
boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)',
}}
/>
<Bar dataKey="value" radius={[0, 4, 4, 0]}>
{chartData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
)}
</CardContent>
</Card>
{/* Gender Distribution */}
<Card className="col-span-1">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Users className="h-5 w-5 text-primary" />
Gender Distribution
</CardTitle>
<CardDescription>
User demographics from onboarding
</CardDescription>
</CardHeader>
<CardContent>
{loading ? (
<Skeleton className="h-[300px] w-full" />
) : genderStats.length === 0 ? (
<div className="flex items-center justify-center h-[300px] text-muted-foreground">
No gender data available yet
</div>
) : (
<div className="h-[300px] flex items-center justify-center">
<ResponsiveContainer width="100%" height="100%">
<RechartsPieChart>
<Pie
data={pieData}
cx="50%"
cy="50%"
innerRadius={60}
outerRadius={100}
paddingAngle={2}
dataKey="value"
label={({ name, percent }) =>
`${name}: ${((percent ?? 0) * 100).toFixed(0)}%`
}
>
{pieData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip
contentStyle={{
borderRadius: '8px',
border: '1px solid #e2e8f0',
boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)',
}}
/>
</RechartsPieChart>
</ResponsiveContainer>
</div>
)}
</CardContent>
</Card>
</div>
{/* Detailed Breakdown */}
<Card>
<CardHeader>
<CardTitle>Detailed Breakdown</CardTitle>
<CardDescription>
Individual statistics for each usage reason
</CardDescription>
</CardHeader>
<CardContent>
{loading ? (
<div className="space-y-3">
{[1, 2, 3, 4, 5].map(i => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
) : usageStats.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
No usage data available yet
</div>
) : (
<div className="space-y-4">
{usageStats.map(stat => {
const config = stat.reason ? usageReasonConfig[stat.reason] : null;
return (
<div
key={stat.reason}
className="flex items-center gap-4 p-4 rounded-lg border"
>
<div
className="p-3 rounded-lg"
style={{ backgroundColor: `${config?.color}20` }}
>
<div style={{ color: config?.color }}>
{config?.icon}
</div>
</div>
<div className="flex-1">
<p className="font-medium">{config?.label || stat.reason}</p>
<div className="flex items-center gap-2 mt-1">
<div className="flex-1 h-2 bg-muted rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all"
style={{
width: `${stat.percentage}%`,
backgroundColor: config?.color,
}}
/>
</div>
<span className="text-sm text-muted-foreground w-12 text-right">
{stat.percentage}%
</span>
</div>
</div>
<div className="text-right">
<p className="text-2xl font-bold">{stat.count}</p>
<p className="text-xs text-muted-foreground">users</p>
</div>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
</div>
);
}
import { useState, useEffect } from 'react';
import { adminData } from '@/lib/adminApi';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { Target, Users, MessageSquare, Flame, Heart, Smile, Shield, TrendingUp, PieChart } from 'lucide-react';
import { Bar, BarChart, ResponsiveContainer, XAxis, YAxis, Tooltip, Cell, Pie, PieChart as RechartsPieChart } from 'recharts';
type UsageReason = 'improve_communication' | 'spice_up_intimacy' | 'deeper_connection' | 'have_fun' | 'strengthen_relationship';
interface UsageReasonStat {
reason: UsageReason | null;
count: number;
percentage: number;
}
interface GenderStat {
gender: string | null;
count: number;
percentage: number;
}
const usageReasonConfig: Record<UsageReason, { label: string; icon: React.ReactNode; color: string }> = {
improve_communication: {
label: 'Improve communication',
icon: <MessageSquare className="h-5 w-5" />,
color: '#3b82f6',
},
spice_up_intimacy: {
label: 'Spice up intimacy',
icon: <Flame className="h-5 w-5" />,
color: '#ef4444',
},
deeper_connection: {
label: 'Build deeper connection',
icon: <Heart className="h-5 w-5" />,
color: '#ec4899',
},
have_fun: {
label: 'Have fun together',
icon: <Smile className="h-5 w-5" />,
color: '#f59e0b',
},
strengthen_relationship: {
label: 'Strengthen relationship',
icon: <Shield className="h-5 w-5" />,
color: '#10b981',
},
};
const genderConfig: Record<string, { label: string; color: string }> = {
male: { label: 'Male', color: '#3b82f6' },
female: { label: 'Female', color: '#ec4899' },
'non-binary': { label: 'Non-binary', color: '#8b5cf6' },
'prefer-not-to-say': { label: 'Prefer not to say', color: '#6b7280' },
};
export function UsageInsightsPage() {
const [usageStats, setUsageStats] = useState<UsageReasonStat[]>([]);
const [genderStats, setGenderStats] = useState<GenderStat[]>([]);
const [totalUsers, setTotalUsers] = useState(0);
const [onboardedUsers, setOnboardedUsers] = useState(0);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchStats = async () => {
try {
// Get total users count
const { count: total } = await adminData
.from('profiles')
.select('*', { count: 'exact', head: true });
setTotalUsers(total || 0);
// Get onboarded users count
const { count: onboarded } = await adminData
.from('profiles')
.select('*', { count: 'exact', head: true })
.eq('onboarding_completed', true);
setOnboardedUsers(onboarded || 0);
// Get usage reason breakdown
const { data: usageData } = await adminData
.from('profiles')
.select('usage_reason')
.not('usage_reason', 'is', null);
const usageCounts: Record<string, number> = {};
usageData?.forEach(profile => {
const reason = profile.usage_reason as string;
usageCounts[reason] = (usageCounts[reason] || 0) + 1;
});
const totalWithReason = Object.values(usageCounts).reduce((a, b) => a + b, 0);
const usageStatsArray: UsageReasonStat[] = Object.entries(usageCounts)
.map(([reason, count]) => ({
reason: reason as UsageReason,
count,
percentage: totalWithReason > 0 ? Math.round((count / totalWithReason) * 100) : 0,
}))
.sort((a, b) => b.count - a.count);
setUsageStats(usageStatsArray);
// Get gender breakdown
const { data: genderData } = await adminData
.from('profiles')
.select('gender')
.not('gender', 'is', null);
const genderCounts: Record<string, number> = {};
genderData?.forEach(profile => {
const gender = profile.gender as string;
genderCounts[gender] = (genderCounts[gender] || 0) + 1;
});
const totalWithGender = Object.values(genderCounts).reduce((a, b) => a + b, 0);
const genderStatsArray: GenderStat[] = Object.entries(genderCounts)
.map(([gender, count]) => ({
gender,
count,
percentage: totalWithGender > 0 ? Math.round((count / totalWithGender) * 100) : 0,
}))
.sort((a, b) => b.count - a.count);
setGenderStats(genderStatsArray);
} catch (error) {
console.error('Failed to fetch usage stats:', error);
} finally {
setLoading(false);
}
};
fetchStats();
}, []);
const chartData = usageStats.map(stat => ({
name: stat.reason ? usageReasonConfig[stat.reason]?.label || stat.reason : 'Unknown',
value: stat.count,
color: stat.reason ? usageReasonConfig[stat.reason]?.color : '#6b7280',
}));
const pieData = genderStats.map(stat => ({
name: stat.gender ? genderConfig[stat.gender]?.label || stat.gender : 'Unknown',
value: stat.count,
color: stat.gender ? genderConfig[stat.gender]?.color : '#6b7280',
}));
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
<Target className="h-8 w-8 text-primary" />
Usage Insights
</h1>
<p className="text-muted-foreground">
Understand why users are using Sauci based on onboarding responses
</p>
</div>
{/* Quick Stats */}
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Users</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
{loading ? (
<Skeleton className="h-8 w-16" />
) : (
<div className="text-2xl font-bold">{totalUsers.toLocaleString()}</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Completed Onboarding</CardTitle>
<TrendingUp className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
{loading ? (
<Skeleton className="h-8 w-16" />
) : (
<>
<div className="text-2xl font-bold">{onboardedUsers.toLocaleString()}</div>
<p className="text-xs text-muted-foreground">
{totalUsers > 0 ? Math.round((onboardedUsers / totalUsers) * 100) : 0}% of total users
</p>
</>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Response Rate</CardTitle>
<PieChart className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
{loading ? (
<Skeleton className="h-8 w-16" />
) : (
<>
<div className="text-2xl font-bold">
{usageStats.reduce((sum, s) => sum + s.count, 0).toLocaleString()}
</div>
<p className="text-xs text-muted-foreground">
users provided usage reason
</p>
</>
)}
</CardContent>
</Card>
</div>
<div className="grid gap-6 md:grid-cols-2">
{/* Usage Reasons Chart */}
<Card className="col-span-1">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Target className="h-5 w-5 text-primary" />
Why Users Join Sauci
</CardTitle>
<CardDescription>
Distribution of user motivations from onboarding
</CardDescription>
</CardHeader>
<CardContent>
{loading ? (
<Skeleton className="h-[300px] w-full" />
) : usageStats.length === 0 ? (
<div className="flex items-center justify-center h-[300px] text-muted-foreground">
No usage data available yet
</div>
) : (
<div className="h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData} layout="vertical" margin={{ left: 20 }}>
<XAxis type="number" />
<YAxis
dataKey="name"
type="category"
width={150}
tick={{ fontSize: 12 }}
/>
<Tooltip
contentStyle={{
borderRadius: '8px',
border: '1px solid #e2e8f0',
boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)',
}}
/>
<Bar dataKey="value" radius={[0, 4, 4, 0]}>
{chartData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
)}
</CardContent>
</Card>
{/* Gender Distribution */}
<Card className="col-span-1">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Users className="h-5 w-5 text-primary" />
Gender Distribution
</CardTitle>
<CardDescription>
User demographics from onboarding
</CardDescription>
</CardHeader>
<CardContent>
{loading ? (
<Skeleton className="h-[300px] w-full" />
) : genderStats.length === 0 ? (
<div className="flex items-center justify-center h-[300px] text-muted-foreground">
No gender data available yet
</div>
) : (
<div className="h-[300px] flex items-center justify-center">
<ResponsiveContainer width="100%" height="100%">
<RechartsPieChart>
<Pie
data={pieData}
cx="50%"
cy="50%"
innerRadius={60}
outerRadius={100}
paddingAngle={2}
dataKey="value"
label={({ name, percent }) =>
`${name}: ${((percent ?? 0) * 100).toFixed(0)}%`
}
>
{pieData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip
contentStyle={{
borderRadius: '8px',
border: '1px solid #e2e8f0',
boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)',
}}
/>
</RechartsPieChart>
</ResponsiveContainer>
</div>
)}
</CardContent>
</Card>
</div>
{/* Detailed Breakdown */}
<Card>
<CardHeader>
<CardTitle>Detailed Breakdown</CardTitle>
<CardDescription>
Individual statistics for each usage reason
</CardDescription>
</CardHeader>
<CardContent>
{loading ? (
<div className="space-y-3">
{[1, 2, 3, 4, 5].map(i => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
) : usageStats.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
No usage data available yet
</div>
) : (
<div className="space-y-4">
{usageStats.map(stat => {
const config = stat.reason ? usageReasonConfig[stat.reason] : null;
return (
<div
key={stat.reason}
className="flex items-center gap-4 p-4 rounded-lg border"
>
<div
className="p-3 rounded-lg"
style={{ backgroundColor: `${config?.color}20` }}
>
<div style={{ color: config?.color }}>
{config?.icon}
</div>
</div>
<div className="flex-1">
<p className="font-medium">{config?.label || stat.reason}</p>
<div className="flex items-center gap-2 mt-1">
<div className="flex-1 h-2 bg-muted rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all"
style={{
width: `${stat.percentage}%`,
backgroundColor: config?.color,
}}
/>
</div>
<span className="text-sm text-muted-foreground w-12 text-right">
{stat.percentage}%
</span>
</div>
</div>
<div className="text-right">
<p className="text-2xl font-bold">{stat.count}</p>
<p className="text-xs text-muted-foreground">users</p>
</div>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
</div>
);
}
@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback } from 'react';
import { Link } from 'react-router-dom';
import { supabase } from '@/config';
import { adminData, adminRequest } from '@/lib/adminApi';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
@@ -149,7 +149,7 @@ export function UserActivityPage() {
const from = (responsesPage - 1) * responsesPageSize;
const to = from + responsesPageSize - 1;
const { data, error, count } = await supabase
const { data, error, count } = await adminData
.from('responses')
.select(`
id, user_id, answer, created_at,
@@ -166,7 +166,7 @@ export function UserActivityPage() {
setResponsesTotal(count || 0);
// Transform data to match our types (Supabase returns nested objects, not arrays for 1:1 relations)
// Transform the API's nested relationship objects to the view model.
const transformed: ResponseActivity[] = (data || []).map((item: any) => ({
id: item.id,
user_id: item.user_id,
@@ -195,7 +195,7 @@ export function UserActivityPage() {
const from = (matchesPage - 1) * matchesPageSize;
const to = from + matchesPageSize - 1;
const { data: matchesData, error: matchesError, count } = await supabase
const { data: matchesData, error: matchesError, count } = await adminData
.from('matches')
.select(`
id, couple_id, match_type, is_new, created_at,
@@ -211,7 +211,7 @@ export function UserActivityPage() {
// Get unique couple IDs and fetch profiles
const coupleIds = [...new Set((matchesData || []).map((m: any) => m.couple_id))];
if (coupleIds.length > 0) {
const { data: profiles, error: profilesError } = await supabase
const { data: profiles, error: profilesError } = await adminData
.from('profiles')
.select('id, name, email, couple_id')
.in('couple_id', coupleIds);
@@ -257,7 +257,7 @@ export function UserActivityPage() {
const to = from + messagesPageSize - 1;
// Fetch messages with match data
const { data: messagesData, error: messagesError, count } = await supabase
const { data: messagesData, error: messagesError, count } = await adminData
.from('messages')
.select(`
id, user_id, content, media_path, media_type, created_at,
@@ -278,7 +278,7 @@ export function UserActivityPage() {
let profilesMap: Record<string, { id: string; name: string | null; email: string | null }> = {};
if (userIds.length > 0) {
const { data: profiles, error: profilesError } = await supabase
const { data: profiles, error: profilesError } = await adminData
.from('profiles')
.select('id, name, email')
.in('id', userIds);
@@ -319,10 +319,7 @@ export function UserActivityPage() {
setLoading(prev => ({ ...prev, signups: true }));
try {
// Use RPC to get profiles with auth info (includes email from auth.users)
const { data: allData, error } = await supabase
.rpc('get_profiles_with_auth_info');
if (error) throw error;
const { users: allData } = await adminRequest<{ users: SignupActivity[] }>('/v1/admin/users');
// Sort by created_at descending and apply pagination
const sorted = (allData || []).sort((a: SignupActivity, b: SignupActivity) =>
+11 -11
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback } from 'react';
import { supabase } from '@/config';
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
import { adminData } from '@/lib/adminApi';
import { auditedAdminData } from '@/hooks/useAuditedAdminData';
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
import { Button } from '@/components/ui/button';
@@ -82,7 +82,7 @@ export function AdminsPage() {
setLoading(true);
try {
// First fetch admin users with permissions
const { data: adminData, error: adminError } = await supabase
const { data: adminRows, error: adminError } = await adminData
.from('admin_users')
.select('id, user_id, role, permissions, created_at')
.order('created_at', { ascending: false });
@@ -91,9 +91,9 @@ export function AdminsPage() {
// Then fetch profiles for these users
// We do this manually to avoid relying on foreign key relationships if they aren't perfect
if (adminData && adminData.length > 0) {
const userIds = adminData.map(a => a.user_id);
const { data: profileData, error: profileError } = await supabase
if (adminRows && adminRows.length > 0) {
const userIds = adminRows.map(a => a.user_id);
const { data: profileData, error: profileError } = await adminData
.from('profiles')
.select('id, name, email, avatar_url')
.in('id', userIds);
@@ -101,7 +101,7 @@ export function AdminsPage() {
if (profileError) throw profileError;
// Merge data
const joinedAdmins = adminData.map(admin => ({
const joinedAdmins = adminRows.map(admin => ({
...admin,
permissions: (admin.permissions as string[]) || [],
profile: profileData?.find(p => p.id === admin.user_id)
@@ -149,7 +149,7 @@ export function AdminsPage() {
// Determine which users are already admins to exclude them
const adminUserIds = new Set(admins.map(a => a.user_id));
const { data, error } = await supabase
const { data, error } = await adminData
.from('profiles')
.select('id, name, email, avatar_url')
.or(`email.ilike.%${userSearch}%,name.ilike.%${userSearch}%`)
@@ -179,7 +179,7 @@ export function AdminsPage() {
const role: AdminRole = isSuperAdminChecked ? 'super_admin' : 'pack_creator';
const permissions = isSuperAdminChecked ? [] : selectedPermissions;
const { error } = await auditedSupabase.insert('admin_users', {
const { error } = await auditedAdminData.insert('admin_users', {
user_id: selectedUser.id,
role,
permissions
@@ -219,7 +219,7 @@ export function AdminsPage() {
const role: AdminRole = editIsSuperAdmin ? 'super_admin' : 'pack_creator';
const permissions = editIsSuperAdmin ? [] : editPermissions;
const { error } = await auditedSupabase.update('admin_users', editingAdmin.id, {
const { error } = await auditedAdminData.update('admin_users', editingAdmin.id, {
role,
permissions
});
@@ -261,7 +261,7 @@ export function AdminsPage() {
}
try {
const { error } = await auditedSupabase.deleteBy('admin_users', 'user_id', admin.user_id);
const { error } = await auditedAdminData.deleteBy('admin_users', 'user_id', admin.user_id);
if (error) throw error;
+10 -10
View File
@@ -1,7 +1,7 @@
import { useEffect, useState, useCallback } from 'react';
import { Link } from 'react-router-dom';
import { supabase } from '@/config';
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
import { adminData } from '@/lib/adminApi';
import { auditedAdminData } from '@/hooks/useAuditedAdminData';
import { useEntityForm } from '@/hooks/useEntityForm';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
@@ -215,7 +215,7 @@ export function CategoriesPage() {
const from = (page - 1) * pageSize;
const to = from + pageSize - 1;
const { data: cats, error, count } = await supabase
const { data: cats, error, count } = await adminData
.from('categories')
.select('*', { count: 'exact' })
.order('sort_order', { ascending: true })
@@ -231,11 +231,11 @@ export function CategoriesPage() {
if (categoryIds.length > 0) {
const [{ data: packs }, { data: darePacks }] = await Promise.all([
supabase
adminData
.from('question_packs')
.select('category_id')
.in('category_id', categoryIds),
supabase
adminData
.from('dare_packs')
.select('category_id')
.in('category_id', categoryIds),
@@ -310,11 +310,11 @@ export function CategoriesPage() {
};
if (form.editingItem) {
const { error } = await auditedSupabase.update('categories', form.editingItem.id, data);
const { error } = await auditedAdminData.update('categories', form.editingItem.id, data);
if (error) throw error;
toast.success('Category updated');
} else {
const { error } = await auditedSupabase.insert('categories', {
const { error } = await auditedAdminData.insert('categories', {
...data,
sort_order: totalCount,
});
@@ -338,7 +338,7 @@ export function CategoriesPage() {
}
try {
const { error } = await auditedSupabase.delete('categories', category.id);
const { error } = await auditedAdminData.delete('categories', category.id);
if (error) throw error;
toast.success('Category deleted');
fetchCategories();
@@ -355,7 +355,7 @@ export function CategoriesPage() {
c.id === category.id ? { ...c, is_public: !c.is_public } : c
));
const { error } = await auditedSupabase.update('categories', category.id, {
const { error } = await auditedAdminData.update('categories', category.id, {
is_public: !category.is_public
});
@@ -399,7 +399,7 @@ export function CategoriesPage() {
// Persist new order to database
try {
const updates = newCategories.map((category, index) =>
auditedSupabase.update('categories', category.id, { sort_order: index })
auditedAdminData.update('categories', category.id, { sort_order: index })
);
const results = await Promise.all(updates);
+11 -11
View File
@@ -1,7 +1,7 @@
import { useEffect, useState, useCallback } from 'react';
import { Link, useParams } from 'react-router-dom';
import { supabase } from '@/config';
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
import { adminData } from '@/lib/adminApi';
import { auditedAdminData } from '@/hooks/useAuditedAdminData';
import { useEntityForm } from '@/hooks/useEntityForm';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
@@ -116,7 +116,7 @@ export function DarePacksPage() {
// Fetch category info
if (categoryId) {
const { data: categoryData } = await supabase
const { data: categoryData } = await adminData
.from('categories')
.select('id, name, icon')
.eq('id', categoryId)
@@ -127,7 +127,7 @@ export function DarePacksPage() {
}
// Fetch dare packs
let query = supabase
let query = adminData
.from('dare_packs')
.select('*', { count: 'exact' })
.order('sort_order', { ascending: true })
@@ -148,7 +148,7 @@ export function DarePacksPage() {
const dareCounts: Record<string, number> = {};
if (packIds.length > 0) {
const { data: dares } = await supabase
const { data: dares } = await adminData
.from('dares')
.select('pack_id')
.in('pack_id', packIds);
@@ -159,7 +159,7 @@ export function DarePacksPage() {
}
// Fetch categories
const { data: categoryData } = await supabase
const { data: categoryData } = await adminData
.from('categories')
.select('id, name')
.order('name');
@@ -208,7 +208,7 @@ export function DarePacksPage() {
form.setSaving(true);
try {
if (form.editingItem) {
const { error } = await auditedSupabase.update('dare_packs', form.editingItem.id, {
const { error } = await auditedAdminData.update('dare_packs', form.editingItem.id, {
name: form.formData.name,
description: form.formData.description || null,
icon: form.formData.icon || null,
@@ -220,7 +220,7 @@ export function DarePacksPage() {
if (error) throw error;
toast.success('Dare pack updated');
} else {
const { error } = await auditedSupabase.insert('dare_packs', {
const { error } = await auditedAdminData.insert('dare_packs', {
name: form.formData.name,
description: form.formData.description || null,
icon: form.formData.icon || null,
@@ -250,7 +250,7 @@ export function DarePacksPage() {
}
try {
const { error } = await auditedSupabase.delete('dare_packs', pack.id);
const { error } = await auditedAdminData.delete('dare_packs', pack.id);
if (error) throw error;
toast.success('Dare pack deleted');
fetchData();
@@ -273,8 +273,8 @@ export function DarePacksPage() {
const targetOrder = targetPack.sort_order ?? targetIndex;
const [currentUpdate, targetUpdate] = await Promise.all([
auditedSupabase.update('dare_packs', pack.id, { sort_order: targetOrder }),
auditedSupabase.update('dare_packs', targetPack.id, { sort_order: currentOrder }),
auditedAdminData.update('dare_packs', pack.id, { sort_order: targetOrder }),
auditedAdminData.update('dare_packs', targetPack.id, { sort_order: currentOrder }),
]);
if (currentUpdate.error || targetUpdate.error) {
+8 -8
View File
@@ -1,7 +1,7 @@
import { useState, useEffect, useCallback } from 'react';
import { useParams } from 'react-router-dom';
import { supabase } from '@/config';
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
import { adminData } from '@/lib/adminApi';
import { auditedAdminData } from '@/hooks/useAuditedAdminData';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
@@ -110,7 +110,7 @@ export function DaresPage() {
setLoading(true);
try {
// Fetch pack info
const { data: packData } = await supabase
const { data: packData } = await adminData
.from('dare_packs')
.select('id, name, description, icon')
.eq('id', packId)
@@ -122,7 +122,7 @@ export function DaresPage() {
const to = from + pageSize - 1;
// Fetch dares
const { data: dareData, error, count } = await supabase
const { data: dareData, error, count } = await adminData
.from('dares')
.select('*', { count: 'exact' })
.eq('pack_id', packId)
@@ -191,11 +191,11 @@ export function DaresPage() {
};
if (editingDare) {
const { error } = await auditedSupabase.update('dares', editingDare.id, dareData);
const { error } = await auditedAdminData.update('dares', editingDare.id, dareData);
if (error) throw error;
toast.success('Dare updated');
} else {
const { error } = await auditedSupabase.insert('dares', {
const { error } = await auditedAdminData.insert('dares', {
...dareData,
pack_id: packId,
});
@@ -219,7 +219,7 @@ export function DaresPage() {
}
try {
const { error } = await auditedSupabase.delete('dares', dare.id);
const { error } = await auditedAdminData.delete('dares', dare.id);
if (error) throw error;
toast.success('Dare deleted');
fetchData();
@@ -259,7 +259,7 @@ export function DaresPage() {
setBulkDeleting(true);
try {
await Promise.all(
Array.from(selectedIds).map(id => auditedSupabase.delete('dares', id))
Array.from(selectedIds).map(id => auditedAdminData.delete('dares', id))
);
toast.success(`${count} dare${count !== 1 ? 's' : ''} deleted`);
setSelectedIds(new Set());
+15 -15
View File
@@ -1,7 +1,7 @@
import { useState, useEffect, useCallback } from 'react';
import { Link, useParams } from 'react-router-dom';
import { supabase } from '@/config';
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
import { adminData } from '@/lib/adminApi';
import { auditedAdminData } from '@/hooks/useAuditedAdminData';
import { useEntityForm } from '@/hooks/useEntityForm';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
@@ -103,7 +103,7 @@ export function PacksPage() {
try {
// Fetch category info
if (categoryId) {
const { data: cat } = await supabase
const { data: cat } = await adminData
.from('categories')
.select('id, name, icon')
.eq('id', categoryId)
@@ -111,7 +111,7 @@ export function PacksPage() {
setCategory(cat);
}
const { data: categoriesData } = await supabase
const { data: categoriesData } = await adminData
.from('categories')
.select('id, name')
.order('name');
@@ -121,7 +121,7 @@ export function PacksPage() {
const to = from + pageSize - 1;
// Fetch packs
let query = supabase
let query = adminData
.from('question_packs')
.select('*', { count: 'exact' })
.order('sort_order', { ascending: true })
@@ -141,7 +141,7 @@ export function PacksPage() {
const questionCounts: Record<string, number> = {};
if (packIds.length > 0) {
const { data: questions } = await supabase
const { data: questions } = await adminData
.from('questions')
.select('pack_id')
.in('pack_id', packIds);
@@ -152,7 +152,7 @@ export function PacksPage() {
}
// Fetch all topics
const { data: topicsData } = await supabase
const { data: topicsData } = await adminData
.from('topics')
.select('id, name')
.order('name');
@@ -161,7 +161,7 @@ export function PacksPage() {
// Fetch pack_topics relationships
let packTopicsData: any[] = [];
if (packIds.length > 0) {
const { data } = await supabase
const { data } = await adminData
.from('pack_topics')
.select('pack_id, topic_id, topics(id, name)')
.in('pack_id', packIds);
@@ -236,7 +236,7 @@ export function PacksPage() {
? true
: (form.formData.scheduled_release_at ? false : undefined);
const { error } = await auditedSupabase.update('question_packs', form.editingItem.id, {
const { error } = await auditedAdminData.update('question_packs', form.editingItem.id, {
name: form.formData.name,
description: form.formData.description || null,
icon: form.formData.icon || null,
@@ -254,7 +254,7 @@ export function PacksPage() {
if (error) throw error;
packId = form.editingItem.id;
} else {
const { data, error } = await supabase
const { data, error } = await adminData
.from('question_packs')
.insert({
name: form.formData.name,
@@ -279,10 +279,10 @@ export function PacksPage() {
}
// Update topics
await supabase.from('pack_topics').delete().eq('pack_id', packId);
await adminData.from('pack_topics').delete().eq('pack_id', packId);
if (selectedTopicIds.size > 0) {
const { error: topicsError } = await supabase
const { error: topicsError } = await adminData
.from('pack_topics')
.insert(
Array.from(selectedTopicIds).map(topicId => ({
@@ -310,7 +310,7 @@ export function PacksPage() {
}
try {
const { error } = await auditedSupabase.delete('question_packs', pack.id);
const { error } = await auditedAdminData.delete('question_packs', pack.id);
if (error) throw error;
toast.success('Pack deleted');
fetchData();
@@ -351,8 +351,8 @@ export function PacksPage() {
const targetOrder = targetPack.sort_order ?? targetIndex;
const [currentUpdate, targetUpdate] = await Promise.all([
auditedSupabase.update('question_packs', pack.id, { sort_order: targetOrder }),
auditedSupabase.update('question_packs', targetPack.id, { sort_order: currentOrder }),
auditedAdminData.update('question_packs', pack.id, { sort_order: targetOrder }),
auditedAdminData.update('question_packs', targetPack.id, { sort_order: currentOrder }),
]);
if (currentUpdate.error || targetUpdate.error) {
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { supabase } from '@/config';
import { adminData } from '@/lib/adminApi';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { Badge } from '@/components/ui/badge';
@@ -126,7 +126,7 @@ export function QuestionAnalyticsPage() {
while (true) {
const from = page * pageSize;
const to = from + pageSize - 1;
const { data, error } = await supabase
const { data, error } = await adminData
.from('questions')
.select('id, pack_id, intensity, partner_text, inverse_of, question_type')
.range(from, to);
@@ -150,7 +150,7 @@ export function QuestionAnalyticsPage() {
setLoading(true);
try {
// Build pack query with optional is_public filter
let packQuery = supabase
let packQuery = adminData
.from('question_packs')
.select('id, name, is_premium, is_explicit, is_public, category_id, category:categories(name)');
@@ -162,11 +162,11 @@ export function QuestionAnalyticsPage() {
const [packsResult, topicsResult, packTopicsResult, questions] = await Promise.all([
packQuery,
supabase
adminData
.from('topics')
.select('id, name')
.order('name'),
supabase
adminData
.from('pack_topics')
.select('pack_id, topic_id, topics(id, name)'),
fetchAllQuestions(),
+16 -16
View File
@@ -1,7 +1,7 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useParams } from 'react-router-dom';
import { supabase } from '@/config';
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
import { adminData } from '@/lib/adminApi';
import { auditedAdminData } from '@/hooks/useAuditedAdminData';
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
import { Button } from '@/components/ui/button';
@@ -150,12 +150,12 @@ export function QuestionsPage() {
const from = (page - 1) * pageSize;
const to = from + pageSize - 1;
const packPromise = supabase
const packPromise = adminData
.from('question_packs')
.select('id, name, description, icon')
.eq('id', packId)
.single();
let questionsQuery = supabase
let questionsQuery = adminData
.from('questions')
.select('*', { count: 'exact' })
.eq('pack_id', packId)
@@ -167,7 +167,7 @@ export function QuestionsPage() {
}
const questionsPromise = questionsQuery;
const allPackPromise = supabase
const allPackPromise = adminData
.from('questions')
.select('id, text, inverse_of')
.eq('pack_id', packId)
@@ -250,7 +250,7 @@ export function QuestionsPage() {
// Fetch all questions in pack for inverse_of dropdown
const fetchAllPackQuestions = useCallback(async () => {
if (!packId) return;
const { data } = await supabase
const { data } = await adminData
.from('questions')
.select('id, text, inverse_of')
.eq('pack_id', packId)
@@ -317,7 +317,7 @@ export function QuestionsPage() {
setSaving(true);
try {
if (editingQuestion) {
const { error } = await auditedSupabase.update('questions', editingQuestion.id, {
const { error } = await auditedAdminData.update('questions', editingQuestion.id, {
text: formData.text,
partner_text: formData.partner_text || null,
allowed_couple_genders: formData.allowed_couple_genders.length > 0 ? formData.allowed_couple_genders : null,
@@ -331,7 +331,7 @@ export function QuestionsPage() {
if (error) throw error;
toast.success('Question updated');
} else {
const { error } = await auditedSupabase.insert('questions', {
const { error } = await auditedAdminData.insert('questions', {
pack_id: packId,
text: formData.text,
partner_text: formData.partner_text || null,
@@ -363,7 +363,7 @@ export function QuestionsPage() {
}
try {
const { error } = await auditedSupabase.delete('questions', question.id);
const { error } = await auditedAdminData.delete('questions', question.id);
if (error) throw error;
toast.success('Question deleted');
@@ -396,7 +396,7 @@ export function QuestionsPage() {
// Insert standalone questions first (no inverse_of)
if (standaloneQuestions.length > 0) {
const { error: standaloneError } = await auditedSupabase.insert(
const { error: standaloneError } = await auditedAdminData.insert(
'questions',
standaloneQuestions.map(q => ({
pack_id: packId,
@@ -415,7 +415,7 @@ export function QuestionsPage() {
if (pair.length >= 2) {
// First question is primary (inverse_of = NULL)
const primary = pair[0];
const { data: primaryData, error: primaryError } = await auditedSupabase.insert(
const { data: primaryData, error: primaryError } = await auditedAdminData.insert(
'questions',
[{
pack_id: packId,
@@ -432,7 +432,7 @@ export function QuestionsPage() {
const primaryId = primaryData?.[0]?.id;
if (primaryId) {
const inverse = pair[1];
const { error: inverseError } = await auditedSupabase.insert(
const { error: inverseError } = await auditedAdminData.insert(
'questions',
[{
pack_id: packId,
@@ -449,7 +449,7 @@ export function QuestionsPage() {
// Handle any additional questions in the pair (shouldn't happen, but just in case)
for (let i = 2; i < pair.length; i++) {
const extra = pair[i];
const { error: extraError } = await auditedSupabase.insert(
const { error: extraError } = await auditedAdminData.insert(
'questions',
[{
pack_id: packId,
@@ -464,7 +464,7 @@ export function QuestionsPage() {
}
} else if (pair.length === 1) {
// Single question with pair_id but no pair - treat as standalone
const { error } = await auditedSupabase.insert(
const { error } = await auditedAdminData.insert(
'questions',
[{
pack_id: packId,
@@ -497,7 +497,7 @@ export function QuestionsPage() {
setReviewLoading(true);
try {
const { data, error } = await supabase
const { data, error } = await adminData
.from('questions')
.select('id, pack_id, text, partner_text, intensity, allowed_couple_genders, target_user_genders, required_props, question_type, created_at')
.eq('pack_id', packId)
@@ -544,7 +544,7 @@ export function QuestionsPage() {
setBulkDeleting(true);
try {
await Promise.all(
Array.from(selectedIds).map(id => auditedSupabase.delete('questions', id))
Array.from(selectedIds).map(id => auditedAdminData.delete('questions', id))
);
toast.success(`${count} question${count !== 1 ? 's' : ''} deleted`);
setSelectedIds(new Set());
+10 -10
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from 'react';
import { supabase } from '@/config';
import { adminData } from '@/lib/adminApi';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { Badge } from '@/components/ui/badge';
@@ -64,9 +64,9 @@ export function TagsPage() {
setLoading(true);
try {
const [topicsResult, packTopicsResult, packsResult] = await Promise.all([
supabase.from('topics').select('id, name').order('name'),
supabase.from('pack_topics').select('pack_id, topic_id'),
supabase
adminData.from('topics').select('id, name').order('name'),
adminData.from('pack_topics').select('pack_id, topic_id'),
adminData
.from('question_packs')
.select('id, name, description, is_public, category:categories(name)')
.order('name'),
@@ -106,7 +106,7 @@ export function TagsPage() {
const questionCounts: Record<string, number> = {};
if (untaggedPackIds.length > 0) {
const { data: questions, error: questionsError } = await supabase
const { data: questions, error: questionsError } = await adminData
.from('questions')
.select('pack_id')
.in('pack_id', untaggedPackIds);
@@ -167,7 +167,7 @@ export function TagsPage() {
setTaggingProgress({ current: 0, total: eligiblePacks.length, packName: '' });
try {
const { data: topicsData, error: topicsError } = await supabase
const { data: topicsData, error: topicsError } = await adminData
.from('topics')
.select('id, name')
.order('name');
@@ -188,7 +188,7 @@ export function TagsPage() {
});
try {
const { data: questions, error: questionsError } = await supabase
const { data: questions, error: questionsError } = await adminData
.from('questions')
.select('text')
.eq('pack_id', pack.id);
@@ -237,7 +237,7 @@ export function TagsPage() {
});
if (newTopics.length > 0) {
const { data: createdTopics, error: createError } = await supabase
const { data: createdTopics, error: createError } = await adminData
.from('topics')
.insert(newTopics)
.select('id, name');
@@ -250,14 +250,14 @@ export function TagsPage() {
});
}
await supabase.from('pack_topics').delete().eq('pack_id', pack.id);
await adminData.from('pack_topics').delete().eq('pack_id', pack.id);
if (topicIds.length === 0) {
skippedCount += 1;
continue;
}
const { error: linkError } = await supabase
const { error: linkError } = await adminData
.from('pack_topics')
.insert(topicIds.map((topicId) => ({
pack_id: pack.id,
+12 -57
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback } from 'react';
import { useParams, Link } from 'react-router-dom';
import { supabase, supabaseConfig } from '@/config';
import { adminData, adminRequest, adminBinaryRequest } from '@/lib/adminApi';
import { useAuth } from '@/contexts/AuthContext';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
@@ -87,29 +87,9 @@ function ChatImage({ messageId }: { messageId: string }) {
setError(null);
setUrl(null);
const res = await fetch(`${supabaseConfig.url}/functions/v1/admin-decrypt-media`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
apikey: supabaseConfig.anonKey,
Authorization: `Bearer ${session.access_token}`,
},
body: JSON.stringify({ messageId }),
const blob = await adminBinaryRequest('/v1/admin/actions/decrypt-media', {
method: 'POST', body: { messageId },
});
if (!res.ok) {
const raw = await res.text().catch(() => '');
let message = raw;
try {
const parsed = JSON.parse(raw) as { error?: unknown };
if (typeof parsed?.error === 'string') message = parsed.error;
} catch {
// ignore
}
throw new Error(message || `Failed to load media (${res.status})`);
}
const blob = await res.blob();
objectUrl = URL.createObjectURL(blob);
if (cancelled) return;
@@ -178,29 +158,9 @@ function ChatVideo({ messageId }: { messageId: string }) {
setError(null);
setUrl(null);
const res = await fetch(`${supabaseConfig.url}/functions/v1/admin-decrypt-media`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
apikey: supabaseConfig.anonKey,
Authorization: `Bearer ${session.access_token}`,
},
body: JSON.stringify({ messageId }),
const blob = await adminBinaryRequest('/v1/admin/actions/decrypt-media', {
method: 'POST', body: { messageId },
});
if (!res.ok) {
const raw = await res.text().catch(() => '');
let message = raw;
try {
const parsed = JSON.parse(raw) as { error?: unknown };
if (typeof parsed?.error === 'string') message = parsed.error;
} catch {
// ignore
}
throw new Error(message || `Failed to load media (${res.status})`);
}
const blob = await res.blob();
objectUrl = URL.createObjectURL(blob);
if (cancelled) return;
@@ -287,18 +247,13 @@ function DecryptedMessageText({ message }: { message: Message }) {
const decrypt = async () => {
setIsDecrypting(true);
try {
const { data, error } = await supabase.functions.invoke('admin-decrypt-message', {
body: { messageId: message.id },
const data = await adminRequest<{ content?: string | null }>('/v1/admin/actions/decrypt-message', {
method: 'POST', body: { messageId: message.id },
});
if (cancelled) return;
if (error) {
setText('[Unable to decrypt]');
return;
}
setText((data as { content?: string | null } | null)?.content ?? null);
setText(data.content ?? null);
} catch (err) {
if (cancelled) return;
setText('[Unable to decrypt]');
@@ -342,7 +297,7 @@ export function MatchChatPage() {
setLoading(true);
try {
// Fetch user profile
const { data: userData } = await supabase
const { data: userData } = await adminData
.from('profiles')
.select('id, name, avatar_url, couple_id')
.eq('id', userId)
@@ -352,7 +307,7 @@ export function MatchChatPage() {
// Fetch partner
if (userData?.couple_id) {
const { data: partnerData } = await supabase
const { data: partnerData } = await adminData
.from('profiles')
.select('id, name, avatar_url')
.eq('couple_id', userData.couple_id)
@@ -365,7 +320,7 @@ export function MatchChatPage() {
}
// Fetch match with question
const { data: matchData } = await supabase
const { data: matchData } = await adminData
.from('matches')
.select(`
id, match_type,
@@ -380,7 +335,7 @@ export function MatchChatPage() {
const to = from + pageSize - 1;
// Fetch messages
const { data: messageData, error: messagesError, count } = await supabase
const { data: messageData, error: messagesError, count } = await adminData
.from('messages')
.select('*', { count: 'exact' })
.eq('match_id', matchId)
+33 -59
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { useParams, Link } from 'react-router-dom';
import { supabase, supabaseConfig } from '@/config';
import { adminData, adminRequest, adminBinaryRequest } from '@/lib/adminApi';
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
import { useAuth, PERMISSION_KEYS } from '@/contexts/AuthContext';
@@ -190,16 +190,9 @@ function AdminDecryptedImage({ messageId, alt }: { messageId: string; alt: strin
const load = async () => {
try {
setError(null);
const res = await fetch(`${supabaseConfig.url}/functions/v1/admin-decrypt-media`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', apikey: supabaseConfig.anonKey, Authorization: `Bearer ${session.access_token}` },
body: JSON.stringify({ messageId }),
const blob = await adminBinaryRequest('/v1/admin/actions/decrypt-media', {
method: 'POST', body: { messageId },
});
if (!res.ok) {
const message = (await res.json().catch(() => ({})))?.error || `Failed to load media (${res.status})`;
throw new Error(message);
}
const blob = await res.blob();
objectUrl = URL.createObjectURL(blob);
if (!cancelled) setUrl(objectUrl);
} catch (err) {
@@ -246,16 +239,9 @@ function AdminDecryptedVideo({ messageId }: { messageId: string }) {
const load = async () => {
try {
setError(null);
const res = await fetch(`${supabaseConfig.url}/functions/v1/admin-decrypt-media`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', apikey: supabaseConfig.anonKey, Authorization: `Bearer ${session.access_token}` },
body: JSON.stringify({ messageId }),
const blob = await adminBinaryRequest('/v1/admin/actions/decrypt-media', {
method: 'POST', body: { messageId },
});
if (!res.ok) {
const message = (await res.json().catch(() => ({})))?.error || `Failed to load media (${res.status})`;
throw new Error(message);
}
const blob = await res.blob();
objectUrl = URL.createObjectURL(blob);
if (!cancelled) setUrl(objectUrl);
} catch (err) {
@@ -308,7 +294,7 @@ function LiveDrawCanvas({ coupleId }: { coupleId: string }) {
const fetchStrokes = async () => {
setLoading(true);
setError(null);
const { data, error: err } = await supabase
const { data, error: err } = await adminData
.from('live_draw_sessions')
.select('strokes')
.eq('couple_id', coupleId)
@@ -322,26 +308,14 @@ function LiveDrawCanvas({ coupleId }: { coupleId: string }) {
setStrokes((data?.strokes as StrokeSegment[]) || []);
setLoading(false);
};
fetchStrokes();
// Subscribe to real-time updates
const channel = supabase
.channel(`admin-livedraw-${coupleId}`)
.on('postgres_changes', {
event: '*',
schema: 'public',
table: 'live_draw_sessions',
filter: `couple_id=eq.${coupleId}`,
}, (payload: any) => {
if (payload.new?.strokes) {
setStrokes(payload.new.strokes as StrokeSegment[]);
}
})
.subscribe();
void fetchStrokes();
const interval = window.setInterval(() => {
if (document.visibilityState === 'visible') void fetchStrokes();
}, 5_000);
return () => {
cancelled = true;
supabase.removeChannel(channel);
window.clearInterval(interval);
};
}, [coupleId]);
@@ -482,13 +456,12 @@ export function UserDetailPage() {
}
expiresAt = date.toISOString();
}
// Use RPC function which handles permissions internally
const { error } = await supabase.rpc('admin_gift_premium', {
target_user_id: userId,
expires_at_param: expiresAt,
const days = expiresAt === null ? null : Math.max(1, Math.ceil((new Date(expiresAt).getTime() - now.getTime()) / 86_400_000));
await adminRequest(`/v1/admin/users/${encodeURIComponent(userId)}/gift-premium`, {
method: 'POST', body: { days, reason: 'Granted from admin user detail' },
});
if (error) throw error;
const { data: updatedProfile } = await supabase.rpc('get_profile_with_auth_info', { user_id: userId }).single<Profile>();
const { users } = await adminRequest<{ users: Profile[] }>(`/v1/admin/users?userId=${encodeURIComponent(userId)}`);
const updatedProfile = users[0] ?? null;
if (updatedProfile) setProfile(updatedProfile);
setUpgradeOpen(false);
alert('User upgraded to premium successfully!');
@@ -504,11 +477,12 @@ export function UserDetailPage() {
if (!userId) return;
setLoading(true);
try {
const { data: profileData } = await supabase.rpc('get_profile_with_auth_info', { user_id: userId }).single<Profile>();
const { users } = await adminRequest<{ users: Profile[] }>(`/v1/admin/users?userId=${encodeURIComponent(userId)}`);
const profileData = users[0] ?? null;
setProfile(profileData);
// Fetch feature interests for the user
const { data: featureInterestsData } = await supabase
const { data: featureInterestsData } = await adminData
.from('feature_interests')
.select('id, feature_name, created_at')
.eq('user_id', userId)
@@ -516,11 +490,11 @@ export function UserDetailPage() {
setFeatureInterests((featureInterestsData || []) as FeatureInterest[]);
if (profileData?.couple_id) {
const { data: partnerData } = await supabase.from('profiles').select('id, name').eq('couple_id', profileData.couple_id).neq('id', userId).single();
const { data: partnerData } = await adminData.from('profiles').select('id, name').eq('couple_id', profileData.couple_id).neq('id', userId).single();
setPartner(partnerData);
// Fetch streak data
const { data: streakData } = await supabase
const { data: streakData } = await adminData
.from('couple_streaks')
.select('*')
.eq('couple_id', profileData.couple_id)
@@ -534,7 +508,7 @@ export function UserDetailPage() {
const responsesFrom = (responsesPage - 1) * responsesPageSize;
const responsesTo = responsesFrom + responsesPageSize - 1;
const { data: responseData, count: responsesCount, error: responsesError } = await supabase
const { data: responseData, count: responsesCount, error: responsesError } = await adminData
.from('responses')
.select('id, answer, created_at, question:questions(id, text, pack:question_packs(id, name))', { count: 'exact' })
.eq('user_id', userId)
@@ -550,7 +524,7 @@ export function UserDetailPage() {
const matchesFrom = (matchesPage - 1) * matchesPageSize;
const matchesTo = matchesFrom + matchesPageSize - 1;
const { data: matchData, count: matchesCount, error: matchesError } = await supabase
const { data: matchData, count: matchesCount, error: matchesError } = await adminData
.from('matches')
.select('id, match_type, is_new, created_at, question:questions(id, text)', { count: 'exact' })
.eq('couple_id', profileData.couple_id)
@@ -563,7 +537,7 @@ export function UserDetailPage() {
const matchIds = (matchData || []).map(m => m.id);
if (matchIds.length > 0) {
const { data: messages } = await supabase.from('messages').select('match_id').in('match_id', matchIds);
const { data: messages } = await adminData.from('messages').select('match_id').in('match_id', matchIds);
const messageCounts: Record<string, number> = {};
messages?.forEach(m => { messageCounts[m.match_id] = (messageCounts[m.match_id] || 0) + 1; });
setMatches((matchData || []).map(m => ({ ...m, message_count: messageCounts[m.id] || 0 })) as unknown as Match[]);
@@ -571,7 +545,7 @@ export function UserDetailPage() {
setMatches([]);
}
const { data: allMatchIdsData } = await supabase
const { data: allMatchIdsData } = await adminData
.from('matches')
.select('id')
.eq('couple_id', profileData.couple_id);
@@ -581,7 +555,7 @@ export function UserDetailPage() {
const mediaFrom = (mediaPage - 1) * mediaPageSize;
const mediaTo = mediaFrom + mediaPageSize - 1;
const { data: mediaMessagesData, error: mediaError, count: mediaCount } = await supabase
const { data: mediaMessagesData, error: mediaError, count: mediaCount } = await adminData
.from('messages')
.select('id, user_id, media_path, media_type, version, created_at, keys_metadata, match_id', { count: 'exact' })
.in('match_id', allMatchIds)
@@ -602,7 +576,7 @@ export function UserDetailPage() {
setMediaTotal(0);
}
// Fetch enabled packs for the couple
const { data: enabledPacksData, error: packsError } = await supabase
const { data: enabledPacksData, error: packsError } = await adminData
.from('couple_packs')
.select(`
pack_id,
@@ -631,17 +605,17 @@ export function UserDetailPage() {
// Fetch outstanding questions (partner answered, user hasn't)
// Use partner from state (set earlier in the first couple_id block)
const partnerProfile = partner || (await supabase.from('profiles').select('id, name').eq('couple_id', profileData.couple_id).neq('id', userId).maybeSingle()).data;
const partnerProfile = partner || (await adminData.from('profiles').select('id, name').eq('couple_id', profileData.couple_id).neq('id', userId).maybeSingle()).data;
if (partnerProfile) {
// Get question IDs user has already answered
const { data: userResponseIds } = await supabase
const { data: userResponseIds } = await adminData
.from('responses')
.select('question_id')
.eq('user_id', userId);
const answeredIds = new Set((userResponseIds || []).map((r: any) => r.question_id));
// Get all partner responses in this couple
const { data: partnerResponses } = await supabase
const { data: partnerResponses } = await adminData
.from('responses')
.select(`
question_id,
@@ -658,7 +632,7 @@ export function UserDetailPage() {
if (unansweredQuestionIds.length > 0) {
// Fetch question details (no pack filter — admin sees all)
const { data: questionDetails } = await supabase
const { data: questionDetails } = await adminData
.from('questions')
.select('id, text, intensity, pack:question_packs(id, name)')
.in('id', unansweredQuestionIds);
@@ -690,7 +664,7 @@ export function UserDetailPage() {
}
// Calculate gap status
const { data: appConfig } = await supabase
const { data: appConfig } = await adminData
.from('app_config')
.select('answer_gap_threshold')
.limit(1)
@@ -702,7 +676,7 @@ export function UserDetailPage() {
if (threshold > 0) {
// Count questions user answered that partner hasn't
const partnerAllAnswered = new Set((partnerResponses || []).map((r: any) => r.question_id));
const { data: userResponses } = await supabase
const { data: userResponses } = await adminData
.from('responses')
.select('question_id')
.eq('user_id', userId)
+3 -20
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback } from 'react';
import { Link } from 'react-router-dom';
import { supabase } from '@/config';
import { adminRequest } from '@/lib/adminApi';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
@@ -55,24 +55,7 @@ export function UsersPage() {
setLoading(true);
try {
// Fetch profiles with auth info (includes last_sign_in_at)
const { data: profilesDataRaw, error } = await supabase
.rpc('get_profiles_with_auth_info');
if (error) throw error;
const profilesData = (profilesDataRaw || []) as Profile[];
// Fetch storage usage per user from storage.objects
const { data: storageData } = await supabase
.rpc('get_user_storage_usage');
// Create a map of user_id -> storage_bytes
const storageMap: Record<string, number> = {};
if (storageData) {
storageData.forEach((row: { owner: string; total_bytes: number }) => {
storageMap[row.owner] = row.total_bytes;
});
}
const { users: profilesData } = await adminRequest<{ users: Profile[] }>('/v1/admin/users');
// Build a map of couple_id -> users with premium in that couple
const couplePartners: Record<string, { id: string; is_premium: boolean }[]> = {};
@@ -100,7 +83,7 @@ export function UsersPage() {
}
return {
...profile,
storage_bytes: storageMap[profile.id] || 0,
storage_bytes: Number(profile.storage_bytes || 0),
partner_is_premium: partnerIsPremium,
partner_count: partnerCount,
};
+10 -10
View File
@@ -1,5 +1,5 @@
import { supabase } from '@/config';
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
import { adminData } from '@/lib/adminApi';
import { auditedAdminData } from '@/hooks/useAuditedAdminData';
// =============================================================================
// Types
@@ -32,7 +32,7 @@ export interface CategoryFormData {
*/
export async function fetchCategories(): Promise<Category[]> {
// Fetch categories
const { data: categories, error: catError } = await supabase
const { data: categories, error: catError } = await adminData
.from('categories')
.select('*')
.order('sort_order', { ascending: true });
@@ -40,7 +40,7 @@ export async function fetchCategories(): Promise<Category[]> {
if (catError) throw catError;
// Fetch pack counts per category
const { data: packs } = await supabase
const { data: packs } = await adminData
.from('question_packs')
.select('category_id');
@@ -61,7 +61,7 @@ export async function fetchCategories(): Promise<Category[]> {
* Fetch a single category by ID
*/
export async function fetchCategoryById(id: string): Promise<Category | null> {
const { data, error } = await supabase
const { data, error } = await adminData
.from('categories')
.select('*')
.eq('id', id)
@@ -82,7 +82,7 @@ export async function createCategory(
data: CategoryFormData,
sortOrder: number
): Promise<Category> {
const { data: created, error } = await auditedSupabase.insert('categories', {
const { data: created, error } = await auditedAdminData.insert('categories', {
name: data.name,
description: data.description || null,
icon: data.icon || null,
@@ -102,7 +102,7 @@ export async function updateCategory(
id: string,
data: Partial<CategoryFormData>
): Promise<void> {
const { error } = await auditedSupabase.update('categories', id, {
const { error } = await auditedAdminData.update('categories', id, {
name: data.name,
description: data.description || null,
icon: data.icon || null,
@@ -116,7 +116,7 @@ export async function updateCategory(
* Delete a category
*/
export async function deleteCategory(id: string): Promise<void> {
const { error } = await auditedSupabase.delete('categories', id);
const { error } = await auditedAdminData.delete('categories', id);
if (error) throw error;
}
@@ -130,7 +130,7 @@ export async function swapCategorySortOrder(
category2Order: number
): Promise<void> {
await Promise.all([
auditedSupabase.update('categories', category1Id, { sort_order: category2Order }),
auditedSupabase.update('categories', category2Id, { sort_order: category1Order }),
auditedAdminData.update('categories', category1Id, { sort_order: category2Order }),
auditedAdminData.update('categories', category2Id, { sort_order: category1Order }),
]);
}
+15 -15
View File
@@ -1,5 +1,5 @@
import { supabase } from '@/config';
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
import { adminData } from '@/lib/adminApi';
import { auditedAdminData } from '@/hooks/useAuditedAdminData';
// =============================================================================
// Types
@@ -42,7 +42,7 @@ export interface Topic {
* Fetch all packs for a category with question counts
*/
export async function fetchPacksForCategory(categoryId: string): Promise<Pack[]> {
const { data: packs, error: packError } = await supabase
const { data: packs, error: packError } = await adminData
.from('question_packs')
.select('*, category:categories(name)')
.eq('category_id', categoryId)
@@ -51,7 +51,7 @@ export async function fetchPacksForCategory(categoryId: string): Promise<Pack[]>
if (packError) throw packError;
// Get question counts per pack
const { data: questions } = await supabase
const { data: questions } = await adminData
.from('questions')
.select('pack_id');
@@ -72,7 +72,7 @@ export async function fetchPacksForCategory(categoryId: string): Promise<Pack[]>
* Fetch a single pack by ID
*/
export async function fetchPackById(id: string): Promise<Pack | null> {
const { data, error } = await supabase
const { data, error } = await adminData
.from('question_packs')
.select('*, category:categories(name)')
.eq('id', id)
@@ -86,7 +86,7 @@ export async function fetchPackById(id: string): Promise<Pack | null> {
* Fetch all topics
*/
export async function fetchAllTopics(): Promise<Topic[]> {
const { data, error } = await supabase
const { data, error } = await adminData
.from('topics')
.select('*')
.order('name');
@@ -99,7 +99,7 @@ export async function fetchAllTopics(): Promise<Topic[]> {
* Fetch topic IDs for a specific pack
*/
export async function fetchPackTopicIds(packId: string): Promise<Set<string>> {
const { data, error } = await supabase
const { data, error } = await adminData
.from('pack_topics')
.select('topic_id')
.eq('pack_id', packId);
@@ -120,7 +120,7 @@ export async function createPack(
data: PackFormData,
sortOrder: number
): Promise<Pack> {
const { data: created, error } = await auditedSupabase.insert('question_packs', {
const { data: created, error } = await auditedAdminData.insert('question_packs', {
name: data.name,
description: data.description || null,
icon: data.icon || null,
@@ -143,7 +143,7 @@ export async function updatePack(
id: string,
data: Partial<PackFormData>
): Promise<void> {
const { error } = await auditedSupabase.update('question_packs', id, {
const { error } = await auditedAdminData.update('question_packs', id, {
name: data.name,
description: data.description || null,
icon: data.icon || null,
@@ -159,7 +159,7 @@ export async function updatePack(
* Delete a pack
*/
export async function deletePack(id: string): Promise<void> {
const { error } = await auditedSupabase.delete('question_packs', id);
const { error } = await auditedAdminData.delete('question_packs', id);
if (error) throw error;
}
@@ -173,8 +173,8 @@ export async function swapPackSortOrder(
pack2Order: number
): Promise<void> {
await Promise.all([
auditedSupabase.update('question_packs', pack1Id, { sort_order: pack2Order }),
auditedSupabase.update('question_packs', pack2Id, { sort_order: pack1Order }),
auditedAdminData.update('question_packs', pack1Id, { sort_order: pack2Order }),
auditedAdminData.update('question_packs', pack2Id, { sort_order: pack1Order }),
]);
}
@@ -190,11 +190,11 @@ export async function updatePackTopics(
topicIds: string[]
): Promise<void> {
// Delete existing topics
await supabase.from('pack_topics').delete().eq('pack_id', packId);
await adminData.from('pack_topics').delete().eq('pack_id', packId);
// Insert new topics
if (topicIds.length > 0) {
const { error } = await supabase.from('pack_topics').insert(
const { error } = await adminData.from('pack_topics').insert(
topicIds.map(topicId => ({ pack_id: packId, topic_id: topicId }))
);
if (error) throw error;
@@ -205,7 +205,7 @@ export async function updatePackTopics(
* Create a new topic
*/
export async function createTopic(name: string): Promise<Topic> {
const { data, error } = await supabase
const { data, error } = await adminData
.from('topics')
.insert({ name })
.select()
+15 -15
View File
@@ -1,5 +1,5 @@
import { supabase } from '@/config';
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
import { adminData } from '@/lib/adminApi';
import { auditedAdminData } from '@/hooks/useAuditedAdminData';
// =============================================================================
// Types
@@ -29,7 +29,7 @@ export interface QuestionFormData {
* Fetch all questions for a pack (excludes soft-deleted by default)
*/
export async function fetchQuestionsForPack(packId: string, includeDeleted = false): Promise<Question[]> {
let query = supabase
let query = adminData
.from('questions')
.select('*')
.eq('pack_id', packId)
@@ -49,7 +49,7 @@ export async function fetchQuestionsForPack(packId: string, includeDeleted = fal
* Fetch a single question by ID
*/
export async function fetchQuestionById(id: string): Promise<Question | null> {
const { data, error } = await supabase
const { data, error } = await adminData
.from('questions')
.select('*')
.eq('id', id)
@@ -63,7 +63,7 @@ export async function fetchQuestionById(id: string): Promise<Question | null> {
* Fetch question count for a pack (excludes soft-deleted)
*/
export async function fetchQuestionCount(packId: string): Promise<number> {
const { count, error } = await supabase
const { count, error } = await adminData
.from('questions')
.select('*', { count: 'exact', head: true })
.eq('pack_id', packId)
@@ -84,7 +84,7 @@ export async function createQuestion(
packId: string,
data: QuestionFormData
): Promise<Question> {
const { data: created, error } = await auditedSupabase.insert('questions', {
const { data: created, error } = await auditedAdminData.insert('questions', {
text: data.text,
partner_text: data.partner_text || null,
intensity: data.intensity,
@@ -103,7 +103,7 @@ export async function updateQuestion(
id: string,
data: Partial<QuestionFormData>
): Promise<void> {
const { error } = await auditedSupabase.update('questions', id, {
const { error } = await auditedAdminData.update('questions', id, {
text: data.text,
partner_text: data.partner_text || null,
intensity: data.intensity,
@@ -116,7 +116,7 @@ export async function updateQuestion(
* Soft delete a question (sets deleted_at timestamp)
*/
export async function deleteQuestion(id: string): Promise<void> {
const { error } = await auditedSupabase.update('questions', id, {
const { error } = await auditedAdminData.update('questions', id, {
deleted_at: new Date().toISOString(),
});
if (error) throw error;
@@ -127,7 +127,7 @@ export async function deleteQuestion(id: string): Promise<void> {
*/
export async function deleteQuestions(ids: string[]): Promise<void> {
await Promise.all(
ids.map(id => auditedSupabase.update('questions', id, {
ids.map(id => auditedAdminData.update('questions', id, {
deleted_at: new Date().toISOString(),
}))
);
@@ -137,7 +137,7 @@ export async function deleteQuestions(ids: string[]): Promise<void> {
* Restore a soft-deleted question
*/
export async function restoreQuestion(id: string): Promise<void> {
const { error } = await auditedSupabase.update('questions', id, {
const { error } = await auditedAdminData.update('questions', id, {
deleted_at: null,
});
if (error) throw error;
@@ -169,7 +169,7 @@ export async function bulkCreateQuestions(
// Insert standalone questions first
for (const q of standaloneQuestions) {
const { data, error } = await auditedSupabase.insert('questions', {
const { data, error } = await auditedAdminData.insert('questions', {
pack_id: packId,
text: q.text,
partner_text: q.partner_text || null,
@@ -186,7 +186,7 @@ export async function bulkCreateQuestions(
if (pair.length >= 2) {
// First question is primary
const primary = pair[0];
const { data: primaryData, error: primaryError } = await auditedSupabase.insert('questions', {
const { data: primaryData, error: primaryError } = await auditedAdminData.insert('questions', {
pack_id: packId,
text: primary.text,
partner_text: primary.partner_text || null,
@@ -201,7 +201,7 @@ export async function bulkCreateQuestions(
// Second question is inverse
const inverse = pair[1];
const { data: inverseData, error: inverseError } = await auditedSupabase.insert('questions', {
const { data: inverseData, error: inverseError } = await auditedAdminData.insert('questions', {
pack_id: packId,
text: inverse.text,
partner_text: inverse.partner_text || null,
@@ -216,7 +216,7 @@ export async function bulkCreateQuestions(
// Handle any additional questions in the pair
for (let i = 2; i < pair.length; i++) {
const extra = pair[i];
const { data, error } = await auditedSupabase.insert('questions', {
const { data, error } = await auditedAdminData.insert('questions', {
pack_id: packId,
text: extra.text,
partner_text: extra.partner_text || null,
@@ -228,7 +228,7 @@ export async function bulkCreateQuestions(
}
} else if (pair.length === 1) {
// Single question with pair_id but no pair
const { data, error } = await auditedSupabase.insert('questions', {
const { data, error } = await auditedAdminData.insert('questions', {
pack_id: packId,
text: pair[0].text,
partner_text: pair[0].partner_text || null,
+6 -6
View File
@@ -1,4 +1,4 @@
import { supabase } from '@/config';
import { adminData } from '@/lib/adminApi';
// =============================================================================
// Types
@@ -25,10 +25,10 @@ export async function fetchDashboardStats(): Promise<DashboardStats> {
{ count: questions },
{ count: users },
] = await Promise.all([
supabase.from('categories').select('*', { count: 'exact', head: true }),
supabase.from('question_packs').select('*', { count: 'exact', head: true }),
supabase.from('questions').select('*', { count: 'exact', head: true }),
supabase.from('profiles').select('*', { count: 'exact', head: true }),
adminData.from('categories').select('*', { count: 'exact', head: true }),
adminData.from('question_packs').select('*', { count: 'exact', head: true }),
adminData.from('questions').select('*', { count: 'exact', head: true }),
adminData.from('profiles').select('*', { count: 'exact', head: true }),
]);
return {
@@ -43,7 +43,7 @@ export async function fetchDashboardStats(): Promise<DashboardStats> {
* Fetch count for a specific table
*/
export async function fetchTableCount(tableName: string): Promise<number> {
const { count, error } = await supabase
const { count, error } = await adminData
.from(tableName)
.select('*', { count: 'exact', head: true });
+1
View File
@@ -3,6 +3,7 @@
interface ImportMetaEnv {
readonly VITE_SUPABASE_URL: string;
readonly VITE_SUPABASE_ANON_KEY: string;
readonly VITE_API_URL: string;
readonly VITE_OPENAI_API_KEY: string;
}