diff --git a/.dockerignore b/.dockerignore index a9dd56c..085f751 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,14 +1,35 @@ node_modules +**/node_modules .git .gitignore *.md .env* .DS_Store dist +**/dist .expo -android -ios +**/.expo +**/android +**/ios *.log .turbo +**/.turbo coverage .nyc_output +apps/admin +apps/mcp +!apps/mcp +!apps/mcp/** +apps/mobile +apps/supabase +apps/web +e2e +evidence +etc +infra +scripts +terraform +.agents +.claude +.codex +.ralph diff --git a/.gitignore b/.gitignore index 04cc315..28d1d48 100755 --- a/.gitignore +++ b/.gitignore @@ -55,6 +55,7 @@ evidence/ e2e/.auth/ e2e/test-results/ e2e/playwright-report/ +.playwright-mcp/ # Misc *.pem @@ -70,3 +71,4 @@ nul # Agent runtime evidence (maintained lessons remain tracked) .ralph/activity.log .ralph/runs/ +.migration-state/ diff --git a/apps/admin/.env.example b/apps/admin/.env.example index adffaeb..05af0bd 100644 --- a/apps/admin/.env.example +++ b/apps/admin/.env.example @@ -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 diff --git a/apps/admin/Dockerfile b/apps/admin/Dockerfile index 02ce1fa..f72bd1e 100755 --- a/apps/admin/Dockerfile +++ b/apps/admin/Dockerfile @@ -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 diff --git a/apps/admin/docker-compose.prod.yml b/apps/admin/docker-compose.prod.yml index b1ef66f..e4e312a 100644 --- a/apps/admin/docker-compose.prod.yml +++ b/apps/admin/docker-compose.prod.yml @@ -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} diff --git a/apps/admin/docker-compose.yml b/apps/admin/docker-compose.yml index 390e77e..117b6c1 100644 --- a/apps/admin/docker-compose.yml +++ b/apps/admin/docker-compose.yml @@ -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} diff --git a/apps/admin/src/App.tsx b/apps/admin/src/App.tsx index e5a8030..66f6b20 100755 --- a/apps/admin/src/App.tsx +++ b/apps/admin/src/App.tsx @@ -195,7 +195,7 @@ function App() { + } diff --git a/apps/admin/src/components/content/ExtractTopicsDialog.tsx b/apps/admin/src/components/content/ExtractTopicsDialog.tsx index 98bb119..8680ded 100644 --- a/apps/admin/src/components/content/ExtractTopicsDialog.tsx +++ b/apps/admin/src/components/content/ExtractTopicsDialog.tsx @@ -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([]); - const [reasoning, setReasoning] = useState(''); - const [selectedTopics, setSelectedTopics] = useState>(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 ( - - - - - - Extract Topics - - - Use AI to identify topics and kinks for "{pack.name}" - - - -
- {step === 'initial' ? ( -
-
- -
-
-

Analyze pack for topics

-

- 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. -

-
- -
- ) : ( -
- {topics.length === 0 ? ( -
- - No topics could be identified for this pack. -
- ) : ( - <> -
-

Suggested Topics

-
- {topics.map((topic) => ( - toggleSelection(topic.name)} - > - {selectedTopics.has(topic.name) ? ( - - ) : null} - {topic.name} - {topic.isNew && ( - - )} - - ))} -
-
- -
- Existing topic - - -
- - New topic - -
-
- - {reasoning && ( -
-

AI Reasoning

-

- {reasoning} -

-
- )} - - )} -
- )} -
- - - {step === 'review' && ( - <> - - - - )} - - -
- ); -} +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([]); + const [reasoning, setReasoning] = useState(''); + const [selectedTopics, setSelectedTopics] = useState>(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 ( + + + + + + Extract Topics + + + Use AI to identify topics and kinks for "{pack.name}" + + + +
+ {step === 'initial' ? ( +
+
+ +
+
+

Analyze pack for topics

+

+ 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. +

+
+ +
+ ) : ( +
+ {topics.length === 0 ? ( +
+ + No topics could be identified for this pack. +
+ ) : ( + <> +
+

Suggested Topics

+
+ {topics.map((topic) => ( + toggleSelection(topic.name)} + > + {selectedTopics.has(topic.name) ? ( + + ) : null} + {topic.name} + {topic.isNew && ( + + )} + + ))} +
+
+ +
+ Existing topic + + +
+ + New topic + +
+
+ + {reasoning && ( +
+

AI Reasoning

+

+ {reasoning} +

+
+ )} + + )} +
+ )} +
+ + + {step === 'review' && ( + <> + + + + )} + + +
+ ); +} diff --git a/apps/admin/src/components/content/FixTargetsDialog.tsx b/apps/admin/src/components/content/FixTargetsDialog.tsx index 741f752..070deae 100644 --- a/apps/admin/src/components/content/FixTargetsDialog.tsx +++ b/apps/admin/src/components/content/FixTargetsDialog.tsx @@ -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, }) diff --git a/apps/admin/src/components/content/FixTextDialog.tsx b/apps/admin/src/components/content/FixTextDialog.tsx index 3e689ef..ea64694 100644 --- a/apps/admin/src/components/content/FixTextDialog.tsx +++ b/apps/admin/src/components/content/FixTextDialog.tsx @@ -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, }) diff --git a/apps/admin/src/components/content/ReviewQuestionsDialog.tsx b/apps/admin/src/components/content/ReviewQuestionsDialog.tsx index 26bda2e..0fbf60a 100644 --- a/apps/admin/src/components/content/ReviewQuestionsDialog.tsx +++ b/apps/admin/src/components/content/ReviewQuestionsDialog.tsx @@ -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[] = 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; }) ); diff --git a/apps/admin/src/components/layout/Breadcrumbs.tsx b/apps/admin/src/components/layout/Breadcrumbs.tsx index 0bd444d..9f8308c 100644 --- a/apps/admin/src/components/layout/Breadcrumbs.tsx +++ b/apps/admin/src/components/layout/Breadcrumbs.tsx @@ -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) diff --git a/apps/admin/src/components/layout/Sidebar.tsx b/apps/admin/src/components/layout/Sidebar.tsx index 0ef29bf..769e697 100644 --- a/apps/admin/src/components/layout/Sidebar.tsx +++ b/apps/admin/src/components/layout/Sidebar.tsx @@ -108,7 +108,7 @@ const systemNav: NavItem[] = [ label: 'Flagged Messages', href: '/flagged-messages', icon: , - requiredPermission: PERMISSION_KEYS.MANAGE_AI_CONFIG, + requiredPermission: PERMISSION_KEYS.VIEW_CHATS, }, { label: 'AI Settings', diff --git a/apps/admin/src/config.ts b/apps/admin/src/config.ts index c373a35..197ac18 100755 --- a/apps/admin/src/config.ts +++ b/apps/admin/src/config.ts @@ -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(/\/$/, ''); diff --git a/apps/admin/src/contexts/AuthContext.tsx b/apps/admin/src/contexts/AuthContext.tsx index 288e2dc..1e99e93 100644 --- a/apps/admin/src/contexts/AuthContext.tsx +++ b/apps/admin/src/contexts/AuthContext.tsx @@ -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('/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('/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'; diff --git a/apps/admin/src/hooks/useAiConfig.ts b/apps/admin/src/hooks/useAiConfig.ts index 31de732..f8fe40f 100644 --- a/apps/admin/src/hooks/useAiConfig.ts +++ b/apps/admin/src/hooks/useAiConfig.ts @@ -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 { } try { - const { data } = await supabase + const { data } = await adminData .from('ai_config') .select('*') .limit(1) diff --git a/apps/admin/src/hooks/useAppConfig.ts b/apps/admin/src/hooks/useAppConfig.ts index 2cea876..8282711 100644 --- a/apps/admin/src/hooks/useAppConfig.ts +++ b/apps/admin/src/hooks/useAppConfig.ts @@ -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); diff --git a/apps/admin/src/hooks/useAuditedSupabase.ts b/apps/admin/src/hooks/useAuditedAdminData.ts similarity index 81% rename from apps/admin/src/hooks/useAuditedSupabase.ts rename to apps/admin/src/hooks/useAuditedAdminData.ts index c24329d..c4e5846 100644 --- a/apps/admin/src/hooks/useAuditedSupabase.ts +++ b/apps/admin/src/hooks/useAuditedAdminData.ts @@ -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 | null, newValues?: Record | null ): Promise { - 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 { @@ -56,9 +43,9 @@ interface SingleResult { } /** - * 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> { 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 | 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[] = []; 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 | 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[] = []; 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 | 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); diff --git a/apps/admin/src/hooks/useCrudOperations.ts b/apps/admin/src/hooks/useCrudOperations.ts index 4b042c3..96ad0e7 100644 --- a/apps/admin/src/hooks/useCrudOperations.ts +++ b/apps/admin/src/hooks/useCrudOperations.ts @@ -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 { /** - * The Supabase table name + * The standalone admin API resource name */ table: string; @@ -41,7 +41,7 @@ interface UseCrudOperationsOptions { } /** - * 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({ * @param filter Optional filter function to modify the query */ const fetchAll = useCallback(async ( - filter?: SupabaseFilterFn + filter?: AdminFilterFn ): Promise => { 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({ */ const fetchById = useCallback(async (id: string): Promise => { 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({ 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({ 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({ ): Promise => { 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({ } try { - const { error: deleteError } = await auditedSupabase.delete(table, id); + const { error: deleteError } = await auditedAdminData.delete(table, id); if (deleteError) throw deleteError; diff --git a/apps/admin/src/hooks/useRealtimeSubscription.ts b/apps/admin/src/hooks/useRealtimeSubscription.ts index 8705520..2f7ada8 100644 --- a/apps/admin/src/hooks/useRealtimeSubscription.ts +++ b/apps/admin/src/hooks/useRealtimeSubscription.ts @@ -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 { - 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( - options: UseRealtimeSubscriptionOptions -): UseRealtimeSubscriptionReturn { - const { - table, - schema = 'public', - event = '*', - filter, - onInsert, - onUpdate, - onDelete, - insertToast, - updateToast, - deleteToast, - debounceMs = 0, - enabled = true, - } = options; - - const [status, setStatus] = useState('CLOSED'); - const [error, setError] = useState(null); - const channelRef = useRef(null); - const debounceTimerRef = useRef | 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 { + 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; + const key = record.id ?? `${record.pack_id ?? ''}:${record.topic_id ?? ''}`; + if (key) return String(key); + } + return String(index); +} + +export function useRealtimeSubscription(options: UseRealtimeSubscriptionOptions): 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('CLOSED'); + const [error, setError] = useState(null); + const snapshotRef = useRef | 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(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 }; +} diff --git a/apps/admin/src/lib/adminApi.test.ts b/apps/admin/src/lib/adminApi.test.ts new file mode 100644 index 0000000..11dc123 --- /dev/null +++ b/apps/admin/src/lib/adminApi.test.ts @@ -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); + }); + + 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'); + }); +}); diff --git a/apps/admin/src/lib/adminApi.ts b/apps/admin/src/lib/adminApi.ts new file mode 100644 index 0000000..ce1535b --- /dev/null +++ b/apps/admin/src/lib/adminApi.ts @@ -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 & { body?: unknown }; + +async function accessToken(): Promise { + 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(path: string, options: RequestOptions = {}): Promise { + 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 { + 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(resource: string, query: AdminQuery = {}): Promise<{ rows: T[]; count: number }> { + return adminRequest(`/v1/admin/query/${encodeURIComponent(resource)}`, { method: 'POST', body: query }); +} + +function uniqueValues(rows: Record[], 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[]> { + if (values.length === 0) return []; + return (await queryAdminRows>(resource, { + filters: [{ column, op: 'in', value: values }], limit: 500, + })).rows; +} + +async function hydrateAdminRows(resource: string, selection: string, input: any[]): Promise { + const rows = input as Record[]; + 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 = { data: T | null; error: Error | null; count: number | null }; +type QueryMode = 'many' | 'single' | 'maybeSingle'; + +class AdminQueryBuilder implements PromiseLike> { + 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[] } | { type: 'update'; values: Record } | { 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(): PromiseLike> { this.mode = 'single'; return this as unknown as PromiseLike>; } + maybeSingle(): PromiseLike> { this.mode = 'maybeSingle'; return this as unknown as PromiseLike>; } + insert(records: Record | Record[]): this { + this.mutation = { type: 'insert', records: Array.isArray(records) ? records : [records] }; + return this; + } + update(values: Record): this { this.mutation = { type: 'update', values }; return this; } + delete(): this { this.mutation = { type: 'delete' }; return this; } + + then, TResult2 = never>( + onfulfilled?: ((value: QueryResult) => TResult1 | PromiseLike) | null, + onrejected?: ((reason: unknown) => TResult2 | PromiseLike) | null, + ): PromiseLike { + return this.execute().then(onfulfilled, onrejected); + } + + private async execute(): Promise> { + 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>(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(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(this.resource, { + filters: [...this.filters, filter], order: this.ordering, limit: 500, + }))); + const unique = new Map(); + for (const result of results) result.rows.forEach((row, index) => { + const record = row as Record; + 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 { + 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(resource: string): AdminQueryBuilder { return new AdminQueryBuilder(resource); }, +}; diff --git a/apps/admin/src/lib/ai/config.ts b/apps/admin/src/lib/ai/config.ts index 48f2df7..ecdc290 100644 --- a/apps/admin/src/lib/ai/config.ts +++ b/apps/admin/src/lib/ai/config.ts @@ -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 { diff --git a/apps/admin/src/pages/AuditLogsPage.tsx b/apps/admin/src/pages/AuditLogsPage.tsx index 864f358..a1b475d 100644 --- a/apps/admin/src/pages/AuditLogsPage.tsx +++ b/apps/admin/src/pages/AuditLogsPage.tsx @@ -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 }) diff --git a/apps/admin/src/pages/DashboardPage.tsx b/apps/admin/src/pages/DashboardPage.tsx index 99d3a4f..857757e 100644 --- a/apps/admin/src/pages/DashboardPage.tsx +++ b/apps/admin/src/pages/DashboardPage.tsx @@ -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, }); diff --git a/apps/admin/src/pages/FeedbackPage.tsx b/apps/admin/src/pages/FeedbackPage.tsx index dceffaa..ecbb0a7 100644 --- a/apps/admin/src/pages/FeedbackPage.tsx +++ b/apps/admin/src/pages/FeedbackPage.tsx @@ -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; - 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 = { - bug: { label: 'Bug', icon: , variant: 'destructive' }, - feature_request: { label: 'Feature', icon: , variant: 'default' }, - general: { label: 'General', icon: , variant: 'secondary' }, - question: { label: 'Question', icon: , variant: 'outline' }, -}; - -const statusConfig: Record = { - 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([]); - 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; + 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 = { + bug: { label: 'Bug', icon: , variant: 'destructive' }, + feature_request: { label: 'Feature', icon: , variant: 'default' }, + general: { label: 'General', icon: , variant: 'secondary' }, + question: { label: 'Question', icon: , variant: 'outline' }, +}; + +const statusConfig: Record = { + 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([]); + const [loading, setLoading] = useState(true); const [search, setSearch] = useState(''); const [typeFilter, setTypeFilter] = useState('all'); const [statusFilter, setStatusFilter] = useState('all'); const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(25); - - // Detail Dialog State - const [selectedFeedback, setSelectedFeedback] = useState(null); - const [isUpdating, setIsUpdating] = useState(false); - const [editStatus, setEditStatus] = useState('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({ - 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(null); + const [isUpdating, setIsUpdating] = useState(false); + const [editStatus, setEditStatus] = useState('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({ + 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 ( - - {config.icon} - {config.label} - - ); - }; - - const getStatusBadge = (status: FeedbackStatus) => { - const config = statusConfig[status]; - return {config.label}; - }; - - const formatDeviceInfo = (info: Record) => { - 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 ( + + {config.icon} + {config.label} + + ); + }; + + const getStatusBadge = (status: FeedbackStatus) => { + const config = statusConfig[status]; + return {config.label}; + }; + + const formatDeviceInfo = (info: Record) => { + 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 (
@@ -246,13 +246,13 @@ export function FeedbackPage() {

User Feedback

-

- Review and respond to user feedback submissions -

-
-
- - {/* Filters */} +

+ Review and respond to user feedback submissions +

+ + + + {/* Filters */}
@@ -267,30 +267,30 @@ export function FeedbackPage() { - - All Types - Bug - Feature Request - General - Question - + + All Types + Bug + Feature Request + General + Question + -
- - {/* Table */} + + All Status + New + Reviewed + In Progress + Resolved + Closed + + +
+ + {/* Table */}
@@ -373,131 +373,131 @@ export function FeedbackPage() { }} /> )} - - {/* Detail Dialog */} - !open && setSelectedFeedback(null)}> - - - - {selectedFeedback && getTypeBadge(selectedFeedback.type)} - {selectedFeedback?.title} - - - Submitted {selectedFeedback && format(new Date(selectedFeedback.created_at), 'MMMM d, yyyy \'at\' h:mm a')} - - - - {selectedFeedback && ( -
- {/* User Info */} -
- -
-
{selectedFeedback.profile?.name || 'Unknown User'}
-
{selectedFeedback.profile?.email}
- {selectedFeedback.profile?.id && ( - e.stopPropagation()} - > - View user profile - - )} -
-
- - {/* Description */} -
- -

{selectedFeedback.description}

-
- - {/* Related Question */} - {selectedFeedback.question && ( -
- -

{selectedFeedback.question.text}

- {selectedFeedback.question.pack && ( -

- From pack: {selectedFeedback.question.pack.name} -

- )} -
- )} - - {/* Screenshot */} - {selectedFeedback.screenshot_url && ( -
- - - Screenshot - -
- )} - - {/* Device Info */} - {selectedFeedback.device_info && Object.keys(selectedFeedback.device_info).length > 0 && ( -
- -
- -
-                                            {formatDeviceInfo(selectedFeedback.device_info)}
-                                        
-
-
- )} - - {/* Status Update */} -
-
- - -
-
- -