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;
}
+5
View File
@@ -0,0 +1,5 @@
node_modules
dist
.env
coverage
test
+31
View File
@@ -0,0 +1,31 @@
PORT=3003
DATABASE_URL=postgresql://sauci:sauci_local_only@127.0.0.1:54320/sauci
SUPABASE_AUTH_URL=https://your-project.supabase.co
SUPABASE_AUTH_AUDIENCE=authenticated
# Server-only hosted Auth Admin credential. Used for account deletion and for
# enriching admin user lists; it is never exposed to the browser or used as a
# Supabase database client.
# SUPABASE_AUTH_SERVICE_ROLE_KEY=
# Optional overrides. The issuer defaults to <SUPABASE_AUTH_URL>/auth/v1 and
# the JWKS endpoint defaults to <SUPABASE_AUTH_URL>/auth/v1/.well-known/jwks.json.
# SUPABASE_AUTH_ISSUER=
# SUPABASE_AUTH_JWKS_URL=
# Test-only. Startup fails if this is set outside NODE_ENV=test.
# AUTH_TEST_JWKS={"keys":[]}
# Optional stable credential for non-browser admin clients such as MCP. MCP
# receives the token as SAUCI_ADMIN_API_TOKEN; it does not receive the user ID.
# Both values are required together. The user ID must belong to an active,
# least-privilege admin_users row; generate a random token of at least 32 chars.
# ADMIN_API_SERVICE_TOKEN=
# ADMIN_API_SERVICE_USER_ID=00000000-0000-4000-8000-000000000002
# Optional admin review decryption. Keep the private JWK server-side only.
# ADMIN_PRIVATE_KEY_JWK=
# Optional local fixture identity used by `npm run db:seed -w @sauci/api`.
# SEED_USER_ID=00000000-0000-4000-8000-000000000001
# SEED_USER_EMAIL=local@sauci.test
+28
View File
@@ -0,0 +1,28 @@
FROM node:20-bookworm-slim AS build
WORKDIR /workspace
COPY package.json package-lock.json ./
COPY apps/api/package.json ./apps/api/package.json
COPY packages/shared/package.json ./packages/shared/package.json
RUN --mount=type=cache,target=/root/.npm \
npm ci --workspace @sauci/api --include-workspace-root=false --ignore-scripts
COPY apps/api ./apps/api
COPY packages/shared ./packages/shared
RUN npm run build --workspace @sauci/api
RUN npm prune --omit=dev --workspace @sauci/api --include-workspace-root=false --ignore-scripts
FROM node:20-bookworm-slim AS runtime
ENV NODE_ENV=production PORT=3003
WORKDIR /app
RUN mkdir -p /data/media && chown node:node /data/media
COPY --from=build /workspace/node_modules ./node_modules
# npm may place 0.x dependency upgrades in the workspace-local tree. Overlay
# that pruned tree so runtime resolution matches the workspace lockfile.
COPY --from=build /workspace/apps/api/node_modules ./node_modules
COPY --from=build --chown=node:node /workspace/apps/api/package.json ./package.json
COPY --from=build --chown=node:node /workspace/apps/api/dist ./dist
COPY --from=build --chown=node:node /workspace/apps/api/drizzle ./drizzle
USER node
EXPOSE 3003
HEALTHCHECK --interval=15s --timeout=3s --start-period=10s --retries=3 \
CMD ["node", "-e", "fetch('http://127.0.0.1:3003/health/live').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"]
CMD ["node", "dist/index.js"]
+14
View File
@@ -0,0 +1,14 @@
import { defineConfig } from 'drizzle-kit';
if (!process.env.DATABASE_URL) {
throw new Error('DATABASE_URL is required for database migrations');
}
export default defineConfig({
dialect: 'postgresql',
schema: './src/db/all-schema.ts',
out: './drizzle',
dbCredentials: { url: process.env.DATABASE_URL },
strict: true,
verbose: true,
});
@@ -0,0 +1,35 @@
CREATE TABLE "profiles" (
"id" uuid PRIMARY KEY NOT NULL,
"name" text,
"email" text,
"avatar_url" text,
"push_token" text,
"is_premium" boolean DEFAULT false NOT NULL,
"couple_id" uuid,
"gender" text,
"show_explicit_content" boolean DEFAULT true NOT NULL,
"max_intensity" integer DEFAULT 5 NOT NULL,
"public_key_jwk" jsonb,
"hide_nsfw" boolean DEFAULT false NOT NULL,
"onboarding_completed" boolean DEFAULT false NOT NULL,
"onboarding_version" integer DEFAULT 0 NOT NULL,
"auth_synced_at" timestamp with time zone DEFAULT now() NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "profiles_gender_check" CHECK ("gender" IS NULL OR "gender" IN ('male', 'female', 'non-binary', 'prefer-not-to-say')),
CONSTRAINT "profiles_max_intensity_check" CHECK ("max_intensity" BETWEEN 1 AND 5)
);
--> statement-breakpoint
CREATE INDEX "profiles_couple_id_idx" ON "profiles" USING btree ("couple_id");
--> statement-breakpoint
CREATE TABLE "feature_interests" (
"user_id" uuid NOT NULL,
"feature" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "feature_interests_user_id_feature_pk" PRIMARY KEY("user_id", "feature")
);
--> statement-breakpoint
ALTER TABLE "feature_interests" ADD CONSTRAINT "feature_interests_user_id_profiles_id_fk"
FOREIGN KEY ("user_id") REFERENCES "profiles"("id") ON DELETE cascade;
--> statement-breakpoint
CREATE INDEX "feature_interests_feature_idx" ON "feature_interests" USING btree ("feature");
+14
View File
@@ -0,0 +1,14 @@
CREATE TABLE IF NOT EXISTS "couples" (
"id" uuid PRIMARY KEY NOT NULL,
"invite_code" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "couples_invite_code_unique" UNIQUE("invite_code"),
CONSTRAINT "couples_invite_code_format" CHECK ("invite_code" ~ '^[A-Z0-9]{8}$')
);
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "profiles" ADD CONSTRAINT "profiles_couple_id_couples_id_fk"
FOREIGN KEY ("couple_id") REFERENCES "couples"("id") ON DELETE SET NULL;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
@@ -0,0 +1,60 @@
CREATE TABLE IF NOT EXISTS "categories" (
"id" uuid PRIMARY KEY,
"name" text NOT NULL,
"description" text,
"icon" text,
"color" text,
"sort_order" integer NOT NULL DEFAULT 0,
"is_public" boolean NOT NULL DEFAULT true,
"created_at" timestamptz NOT NULL DEFAULT now()
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "question_packs" (
"id" uuid PRIMARY KEY,
"name" text NOT NULL,
"description" text,
"icon" text,
"is_premium" boolean NOT NULL DEFAULT false,
"is_public" boolean NOT NULL DEFAULT true,
"is_explicit" boolean NOT NULL DEFAULT false,
"min_intensity" integer,
"max_intensity" integer,
"avg_intensity" integer,
"sort_order" integer NOT NULL DEFAULT 0,
"category_id" uuid REFERENCES "categories"("id") ON DELETE SET NULL,
"created_at" timestamptz NOT NULL DEFAULT now()
);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "question_packs_category_id_idx" ON "question_packs" ("category_id");
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "questions" (
"id" uuid PRIMARY KEY,
"pack_id" uuid NOT NULL REFERENCES "question_packs"("id") ON DELETE CASCADE,
"text" text NOT NULL,
"partner_text" text,
"intensity" integer NOT NULL DEFAULT 1 CHECK ("intensity" BETWEEN 1 AND 5),
"created_at" timestamptz NOT NULL DEFAULT now()
);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "questions_pack_id_idx" ON "questions" ("pack_id");
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "responses" (
"id" uuid PRIMARY KEY,
"user_id" uuid NOT NULL REFERENCES "profiles"("id") ON DELETE CASCADE,
"question_id" uuid NOT NULL REFERENCES "questions"("id") ON DELETE CASCADE,
"couple_id" uuid NOT NULL REFERENCES "couples"("id") ON DELETE CASCADE,
"answer" text NOT NULL CHECK ("answer" IN ('yes', 'no', 'maybe')),
"response_data" jsonb,
"created_at" timestamptz NOT NULL DEFAULT now(),
UNIQUE ("user_id", "question_id")
);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "responses_user_id_idx" ON "responses" ("user_id");
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "couple_packs" (
"couple_id" uuid NOT NULL REFERENCES "couples"("id") ON DELETE CASCADE,
"pack_id" uuid NOT NULL REFERENCES "question_packs"("id") ON DELETE CASCADE,
"enabled" boolean NOT NULL DEFAULT true,
"created_at" timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY ("couple_id", "pack_id")
);
+71
View File
@@ -0,0 +1,71 @@
ALTER TABLE "questions"
ADD COLUMN IF NOT EXISTS "allowed_couple_genders" text[],
ADD COLUMN IF NOT EXISTS "target_user_genders" text[],
ADD COLUMN IF NOT EXISTS "question_type" text NOT NULL DEFAULT 'swipe',
ADD COLUMN IF NOT EXISTS "config" jsonb NOT NULL DEFAULT '{}'::jsonb,
ADD COLUMN IF NOT EXISTS "deleted_at" timestamptz;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "questions" ADD CONSTRAINT "questions_type_check"
CHECK ("question_type" IN ('swipe', 'text_answer', 'audio', 'photo', 'who_likely'));
EXCEPTION WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "app_config" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"answer_gap_threshold" integer NOT NULL DEFAULT 10 CHECK ("answer_gap_threshold" >= 0),
"daily_response_limit" integer NOT NULL DEFAULT 0 CHECK ("daily_response_limit" >= 0),
"couple_intensity_gate_enabled" boolean NOT NULL DEFAULT false,
"updated_at" timestamptz NOT NULL DEFAULT now()
);
--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "app_config_singleton_idx" ON "app_config" ((true));
--> statement-breakpoint
INSERT INTO "app_config" ("id") VALUES (gen_random_uuid()) ON CONFLICT DO NOTHING;
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "matches" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"couple_id" uuid NOT NULL REFERENCES "couples"("id") ON DELETE CASCADE,
"question_id" uuid NOT NULL REFERENCES "questions"("id") ON DELETE CASCADE,
"match_type" text NOT NULL CHECK ("match_type" IN ('yes_yes', 'yes_maybe', 'maybe_maybe', 'both_answered')),
"is_new" boolean NOT NULL DEFAULT true,
"response_summary" jsonb,
"created_at" timestamptz NOT NULL DEFAULT now(),
UNIQUE ("couple_id", "question_id")
);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "matches_couple_created_idx" ON "matches" ("couple_id", "created_at" DESC);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "messages" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"match_id" uuid NOT NULL REFERENCES "matches"("id") ON DELETE CASCADE,
"user_id" uuid NOT NULL REFERENCES "profiles"("id") ON DELETE CASCADE,
"content" text,
"created_at" timestamptz NOT NULL DEFAULT now(),
"read_at" timestamptz
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "match_archives" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"match_id" uuid NOT NULL REFERENCES "matches"("id") ON DELETE CASCADE,
"user_id" uuid NOT NULL REFERENCES "profiles"("id") ON DELETE CASCADE,
"archived_at" timestamptz NOT NULL DEFAULT now(),
UNIQUE ("match_id", "user_id")
);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "match_archives_user_idx" ON "match_archives" ("user_id", "match_id");
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "couple_streaks" (
"couple_id" uuid PRIMARY KEY REFERENCES "couples"("id") ON DELETE CASCADE,
"current_streak" integer NOT NULL DEFAULT 0,
"longest_streak" integer NOT NULL DEFAULT 0,
"last_active_date" date,
"last_completed_date" date,
"user1_answered_today" boolean NOT NULL DEFAULT false,
"user2_answered_today" boolean NOT NULL DEFAULT false,
"streak_celebrated_at" integer NOT NULL DEFAULT 0,
"created_at" timestamptz NOT NULL DEFAULT now(),
"updated_at" timestamptz NOT NULL DEFAULT now()
);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "responses_couple_question_idx" ON "responses" ("couple_id", "question_id");
+85
View File
@@ -0,0 +1,85 @@
CREATE TABLE IF NOT EXISTS "messages" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"match_id" uuid NOT NULL REFERENCES "matches"("id") ON DELETE CASCADE,
"user_id" uuid NOT NULL REFERENCES "profiles"("id") ON DELETE CASCADE,
"content" text,
"created_at" timestamptz NOT NULL DEFAULT now(),
"read_at" timestamptz,
"delivered_at" timestamptz,
"deleted_at" timestamptz,
"media_path" text,
"media_type" text CHECK ("media_type" IS NULL OR "media_type" IN ('image', 'video')),
"media_expires_at" timestamptz,
"media_expired" boolean NOT NULL DEFAULT false,
"media_viewed_at" timestamptz,
"version" integer NOT NULL DEFAULT 1,
"encrypted_content" text,
"encryption_iv" text,
"keys_metadata" jsonb,
"moderation_status" text,
"flag_reason" text,
"category" text,
CONSTRAINT "messages_content_or_media" CHECK (
"content" IS NOT NULL OR "media_path" IS NOT NULL OR "encrypted_content" IS NOT NULL
)
);
--> statement-breakpoint
ALTER TABLE "messages"
ADD COLUMN IF NOT EXISTS "delivered_at" timestamptz,
ADD COLUMN IF NOT EXISTS "deleted_at" timestamptz,
ADD COLUMN IF NOT EXISTS "media_path" text,
ADD COLUMN IF NOT EXISTS "media_type" text,
ADD COLUMN IF NOT EXISTS "media_expires_at" timestamptz,
ADD COLUMN IF NOT EXISTS "media_expired" boolean NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS "media_viewed_at" timestamptz,
ADD COLUMN IF NOT EXISTS "version" integer NOT NULL DEFAULT 1,
ADD COLUMN IF NOT EXISTS "encrypted_content" text,
ADD COLUMN IF NOT EXISTS "encryption_iv" text,
ADD COLUMN IF NOT EXISTS "keys_metadata" jsonb,
ADD COLUMN IF NOT EXISTS "moderation_status" text,
ADD COLUMN IF NOT EXISTS "flag_reason" text,
ADD COLUMN IF NOT EXISTS "category" text;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "messages" ADD CONSTRAINT "messages_media_type_check"
CHECK ("media_type" IS NULL OR "media_type" IN ('image', 'video'));
EXCEPTION WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "messages" ADD CONSTRAINT "messages_content_or_media"
CHECK ("content" IS NOT NULL OR "media_path" IS NOT NULL OR "encrypted_content" IS NOT NULL);
EXCEPTION WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "messages_match_created_idx" ON "messages" ("match_id", "created_at" DESC);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "messages_unread_idx" ON "messages" ("match_id", "user_id") WHERE "read_at" IS NULL;
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "message_deletions" (
"message_id" uuid NOT NULL REFERENCES "messages"("id") ON DELETE CASCADE,
"user_id" uuid NOT NULL REFERENCES "profiles"("id") ON DELETE CASCADE,
"deleted_at" timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY ("message_id", "user_id")
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "message_reports" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"message_id" uuid NOT NULL REFERENCES "messages"("id") ON DELETE CASCADE,
"reporter_id" uuid NOT NULL REFERENCES "profiles"("id") ON DELETE CASCADE,
"reason" text NOT NULL CHECK ("reason" IN ('harassment', 'spam', 'inappropriate_content', 'other')),
"status" text NOT NULL DEFAULT 'pending' CHECK ("status" IN ('pending', 'reviewed', 'dismissed')),
"created_at" timestamptz NOT NULL DEFAULT now(),
UNIQUE ("message_id", "reporter_id")
);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "message_reports_status_idx" ON "message_reports" ("status", "created_at" DESC);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "chat_typing_states" (
"match_id" uuid NOT NULL REFERENCES "matches"("id") ON DELETE CASCADE,
"user_id" uuid NOT NULL REFERENCES "profiles"("id") ON DELETE CASCADE,
"expires_at" timestamptz NOT NULL,
PRIMARY KEY ("match_id", "user_id")
);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "chat_typing_expires_idx" ON "chat_typing_states" ("expires_at");
@@ -0,0 +1,35 @@
ALTER TABLE profiles
ADD COLUMN IF NOT EXISTS usage_reason text,
ADD COLUMN IF NOT EXISTS last_active_at timestamptz;
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS notification_preferences (
user_id uuid PRIMARY KEY REFERENCES profiles(id) ON DELETE CASCADE,
matches_enabled boolean NOT NULL DEFAULT true,
messages_enabled boolean NOT NULL DEFAULT true,
partner_activity_enabled boolean NOT NULL DEFAULT true,
nudges_enabled boolean NOT NULL DEFAULT true,
pack_changes_enabled boolean NOT NULL DEFAULT true,
new_packs_enabled boolean NOT NULL DEFAULT true,
streak_milestones_enabled boolean NOT NULL DEFAULT true,
weekly_summary_enabled boolean NOT NULL DEFAULT true,
unpaired_reminders_enabled boolean NOT NULL DEFAULT true,
catchup_reminders_enabled boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS feedback (
id uuid PRIMARY KEY,
user_id uuid NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
type text NOT NULL CHECK (type IN ('bug', 'feature_request', 'general', 'question')),
title text NOT NULL,
description text NOT NULL,
device_info jsonb NOT NULL DEFAULT '{}'::jsonb,
status text NOT NULL DEFAULT 'new' CHECK (status IN ('new', 'reviewed', 'in_progress', 'resolved', 'closed')),
question_id uuid,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS feedback_user_id_idx ON feedback(user_id);
+35
View File
@@ -0,0 +1,35 @@
CREATE TABLE IF NOT EXISTS media_objects (
id uuid PRIMARY KEY,
owner_id uuid NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
couple_id uuid REFERENCES couples(id) ON DELETE CASCADE,
kind text NOT NULL CHECK (kind IN ('avatar', 'response', 'chat', 'feedback')),
storage_key text NOT NULL UNIQUE,
mime_type text NOT NULL,
byte_size integer NOT NULL CHECK (byte_size > 0),
question_id uuid REFERENCES questions(id) ON DELETE CASCADE,
match_id uuid REFERENCES matches(id) ON DELETE CASCADE,
expires_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS media_objects_owner_idx ON media_objects(owner_id, created_at DESC);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS media_objects_couple_idx ON media_objects(couple_id) WHERE couple_id IS NOT NULL;
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS media_deletion_queue (
storage_key text PRIMARY KEY,
queued_at timestamptz NOT NULL DEFAULT now()
);
--> statement-breakpoint
CREATE OR REPLACE FUNCTION queue_media_blob_deletion() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
INSERT INTO media_deletion_queue(storage_key) VALUES(OLD.storage_key) ON CONFLICT DO NOTHING;
RETURN OLD;
END $$;
--> statement-breakpoint
DROP TRIGGER IF EXISTS media_objects_queue_blob_deletion ON media_objects;
CREATE TRIGGER media_objects_queue_blob_deletion BEFORE DELETE ON media_objects
FOR EACH ROW EXECUTE FUNCTION queue_media_blob_deletion();
--> statement-breakpoint
ALTER TABLE feedback ADD COLUMN IF NOT EXISTS screenshot_media_id uuid REFERENCES media_objects(id) ON DELETE SET NULL;
@@ -0,0 +1,62 @@
CREATE TABLE IF NOT EXISTS subscriptions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
revenuecat_app_user_id text NOT NULL,
product_id text NOT NULL,
status text NOT NULL DEFAULT 'active'
CHECK (status IN ('active', 'cancelled', 'expired', 'billing_issue', 'paused')),
entitlement_ids text[] NOT NULL DEFAULT '{}',
purchased_at timestamptz NOT NULL,
expires_at timestamptz,
original_transaction_id text,
store text NOT NULL DEFAULT 'APP_STORE',
is_sandbox boolean NOT NULL DEFAULT false,
cancel_reason text,
grace_period_expires_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (user_id, original_transaction_id)
);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS subscriptions_user_id_idx ON subscriptions(user_id);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS subscriptions_status_idx ON subscriptions(status);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS revenuecat_webhook_events (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
event_id text NOT NULL UNIQUE,
event_type text NOT NULL,
app_user_id text NOT NULL,
processed_at timestamptz NOT NULL DEFAULT now(),
payload jsonb NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS redemption_codes (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
code text NOT NULL,
description text,
max_uses integer DEFAULT 1 CHECK (max_uses IS NULL OR max_uses >= 0),
current_uses integer NOT NULL DEFAULT 0 CHECK (current_uses >= 0),
expires_at timestamptz,
is_active boolean NOT NULL DEFAULT true,
created_by uuid REFERENCES profiles(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS redemption_codes_upper_code_idx ON redemption_codes(upper(code));
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS code_redemptions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
code_id uuid NOT NULL REFERENCES redemption_codes(id) ON DELETE CASCADE,
user_id uuid NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
redeemed_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (code_id, user_id)
);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS code_redemptions_user_id_idx ON code_redemptions(user_id);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS redemption_rate_limits (
bucket timestamptz PRIMARY KEY,
attempts integer NOT NULL CHECK (attempts >= 0)
);
@@ -0,0 +1,15 @@
ALTER TABLE profiles
ADD COLUMN IF NOT EXISTS last_nudge_sent_at timestamptz;
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS live_draw_sessions (
couple_id uuid PRIMARY KEY REFERENCES couples(id) ON DELETE CASCADE,
strokes jsonb NOT NULL DEFAULT '[]'::jsonb,
revision bigint NOT NULL DEFAULT 1,
updated_by uuid NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT live_draw_sessions_strokes_array CHECK (jsonb_typeof(strokes) = 'array')
);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS live_draw_sessions_updated_at_idx
ON live_draw_sessions(updated_at DESC);
@@ -0,0 +1,161 @@
ALTER TABLE question_packs
ADD COLUMN IF NOT EXISTS scheduled_release_at timestamptz,
ADD COLUMN IF NOT EXISTS release_notified boolean NOT NULL DEFAULT false;
--> statement-breakpoint
ALTER TABLE profiles
ADD COLUMN IF NOT EXISTS last_unpaired_reminder_at timestamptz;
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS pending_match_notifications (
couple_id uuid PRIMARY KEY REFERENCES couples(id) ON DELETE CASCADE,
active_user_id uuid NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
match_count integer NOT NULL DEFAULT 0,
response_count integer NOT NULL DEFAULT 1,
latest_match_id uuid REFERENCES matches(id) ON DELETE SET NULL,
notify_at timestamptz NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS pending_match_notifications_due_idx ON pending_match_notifications(notify_at);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS pending_pack_notifications (
couple_id uuid PRIMARY KEY REFERENCES couples(id) ON DELETE CASCADE,
changed_by_user_id uuid NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
notify_at timestamptz NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS pending_pack_notifications_due_idx ON pending_pack_notifications(notify_at);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS catchup_reminder_tracking (
user_id uuid PRIMARY KEY REFERENCES profiles(id) ON DELETE CASCADE,
pending_since timestamptz,
last_reminder_sent_at timestamptz,
reminder_count integer NOT NULL DEFAULT 0 CHECK (reminder_count >= 0)
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS operations_outbox (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
kind text NOT NULL CHECK (kind IN ('expo', 'discord', 'classify')),
dedupe_key text NOT NULL UNIQUE,
recipient_id uuid REFERENCES profiles(id) ON DELETE CASCADE,
payload jsonb NOT NULL,
available_at timestamptz NOT NULL DEFAULT now(),
attempts integer NOT NULL DEFAULT 0 CHECK (attempts >= 0),
locked_at timestamptz,
sent_at timestamptz,
last_error text,
created_at timestamptz NOT NULL DEFAULT now()
);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS operations_outbox_due_idx
ON operations_outbox(available_at, created_at) WHERE sent_at IS NULL;
--> statement-breakpoint
CREATE OR REPLACE FUNCTION queue_response_digest() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF current_setting('sauci.suppress_operations',true)='on' THEN RETURN NEW; END IF;
INSERT INTO pending_match_notifications(couple_id, active_user_id, notify_at)
VALUES(NEW.couple_id, NEW.user_id, now() + interval '5 minutes')
ON CONFLICT(couple_id) DO UPDATE SET
active_user_id=excluded.active_user_id,
response_count=pending_match_notifications.response_count+1,
notify_at=excluded.notify_at,
updated_at=now();
RETURN NEW;
END $$;
--> statement-breakpoint
DROP TRIGGER IF EXISTS operations_response_digest ON responses;
CREATE TRIGGER operations_response_digest AFTER INSERT ON responses
FOR EACH ROW EXECUTE FUNCTION queue_response_digest();
--> statement-breakpoint
CREATE OR REPLACE FUNCTION count_digest_match() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF current_setting('sauci.suppress_operations',true)='on' THEN RETURN NEW; END IF;
UPDATE pending_match_notifications SET match_count=match_count+1,
latest_match_id=NEW.id, updated_at=now() WHERE couple_id=NEW.couple_id;
RETURN NEW;
END $$;
--> statement-breakpoint
DROP TRIGGER IF EXISTS operations_match_digest ON matches;
CREATE TRIGGER operations_match_digest AFTER INSERT ON matches
FOR EACH ROW EXECUTE FUNCTION count_digest_match();
--> statement-breakpoint
CREATE OR REPLACE FUNCTION queue_message_operations() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE v_couple_id uuid; v_recipient record;
BEGIN
IF current_setting('sauci.suppress_operations',true)='on' THEN RETURN NEW; END IF;
SELECT couple_id INTO v_couple_id FROM matches WHERE id=NEW.match_id;
FOR v_recipient IN
SELECT p.id FROM profiles p
LEFT JOIN notification_preferences np ON np.user_id=p.id
WHERE p.couple_id=v_couple_id AND p.id<>NEW.user_id
AND COALESCE(np.messages_enabled,true)=true
LOOP
INSERT INTO operations_outbox(kind,dedupe_key,recipient_id,payload)
VALUES('expo','message:'||NEW.id||':'||v_recipient.id,v_recipient.id,
jsonb_build_object('title','New message','body','Your partner sent you a message',
'data',jsonb_build_object('type','message','match_id',NEW.match_id,'message_id',NEW.id)))
ON CONFLICT(dedupe_key) DO NOTHING;
END LOOP;
INSERT INTO operations_outbox(kind,dedupe_key,payload)
VALUES('classify','classify:'||NEW.id,jsonb_build_object('message_id',NEW.id))
ON CONFLICT(dedupe_key) DO NOTHING;
RETURN NEW;
END $$;
--> statement-breakpoint
DROP TRIGGER IF EXISTS operations_message_created ON messages;
CREATE TRIGGER operations_message_created AFTER INSERT ON messages
FOR EACH ROW EXECUTE FUNCTION queue_message_operations();
--> statement-breakpoint
CREATE OR REPLACE FUNCTION queue_pack_change(p_couple_id uuid, p_user_id uuid) RETURNS void LANGUAGE plpgsql AS $$
BEGIN
IF NOT EXISTS(SELECT 1 FROM profiles WHERE id=p_user_id AND couple_id=p_couple_id) THEN
RAISE EXCEPTION 'user is not a member of couple';
END IF;
INSERT INTO pending_pack_notifications(couple_id,changed_by_user_id,notify_at)
VALUES(p_couple_id,p_user_id,now()+interval '30 minutes')
ON CONFLICT(couple_id) DO UPDATE SET changed_by_user_id=excluded.changed_by_user_id,
notify_at=excluded.notify_at,updated_at=now();
END $$;
--> statement-breakpoint
CREATE OR REPLACE FUNCTION queue_discord_operations() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF current_setting('sauci.suppress_operations',true)='on' THEN RETURN NEW; END IF;
IF TG_TABLE_NAME='profiles' AND NEW.onboarding_completed=true
AND (TG_OP='INSERT' OR OLD.onboarding_completed=false) THEN
INSERT INTO operations_outbox(kind,dedupe_key,payload)
VALUES('discord','discord:new_user:'||NEW.id,
jsonb_build_object('event','new_user','user_id',NEW.id,'name',NEW.name,'email',NEW.email,'created_at',NEW.created_at))
ON CONFLICT(dedupe_key) DO NOTHING;
END IF;
IF TG_TABLE_NAME='profiles' AND NEW.couple_id IS NOT NULL
AND (TG_OP='INSERT' OR OLD.couple_id IS DISTINCT FROM NEW.couple_id)
AND EXISTS(SELECT 1 FROM profiles partner WHERE partner.couple_id=NEW.couple_id AND partner.id<>NEW.id) THEN
INSERT INTO operations_outbox(kind,dedupe_key,payload)
VALUES('discord','discord:couple_paired:'||NEW.couple_id,
jsonb_build_object('event','couple_paired','couple_id',NEW.couple_id,'paired_at',now()))
ON CONFLICT(dedupe_key) DO NOTHING;
END IF;
RETURN NEW;
END $$;
--> statement-breakpoint
DROP TRIGGER IF EXISTS operations_profile_discord ON profiles;
CREATE TRIGGER operations_profile_discord AFTER INSERT OR UPDATE OF onboarding_completed,couple_id ON profiles
FOR EACH ROW EXECUTE FUNCTION queue_discord_operations();
--> statement-breakpoint
CREATE OR REPLACE FUNCTION queue_feedback_discord() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF current_setting('sauci.suppress_operations',true)='on' THEN RETURN NEW; END IF;
INSERT INTO operations_outbox(kind,dedupe_key,payload)
VALUES('discord','discord:feedback:'||NEW.id,
jsonb_build_object('event','feedback_submitted','feedback_id',NEW.id,'user_id',NEW.user_id,
'type',NEW.type,'status',NEW.status,'title',NEW.title,'description',NEW.description,
'question_id',NEW.question_id,'created_at',NEW.created_at))
ON CONFLICT(dedupe_key) DO NOTHING;
RETURN NEW;
END $$;
--> statement-breakpoint
DROP TRIGGER IF EXISTS operations_feedback_discord ON feedback;
CREATE TRIGGER operations_feedback_discord AFTER INSERT ON feedback
FOR EACH ROW EXECUTE FUNCTION queue_feedback_discord();
+178
View File
@@ -0,0 +1,178 @@
CREATE TABLE IF NOT EXISTS admin_users (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL UNIQUE REFERENCES profiles(id) ON DELETE CASCADE,
role text NOT NULL CHECK (role IN ('pack_creator', 'super_admin')),
permissions text[] NOT NULL DEFAULT '{}',
is_active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS admin_users_active_user_idx ON admin_users(user_id) WHERE is_active = true;
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS audit_logs (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
admin_user_id uuid REFERENCES admin_users(id) ON DELETE SET NULL,
actor_user_id uuid NOT NULL REFERENCES profiles(id) ON DELETE RESTRICT,
table_name text NOT NULL,
action text NOT NULL CHECK (action IN ('INSERT', 'UPDATE', 'DELETE', 'ACTION')),
record_id text,
old_values jsonb,
new_values jsonb,
created_at timestamptz NOT NULL DEFAULT now()
);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS audit_logs_created_idx ON audit_logs(created_at DESC);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS audit_logs_actor_idx ON audit_logs(actor_user_id, created_at DESC);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS topics (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL UNIQUE,
created_at timestamptz NOT NULL DEFAULT now()
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS pack_topics (
pack_id uuid NOT NULL REFERENCES question_packs(id) ON DELETE CASCADE,
topic_id uuid NOT NULL REFERENCES topics(id) ON DELETE CASCADE,
PRIMARY KEY (pack_id, topic_id)
);
--> statement-breakpoint
ALTER TABLE categories ALTER COLUMN id SET DEFAULT gen_random_uuid();
--> statement-breakpoint
ALTER TABLE question_packs ALTER COLUMN id SET DEFAULT gen_random_uuid();
--> statement-breakpoint
ALTER TABLE questions ALTER COLUMN id SET DEFAULT gen_random_uuid();
--> statement-breakpoint
ALTER TABLE question_packs
ADD COLUMN IF NOT EXISTS scheduled_release_at timestamptz,
ADD COLUMN IF NOT EXISTS release_notified boolean NOT NULL DEFAULT false;
--> statement-breakpoint
ALTER TABLE questions
ADD COLUMN IF NOT EXISTS inverse_of uuid REFERENCES questions(id) ON DELETE SET NULL;
--> statement-breakpoint
ALTER TABLE messages
ADD COLUMN IF NOT EXISTS moderation_status text DEFAULT 'unmoderated',
ADD COLUMN IF NOT EXISTS flag_reason text;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE messages ADD CONSTRAINT messages_moderation_status_check
CHECK (moderation_status IS NULL OR moderation_status IN ('safe', 'flagged', 'unmoderated'));
EXCEPTION WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
ALTER TABLE message_reports
ADD COLUMN IF NOT EXISTS reviewed_by uuid REFERENCES profiles(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS reviewed_at timestamptz;
--> statement-breakpoint
ALTER TABLE feedback
ADD COLUMN IF NOT EXISTS admin_notes text;
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS ai_config (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
openrouter_api_key text,
default_model text DEFAULT 'openai/gpt-4o-mini',
default_temperature double precision,
model_generate text,
temperature_generate double precision,
model_fix text,
temperature_fix double precision,
model_polish text,
temperature_polish double precision,
council_enabled boolean NOT NULL DEFAULT false,
council_generator_model text DEFAULT 'anthropic/claude-3.5-sonnet',
council_generators jsonb DEFAULT '[{"model":"anthropic/claude-3.5-sonnet"}]'::jsonb,
council_reviewer_model text DEFAULT 'google/gemini-pro-1.5',
council_reviewer_temperature double precision,
council_selection_mode text DEFAULT 'whole_set' CHECK (
council_selection_mode IS NULL OR council_selection_mode IN ('whole_set', 'cherry_pick')
),
cherry_pick_ensure_intensity_distribution boolean DEFAULT true,
classifier_enabled boolean DEFAULT true,
classifier_model text DEFAULT 'openai/gpt-4o',
classifier_temperature double precision,
classifier_prompt text,
heuristics_enabled boolean NOT NULL DEFAULT false,
heuristic_min_text_length integer NOT NULL DEFAULT 12,
heuristic_whitelist_max_length integer NOT NULL DEFAULT 30,
heuristic_skip_if_no_alnum boolean NOT NULL DEFAULT true,
heuristic_skip_media_without_text boolean NOT NULL DEFAULT false,
heuristic_record_reason boolean NOT NULL DEFAULT false,
heuristic_use_default_whitelist boolean NOT NULL DEFAULT true,
heuristic_use_default_keywords boolean NOT NULL DEFAULT true,
heuristic_whitelist text,
heuristic_keyword_triggers text,
updated_at timestamptz DEFAULT now(),
updated_by uuid REFERENCES profiles(id) ON DELETE SET NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS ai_config_singleton_idx ON ai_config ((true));
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS dare_packs (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(), name text NOT NULL, description text, icon text,
is_premium boolean NOT NULL DEFAULT false, is_public boolean NOT NULL DEFAULT true,
is_explicit boolean NOT NULL DEFAULT false, sort_order integer NOT NULL DEFAULT 0,
category_id uuid REFERENCES categories(id) ON DELETE SET NULL,
min_intensity integer, max_intensity integer, avg_intensity numeric(3,2),
created_at timestamptz NOT NULL DEFAULT now()
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS dares (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
pack_id uuid NOT NULL REFERENCES dare_packs(id) ON DELETE CASCADE,
text text NOT NULL, intensity integer NOT NULL DEFAULT 1 CHECK (intensity BETWEEN 1 AND 5),
suggested_duration_hours integer, created_at timestamptz NOT NULL DEFAULT now()
);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS dares_pack_idx ON dares(pack_id);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS master_keys (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
key_name text NOT NULL UNIQUE,
public_key_jwk jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
rotated_at timestamptz,
is_active boolean NOT NULL DEFAULT true
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS sent_dares (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
couple_id uuid NOT NULL REFERENCES couples(id) ON DELETE CASCADE,
dare_id uuid REFERENCES dares(id) ON DELETE SET NULL,
custom_dare_text text,
custom_dare_intensity integer CHECK (
custom_dare_intensity IS NULL OR custom_dare_intensity BETWEEN 1 AND 5
),
sender_id uuid NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
recipient_id uuid NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
status text NOT NULL DEFAULT 'pending' CHECK (
status IN ('pending', 'active', 'completed', 'expired', 'declined', 'cancelled')
),
sent_at timestamptz NOT NULL DEFAULT now(),
accepted_at timestamptz,
expires_at timestamptz,
completed_at timestamptz,
sender_notes text,
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT sent_dares_dare_or_custom_check CHECK (
dare_id IS NOT NULL OR custom_dare_text IS NOT NULL
)
);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS sent_dares_couple_idx ON sent_dares(couple_id);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS sent_dares_dare_idx ON sent_dares(dare_id);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS dare_messages (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
sent_dare_id uuid NOT NULL REFERENCES sent_dares(id) ON DELETE CASCADE,
sender_id uuid NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
content text NOT NULL,
read_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now()
);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS dare_messages_sent_dare_created_idx
ON dare_messages(sent_dare_id, created_at);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS dare_messages_sender_idx ON dare_messages(sender_id);
@@ -0,0 +1,3 @@
ALTER TABLE "question_packs"
ALTER COLUMN "avg_intensity" TYPE numeric(3,2)
USING "avg_intensity"::numeric(3,2);
@@ -0,0 +1,7 @@
ALTER TABLE "topics"
ADD COLUMN IF NOT EXISTS "description" text,
ADD COLUMN IF NOT EXISTS "icon" text,
ADD COLUMN IF NOT EXISTS "sort_order" integer NOT NULL DEFAULT 0;
--> statement-breakpoint
ALTER TABLE "questions"
ADD COLUMN IF NOT EXISTS "required_props" text[];
+97
View File
@@ -0,0 +1,97 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1787853600000,
"tag": "0000_identity_and_feature_interests",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1787864000000,
"tag": "0001_couples",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1787864744000,
"tag": "0002_packs_catalog_progress",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1787865000000,
"tag": "0003_answers_matches",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1787865100000,
"tag": "0004_chat",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1787865200000,
"tag": "0005_profile_settings",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1787865300000,
"tag": "0006_media_storage",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1787865400000,
"tag": "0007_billing_redemption",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1787865500000,
"tag": "0008_residual_realtime",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1787865600000,
"tag": "0009_operations_workers",
"breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1787865700000,
"tag": "0010_admin",
"breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1787907600000,
"tag": "0011_question_pack_average_precision",
"breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1787908500000,
"tag": "0012_catalogue_field_parity",
"breakpoints": true
}
]
}
+43
View File
@@ -0,0 +1,43 @@
{
"name": "@sauci/api",
"version": "0.1.0",
"private": true,
"type": "module",
"engines": {
"node": ">=20 <21"
},
"scripts": {
"dev": "tsx watch src/index.ts",
"dev:worker": "tsx watch src/workers/index.ts",
"build": "tsup src/index.ts src/db/migrate.ts src/migration/run.ts src/workers/index.ts --format esm --clean",
"start": "node dist/index.js",
"start:worker": "node dist/workers/index.js",
"db:generate": "drizzle-kit generate",
"db:migrate": "tsx src/db/migrate.ts",
"db:migrate:prod": "node dist/db/migrate.js",
"db:seed": "tsx src/db/seed.ts",
"migration:cutover": "tsx src/migration/run.ts",
"test": "vitest run",
"test:integration": "vitest run --config vitest.integration.config.ts",
"typecheck": "tsc --noEmit",
"lint": "tsc --noEmit"
},
"dependencies": {
"@hono/node-server": "^1.13.7",
"@sauci/shared": "*",
"drizzle-orm": "^0.45.2",
"hono": "^4.6.0",
"jose": "^6.1.3",
"pg": "^8.17.2",
"zod": "^3.23.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/pg": "^8.15.5",
"drizzle-kit": "^0.31.4",
"tsup": "^8.2.4",
"tsx": "^4.19.0",
"typescript": "^5.5.0",
"vitest": "^2.1.0"
}
}
+128
View File
@@ -0,0 +1,128 @@
import type { ApiErrorResponse, MeResponse } from '@sauci/shared';
import { Hono } from 'hono';
import { z } from 'zod';
import { bearerToken, type AuthIdentity, type AuthVerifier } from './auth.js';
import type { ApiRepository } from './db/repository.js';
import type { AppDataRepository } from './domains/app-data/repository.js';
import { registerAppDataRoutes } from './domains/app-data/routes.js';
import type { BillingService } from './domains/billing/service.js';
import { registerBillingRoutes } from './domains/billing/routes.js';
import type { AccountOperationsService } from './domains/account-operations/service.js';
import { registerAccountOperationRoutes } from './domains/account-operations/routes.js';
import type { AnswersRepository } from './domains/answers/repository.js';
import { registerAnswerRoutes } from './domains/answers/routes.js';
import type { ChatRepository } from './domains/chat/repository.js';
import { registerChatRoutes } from './domains/chat/routes.js';
import { registerCoupleRoutes } from './domains/couples/routes.js';
import type { CoupleService } from './domains/couples/service.js';
import type { PacksRepository } from './domains/packs/repository.js';
import { registerPackRoutes } from './domains/packs/routes.js';
import type { ProfileSettingsRepository } from './domains/profile-settings/repository.js';
import { registerProfileSettingsRoutes } from './domains/profile-settings/routes.js';
import type { MediaRepository } from './domains/media/repository.js';
import { registerMediaRoutes } from './domains/media/routes.js';
import type { FilesystemMediaStorage } from './domains/media/storage.js';
import type { AdminRequestAuth } from './domains/admin/auth.js';
import type { AdminRepository } from './domains/admin/repository.js';
import { registerAdminRoutes } from './domains/admin/routes.js';
type Variables = { identity: AuthIdentity };
export interface AppDependencies {
auth: AuthVerifier;
repository: ApiRepository;
coupleService?: CoupleService;
packsRepository?: PacksRepository;
answersRepository?: AnswersRepository;
chatRepository?: ChatRepository;
profileSettingsRepository?: ProfileSettingsRepository;
accountOperationsService?: AccountOperationsService;
mediaRepository?: MediaRepository;
mediaStorage?: FilesystemMediaStorage;
appDataRepository?: AppDataRepository;
billingService?: BillingService;
adminRepository?: AdminRepository;
adminAuth?: AdminRequestAuth;
}
const featureSchema = z.string().min(1).max(80).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/);
function error(code: string, message: string): ApiErrorResponse {
return { error: { code, message } };
}
export function createApp(deps: AppDependencies): Hono<{ Variables: Variables }> {
const app = new Hono<{ Variables: Variables }>();
app.get('/health/live', (c) => c.json({ status: 'ok' }));
app.get('/health/ready', async (c) => {
try {
await deps.repository.ready();
return c.json({ status: 'ready' });
} catch {
return c.json(error('not_ready', 'Database is unavailable'), 503);
}
});
app.use('/v1/*', async (c, next) => {
// Admin routes accept either a hosted user token or the separately scoped
// MCP service credential and therefore own their authentication middleware.
if (c.req.path.startsWith('/v1/admin/')) {
await next();
return;
}
const token = bearerToken(c.req.header('authorization'));
if (!token) return c.json(error('unauthorized', 'A bearer token is required'), 401);
try {
c.set('identity', await deps.auth.verify(token));
} catch {
return c.json(error('unauthorized', 'The bearer token is invalid'), 401);
}
await next();
});
app.get('/v1/me', async (c) => {
const profile = await deps.repository.upsertProfile(c.get('identity'));
return c.json<MeResponse>({ profile });
});
app.get('/v1/me/feature-interests/:feature', async (c) => {
const parsed = featureSchema.safeParse(c.req.param('feature'));
if (!parsed.success) return c.json(error('invalid_feature', 'Feature must be a lowercase slug'), 400);
return c.json(await deps.repository.getFeatureInterest(c.get('identity').id, parsed.data));
});
app.put('/v1/me/feature-interests/:feature', async (c) => {
const parsed = featureSchema.safeParse(c.req.param('feature'));
if (!parsed.success) return c.json(error('invalid_feature', 'Feature must be a lowercase slug'), 400);
await deps.repository.upsertProfile(c.get('identity'));
return c.json(await deps.repository.putFeatureInterest(c.get('identity').id, parsed.data));
});
app.delete('/v1/me/feature-interests/:feature', async (c) => {
const parsed = featureSchema.safeParse(c.req.param('feature'));
if (!parsed.success) return c.json(error('invalid_feature', 'Feature must be a lowercase slug'), 400);
return c.json(await deps.repository.deleteFeatureInterest(c.get('identity').id, parsed.data));
});
if (deps.coupleService) registerCoupleRoutes(app, deps.coupleService);
if (deps.packsRepository) registerPackRoutes(app, deps.packsRepository);
if (deps.answersRepository) registerAnswerRoutes(app, deps.answersRepository);
if (deps.chatRepository) registerChatRoutes(app, deps.chatRepository);
if (deps.profileSettingsRepository) registerProfileSettingsRoutes(app, deps.profileSettingsRepository);
if (deps.accountOperationsService) registerAccountOperationRoutes(app, deps.accountOperationsService);
if (deps.mediaRepository && deps.mediaStorage) registerMediaRoutes(app, deps.mediaRepository, deps.mediaStorage);
if (deps.appDataRepository) registerAppDataRoutes(app, deps.appDataRepository);
if (deps.billingService) registerBillingRoutes(app, deps.billingService);
if (deps.adminRepository && deps.adminAuth) {
registerAdminRoutes(app, deps.adminRepository, deps.adminAuth, deps.mediaStorage);
}
app.notFound((c) => c.json(error('not_found', 'Route not found'), 404));
app.onError((cause, c) => {
console.error(cause);
return c.json(error('internal_error', 'An unexpected error occurred'), 500);
});
return app;
}
+74
View File
@@ -0,0 +1,74 @@
import {
createLocalJWKSet,
createRemoteJWKSet,
jwtVerify,
type JSONWebKeySet,
type JWTVerifyGetKey,
type JWTPayload,
} from 'jose';
import { z } from 'zod';
import type { AppConfig } from './config.js';
const uuidSchema = z.string().uuid();
export interface AuthIdentity {
id: string;
email: string | null;
name: string | null;
avatarUrl: string | null;
}
export interface AuthVerifier {
verify(token: string): Promise<AuthIdentity>;
}
function optionalString(value: unknown): string | null {
return typeof value === 'string' && value.trim() !== '' ? value : null;
}
function identityFromClaims(claims: JWTPayload): AuthIdentity {
const id = uuidSchema.parse(claims.sub);
const metadata = claims.user_metadata && typeof claims.user_metadata === 'object'
? claims.user_metadata as Record<string, unknown>
: {};
return {
id,
email: optionalString(claims.email),
name: optionalString(metadata.full_name) ?? optionalString(metadata.name),
avatarUrl: optionalString(metadata.avatar_url) ?? optionalString(metadata.picture),
};
}
export function createAuthVerifier(config: AppConfig): AuthVerifier {
let keySet: JWTVerifyGetKey;
if (config.authTestJwks) {
if (config.nodeEnv !== 'test') {
throw new Error('Local JWKS verification is forbidden outside test');
}
const jwks = JSON.parse(config.authTestJwks) as JSONWebKeySet;
keySet = createLocalJWKSet(jwks);
} else {
if (!config.authJwksUrl) {
throw new Error('A remote Supabase Auth JWKS URL is required');
}
keySet = createRemoteJWKSet(new URL(config.authJwksUrl));
}
return {
async verify(token: string): Promise<AuthIdentity> {
const { payload } = await jwtVerify(token, keySet, {
issuer: config.authIssuer,
audience: config.authAudience,
algorithms: ['ES256', 'RS256'],
});
return identityFromClaims(payload);
},
};
}
export function bearerToken(header: string | undefined): string | null {
if (!header) return null;
const match = /^Bearer\s+(\S+)$/i.exec(header);
return match?.[1] ?? null;
}
+96
View File
@@ -0,0 +1,96 @@
import { z } from 'zod';
const baseSchema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
PORT: z.coerce.number().int().min(1).max(65535).default(3003),
DATABASE_URL: z.string().url(),
SUPABASE_AUTH_URL: z.string().url().optional(),
SUPABASE_AUTH_ISSUER: z.string().url().optional(),
SUPABASE_AUTH_JWKS_URL: z.string().url().optional(),
SUPABASE_AUTH_AUDIENCE: z.string().min(1).default('authenticated'),
AUTH_TEST_JWKS: z.string().optional(),
MEDIA_ROOT: z.string().min(1).default('/data/media'),
MEDIA_SIGNING_SECRET: z.string().min(32).optional(),
MEDIA_PUBLIC_BASE_URL: z.string().url().optional(),
SUPABASE_AUTH_SERVICE_ROLE_KEY: z.string().min(1).optional(),
REVENUECAT_API_KEY: z.string().min(1).optional(),
REVENUECAT_ENTITLEMENT_ID: z.string().min(1).default('Sauci Pro'),
REVENUECAT_WEBHOOK_SECRET: z.string().min(1).optional(),
ADMIN_API_SERVICE_TOKEN: z.string().min(32).optional(),
ADMIN_API_SERVICE_USER_ID: z.string().uuid().optional(),
ADMIN_PRIVATE_KEY_JWK: z.string().optional(),
});
export interface AppConfig {
nodeEnv: 'development' | 'test' | 'production';
port: number;
databaseUrl: string;
authIssuer: string;
authJwksUrl?: string;
authAudience: string;
authTestJwks?: string;
mediaRoot: string;
mediaSigningSecret: string;
mediaPublicBaseUrl: string;
supabaseAuthServiceRoleKey?: string;
revenueCatApiKey?: string;
revenueCatEntitlementId: string;
revenueCatWebhookSecret?: string;
adminApiServiceToken?: string;
adminApiServiceUserId?: string;
adminPrivateKeyJwk?: string;
}
export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
const parsed = baseSchema.parse(env);
if (Boolean(parsed.ADMIN_API_SERVICE_TOKEN) !== Boolean(parsed.ADMIN_API_SERVICE_USER_ID)) {
throw new Error('ADMIN_API_SERVICE_TOKEN and ADMIN_API_SERVICE_USER_ID must be configured together');
}
if (parsed.ADMIN_PRIVATE_KEY_JWK) {
try { JSON.parse(parsed.ADMIN_PRIVATE_KEY_JWK); } catch { throw new Error('ADMIN_PRIVATE_KEY_JWK must be valid JSON'); }
}
if (parsed.AUTH_TEST_JWKS && parsed.NODE_ENV !== 'test') {
throw new Error('AUTH_TEST_JWKS is forbidden outside NODE_ENV=test');
}
const authUrl = parsed.SUPABASE_AUTH_URL?.replace(/\/$/, '');
const authIssuer = parsed.SUPABASE_AUTH_ISSUER ?? (authUrl ? `${authUrl}/auth/v1` : undefined);
const authJwksUrl = parsed.SUPABASE_AUTH_JWKS_URL ??
(authUrl ? `${authUrl}/auth/v1/.well-known/jwks.json` : undefined);
if (!authIssuer || (!authJwksUrl && !parsed.AUTH_TEST_JWKS)) {
throw new Error('Supabase Auth issuer and JWKS configuration are required');
}
if (parsed.NODE_ENV === 'production' &&
(new URL(authIssuer).protocol !== 'https:' ||
(authJwksUrl && new URL(authJwksUrl).protocol !== 'https:'))) {
throw new Error('Supabase Auth issuer and JWKS URLs must use HTTPS in production');
}
if (parsed.NODE_ENV === 'production' && !parsed.MEDIA_SIGNING_SECRET) {
throw new Error('MEDIA_SIGNING_SECRET is required in production');
}
const mediaPublicBaseUrl = parsed.MEDIA_PUBLIC_BASE_URL ?? `http://127.0.0.1:${parsed.PORT}`;
if (parsed.NODE_ENV === 'production' && new URL(mediaPublicBaseUrl).protocol !== 'https:') {
throw new Error('MEDIA_PUBLIC_BASE_URL must use HTTPS in production');
}
return {
nodeEnv: parsed.NODE_ENV,
port: parsed.PORT,
databaseUrl: parsed.DATABASE_URL,
authIssuer,
authJwksUrl,
authAudience: parsed.SUPABASE_AUTH_AUDIENCE,
authTestJwks: parsed.AUTH_TEST_JWKS,
mediaRoot: parsed.MEDIA_ROOT,
mediaSigningSecret: parsed.MEDIA_SIGNING_SECRET ?? 'local-media-signing-secret-change-me-123456',
mediaPublicBaseUrl: mediaPublicBaseUrl.replace(/\/$/, ''),
supabaseAuthServiceRoleKey: parsed.SUPABASE_AUTH_SERVICE_ROLE_KEY,
revenueCatApiKey: parsed.REVENUECAT_API_KEY,
revenueCatEntitlementId: parsed.REVENUECAT_ENTITLEMENT_ID,
revenueCatWebhookSecret: parsed.REVENUECAT_WEBHOOK_SECRET,
adminApiServiceToken: parsed.ADMIN_API_SERVICE_TOKEN,
adminApiServiceUserId: parsed.ADMIN_API_SERVICE_USER_ID,
adminPrivateKeyJwk: parsed.ADMIN_PRIVATE_KEY_JWK,
};
}
+6
View File
@@ -0,0 +1,6 @@
export * from './schema.js';
export * from '../domains/couples/schema.js';
export * from '../domains/packs/schema.js';
export * from '../domains/answers/schema.js';
export * from '../domains/chat/schema.js';
export * from '../domains/app-data/schema.js';
+25
View File
@@ -0,0 +1,25 @@
import { resolve } from 'node:path';
import { drizzle } from 'drizzle-orm/node-postgres';
import { migrate } from 'drizzle-orm/node-postgres/migrator';
import { Pool } from 'pg';
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
throw new Error('DATABASE_URL is required for database migrations');
}
const migrationsFolder = resolve(process.env.MIGRATIONS_DIR ?? 'drizzle');
const pool = new Pool({ connectionString: databaseUrl, max: 2 });
const lockClient = await pool.connect();
try {
// Serializes migration containers during rolling/retried deployments.
await lockClient.query('select pg_advisory_lock($1)', [2_024_082_701]);
await migrate(drizzle(pool), { migrationsFolder });
console.log(`Database migrations completed from ${migrationsFolder}`);
} finally {
await lockClient.query('select pg_advisory_unlock($1)', [2_024_082_701]).catch(() => undefined);
lockClient.release();
await pool.end();
}
+98
View File
@@ -0,0 +1,98 @@
import type { FeatureInterestResponse, Profile } from '@sauci/shared';
import { and, eq, sql } from 'drizzle-orm';
import { drizzle, type NodePgDatabase } from 'drizzle-orm/node-postgres';
import { Pool } from 'pg';
import type { AuthIdentity } from '../auth.js';
import { featureInterests, profiles, type ProfileRow } from './schema.js';
export type MobileCompatibleProfile = Profile & {
email: string | null;
gender: 'male' | 'female' | 'non-binary' | 'prefer-not-to-say' | null;
show_explicit_content: boolean;
max_intensity: 1 | 2 | 3 | 4 | 5;
};
export interface ApiRepository {
ready(): Promise<void>;
upsertProfile(identity: AuthIdentity): Promise<MobileCompatibleProfile>;
getFeatureInterest(userId: string, feature: string): Promise<FeatureInterestResponse>;
putFeatureInterest(userId: string, feature: string): Promise<FeatureInterestResponse>;
deleteFeatureInterest(userId: string, feature: string): Promise<FeatureInterestResponse>;
close(): Promise<void>;
}
function toProfile(row: ProfileRow): MobileCompatibleProfile {
return {
id: row.id,
name: row.name,
email: row.email,
avatar_url: row.avatarUrl,
push_token: row.pushToken,
is_premium: row.isPremium,
couple_id: row.coupleId,
gender: row.gender,
show_explicit_content: row.showExplicitContent,
max_intensity: row.maxIntensity as 1 | 2 | 3 | 4 | 5,
created_at: row.createdAt.toISOString(),
updated_at: row.updatedAt.toISOString(),
public_key_jwk: row.publicKeyJwk,
hide_nsfw: row.hideNsfw,
onboarding_completed: row.onboardingCompleted,
onboarding_version: row.onboardingVersion,
};
}
export class PostgresRepository implements ApiRepository {
private readonly pool: Pool;
private readonly db: NodePgDatabase;
constructor(databaseUrl: string) {
this.pool = new Pool({ connectionString: databaseUrl });
this.db = drizzle(this.pool);
}
async ready(): Promise<void> {
await this.db.select({ id: profiles.id }).from(profiles).limit(1);
}
async upsertProfile(identity: AuthIdentity): Promise<MobileCompatibleProfile> {
const [row] = await this.db.insert(profiles).values({
id: identity.id,
name: identity.name,
avatarUrl: identity.avatarUrl,
email: identity.email,
}).onConflictDoUpdate({
target: profiles.id,
set: {
email: sql`coalesce(excluded.email, ${profiles.email})`,
authSyncedAt: new Date(),
},
}).returning();
if (!row) throw new Error('Profile upsert returned no row');
return toProfile(row);
}
async getFeatureInterest(userId: string, feature: string): Promise<FeatureInterestResponse> {
const [row] = await this.db.select({ feature: featureInterests.feature })
.from(featureInterests)
.where(and(eq(featureInterests.userId, userId), eq(featureInterests.feature, feature)))
.limit(1);
return { feature, interested: Boolean(row) };
}
async putFeatureInterest(userId: string, feature: string): Promise<FeatureInterestResponse> {
await this.db.insert(featureInterests).values({ userId, feature }).onConflictDoNothing();
return { feature, interested: true };
}
async deleteFeatureInterest(userId: string, feature: string): Promise<FeatureInterestResponse> {
await this.db.delete(featureInterests)
.where(and(eq(featureInterests.userId, userId), eq(featureInterests.feature, feature)));
return { feature, interested: false };
}
async close(): Promise<void> {
await this.pool.end();
}
}
+32
View File
@@ -0,0 +1,32 @@
import { boolean, index, integer, jsonb, pgTable, primaryKey, text, timestamp, uuid } from 'drizzle-orm/pg-core';
export const profiles = pgTable('profiles', {
id: uuid('id').primaryKey(),
name: text('name'),
email: text('email'),
avatarUrl: text('avatar_url'),
pushToken: text('push_token'),
isPremium: boolean('is_premium').notNull().default(false),
coupleId: uuid('couple_id'),
gender: text('gender').$type<'male' | 'female' | 'non-binary' | 'prefer-not-to-say' | null>(),
showExplicitContent: boolean('show_explicit_content').notNull().default(true),
maxIntensity: integer('max_intensity').notNull().default(5),
publicKeyJwk: jsonb('public_key_jwk').$type<Record<string, unknown> | null>(),
hideNsfw: boolean('hide_nsfw').notNull().default(false),
onboardingCompleted: boolean('onboarding_completed').notNull().default(false),
onboardingVersion: integer('onboarding_version').notNull().default(0),
authSyncedAt: timestamp('auth_synced_at', { withTimezone: true }).notNull().defaultNow(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
}, (table) => [index('profiles_couple_id_idx').on(table.coupleId)]);
export const featureInterests = pgTable('feature_interests', {
userId: uuid('user_id').notNull().references(() => profiles.id, { onDelete: 'cascade' }),
feature: text('feature').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
}, (table) => [
primaryKey({ columns: [table.userId, table.feature] }),
index('feature_interests_feature_idx').on(table.feature),
]);
export type ProfileRow = typeof profiles.$inferSelect;
+41
View File
@@ -0,0 +1,41 @@
import 'dotenv/config';
import { Pool } from 'pg';
import { z } from 'zod';
import { PostgresRepository } from './repository.js';
const schema = z.object({
DATABASE_URL: z.string().url(),
SEED_USER_ID: z.string().uuid().optional(),
SEED_USER_EMAIL: z.string().email().default('local@sauci.test'),
SEED_ADMIN_USER_ID: z.string().uuid().default('00000000-0000-4000-8000-000000000002'),
SEED_ADMIN_USER_EMAIL: z.string().email().default('local-admin@sauci.test'),
});
const env = schema.parse(process.env);
const databaseUrl = new URL(env.DATABASE_URL);
if (!['127.0.0.1', 'localhost', '::1'].includes(databaseUrl.hostname)) {
throw new Error('db:seed only permits a localhost DATABASE_URL');
}
const repository = new PostgresRepository(env.DATABASE_URL);
const pool = new Pool({ connectionString: env.DATABASE_URL });
try {
if (env.SEED_USER_ID) await repository.upsertProfile({
id: env.SEED_USER_ID, email: env.SEED_USER_EMAIL, name: 'Local Sauci User', avatarUrl: null,
});
await repository.upsertProfile({
id: env.SEED_ADMIN_USER_ID,
email: env.SEED_ADMIN_USER_EMAIL,
name: 'Local Sauci Admin Service',
avatarUrl: null,
});
await pool.query(`
insert into admin_users(user_id,role,permissions,is_active)
values($1,'super_admin','{}',true)
on conflict(user_id) do update set role='super_admin',is_active=true,updated_at=now()
`, [env.SEED_ADMIN_USER_ID]);
console.log(`Seeded local admin service profile ${env.SEED_ADMIN_USER_ID}`);
if (env.SEED_USER_ID) console.log(`Seeded local profile ${env.SEED_USER_ID}`);
} finally {
await Promise.all([repository.close(), pool.end()]);
}
@@ -0,0 +1,43 @@
# Account operations and billing
This domain replaces the `delete-relationship`, `reset-couple-progress`,
`delete-account`, and `sync-subscription` Supabase Edge Functions. The request
identity always comes from the verified bearer token; no route accepts a user or
couple identifier from the client.
## Server-only configuration
- `SUPABASE_AUTH_URL`: hosted Supabase project URL (or its `/auth/v1`
endpoint), used only for Auth.
- `SUPABASE_AUTH_SERVICE_ROLE_KEY`: server-side Auth Admin credential required
for account deletion. Never expose this as an `EXPO_PUBLIC_*` value.
- `REVENUECAT_API_KEY`: secret RevenueCat REST API key used to verify the token
owner's entitlement.
- `REVENUECAT_ENTITLEMENT_ID`: entitlement checked by sync; defaults to
`Sauci Pro` to preserve the previous function behavior.
Missing provider secrets fail the affected operation with `503` before any
durable local change. Account deletion holds its local transaction open until
hosted Auth confirms deletion, and rolls the transaction back on Auth failure.
Provider error bodies are not returned to the mobile app.
## Application wiring
Create one `PostgresAccountOperationsRepository`, then construct:
```ts
const accountOperationsService = new AccountOperationsService(
accountOperationsRepository,
new SupabaseAuthAdminClient(process.env.SUPABASE_AUTH_URL, process.env.SUPABASE_AUTH_SERVICE_ROLE_KEY),
new HttpRevenueCatClient(process.env.REVENUECAT_API_KEY, process.env.REVENUECAT_ENTITLEMENT_ID),
new ExpoPartnerNotifier(),
);
```
Pass it to the app and call `registerAccountOperationRoutes`. Close the
repository during graceful shutdown. No schema migration is required for these
four operations: they use `profiles`, `couples`, `responses`, and `matches`.
Blob deletion must be handled by the self-hosted media/storage lifecycle when
that vertical is wired. These operations intentionally never call the hosted
Supabase database, Storage API, or Edge Functions.
@@ -0,0 +1,139 @@
import { z } from 'zod';
import { AccountOperationError } from './types.js';
export interface AuthAdminClient {
deleteUser(userId: string): Promise<void>;
}
export interface RevenueCatClient {
isEntitled(userId: string): Promise<boolean>;
}
export interface PartnerNotifier {
relationshipDeleted(pushToken: string): Promise<void>;
progressReset(pushToken: string): Promise<void>;
partnerAccountDeleted(pushToken: string): Promise<void>;
}
type Fetch = typeof globalThis.fetch;
const revenueCatResponse = z.object({
subscriber: z.object({
entitlements: z.record(z.object({ expires_date: z.string().datetime().nullable() }).passthrough()),
}).passthrough(),
}).passthrough();
export class SupabaseAuthAdminClient implements AuthAdminClient {
constructor(
private readonly authUrl: string | undefined,
private readonly serviceRoleKey: string | undefined,
private readonly request: Fetch = globalThis.fetch,
) {}
async deleteUser(userId: string): Promise<void> {
if (!this.authUrl || !this.serviceRoleKey) {
throw new AccountOperationError(
'account_deletion_unavailable',
'Account deletion is temporarily unavailable',
503,
);
}
const configuredUrl = this.authUrl.replace(/\/$/, '');
const authApiUrl = configuredUrl.endsWith('/auth/v1') ? configuredUrl : `${configuredUrl}/auth/v1`;
const response = await this.request(
`${authApiUrl}/admin/users/${encodeURIComponent(userId)}`,
{
method: 'DELETE',
headers: {
apikey: this.serviceRoleKey,
authorization: `Bearer ${this.serviceRoleKey}`,
},
signal: AbortSignal.timeout(10_000),
},
).catch(() => {
throw new AccountOperationError('auth_provider_unavailable', 'Authentication provider is unavailable', 502);
});
if (!response.ok) {
throw new AccountOperationError('auth_delete_failed', 'Failed to delete authentication record', 502);
}
}
}
export class HttpRevenueCatClient implements RevenueCatClient {
constructor(
private readonly apiKey: string | undefined,
private readonly entitlementId = 'Sauci Pro',
private readonly request: Fetch = globalThis.fetch,
private readonly now: () => Date = () => new Date(),
) {}
async isEntitled(userId: string): Promise<boolean> {
if (!this.apiKey) {
throw new AccountOperationError(
'subscription_sync_unavailable',
'Subscription verification is temporarily unavailable',
503,
);
}
const response = await this.request(
`https://api.revenuecat.com/v1/subscribers/${encodeURIComponent(userId)}`,
{
headers: {
authorization: `Bearer ${this.apiKey}`,
accept: 'application/json',
},
signal: AbortSignal.timeout(10_000),
},
).catch(() => {
throw new AccountOperationError('revenuecat_unavailable', 'Subscription provider is unavailable', 502);
});
if (!response.ok) {
throw new AccountOperationError('subscription_verification_failed', 'Failed to verify subscription', 502);
}
const parsed = revenueCatResponse.safeParse(await response.json().catch(() => null));
if (!parsed.success) {
throw new AccountOperationError('invalid_subscription_response', 'Subscription provider returned invalid data', 502);
}
const entitlement = parsed.data.subscriber.entitlements[this.entitlementId];
if (!entitlement) return false;
return entitlement.expires_date === null || new Date(entitlement.expires_date) > this.now();
}
}
export class ExpoPartnerNotifier implements PartnerNotifier {
constructor(private readonly request: Fetch = globalThis.fetch) {}
relationshipDeleted(pushToken: string): Promise<void> {
return this.send(pushToken, 'Relationship Deleted',
'Your partner has deleted all shared data. You can start fresh with a new partner anytime.',
'relationship_deleted');
}
progressReset(pushToken: string): Promise<void> {
return this.send(pushToken, 'Progress Reset',
'Your partner has reset all progress. Your matches and chats have been cleared to start fresh.',
'progress_reset');
}
partnerAccountDeleted(pushToken: string): Promise<void> {
return this.send(pushToken, 'Partner Left',
'Your partner has deleted their account. You can start fresh with a new partner anytime.',
'partner_account_deleted');
}
private async send(to: string, title: string, body: string, type: string): Promise<void> {
const response = await this.request('https://exp.host/--/api/v2/push/send', {
method: 'POST',
headers: { 'content-type': 'application/json', accept: 'application/json' },
body: JSON.stringify({ to, title, body, sound: 'default', data: { type } }),
signal: AbortSignal.timeout(10_000),
});
if (!response.ok) throw new Error(`Expo push failed with status ${response.status}`);
}
}
@@ -0,0 +1,6 @@
export * from './clients.js';
export * from './repository.js';
export * from './routes.js';
export * from './service.js';
export * from './types.js';
@@ -0,0 +1,141 @@
import { Pool, type PoolClient, type QueryResultRow } from 'pg';
import { AccountOperationError } from './types.js';
interface ProfileOperationRecord extends QueryResultRow {
couple_id: string | null;
}
interface PartnerRecord extends QueryResultRow {
push_token: string | null;
}
export interface DestructiveOperationResult {
partnerPushToken: string | null;
}
export interface AccountOperationsRepository {
deleteRelationship(userId: string): Promise<DestructiveOperationResult>;
resetProgress(userId: string): Promise<DestructiveOperationResult>;
deleteAccount(userId: string, deleteAuthUser: () => Promise<void>): Promise<DestructiveOperationResult>;
setPremium(userId: string, isPremium: boolean): Promise<void>;
close(): Promise<void>;
}
async function transaction<T>(pool: Pool, operation: (client: PoolClient) => Promise<T>): Promise<T> {
const client = await pool.connect();
try {
await client.query('begin');
const result = await operation(client);
await client.query('commit');
return result;
} catch (error) {
await client.query('rollback');
throw error;
} finally {
client.release();
}
}
async function lockedProfile(client: PoolClient, userId: string): Promise<ProfileOperationRecord> {
const result = await client.query<ProfileOperationRecord>(
'select couple_id from profiles where id = $1 for update',
[userId],
);
const profile = result.rows[0];
if (!profile) throw new AccountOperationError('profile_not_found', 'Profile not found', 404);
return profile;
}
async function lockCoupleAndPartner(
client: PoolClient,
userId: string,
coupleId: string,
): Promise<PartnerRecord | undefined> {
await client.query('select id from couples where id = $1 for update', [coupleId]);
const partner = await client.query<PartnerRecord>(
`select push_token from profiles
where couple_id = $1 and id <> $2
for update`,
[coupleId, userId],
);
return partner.rows[0];
}
export class PostgresAccountOperationsRepository implements AccountOperationsRepository {
private readonly pool: Pool;
constructor(databaseUrl: string) {
this.pool = new Pool({ connectionString: databaseUrl });
}
async deleteRelationship(userId: string): Promise<DestructiveOperationResult> {
return transaction(this.pool, async (client) => {
const profile = await lockedProfile(client, userId);
if (!profile.couple_id) {
throw new AccountOperationError('not_paired', 'You are not in a relationship', 400);
}
const partner = await lockCoupleAndPartner(client, userId, profile.couple_id);
await client.query(
'update profiles set couple_id = null, updated_at = now() where couple_id = $1',
[profile.couple_id],
);
await client.query('delete from couples where id = $1', [profile.couple_id]);
return { partnerPushToken: partner?.push_token ?? null };
});
}
async resetProgress(userId: string): Promise<DestructiveOperationResult> {
return transaction(this.pool, async (client) => {
const profile = await lockedProfile(client, userId);
if (!profile.couple_id) {
throw new AccountOperationError('not_paired', 'You are not in a relationship', 400);
}
const partner = await lockCoupleAndPartner(client, userId, profile.couple_id);
// Matches cascade to messages and per-user match archives. Responses are separate.
await client.query('delete from matches where couple_id = $1', [profile.couple_id]);
await client.query('delete from responses where couple_id = $1', [profile.couple_id]);
return { partnerPushToken: partner?.push_token ?? null };
});
}
async deleteAccount(
userId: string,
deleteAuthUser: () => Promise<void>,
): Promise<DestructiveOperationResult> {
return transaction(this.pool, async (client) => {
const profileResult = await client.query<ProfileOperationRecord>(
'select couple_id from profiles where id = $1 for update',
[userId],
);
const profile = profileResult.rows[0];
let partner: PartnerRecord | undefined;
if (profile?.couple_id) {
partner = await lockCoupleAndPartner(client, userId, profile.couple_id);
await client.query(
'update profiles set couple_id = null, updated_at = now() where couple_id = $1 and id <> $2',
[profile.couple_id, userId],
);
await client.query('delete from couples where id = $1', [profile.couple_id]);
}
if (profile) await client.query('delete from profiles where id = $1', [userId]);
// Keep local deletion uncommitted until hosted Auth confirms deletion. A provider
// failure therefore rolls the entire local transaction back for a safe retry.
await deleteAuthUser();
return { partnerPushToken: partner?.push_token ?? null };
});
}
async setPremium(userId: string, isPremium: boolean): Promise<void> {
const result = await this.pool.query(
'update profiles set is_premium = $2, updated_at = now() where id = $1',
[userId, isPremium],
);
if (result.rowCount === 0) throw new AccountOperationError('profile_not_found', 'Profile not found', 404);
}
async close(): Promise<void> {
await this.pool.end();
}
}
@@ -0,0 +1,48 @@
import type { ApiErrorResponse } from '@sauci/shared';
import type { Hono } from 'hono';
import type { AuthIdentity } from '../../auth.js';
import type { AccountOperationsService } from './service.js';
import { AccountOperationError } from './types.js';
type AuthenticatedApp = Hono<{ Variables: { identity: AuthIdentity } }>;
function error(code: string, message: string): ApiErrorResponse {
return { error: { code, message } };
}
async function run<T>(operation: () => Promise<T>): Promise<
{ ok: true; value: T } |
{ ok: false; body: ApiErrorResponse; status: 400 | 404 | 502 | 503 }
> {
try {
return { ok: true, value: await operation() };
} catch (cause) {
if (cause instanceof AccountOperationError) {
return { ok: false, body: error(cause.code, cause.message), status: cause.status };
}
throw cause;
}
}
export function registerAccountOperationRoutes(app: AuthenticatedApp, service: AccountOperationsService): void {
app.delete('/v1/couple/data', async (c) => {
const result = await run(() => service.deleteRelationship(c.get('identity').id));
return result.ok ? c.json(result.value) : c.json(result.body, result.status);
});
app.delete('/v1/couple/progress', async (c) => {
const result = await run(() => service.resetProgress(c.get('identity').id));
return result.ok ? c.json(result.value) : c.json(result.body, result.status);
});
app.delete('/v1/me', async (c) => {
const result = await run(() => service.deleteAccount(c.get('identity').id));
return result.ok ? c.json(result.value) : c.json(result.body, result.status);
});
app.post('/v1/me/subscription/sync', async (c) => {
const result = await run(() => service.syncSubscription(c.get('identity').id));
return result.ok ? c.json(result.value) : c.json(result.body, result.status);
});
}
@@ -0,0 +1,53 @@
import type { AuthAdminClient, PartnerNotifier, RevenueCatClient } from './clients.js';
import type { AccountOperationsRepository } from './repository.js';
import type { OperationResult, SubscriptionSyncResult } from './types.js';
export class AccountOperationsService {
constructor(
private readonly repository: AccountOperationsRepository,
private readonly authAdmin: AuthAdminClient,
private readonly revenueCat: RevenueCatClient,
private readonly notifier: PartnerNotifier,
) {}
async deleteRelationship(userId: string): Promise<OperationResult> {
const result = await this.repository.deleteRelationship(userId);
if (result.partnerPushToken) {
await this.notify(() => this.notifier.relationshipDeleted(result.partnerPushToken!));
}
return { success: true, message: 'Relationship data deleted successfully' };
}
async resetProgress(userId: string): Promise<OperationResult> {
const result = await this.repository.resetProgress(userId);
if (result.partnerPushToken) {
await this.notify(() => this.notifier.progressReset(result.partnerPushToken!));
}
return { success: true, message: 'Progress reset successfully' };
}
async deleteAccount(userId: string): Promise<OperationResult> {
const result = await this.repository.deleteAccount(userId, () => this.authAdmin.deleteUser(userId));
if (result.partnerPushToken) {
await this.notify(() => this.notifier.partnerAccountDeleted(result.partnerPushToken!));
}
return { success: true, message: 'Account deleted successfully' };
}
async syncSubscription(userId: string): Promise<SubscriptionSyncResult> {
const isPremium = await this.revenueCat.isEntitled(userId);
await this.repository.setPremium(userId, isPremium);
return { success: true, is_premium: isPremium };
}
private async notify(operation: () => Promise<void>): Promise<void> {
try {
await operation();
} catch (error) {
// Destructive data changes have already committed. Notification delivery is
// deliberately best-effort, matching the previous Edge Function behavior.
console.error('Partner notification failed', error);
}
}
}
@@ -0,0 +1,21 @@
export class AccountOperationError extends Error {
constructor(
readonly code: string,
message: string,
readonly status: 400 | 404 | 502 | 503,
) {
super(message);
this.name = 'AccountOperationError';
}
}
export interface OperationResult {
success: true;
message: string;
}
export interface SubscriptionSyncResult {
success: true;
is_premium: boolean;
}
+29
View File
@@ -0,0 +1,29 @@
# Admin API
Browser administrators authenticate with hosted Supabase Auth. The API then
loads the active `admin_users` row for the token subject and enforces each role
permission again server-side. UI role state is never trusted for authorization.
Non-browser clients can use `ADMIN_API_SERVICE_TOKEN` with
`ADMIN_API_SERVICE_USER_ID`. The opaque token is compared in constant time and
maps to an explicit active `admin_users` record, so its permissions and audit
actor are identical to a browser administrator. It does not grant Supabase
service-role access.
Every generic mutation is transacted with an `audit_logs` write. Sensitive
fields are redacted from audit payloads. Admin data routes are resource-
whitelisted; arbitrary table names are rejected.
Central application wiring must exempt `/v1/admin/*` from the general hosted
Auth middleware and pass `AdminRequestAuth` to `registerAdminRoutes`, which
accepts either hosted JWTs or the configured service token.
`SupabaseAdminAuthDirectory` may use the existing server-side Auth Admin
credential to enrich `/v1/admin/users` with email confirmation and last sign-in
timestamps. It talks only to hosted Auth, never the Supabase data plane, and
degrades to explicit null metadata when the credential or provider is absent.
`GET /v1/admin/media/:mediaId/url` requires `view_media` and issues a five-minute
signed URL with `private, no-store` response caching. The object must exist and
not be marked deleted. The opaque service credential can therefore request a
URL without being accepted by customer media authorization.
@@ -0,0 +1,54 @@
import { z } from 'zod';
type Fetch = typeof globalThis.fetch;
const authUser = z.object({
id: z.string().uuid(),
email: z.string().nullable().optional(),
last_sign_in_at: z.string().nullable().optional(),
email_confirmed_at: z.string().nullable().optional(),
confirmed_at: z.string().nullable().optional(),
}).passthrough();
export type AdminAuthUser = {
id: string;
email: string | null;
last_sign_in_at: string | null;
email_confirmed_at: string | null;
};
export interface AdminAuthDirectory {
users(userId?: string): Promise<AdminAuthUser[]>;
}
export class SupabaseAdminAuthDirectory implements AdminAuthDirectory {
constructor(
private readonly authUrl: string | undefined,
private readonly serviceRoleKey: string | undefined,
private readonly request: Fetch = globalThis.fetch,
) {}
async users(userId?: string): Promise<AdminAuthUser[]> {
if (!this.authUrl || !this.serviceRoleKey) return [];
const base = this.authUrl.replace(/\/$/, '').replace(/\/auth\/v1$/, '');
const url = userId
? `${base}/auth/v1/admin/users/${encodeURIComponent(userId)}`
: `${base}/auth/v1/admin/users?page=1&per_page=1000`;
const response = await this.request(url, {
headers: { apikey: this.serviceRoleKey, authorization: `Bearer ${this.serviceRoleKey}` },
signal: AbortSignal.timeout(10_000),
}).catch(() => null);
if (!response?.ok) return [];
const payload = await response.json().catch(() => null);
const candidates = userId ? [payload] : z.object({ users: z.array(z.unknown()) }).safeParse(payload).data?.users ?? [];
return candidates.flatMap((candidate) => {
const parsed = authUser.safeParse(candidate); if (!parsed.success) return [];
return [{
id: parsed.data.id,
email: parsed.data.email ?? null,
last_sign_in_at: parsed.data.last_sign_in_at ?? null,
email_confirmed_at: parsed.data.email_confirmed_at ?? parsed.data.confirmed_at ?? null,
}];
});
}
}
+26
View File
@@ -0,0 +1,26 @@
import { createHash, timingSafeEqual } from 'node:crypto';
import type { AuthIdentity, AuthVerifier } from '../../auth.js';
export class AdminRequestAuth {
private readonly serviceDigest?: Buffer;
constructor(
private readonly hostedAuth: AuthVerifier,
serviceToken?: string,
private readonly serviceUserId?: string,
) {
if (Boolean(serviceToken) !== Boolean(serviceUserId)) throw new Error('Admin service token and user ID must be configured together');
if (serviceToken && serviceToken.length < 32) throw new Error('Admin service token must contain at least 32 characters');
this.serviceDigest = serviceToken ? createHash('sha256').update(serviceToken).digest() : undefined;
}
async verify(token: string): Promise<AuthIdentity> {
if (this.serviceDigest && this.serviceUserId) {
const candidate = createHash('sha256').update(token).digest();
if (timingSafeEqual(candidate, this.serviceDigest)) {
return { id: this.serviceUserId, email: null, name: 'Admin API service', avatarUrl: null };
}
}
return this.hostedAuth.verify(token);
}
}
+340
View File
@@ -0,0 +1,340 @@
import { Pool, type PoolClient, type QueryResultRow } from 'pg';
import { readFile } from 'node:fs/promises';
import { resolve, sep } from 'node:path';
import { AdminError, type AdminPrincipal, type AdminQuery } from './types.js';
import type { AdminAuthDirectory } from './auth-directory.js';
type Permission =
| 'manage_packs' | 'manage_questions' | 'manage_categories' | 'view_users'
| 'view_chats' | 'view_media' | 'view_matches' | 'view_responses' | 'view_activity'
| 'manage_feedback' | 'gift_premium' | 'manage_codes' | 'manage_admins'
| 'manage_ai_config' | 'manage_app_config' | 'view_audit_logs';
interface ResourceRule { read: Permission | Permission[]; write?: Permission; idColumn?: string }
const resources: Record<string, ResourceRule> = {
categories: { read: ['manage_categories', 'manage_packs', 'manage_questions', 'view_activity', 'view_users'], write: 'manage_categories' },
question_packs: { read: ['manage_packs', 'manage_questions', 'view_activity', 'view_users'], write: 'manage_packs' },
questions: { read: ['manage_questions', 'view_activity', 'view_responses', 'view_matches', 'view_users', 'manage_ai_config'], write: 'manage_questions' },
topics: { read: 'manage_packs', write: 'manage_packs' },
pack_topics: { read: 'manage_packs', write: 'manage_packs', idColumn: 'pack_id' },
profiles: { read: ['view_users', 'view_activity', 'manage_admins', 'manage_feedback', 'manage_ai_config', 'manage_codes'] }, couples: { read: ['view_users', 'view_activity'] },
responses: { read: ['view_responses', 'view_activity', 'view_users'] }, matches: { read: ['view_matches', 'view_activity', 'view_users'] },
messages: { read: ['view_chats', 'view_activity'], write: 'view_chats' },
message_reports: { read: 'view_chats', write: 'view_chats' },
feedback: { read: 'manage_feedback', write: 'manage_feedback' },
admin_users: { read: 'manage_admins', write: 'manage_admins' },
audit_logs: { read: 'view_audit_logs' },
redemption_codes: { read: 'manage_codes', write: 'manage_codes' },
code_redemptions: { read: 'manage_codes' },
ai_config: { read: 'manage_ai_config', write: 'manage_ai_config' },
app_config: { read: 'manage_app_config', write: 'manage_app_config' },
feature_interests: { read: 'view_users' }, couple_packs: { read: 'view_users' },
couple_streaks: { read: 'view_users' }, live_draw_sessions: { read: 'view_users' },
subscriptions: { read: 'view_users' },
media_objects: { read: 'view_media' },
dare_packs: { read: 'manage_packs', write: 'manage_packs' },
dares: { read: 'manage_questions', write: 'manage_questions' },
};
function identifier(value: string): string {
if (!/^[a-z_][a-z0-9_]*$/.test(value)) throw new AdminError('invalid_identifier', 'Invalid field name', 400);
return `"${value}"`;
}
function rule(resource: string): ResourceRule {
const value = resources[resource];
if (!value) throw new AdminError('unsupported_resource', 'Resource is not available to the admin API', 404);
return value;
}
function requirePermission(principal: AdminPrincipal, permission: Permission): void {
if (principal.role !== 'super_admin' && !principal.permissions.includes(permission)) {
throw new AdminError('forbidden', 'This administrator lacks the required permission', 403);
}
}
function requireAnyPermission(principal: AdminPrincipal, permissions: Permission | Permission[]): void {
const choices = Array.isArray(permissions) ? permissions : [permissions];
if (principal.role !== 'super_admin' && !choices.some((permission) => principal.permissions.includes(permission))) {
throw new AdminError('forbidden', 'This administrator lacks the required permission', 403);
}
}
function whereParts(query: AdminQuery): { sql: string; values: unknown[] } {
const values: unknown[] = [];
const clauses: string[] = [];
for (const filter of query.filters ?? []) {
const column = identifier(filter.column);
const position = values.length + 1;
if (filter.op === 'is' && filter.value === null) clauses.push(`${column} is null`);
else if (filter.op === 'neq' && filter.value === null) clauses.push(`${column} is not null`);
else if (filter.op === 'in') {
if (!Array.isArray(filter.value)) throw new AdminError('invalid_filter', 'in filters require an array', 400);
values.push(filter.value); clauses.push(`${column} = any($${position})`);
} else {
const operators = { eq: '=', neq: '<>', gte: '>=', lte: '<=', ilike: 'ilike' } as const;
const operator = operators[filter.op as keyof typeof operators];
if (!operator) throw new AdminError('invalid_filter', 'Unsupported filter', 400);
values.push(filter.value); clauses.push(`${column} ${operator} $${position}`);
}
}
return { sql: clauses.length ? ` where ${clauses.join(' and ')}` : '', values };
}
const restrictedReadColumns: Record<string, ReadonlySet<string>> = {
profiles: new Set(['push_token', 'public_key_jwk']),
messages: new Set(['encrypted_content', 'encryption_iv', 'keys_metadata']),
media_objects: new Set(['storage_key']),
};
function projection(resource: string, columns: string[] | undefined): string {
if (!columns) return '*';
const restricted = restrictedReadColumns[resource] ?? new Set<string>();
if (columns.some((column) => restricted.has(column))) {
throw new AdminError('restricted_column', 'The requested field is not available through generic admin queries', 403);
}
return columns.map(identifier).join(',');
}
function sanitizeRows(resource: string, rows: QueryResultRow[]): QueryResultRow[] {
const restricted = restrictedReadColumns[resource];
if (!restricted) return rows;
return rows.map((row) => Object.fromEntries(Object.entries(row).filter(([column]) => !restricted.has(column))));
}
async function transaction<T>(pool: Pool, operation: (client: PoolClient) => Promise<T>): Promise<T> {
const client = await pool.connect();
try { await client.query('begin'); const value = await operation(client); await client.query('commit'); return value; }
catch (error) { await client.query('rollback'); throw error; }
finally { client.release(); }
}
function redactAudit(value: unknown): unknown {
if (Array.isArray(value)) return value.map(redactAudit);
if (!value || typeof value !== 'object') return value;
return Object.fromEntries(Object.entries(value as Record<string, unknown>).map(([key, item]) => [
key,
/(key|secret|token|password|credential)/i.test(key) ? '[REDACTED]' : redactAudit(item),
]));
}
export interface AdminRepository {
principal(userId: string): Promise<AdminPrincipal>;
query(principal: AdminPrincipal, resource: string, query: AdminQuery): Promise<{ rows: Record<string, unknown>[]; count: number }>;
insert(principal: AdminPrincipal, resource: string, records: Record<string, unknown>[]): Promise<Record<string, unknown>[]>;
update(principal: AdminPrincipal, resource: string, id: string, values: Record<string, unknown>): Promise<Record<string, unknown>>;
delete(principal: AdminPrincipal, resource: string, id: string): Promise<void>;
dashboard(principal: AdminPrincipal): Promise<Record<string, number>>;
giftPremium(principal: AdminPrincipal, userId: string, days: number | null, reason?: string): Promise<{ subscription_id: string; expires_at: string | null }>;
featureInterestCounts(principal: AdminPrincipal): Promise<Array<{
feature_name: string; opt_in_count: number; opt_in_count_last_7_days: number;
}>>;
users(principal: AdminPrincipal, userId?: string): Promise<Record<string, unknown>[]>;
authorizeMedia(principal: AdminPrincipal, mediaId: string): Promise<void>;
decryptMessage(principal: AdminPrincipal, messageId: string): Promise<{ content: string | null }>;
decryptMedia(principal: AdminPrincipal, messageId: string): Promise<{ bytes: Buffer; mimeType: string }>;
close(): Promise<void>;
}
export class PostgresAdminRepository implements AdminRepository {
private readonly pool: Pool;
constructor(databaseUrl: string, private readonly options: {
adminPrivateKeyJwk?: string; mediaRoot?: string; authDirectory?: AdminAuthDirectory;
} = {}) { this.pool = new Pool({ connectionString: databaseUrl }); }
async principal(userId: string): Promise<AdminPrincipal> {
const result = await this.pool.query<{ id: string; user_id: string; role: AdminPrincipal['role']; permissions: string[] }>(
'select id, user_id, role, permissions from admin_users where user_id=$1 and is_active=true', [userId],
);
const row = result.rows[0];
if (!row) throw new AdminError('admin_access_denied', 'Administrator access denied', 403);
return { adminId: row.id, userId: row.user_id, role: row.role, permissions: row.permissions ?? [] };
}
async query(principal: AdminPrincipal, resource: string, query: AdminQuery) {
requireAnyPermission(principal, rule(resource).read);
const where = whereParts(query);
const table = identifier(resource);
const selected = projection(resource, query.columns);
let pageSql = where.sql;
if (query.order) pageSql += ` order by ${identifier(query.order.column)} ${query.order.ascending === false ? 'desc' : 'asc'}`;
const pageValues = [...where.values, Math.min(Math.max(query.limit ?? 100, 1), 500)];
pageSql += ` limit $${pageValues.length}`;
if (query.offset) { pageValues.push(Math.max(query.offset, 0)); pageSql += ` offset $${pageValues.length}`; }
const [rows, count] = await Promise.all([
this.pool.query<QueryResultRow>(`select ${selected} from ${table}${pageSql}`, pageValues),
this.pool.query<{ count: string }>(`select count(*)::text count from ${table}${where.sql}`, where.values),
]);
return { rows: sanitizeRows(resource, rows.rows), count: Number(count.rows[0]?.count ?? 0) };
}
async insert(principal: AdminPrincipal, resource: string, records: Record<string, unknown>[]) {
const permission = rule(resource).write;
if (!permission) throw new AdminError('forbidden', 'Resource is read-only', 403);
requirePermission(principal, permission);
if (!records.length) throw new AdminError('invalid_records', 'At least one record is required', 400);
return transaction(this.pool, async (client) => {
const inserted: Record<string, unknown>[] = [];
for (const record of records) {
const keys = Object.keys(record); if (!keys.length) throw new AdminError('invalid_record', 'Record cannot be empty', 400);
const result = await client.query<QueryResultRow>(
`insert into ${identifier(resource)} (${keys.map(identifier).join(',')}) values (${keys.map((_, index) => `$${index + 1}`).join(',')}) returning *`,
keys.map((key) => record[key]),
);
const row = result.rows[0]!; inserted.push(row);
await this.audit(client, principal, resource, 'INSERT', String(row.id ?? row[rule(resource).idColumn ?? 'id'] ?? ''), null, row);
}
return inserted;
});
}
async update(principal: AdminPrincipal, resource: string, id: string, values: Record<string, unknown>) {
const resourceRule = rule(resource); if (!resourceRule.write) throw new AdminError('forbidden', 'Resource is read-only', 403);
requirePermission(principal, resourceRule.write);
const keys = Object.keys(values); if (!keys.length) throw new AdminError('invalid_record', 'Updates cannot be empty', 400);
const idColumn = resourceRule.idColumn ?? 'id';
return transaction(this.pool, async (client) => {
const old = await client.query<QueryResultRow>(`select * from ${identifier(resource)} where ${identifier(idColumn)}=$1 for update`, [id]);
if (!old.rows.length) throw new AdminError('not_found', 'Record not found', 404);
const updated = await client.query<QueryResultRow>(
`update ${identifier(resource)} set ${keys.map((key, index) => `${identifier(key)}=$${index + 2}`).join(',')} where ${identifier(idColumn)}=$1 returning *`,
[id, ...keys.map((key) => values[key])],
);
for (let index = 0; index < updated.rows.length; index += 1) {
const oldRow = old.rows[index]; const updatedRow = updated.rows[index]!;
await this.audit(client, principal, resource, 'UPDATE', String(updatedRow.id ?? id), oldRow, updatedRow);
}
return updated.rows[0]!;
});
}
async delete(principal: AdminPrincipal, resource: string, id: string): Promise<void> {
const resourceRule = rule(resource); if (!resourceRule.write) throw new AdminError('forbidden', 'Resource is read-only', 403);
requirePermission(principal, resourceRule.write);
const idColumn = resourceRule.idColumn ?? 'id';
await transaction(this.pool, async (client) => {
const deleted = await client.query<QueryResultRow>(`delete from ${identifier(resource)} where ${identifier(idColumn)}=$1 returning *`, [id]);
if (!deleted.rows.length) throw new AdminError('not_found', 'Record not found', 404);
for (const row of deleted.rows) {
await this.audit(client, principal, resource, 'DELETE', String(row.id ?? id), row, null);
}
});
}
async dashboard(principal: AdminPrincipal): Promise<Record<string, number>> {
const result = await this.pool.query<{ categories: number; packs: number; questions: number; users: number }>(
'select (select count(*)::int from categories) categories, (select count(*)::int from question_packs) packs, (select count(*)::int from questions) questions, (select count(*)::int from profiles) users',
);
return result.rows[0] ?? { categories: 0, packs: 0, questions: 0, users: 0 };
}
async giftPremium(principal: AdminPrincipal, userId: string, days: number | null, reason?: string) {
requirePermission(principal, 'gift_premium');
return transaction(this.pool, async (client) => {
const profile = await client.query('select id from profiles where id=$1 for update', [userId]);
if (!profile.rows[0]) throw new AdminError('user_not_found', 'User not found', 404);
const expiresAt = days === null ? null : new Date(Date.now() + days * 86_400_000);
const inserted = await client.query<{ id: string }>(
`insert into subscriptions(user_id,revenuecat_app_user_id,product_id,status,entitlement_ids,purchased_at,expires_at,store)
values($1,$2,'admin_premium','active',array['premium'],now(),$3,'manual') returning id`,
[userId, `admin_grant:${userId}`, expiresAt],
);
await client.query('update profiles set is_premium=true,updated_at=now() where id=$1', [userId]);
await client.query(
`insert into audit_logs(admin_user_id,actor_user_id,table_name,action,record_id,new_values)
values($1,$2,'subscriptions','ACTION',$3,$4)`,
[principal.adminId, principal.userId, inserted.rows[0]!.id, { action: 'gift_premium', target_user_id: userId, days, reason: reason ?? null, expires_at: expiresAt?.toISOString() ?? null }],
);
return { subscription_id: inserted.rows[0]!.id, expires_at: expiresAt?.toISOString() ?? null };
});
}
async featureInterestCounts(principal: AdminPrincipal) {
requirePermission(principal, 'view_users');
const result = await this.pool.query<{
feature_name: string; opt_in_count: number; opt_in_count_last_7_days: number;
}>(`select feature as feature_name,
count(*)::int as opt_in_count,
count(*) filter (where created_at >= now() - interval '7 days')::int as opt_in_count_last_7_days
from feature_interests group by feature order by feature`);
return result.rows;
}
async users(principal: AdminPrincipal, userId?: string) {
requirePermission(principal, 'view_users');
const result = await this.pool.query<QueryResultRow & { id: string }>(
`select p.*,coalesce(sum(mo.byte_size) filter(where mo.deleted_at is null),0)::bigint::text as storage_bytes
from profiles p left join media_objects mo on mo.owner_id=p.id
${userId ? 'where p.id=$1' : ''} group by p.id order by p.created_at desc limit 500`,
userId ? [userId] : [],
);
const authUsers = await this.options.authDirectory?.users(userId).catch(() => []) ?? [];
const authById = new Map(authUsers.map((user) => [user.id, user]));
return result.rows.map((profile) => {
const authUser = authById.get(profile.id);
return {
...profile,
email: authUser?.email ?? profile.email ?? null,
last_sign_in_at: authUser?.last_sign_in_at ?? null,
email_confirmed_at: authUser?.email_confirmed_at ?? null,
};
});
}
async authorizeMedia(principal: AdminPrincipal, mediaId: string): Promise<void> {
requirePermission(principal, 'view_media');
const result = await this.pool.query('select 1 from media_objects where id=$1 and deleted_at is null', [mediaId]);
if (!result.rows[0]) throw new AdminError('media_not_found', 'Media not found', 404);
}
private async messageForDecryption(messageId: string) {
const result = await this.pool.query<{
content: string | null; encrypted_content: string | null; encryption_iv: string | null;
keys_metadata: { admin_wrapped_key?: string } | null; version: number; media_path: string | null;
storage_key: string | null; mime_type: string | null;
}>(
`select m.content,m.encrypted_content,m.encryption_iv,m.keys_metadata,m.version,m.media_path,mo.storage_key,mo.mime_type
from messages m left join media_objects mo on m.media_path='media:'||mo.id::text where m.id=$1`, [messageId],
);
const row = result.rows[0]; if (!row) throw new AdminError('message_not_found', 'Message not found', 404); return row;
}
private async messageKey(row: { keys_metadata: { admin_wrapped_key?: string } | null }): Promise<CryptoKey> {
const wrapped = row.keys_metadata?.admin_wrapped_key;
if (!wrapped || !this.options.adminPrivateKeyJwk) throw new AdminError('decryption_unavailable', 'Admin decryption key is unavailable', 403);
const privateKey = await crypto.subtle.importKey('jwk', JSON.parse(this.options.adminPrivateKeyJwk) as JsonWebKey, { name: 'RSA-OAEP', hash: 'SHA-256' }, false, ['decrypt']);
const raw = await crypto.subtle.decrypt({ name: 'RSA-OAEP' }, privateKey, Buffer.from(wrapped, 'base64'));
return crypto.subtle.importKey('raw', raw, 'AES-GCM', false, ['decrypt']);
}
async decryptMessage(principal: AdminPrincipal, messageId: string) {
requirePermission(principal, 'view_chats');
const row = await this.messageForDecryption(messageId);
if (row.version !== 2 || !row.encrypted_content) return { content: row.content };
if (!row.encryption_iv) throw new AdminError('invalid_encrypted_message', 'Message encryption metadata is incomplete', 409);
const key = await this.messageKey(row);
const plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: Buffer.from(row.encryption_iv, 'base64') }, key, Buffer.from(row.encrypted_content, 'base64'));
return { content: new TextDecoder().decode(plain) };
}
async decryptMedia(principal: AdminPrincipal, messageId: string) {
requirePermission(principal, 'view_media');
const row = await this.messageForDecryption(messageId);
if (!row.storage_key || !row.mime_type) throw new AdminError('media_not_found', 'Message media not found', 404);
const root = resolve(this.options.mediaRoot ?? '/data/media'); const path = resolve(root, row.storage_key);
if (!path.startsWith(`${root}${sep}`)) throw new AdminError('invalid_media_path', 'Invalid media path', 400);
let bytes = await readFile(path);
if (row.version === 2) {
if (!row.encryption_iv) throw new AdminError('invalid_encrypted_message', 'Message encryption metadata is incomplete', 409);
const key = await this.messageKey(row);
bytes = Buffer.from(await crypto.subtle.decrypt({ name: 'AES-GCM', iv: Buffer.from(row.encryption_iv, 'base64') }, key, bytes));
}
return { bytes, mimeType: row.mime_type };
}
private async audit(client: PoolClient, principal: AdminPrincipal, table: string, action: 'INSERT' | 'UPDATE' | 'DELETE', recordId: string, oldValues: unknown, newValues: unknown) {
await client.query('insert into audit_logs(admin_user_id,actor_user_id,table_name,action,record_id,old_values,new_values) values($1,$2,$3,$4,$5,$6,$7)', [principal.adminId, principal.userId, table, action, recordId || null, redactAudit(oldValues), redactAudit(newValues)]);
}
async close(): Promise<void> { await this.pool.end(); }
}
+122
View File
@@ -0,0 +1,122 @@
import type { ApiErrorResponse } from '@sauci/shared';
import type { Hono } from 'hono';
import { z } from 'zod';
import type { AuthIdentity } from '../../auth.js';
import { bearerToken } from '../../auth.js';
import type { AdminRequestAuth } from './auth.js';
import type { AdminRepository } from './repository.js';
import { AdminError } from './types.js';
import type { FilesystemMediaStorage } from '../media/storage.js';
type App = Hono<{ Variables: { identity: AuthIdentity } }>;
const resource = z.string().regex(/^[a-z_][a-z0-9_]*$/);
const queryBody = z.object({
columns: z.array(z.string().regex(/^[a-z_][a-z0-9_]*$/)).min(1).max(100).optional(),
filters: z.array(z.object({ column: z.string(), op: z.enum(['eq', 'neq', 'in', 'is', 'gte', 'lte', 'ilike']), value: z.unknown() })).optional(),
order: z.object({ column: z.string(), ascending: z.boolean().optional() }).optional(),
limit: z.number().int().positive().max(500).optional(), offset: z.number().int().nonnegative().optional(),
}).strict();
const insertBody = z.object({ records: z.array(z.record(z.unknown())).min(1) }).strict();
const updateBody = z.object({ values: z.record(z.unknown()) }).strict();
function failure(code: string, message: string): ApiErrorResponse { return { error: { code, message } }; }
async function execute<T>(operation: () => Promise<T>) {
try { return { ok: true as const, value: await operation() }; }
catch (cause) { if (cause instanceof AdminError) return { ok: false as const, cause }; throw cause; }
}
export function registerAdminRoutes(app: App, repository: AdminRepository, auth?: AdminRequestAuth, mediaStorage?: FilesystemMediaStorage): void {
if (auth) {
app.use('/v1/admin/*', async (c, next) => {
const token = bearerToken(c.req.header('authorization'));
if (!token) return c.json(failure('unauthorized', 'A bearer token is required'), 401);
try { c.set('identity', await auth.verify(token)); }
catch { return c.json(failure('unauthorized', 'The bearer token is invalid'), 401); }
await next();
});
}
app.get('/v1/admin/me', async (c) => {
const result = await execute(() => repository.principal(c.get('identity').id));
return result.ok ? c.json({ role: result.value.role, permissions: result.value.permissions }) : c.json(failure(result.cause.code, result.cause.message), result.cause.status);
});
app.post('/v1/admin/query/:resource', async (c) => {
const parsedResource = resource.safeParse(c.req.param('resource'));
const parsedBody = queryBody.safeParse(await c.req.json().catch(() => ({})));
if (!parsedResource.success || !parsedBody.success) return c.json(failure('invalid_admin_query', 'Invalid admin query'), 400);
const result = await execute(async () => repository.query(await repository.principal(c.get('identity').id), parsedResource.data, parsedBody.data));
return result.ok ? c.json(result.value) : c.json(failure(result.cause.code, result.cause.message), result.cause.status);
});
app.post('/v1/admin/data/:resource', async (c) => {
const parsedResource = resource.safeParse(c.req.param('resource'));
const parsedBody = insertBody.safeParse(await c.req.json().catch(() => ({})));
if (!parsedResource.success || !parsedBody.success) return c.json(failure('invalid_admin_mutation', 'Invalid admin mutation'), 400);
const result = await execute(async () => repository.insert(await repository.principal(c.get('identity').id), parsedResource.data, parsedBody.data.records));
return result.ok ? c.json({ rows: result.value }, 201) : c.json(failure(result.cause.code, result.cause.message), result.cause.status);
});
app.patch('/v1/admin/data/:resource/:id', async (c) => {
const parsedResource = resource.safeParse(c.req.param('resource'));
const parsedBody = updateBody.safeParse(await c.req.json().catch(() => ({})));
if (!parsedResource.success || !parsedBody.success) return c.json(failure('invalid_admin_mutation', 'Invalid admin mutation'), 400);
const result = await execute(async () => repository.update(await repository.principal(c.get('identity').id), parsedResource.data, c.req.param('id'), parsedBody.data.values));
return result.ok ? c.json({ row: result.value }) : c.json(failure(result.cause.code, result.cause.message), result.cause.status);
});
app.delete('/v1/admin/data/:resource/:id', async (c) => {
const parsedResource = resource.safeParse(c.req.param('resource'));
if (!parsedResource.success) return c.json(failure('invalid_admin_mutation', 'Invalid admin mutation'), 400);
const result = await execute(async () => repository.delete(await repository.principal(c.get('identity').id), parsedResource.data, c.req.param('id')));
return result.ok ? c.json({ deleted: true }) : c.json(failure(result.cause.code, result.cause.message), result.cause.status);
});
app.get('/v1/admin/dashboard', async (c) => {
const result = await execute(async () => repository.dashboard(await repository.principal(c.get('identity').id)));
return result.ok ? c.json(result.value) : c.json(failure(result.cause.code, result.cause.message), result.cause.status);
});
app.get('/v1/admin/users', async (c) => {
const result = await execute(async () => repository.users(await repository.principal(c.get('identity').id), c.req.query('userId')));
return result.ok ? c.json({ users: result.value }) : c.json(failure(result.cause.code, result.cause.message), result.cause.status);
});
app.get('/v1/admin/feature-interest-counts', async (c) => {
const result = await execute(async () => repository.featureInterestCounts(await repository.principal(c.get('identity').id)));
return result.ok ? c.json({ counts: result.value }) : c.json(failure(result.cause.code, result.cause.message), result.cause.status);
});
app.get('/v1/admin/media/:mediaId/url', async (c) => {
const parsed = z.string().uuid().safeParse(c.req.param('mediaId'));
if (!parsed.success) return c.json(failure('invalid_media_id', 'A valid media ID is required'), 400);
if (!mediaStorage) return c.json(failure('media_unavailable', 'Media storage is unavailable'), 503);
const result = await execute(async () => {
await repository.authorizeMedia(await repository.principal(c.get('identity').id), parsed.data);
return mediaStorage.signedUrl(parsed.data, 300);
});
return result.ok ? c.json(result.value, 200, { 'cache-control': 'private, no-store' }) : c.json(failure(result.cause.code, result.cause.message), result.cause.status);
});
app.post('/v1/admin/users/:userId/gift-premium', async (c) => {
const parsed = z.object({ days: z.number().int().min(1).max(3650).nullable(), reason: z.string().trim().max(500).optional() }).strict()
.safeParse(await c.req.json().catch(() => ({})));
if (!parsed.success) return c.json(failure('invalid_gift', 'A valid premium duration is required'), 400);
const result = await execute(async () => repository.giftPremium(await repository.principal(c.get('identity').id), c.req.param('userId'), parsed.data.days, parsed.data.reason));
return result.ok ? c.json(result.value, 201) : c.json(failure(result.cause.code, result.cause.message), result.cause.status);
});
app.post('/v1/admin/actions/decrypt-message', async (c) => {
const parsed = z.object({ messageId: z.string().uuid() }).strict().safeParse(await c.req.json().catch(() => ({})));
if (!parsed.success) return c.json(failure('invalid_message_id', 'A valid message ID is required'), 400);
const result = await execute(async () => repository.decryptMessage(await repository.principal(c.get('identity').id), parsed.data.messageId));
return result.ok ? c.json(result.value) : c.json(failure(result.cause.code, result.cause.message), result.cause.status);
});
app.post('/v1/admin/actions/decrypt-media', async (c) => {
const parsed = z.object({ messageId: z.string().uuid() }).strict().safeParse(await c.req.json().catch(() => ({})));
if (!parsed.success) return c.json(failure('invalid_message_id', 'A valid message ID is required'), 400);
const result = await execute(async () => repository.decryptMedia(await repository.principal(c.get('identity').id), parsed.data.messageId));
if (!result.ok) return c.json(failure(result.cause.code, result.cause.message), result.cause.status);
return c.body(new Uint8Array(result.value.bytes), 200, { 'content-type': result.value.mimeType, 'cache-control': 'private, no-store' });
});
}
+24
View File
@@ -0,0 +1,24 @@
export type AdminRole = 'pack_creator' | 'super_admin';
export interface AdminPrincipal {
adminId: string;
userId: string;
role: AdminRole;
permissions: string[];
}
export type AdminFilterOperator = 'eq' | 'neq' | 'in' | 'is' | 'gte' | 'lte' | 'ilike';
export interface AdminFilter { column: string; op: AdminFilterOperator; value?: unknown }
export interface AdminQuery {
columns?: string[];
filters?: AdminFilter[];
order?: { column: string; ascending?: boolean };
limit?: number;
offset?: number;
}
export class AdminError extends Error {
constructor(public readonly code: string, message: string, public readonly status: 400 | 403 | 404 | 409) {
super(message);
}
}
+211
View File
@@ -0,0 +1,211 @@
import { Pool, type PoolClient } from 'pg';
import { AnswersError, calculateMatchType, type Answer, type MatchType, type QuestionType, type ResponseData } from './types.js';
interface ContextRow { couple_id: string | null; gender: string | null; max_intensity: number; is_premium: boolean }
interface QuestionRow { id: string; pack_id: string; text: string; partner_text: string | null; intensity: number; allowed_couple_genders: string[] | null; target_user_genders: string[] | null; question_type: QuestionType; config: Record<string, unknown>; created_at: string }
interface ResponseRow { id: string; user_id: string; question_id: string; couple_id: string; answer: Answer; response_data: ResponseData; created_at: string }
interface MatchRow { id: string; couple_id: string; question_id: string; match_type: MatchType; is_new: boolean; response_summary: Record<string, unknown> | null; created_at: string }
export interface SubmitInput { questionId: string; answer: Answer; responseData?: ResponseData }
export interface UpdateInput extends SubmitInput { confirmDeleteMatch: boolean }
export interface AnswersRepository {
recommended(userId: string, packId?: string): Promise<QuestionRow[]>;
pending(userId: string, direction: 'mine' | 'partner', startQuestionId?: string): Promise<Array<{ id: string; question: QuestionRow & { pack: { id: string; name: string; icon: string | null } }; partnerAnsweredAt: string }>>;
answerGap(userId: string): Promise<{ unanswered_by_partner: number; threshold: number; is_blocked: boolean }>;
dailyLimit(userId: string): Promise<{ responses_today: number; limit_value: number; remaining: number; reset_at: string | null; is_blocked: boolean }>;
submit(userId: string, input: SubmitInput): Promise<{ response: ResponseRow; match: (MatchRow & { question: QuestionRow }) | null }>;
update(userId: string, input: UpdateInput): Promise<Record<string, unknown>>;
responses(userId: string, page: number, limit: number): Promise<{ responses: unknown[]; totalCount: number }>;
matches(userId: string, page: number, limit: number, archived: boolean): Promise<{ matches: unknown[]; totalCount: number }>;
markSeen(userId: string, ids: string[]): Promise<void>;
archive(userId: string, matchId: string, archived: boolean): Promise<void>;
streak(userId: string): Promise<Record<string, unknown> | null>;
close(): Promise<void>;
}
export class PostgresAnswersRepository implements AnswersRepository {
private readonly pool: Pool;
constructor(databaseUrl: string) { this.pool = new Pool({ connectionString: databaseUrl }); }
private async context(client: Pool | PoolClient, userId: string): Promise<ContextRow> {
const result = await client.query<ContextRow>('select couple_id, gender, max_intensity, is_premium from profiles where id=$1', [userId]);
const row = result.rows[0];
if (!row?.couple_id) throw new AnswersError('no_couple', 409, 'Join a couple before answering questions');
return row;
}
async recommended(userId: string, packId?: string): Promise<QuestionRow[]> {
const ctx = await this.context(this.pool, userId);
const partner = await this.pool.query<ContextRow & { id: string }>('select id, couple_id, gender, max_intensity, is_premium from profiles where couple_id=$1 and id<>$2 order by id limit 1', [ctx.couple_id, userId]);
const p = partner.rows[0];
const config = await this.config();
if (!packId && p && (await this.answerGap(userId)).is_blocked) return [];
if (!packId && !ctx.is_premium && !p?.is_premium && (await this.dailyLimit(userId)).is_blocked) return [];
const genders = ctx.gender && p?.gender ? [ctx.gender, p.gender].sort().join('+') : null;
const maxIntensity = config.couple_intensity_gate_enabled && p ? Math.min(ctx.max_intensity, p.max_intensity) : ctx.max_intensity;
const result = await this.pool.query<QuestionRow>(`
with configured as (select exists(select 1 from couple_packs where couple_id=$2) value),
active as (
select $3::uuid pack_id where $3::uuid is not null
union all select cp.pack_id from couple_packs cp, configured c where $3::uuid is null and cp.couple_id=$2 and cp.enabled and c.value
union all select qp.id from question_packs qp, configured c where $3::uuid is null and qp.is_public and not c.value
)
select q.id,q.pack_id,
case when q.partner_text is not null and pr.id is not null then q.partner_text else q.text end text,
q.partner_text,q.intensity,q.allowed_couple_genders,q.target_user_genders,q.question_type,q.config,q.created_at
from questions q join active a on a.pack_id=q.pack_id join question_packs qp on qp.id=q.pack_id
left join responses pr on pr.question_id=q.id and pr.couple_id=$2 and pr.user_id<>$1
where q.deleted_at is null and not exists(select 1 from responses r where r.user_id=$1 and r.question_id=q.id)
and (not qp.is_premium or $4)
and (q.allowed_couple_genders is null or $5::text is null or $5=any(q.allowed_couple_genders))
and (q.target_user_genders is null or $6=any(q.target_user_genders) or pr.id is not null)
and q.intensity <= $7
order by (pr.id is not null) desc,q.intensity,q.id`,
[userId, ctx.couple_id, packId ?? null, ctx.is_premium || !!p?.is_premium, genders, ctx.gender, maxIntensity]);
return result.rows.map(this.dates);
}
async pending(userId: string, direction: 'mine' | 'partner', startQuestionId?: string) {
const ctx = await this.context(this.pool, userId);
const result = await this.pool.query<{ id: string; created_at: string; question: QuestionRow & { pack: { id: string; name: string; icon: string | null } } }>(`
select r.id,r.created_at,json_build_object('id',q.id,'pack_id',q.pack_id,'text',q.text,'partner_text',q.partner_text,
'intensity',q.intensity,'allowed_couple_genders',q.allowed_couple_genders,'target_user_genders',q.target_user_genders,
'question_type',q.question_type,'config',q.config,'created_at',q.created_at,
'pack',json_build_object('id',qp.id,'name',qp.name,'icon',qp.icon)) question
from responses r join questions q on q.id=r.question_id join question_packs qp on qp.id=q.pack_id
where r.couple_id=$2 and r.user_id ${direction === 'partner' ? '<>' : '='} $1 and q.deleted_at is null
and not exists(select 1 from responses other where other.question_id=r.question_id and other.user_id ${direction === 'partner' ? '=' : '<>'} $1 and other.couple_id=$2)
order by case when r.question_id=$3 then 0 else 1 end,r.created_at ${direction === 'partner' ? 'asc' : 'desc'}`,
[userId, ctx.couple_id, startQuestionId ?? null]);
return result.rows.map(row => ({ id: row.id, question: row.question, partnerAnsweredAt: new Date(row.created_at).toISOString() }));
}
private async config(client: Pool | PoolClient = this.pool) {
const result = await client.query<{ answer_gap_threshold: number; daily_response_limit: number; couple_intensity_gate_enabled: boolean }>('select answer_gap_threshold,daily_response_limit,couple_intensity_gate_enabled from app_config limit 1');
return result.rows[0] ?? { answer_gap_threshold: 10, daily_response_limit: 0, couple_intensity_gate_enabled: false };
}
async answerGap(userId: string) {
let ctx: ContextRow; try { ctx = await this.context(this.pool, userId); } catch { return { unanswered_by_partner: 0, threshold: 0, is_blocked: false }; }
const cfg = await this.config();
const partner = await this.pool.query<{ id: string; max_intensity: number }>('select id,max_intensity from profiles where couple_id=$1 and id<>$2 order by id limit 1', [ctx.couple_id, userId]);
if (!partner.rows[0] || cfg.answer_gap_threshold === 0) return { unanswered_by_partner: 0, threshold: cfg.answer_gap_threshold, is_blocked: false };
const gate = cfg.couple_intensity_gate_enabled ? Math.min(ctx.max_intensity, partner.rows[0].max_intensity) : 5;
const counts = await this.pool.query<{ mine: number; theirs: number }>(`
with enabled as(select pack_id from couple_packs where couple_id=$2 and enabled), eligible as(
select r.* from responses r join questions q on q.id=r.question_id
where r.couple_id=$2 and exists(select 1 from enabled) and q.pack_id in(select pack_id from enabled) and q.intensity <= $4)
select count(*) filter(where e.user_id=$1 and not exists(select 1 from eligible x where x.question_id=e.question_id and x.user_id=$3))::int mine,
count(*) filter(where e.user_id=$3 and not exists(select 1 from eligible x where x.question_id=e.question_id and x.user_id=$1))::int theirs from eligible e`,
[userId, ctx.couple_id, partner.rows[0].id, gate]);
const net = Math.max(0, (counts.rows[0]?.mine ?? 0) - (counts.rows[0]?.theirs ?? 0));
return { unanswered_by_partner: net, threshold: cfg.answer_gap_threshold, is_blocked: net >= cfg.answer_gap_threshold };
}
async dailyLimit(userId: string) {
return this.dailyLimitWith(this.pool,userId);
}
async submit(userId: string, input: SubmitInput) {
return this.tx(async client => {
// Profile lock serializes a user's daily-limit count across different questions.
const ctx = await this.lockedContext(client, userId);
// Question lock serializes both partners so the second transaction observes
// the first committed response and creates exactly one match.
const q = await this.question(client, input.questionId, true);
await this.assertEligible(client,userId,ctx,q);
const existing = await client.query('select id from responses where user_id=$1 and question_id=$2 for update', [userId,input.questionId]);
if (!existing.rowCount) {
const limit = await this.dailyLimitWith(client,userId); if (limit.is_blocked) throw new AnswersError('daily_limit',429,'Daily response limit reached',{ daily_limit: limit.limit_value, reset_at: limit.reset_at });
}
const response = await client.query<ResponseRow>(`insert into responses(id,user_id,question_id,couple_id,answer,response_data) values(gen_random_uuid(),$1,$2,$3,$4,$5)
on conflict(user_id,question_id) do update set answer=excluded.answer,response_data=excluded.response_data returning *`,[userId,input.questionId,ctx.couple_id,input.answer,input.responseData ?? null]);
const match = await this.reconcileMatch(client,userId,ctx.couple_id!,q,input.answer,input.responseData ?? null,true);
await this.touchStreak(client,userId,ctx.couple_id!);
return { response: this.dates(response.rows[0]), match: match ? { ...this.dates(match), question: this.dates(q) } : null };
});
}
async update(userId: string, input: UpdateInput) {
return this.tx(async client => {
const ctx = await this.lockedContext(client,userId); const q=await this.question(client,input.questionId,true);
const current=await client.query<ResponseRow>('select * from responses where user_id=$1 and question_id=$2 for update',[userId,input.questionId]);
if (!current.rows[0]) throw new AnswersError('response_not_found',404,'No existing response found for this question');
if (current.rows[0].answer===input.answer && typeof input.responseData==='undefined') return { success:true,message:'Answer unchanged' };
const existing=await client.query<MatchRow>('select * from matches where couple_id=$1 and question_id=$2 for update',[ctx.couple_id,input.questionId]);
if (input.answer==='no' && existing.rows[0] && !input.confirmDeleteMatch) {
const count=await client.query<{ count:number }>('select count(*)::int count from messages where match_id=$1',[existing.rows[0].id]);
return { success:false,requires_confirmation:true,match_id:existing.rows[0].id,message_count:count.rows[0]?.count ?? 0 };
}
const normalized=q.question_type!=='swipe' && input.answer!=='no' ? (typeof input.responseData==='undefined' ? current.rows[0].response_data : input.responseData) : null;
const before=existing.rows[0]; const match=await this.reconcileMatch(client,userId,ctx.couple_id!,q,input.answer,normalized,false);
await client.query('update responses set answer=$3,response_data=$4 where user_id=$1 and question_id=$2',[userId,input.questionId,input.answer,normalized]);
await this.touchStreak(client,userId,ctx.couple_id!);
return { success:true, ...(before&&!match?{match_deleted:true}:{ }), ...(!before&&match?{new_match:this.dates(match)}:{}), ...(before&&match?{match_type_updated:true}:{}) };
});
}
private async reconcileMatch(client:PoolClient,userId:string,coupleId:string,q:QuestionRow,answer:Answer,data:ResponseData,isNew:boolean) {
const partner=await client.query<ResponseRow>('select * from responses where couple_id=$1 and question_id=$2 and user_id<>$3 limit 1',[coupleId,q.id,userId]);
if(!partner.rows[0]) return null;
const type=calculateMatchType(q,answer,partner.rows[0].answer);
if(!type){ await client.query('delete from matches where couple_id=$1 and question_id=$2',[coupleId,q.id]); return null; }
const summary=type==='both_answered'?{[userId]:data,[partner.rows[0].user_id]:partner.rows[0].response_data}:null;
const result=await client.query<MatchRow>(`insert into matches(couple_id,question_id,match_type,is_new,response_summary) values($1,$2,$3,true,$4)
on conflict(couple_id,question_id) do update set match_type=excluded.match_type,response_summary=excluded.response_summary,is_new=case when $5 then true else matches.is_new end returning *`,[coupleId,q.id,type,summary,isNew]);
return result.rows[0];
}
private async touchStreak(client:PoolClient,userId:string,coupleId:string){
const users=await client.query<{id:string}>('select id from profiles where couple_id=$1 order by id',[coupleId]); if(users.rows.length<2)return;
const user1=users.rows[0].id===userId;
await client.query('insert into couple_streaks(couple_id) values($1) on conflict do nothing',[coupleId]);
const locked=await client.query<{current_streak:number;longest_streak:number;last_active_date:string|null;last_completed_date:string|null;user1_answered_today:boolean;user2_answered_today:boolean}>(`select current_streak,longest_streak,last_active_date::text,last_completed_date::text,user1_answered_today,user2_answered_today from couple_streaks where couple_id=$1 for update`,[coupleId]);
const row=locked.rows[0]; const todayResult=await client.query<{today:string;yesterday:string}>('select current_date::text today,(current_date-1)::text yesterday'); const {today,yesterday}=todayResult.rows[0];
let first=row.last_active_date===today?row.user1_answered_today:false; let second=row.last_active_date===today?row.user2_answered_today:false;
if(user1)first=true;else second=true;
let current=row.last_completed_date && row.last_completed_date!==yesterday && row.last_completed_date!==today?0:row.current_streak; let completed=row.last_completed_date;
if(first&&second&&completed!==today){current=completed===yesterday?current+1:1;completed=today;}
await client.query(`update couple_streaks set current_streak=$2,longest_streak=greatest(longest_streak,$2),last_active_date=$3,last_completed_date=$4,user1_answered_today=$5,user2_answered_today=$6,updated_at=now() where couple_id=$1`,[coupleId,current,today,completed,first,second]);
}
async responses(userId:string,page:number,limit:number){
const ctx=await this.context(this.pool,userId); const offset=page*limit;
const [rows,count]=await Promise.all([
this.pool.query(`select r.id,r.question_id,r.answer,r.response_data,r.created_at,
json_build_object('id',q.id,'text',q.text,'partner_text',q.partner_text,'intensity',q.intensity,'pack_id',q.pack_id,'question_type',q.question_type,'config',q.config,'created_at',q.created_at,'pack',json_build_object('id',qp.id,'name',qp.name,'icon',qp.icon)) question,
m.id is not null has_match,m.id match_id,pr.id is not null partner_answered
from responses r join questions q on q.id=r.question_id join question_packs qp on qp.id=q.pack_id left join matches m on m.couple_id=r.couple_id and m.question_id=r.question_id left join responses pr on pr.couple_id=r.couple_id and pr.question_id=r.question_id and pr.user_id<>$1 where r.user_id=$1 and r.couple_id=$2 order by r.created_at desc limit $3 offset $4`,[userId,ctx.couple_id,limit,offset]),
this.pool.query<{count:number}>('select count(*)::int count from responses where user_id=$1 and couple_id=$2',[userId,ctx.couple_id])]);
return {responses:rows.rows.map(this.dates),totalCount:count.rows[0]?.count??0};
}
async matches(userId:string,page:number,limit:number,archived:boolean){
const ctx=await this.context(this.pool,userId); const offset=page*limit;
const params=[userId,ctx.couple_id,archived,limit,offset];
const base=`from matches m join questions q on q.id=m.question_id left join match_archives a on a.match_id=m.id and a.user_id=$1 where m.couple_id=$2 and (($3 and a.id is not null) or (not $3 and a.id is null))`;
const [rows,count]=await Promise.all([this.pool.query(`select m.*,json_build_object('id',q.id,'pack_id',q.pack_id,'text',q.text,'partner_text',q.partner_text,'intensity',q.intensity,'question_type',q.question_type,'config',q.config,'created_at',q.created_at) question,(select count(*)::int from messages msg where msg.match_id=m.id and msg.user_id<>$1 and msg.read_at is null) "unreadCount" ${base} order by "unreadCount" desc,m.created_at desc limit $4 offset $5`,params),this.pool.query<{count:number}>(`select count(*)::int count ${base}`,params.slice(0,3))]);
return {matches:rows.rows.map(this.dates),totalCount:count.rows[0]?.count??0};
}
async markSeen(userId:string,ids:string[]){const ctx=await this.context(this.pool,userId);await this.pool.query('update matches set is_new=false where couple_id=$1 and id=any($2::uuid[])',[ctx.couple_id,ids]);}
async archive(userId:string,matchId:string,archived:boolean){const ctx=await this.context(this.pool,userId);const found=await this.pool.query('select 1 from matches where id=$1 and couple_id=$2',[matchId,ctx.couple_id]);if(!found.rowCount)throw new AnswersError('match_not_found',404,'Match was not found');if(archived)await this.pool.query('insert into match_archives(match_id,user_id) values($1,$2) on conflict do nothing',[matchId,userId]);else await this.pool.query('delete from match_archives where match_id=$1 and user_id=$2',[matchId,userId]);}
async streak(userId:string){const ctx=await this.context(this.pool,userId);const r=await this.pool.query('select * from couple_streaks where couple_id=$1',[ctx.couple_id]);return r.rows[0]?this.dates(r.rows[0]):null;}
private async question(client:PoolClient,id:string,lock=false){const r=await client.query<QuestionRow>(`select * from questions where id=$1 and deleted_at is null${lock?' for update':''}`,[id]);if(!r.rows[0])throw new AnswersError('question_not_found',404,'Question was not found');return r.rows[0];}
private async lockedContext(client:PoolClient,userId:string){await client.query('select id from profiles where id=$1 for update',[userId]);return this.context(client,userId);}
private async dailyLimitWith(client:Pool|PoolClient,userId:string){
const cfg=await this.config(client);if(cfg.daily_response_limit===0)return {responses_today:0,limit_value:0,remaining:0,reset_at:null,is_blocked:false};
const premium=await client.query<{premium:boolean}>('select (p.is_premium or coalesce(bool_or(o.is_premium),false)) premium from profiles p left join profiles o on o.couple_id=p.couple_id and o.id<>p.id where p.id=$1 group by p.is_premium',[userId]);if(premium.rows[0]?.premium)return {responses_today:0,limit_value:0,remaining:0,reset_at:null,is_blocked:false};
const r=await client.query<{count:number;reset_at:string}>("select count(*)::int count,((date_trunc('day',now() at time zone 'UTC') at time zone 'UTC')+interval '1 day') reset_at from responses where user_id=$1 and created_at>=(date_trunc('day',now() at time zone 'UTC') at time zone 'UTC')",[userId]);const count=r.rows[0]?.count??0;return {responses_today:count,limit_value:cfg.daily_response_limit,remaining:Math.max(0,cfg.daily_response_limit-count),reset_at:new Date(r.rows[0].reset_at).toISOString(),is_blocked:count>=cfg.daily_response_limit};
}
private async assertEligible(client:PoolClient,userId:string,ctx:ContextRow,q:QuestionRow){
const partner=await client.query<ContextRow&{id:string}>('select id,couple_id,gender,max_intensity,is_premium from profiles where couple_id=$1 and id<>$2 order by id limit 1',[ctx.couple_id,userId]);const p=partner.rows[0];
const cfg=await this.config(client);const max=cfg.couple_intensity_gate_enabled&&p?Math.min(ctx.max_intensity,p.max_intensity):ctx.max_intensity;const genders=ctx.gender&&p?.gender?[ctx.gender,p.gender].sort().join('+'):null;
const allowed=!q.allowed_couple_genders||!genders||q.allowed_couple_genders.includes(genders);const targeted=!q.target_user_genders||!!ctx.gender&&q.target_user_genders.includes(ctx.gender)||!!(await client.query('select 1 from responses where couple_id=$1 and question_id=$2 and user_id<>$3',[ctx.couple_id,q.id,userId])).rowCount;
const pack=await client.query<{is_public:boolean;is_premium:boolean;enabled:boolean|null;configured:boolean}>(`select qp.is_public,qp.is_premium,cp.enabled,exists(select 1 from couple_packs where couple_id=$2) configured from question_packs qp left join couple_packs cp on cp.pack_id=qp.id and cp.couple_id=$2 where qp.id=$1`,[q.pack_id,ctx.couple_id]);const pr=pack.rows[0];const active=!!pr&&(pr.configured?pr.enabled===true:pr.is_public);const premium=!pr?.is_premium||ctx.is_premium||!!p?.is_premium;
if(!active||!premium||q.intensity>max||!allowed||!targeted)throw new AnswersError('question_not_eligible',404,'Question was not found');
}
private async tx<T>(fn:(client:PoolClient)=>Promise<T>):Promise<T>{const c=await this.pool.connect();try{await c.query('begin');const value=await fn(c);await c.query('commit');return value;}catch(e){await c.query('rollback');throw e;}finally{c.release();}}
private dates<T extends Record<string,any>>(row:T):T{return Object.fromEntries(Object.entries(row).map(([k,v])=>[k,v instanceof Date?v.toISOString():v])) as T;}
async close(){await this.pool.end();}
}
+28
View File
@@ -0,0 +1,28 @@
import type { ApiErrorResponse } from '@sauci/shared';
import type { Hono } from 'hono';
import { z } from 'zod';
import type { AuthIdentity } from '../../auth.js';
import type { AnswersRepository } from './repository.js';
import { AnswersError } from './types.js';
type App = Hono<{Variables:{identity:AuthIdentity}}>;
const uuid=z.string().uuid(); const page=z.coerce.number().int().min(0).default(0); const limit=z.coerce.number().int().min(1).max(100).default(20);
const responseData=z.record(z.unknown()).nullable();
const submit=z.object({question_id:uuid,answer:z.enum(['yes','no','maybe']),response_data:responseData.optional()}).strict();
const update=z.object({new_answer:z.enum(['yes','no','maybe']),response_data:responseData.optional(),confirm_delete_match:z.boolean().optional().default(false)}).strict();
const err=(code:string,message:string,details?:Record<string,unknown>):ApiErrorResponse & {details?:Record<string,unknown>}=>({error:{code,message},...(details?{details}:{})});
async function run<T>(op:()=>Promise<T>){try{return {ok:true as const,value:await op()};}catch(e){if(e instanceof AnswersError)return {ok:false as const,status:e.status,body:err(e.code,e.message,e.details)};throw e;}}
export function registerAnswerRoutes(app:App,repo:AnswersRepository):void{
app.get('/v1/questions/recommended',async c=>{const packId=c.req.query('packId');if(packId&&!uuid.safeParse(packId).success)return c.json(err('invalid_pack_id','Pack ID must be a UUID'),400);const r=await run(()=>repo.recommended(c.get('identity').id,packId));return r.ok?c.json({questions:r.value}):c.json(r.body,r.status);});
app.get('/v1/questions/pending',async c=>{const direction=c.req.query('direction')==='mine'?'mine':'partner';const start=c.req.query('startQuestionId');if(start&&!uuid.safeParse(start).success)return c.json(err('invalid_question_id','startQuestionId must be a UUID'),400);const r=await run(()=>repo.pending(c.get('identity').id,direction,start));return r.ok?c.json({questions:r.value}):c.json(r.body,r.status);});
app.get('/v1/me/answer-gap',async c=>c.json(await repo.answerGap(c.get('identity').id)));
app.get('/v1/me/daily-limit',async c=>c.json(await repo.dailyLimit(c.get('identity').id)));
app.post('/v1/responses',async c=>{const b=submit.safeParse(await c.req.json().catch(()=>null));if(!b.success)return c.json(err('invalid_request','question_id, answer, and valid response_data are required'),400);const r=await run(()=>repo.submit(c.get('identity').id,{questionId:b.data.question_id,answer:b.data.answer,responseData:b.data.response_data}));return r.ok?c.json(r.value):c.json(r.body,r.status);});
app.patch('/v1/responses/:questionId',async c=>{const id=uuid.safeParse(c.req.param('questionId'));const b=update.safeParse(await c.req.json().catch(()=>null));if(!id.success||!b.success)return c.json(err('invalid_request','A valid question ID and answer are required'),400);const r=await run(()=>repo.update(c.get('identity').id,{questionId:id.data,answer:b.data.new_answer,responseData:b.data.response_data,confirmDeleteMatch:b.data.confirm_delete_match}));return r.ok?c.json(r.value):c.json(r.body,r.status);});
app.get('/v1/me/responses',async c=>{const p=page.safeParse(c.req.query('page')),l=limit.safeParse(c.req.query('limit'));if(!p.success||!l.success)return c.json(err('invalid_pagination','page and limit are out of range'),400);return c.json(await repo.responses(c.get('identity').id,p.data,l.data));});
app.get('/v1/matches',async c=>{const p=page.safeParse(c.req.query('page')),l=limit.safeParse(c.req.query('limit'));if(!p.success||!l.success)return c.json(err('invalid_pagination','page and limit are out of range'),400);return c.json(await repo.matches(c.get('identity').id,p.data,l.data,c.req.query('archived')==='true'));});
app.patch('/v1/matches/seen',async c=>{const b=z.object({ids:z.array(uuid).min(1)}).safeParse(await c.req.json().catch(()=>null));if(!b.success)return c.json(err('invalid_request','ids must contain valid match IDs'),400);await repo.markSeen(c.get('identity').id,b.data.ids);return c.json({success:true});});
app.put('/v1/matches/:matchId/archive',async c=>{const id=uuid.safeParse(c.req.param('matchId'));const b=z.object({archived:z.boolean()}).safeParse(await c.req.json().catch(()=>null));if(!id.success||!b.success)return c.json(err('invalid_request','A valid match ID and archived flag are required'),400);const r=await run(()=>repo.archive(c.get('identity').id,id.data,b.data.archived));return r.ok?c.json({success:true}):c.json(r.body,r.status);});
app.get('/v1/me/streak',async c=>{const r=await run(()=>repo.streak(c.get('identity').id));return r.ok?c.json({streak:r.value}):c.json(r.body,r.status);});
}
+4
View File
@@ -0,0 +1,4 @@
// Registration contract: export this module from db/all-schema.ts when the
// application's Drizzle schema becomes authoritative. Migration 0003 is the
// current source of truth for this domain.
export const answersSchemaVersion = 3 as const;
+29
View File
@@ -0,0 +1,29 @@
export type Answer = 'yes' | 'no' | 'maybe';
export type QuestionType = 'swipe' | 'text_answer' | 'audio' | 'photo' | 'who_likely';
export type MatchType = 'yes_yes' | 'yes_maybe' | 'maybe_maybe' | 'both_answered';
export type ResponseData = Record<string, unknown> | null;
export interface QuestionRef {
id: string;
question_type: QuestionType;
}
export function calculateMatchType(question: QuestionRef, first: Answer, second: Answer): MatchType | null {
if (question.question_type === 'swipe') {
if (first === 'no' || second === 'no') return null;
if (first === 'yes' && second === 'yes') return 'yes_yes';
if (first === 'maybe' && second === 'maybe') return 'maybe_maybe';
return 'yes_maybe';
}
if (question.question_type === 'who_likely') return 'both_answered';
return first === 'no' || second === 'no' ? null : 'both_answered';
}
export class AnswersError extends Error {
constructor(
readonly code: 'no_couple' | 'question_not_found' | 'question_not_eligible' | 'response_not_found' | 'daily_limit' | 'match_not_found',
readonly status: 400 | 404 | 409 | 429,
message: string,
readonly details?: Record<string, unknown>,
) { super(message); }
}
+193
View File
@@ -0,0 +1,193 @@
import { Pool, type QueryResultRow } from 'pg';
export interface PackContext { id: string; name: string; icon: string | null }
export interface AppQuestion {
id: string; pack_id: string; text: string; partner_text: string | null; intensity: number;
allowed_couple_genders: string[] | null; target_user_genders: string[] | null;
question_type: string; config: Record<string, unknown>; created_at: string;
}
export interface MatchContext {
id: string; question_id: string; couple_id: string; match_type: string; created_at: string;
response_summary: Record<string, unknown> | null;
question: { id: string; text: string; partner_text: string | null };
responses: Array<{ user_id: string; answer: string; created_at: string; profiles: { name: string | null } }>;
}
export interface StrokePoint { x: number; y: number }
export interface StrokeSegment {
id: string; userId: string; points: StrokePoint[]; color: string; width: number;
timestamp: number; isEraser: boolean;
}
export interface LiveDrawState { strokes: StrokeSegment[]; revision: number; updated_at: string | null; updated_by: string | null }
export class AppDataError extends Error {
constructor(public readonly code: 'not_found' | 'no_couple' | 'not_recipient' | 'rate_limited' | 'revision_conflict', message: string, public readonly status: 403 | 404 | 409 | 429, public readonly details?: Record<string, unknown>) { super(message); }
}
export interface AppDataRepository {
packContext(userId: string, packId: string): Promise<PackContext>;
packQuestions(userId: string, packId: string): Promise<AppQuestion[]>;
packTeaser(userId: string, packId: string): Promise<Array<Pick<AppQuestion, 'id' | 'text' | 'intensity'>>>;
matchContext(userId: string, matchId: string): Promise<MatchContext>;
markMediaViewed(userId: string, messageId: string, expiresAt: string | null): Promise<{ media_viewed_at: string; media_expires_at: string | null }>;
nudgeStatus(userId: string): Promise<{ last_nudge_sent_at: string | null }>;
sendNudge(userId: string): Promise<{ success: true; notification_sent: boolean; reason?: string; next_nudge_available_at: string }>;
getLiveDraw(userId: string): Promise<LiveDrawState>;
putLiveDraw(userId: string, strokes: StrokeSegment[], baseRevision: number): Promise<LiveDrawState>;
close(): Promise<void>;
}
interface QuestionRow extends QueryResultRow, Omit<AppQuestion, 'created_at'> { created_at: Date }
function question(row: QuestionRow): AppQuestion { return { ...row, created_at: row.created_at.toISOString() }; }
export class PostgresAppDataRepository implements AppDataRepository {
private readonly pool: Pool;
constructor(databaseUrl: string) { this.pool = new Pool({ connectionString: databaseUrl }); }
private async visiblePack(userId: string, packId: string): Promise<PackContext> {
const result = await this.pool.query<PackContext>(`
select qp.id, qp.name, qp.icon
from question_packs qp
left join categories c on c.id = qp.category_id
left join profiles p on p.id = $1
where qp.id = $2 and qp.is_public = true
and (qp.category_id is null or c.is_public = true)
and (qp.is_explicit = false or coalesce(p.hide_nsfw, true) = false)
limit 1`, [userId, packId]);
if (!result.rows[0]) throw new AppDataError('not_found', 'Pack not found', 404);
return result.rows[0];
}
async packContext(userId: string, packId: string): Promise<PackContext> { return this.visiblePack(userId, packId); }
async packQuestions(userId: string, packId: string): Promise<AppQuestion[]> {
await this.visiblePack(userId, packId);
const result = await this.pool.query<QuestionRow>(`
select id, pack_id, text, partner_text, intensity, allowed_couple_genders,
target_user_genders, question_type, config, created_at
from questions where pack_id = $1 and deleted_at is null
order by created_at, id`, [packId]);
return result.rows.map(question);
}
async packTeaser(userId: string, packId: string) {
await this.visiblePack(userId, packId);
const result = await this.pool.query<{ id: string; text: string; intensity: number }>(`
select id, text, intensity from questions
where pack_id = $1 and deleted_at is null
order by intensity, md5(id::text || current_date::text) limit 3`, [packId]);
return result.rows;
}
async matchContext(userId: string, matchId: string): Promise<MatchContext> {
const match = await this.pool.query<{
id: string; question_id: string; couple_id: string; match_type: string; created_at: Date;
response_summary: Record<string, unknown> | null; question: MatchContext['question'];
}>(`select m.id,m.question_id,m.couple_id,m.match_type,m.created_at,m.response_summary,
json_build_object('id',q.id,'text',q.text,'partner_text',q.partner_text) question
from matches m join questions q on q.id=m.question_id join profiles p on p.couple_id=m.couple_id
where p.id=$1 and m.id=$2 limit 1`, [userId, matchId]);
if (!match.rows[0]) throw new AppDataError('not_found', 'Match not found', 404);
const responses = await this.pool.query<{
user_id: string; answer: string; created_at: Date; profiles: { name: string | null };
}>(`select r.user_id,r.answer,r.created_at,json_build_object('name',p.name) profiles
from responses r join profiles p on p.id=r.user_id
where r.question_id=$1 and r.couple_id=$2 order by r.created_at,r.id`, [match.rows[0].question_id, match.rows[0].couple_id]);
return { ...match.rows[0], created_at: match.rows[0].created_at.toISOString(), responses: responses.rows.map(row => ({ ...row, created_at: row.created_at.toISOString() })) };
}
async markMediaViewed(userId: string, messageId: string, expiresAt: string | null) {
const result = await this.pool.query<{ media_viewed_at: Date; media_expires_at: Date | null }>(`
update messages m set media_viewed_at=coalesce(media_viewed_at,now()),
media_expires_at=case when m.media_type='video' then coalesce(media_expires_at,$3::timestamptz) else null end
from matches ma join profiles p on p.couple_id=ma.couple_id
where m.match_id=ma.id and p.id=$1 and m.id=$2 and m.user_id<>$1
returning m.media_viewed_at,m.media_expires_at`, [userId, messageId, expiresAt]);
if (!result.rows[0]) throw new AppDataError('not_recipient', 'Message not found', 404);
return { media_viewed_at: result.rows[0].media_viewed_at.toISOString(), media_expires_at: result.rows[0].media_expires_at?.toISOString() ?? null };
}
async nudgeStatus(userId: string) {
const result = await this.pool.query<{ last_nudge_sent_at: Date | null }>('select last_nudge_sent_at from profiles where id=$1', [userId]);
return { last_nudge_sent_at: result.rows[0]?.last_nudge_sent_at?.toISOString() ?? null };
}
async sendNudge(userId: string) {
const client = await this.pool.connect();
let senderName: string | null = null; let partnerToken: string | null = null; let nudgesEnabled = true; let nextAvailable = '';
try {
await client.query('begin');
const sender = await client.query<{ name: string | null; couple_id: string | null; last_nudge_sent_at: Date | null }>(
'select name,couple_id,last_nudge_sent_at from profiles where id=$1 for update', [userId]);
if (!sender.rows[0]) throw new AppDataError('not_found', 'Profile not found', 404);
if (!sender.rows[0].couple_id) throw new AppDataError('no_couple', 'Join a couple first', 409);
const now = new Date(); const last = sender.rows[0].last_nudge_sent_at;
if (last && now.getTime() - last.getTime() < 12 * 60 * 60 * 1000) {
const next = new Date(last.getTime() + 12 * 60 * 60 * 1000);
throw new AppDataError('rate_limited', 'Nudge cooldown is active', 429, {
cooldown_remaining_seconds: Math.ceil((next.getTime() - now.getTime()) / 1000), next_nudge_available_at: next.toISOString(),
});
}
const partner = await client.query<{ id: string; push_token: string | null; nudges_enabled: boolean | null }>(`
select p.id,p.push_token,np.nudges_enabled from profiles p
left join notification_preferences np on np.user_id=p.id
where p.couple_id=$1 and p.id<>$2 order by p.id limit 1`, [sender.rows[0].couple_id, userId]);
if (!partner.rows[0]) throw new AppDataError('not_found', 'Partner not found', 404);
await client.query('update profiles set last_nudge_sent_at=$2 where id=$1', [userId, now]);
await client.query('commit');
senderName = sender.rows[0].name; partnerToken = partner.rows[0].push_token; nudgesEnabled = partner.rows[0].nudges_enabled !== false;
nextAvailable = new Date(now.getTime() + 12 * 60 * 60 * 1000).toISOString();
} catch (cause) {
await client.query('rollback').catch(() => undefined);
throw cause;
} finally { client.release(); }
if (!partnerToken) return { success: true as const, notification_sent: false, reason: 'no_push_token', next_nudge_available_at: nextAvailable };
if (!nudgesEnabled) return { success: true as const, notification_sent: false, reason: 'nudges_disabled', next_nudge_available_at: nextAvailable };
const response = await fetch('https://exp.host/--/api/v2/push/send', {
method: 'POST', headers: { 'content-type': 'application/json', accept: 'application/json' },
body: JSON.stringify({ to: partnerToken, title: 'Partner nudge', body: `${senderName || 'Your partner'} wants you to catch up!`, sound: 'default', data: { type: 'nudge' } }),
});
if (!response.ok) throw new Error(`Expo push request failed with status ${response.status}`);
return { success: true as const, notification_sent: true, next_nudge_available_at: nextAvailable };
}
private async coupleId(userId: string): Promise<string> {
const result = await this.pool.query<{ couple_id: string | null }>('select couple_id from profiles where id=$1', [userId]);
const coupleId = result.rows[0]?.couple_id;
if (!coupleId) throw new AppDataError('no_couple', 'Join a couple first', 409);
return coupleId;
}
async getLiveDraw(userId: string): Promise<LiveDrawState> {
const coupleId = await this.coupleId(userId);
const result = await this.pool.query<{ strokes: StrokeSegment[]; revision: string; updated_at: Date; updated_by: string }>(
'select strokes,revision::text,updated_at,updated_by from live_draw_sessions where couple_id=$1', [coupleId]);
const row = result.rows[0];
return row ? { strokes: row.strokes, revision: Number(row.revision), updated_at: row.updated_at.toISOString(), updated_by: row.updated_by } : { strokes: [], revision: 0, updated_at: null, updated_by: null };
}
async putLiveDraw(userId: string, strokes: StrokeSegment[], baseRevision: number): Promise<LiveDrawState> {
const coupleId = await this.coupleId(userId);
const memberIds = await this.pool.query<{ id: string }>('select id from profiles where couple_id=$1', [coupleId]);
const allowed = new Set(memberIds.rows.map(row => row.id));
if (strokes.some(stroke => !allowed.has(stroke.userId))) {
throw new AppDataError('not_recipient', 'Drawing contains a stroke from outside this couple', 403);
}
let result = await this.pool.query<{ strokes: StrokeSegment[]; revision: string; updated_at: Date; updated_by: string }>(`
update live_draw_sessions set strokes=$2::jsonb,updated_by=$3,revision=revision+1,updated_at=now()
where couple_id=$1 and revision=$4
returning strokes,revision::text,updated_at,updated_by`, [coupleId, JSON.stringify(strokes), userId, baseRevision]);
if (!result.rows[0] && baseRevision === 0) {
result = await this.pool.query<{ strokes: StrokeSegment[]; revision: string; updated_at: Date; updated_by: string }>(`
insert into live_draw_sessions(couple_id,strokes,updated_by) values($1,$2::jsonb,$3)
on conflict(couple_id) do nothing returning strokes,revision::text,updated_at,updated_by`, [coupleId, JSON.stringify(strokes), userId]);
}
const row = result.rows[0];
if (!row) {
const current = await this.getLiveDraw(userId);
throw new AppDataError('revision_conflict', 'Drawing changed on another device', 409, { current_state: current });
}
return { strokes: row.strokes, revision: Number(row.revision), updated_at: row.updated_at.toISOString(), updated_by: row.updated_by };
}
async close(): Promise<void> { await this.pool.end(); }
}
+58
View File
@@ -0,0 +1,58 @@
import type { ApiErrorResponse } from '@sauci/shared';
import type { Hono } from 'hono';
import { z } from 'zod';
import type { AuthIdentity } from '../../auth.js';
import { AppDataError, type AppDataRepository } from './repository.js';
type App = Hono<{ Variables: { identity: AuthIdentity } }>;
const uuid = z.string().uuid();
const point = z.object({ x: z.number().finite().min(0).max(1), y: z.number().finite().min(0).max(1) }).strict();
const stroke = z.object({
id: z.string().min(1).max(120), userId: uuid, points: z.array(point).max(20_000),
color: z.string().min(1).max(64), width: z.number().finite().positive().max(100),
timestamp: z.number().finite().nonnegative(), isEraser: z.boolean(),
}).strict();
const liveDrawBody = z.object({ strokes: z.array(stroke).max(2_000), base_revision: z.number().int().nonnegative() }).strict();
const viewedBody = z.object({ expires_at: z.string().datetime().nullable() }).strict();
const error = (code: string, message: string): ApiErrorResponse => ({ error: { code, message } });
async function run<T>(op: () => Promise<T>) { try { return { ok: true as const, value: await op() }; } catch (cause) { if (cause instanceof AppDataError) return { ok: false as const, cause }; throw cause; } }
export function registerAppDataRoutes(app: App, repository: AppDataRepository): void {
app.get('/v1/packs/:packId/context', async c => {
const id = uuid.safeParse(c.req.param('packId')); if (!id.success) return c.json(error('invalid_pack_id', 'A valid pack ID is required'), 400);
const result = await run(() => repository.packContext(c.get('identity').id, id.data));
return result.ok ? c.json({ pack: result.value }) : c.json(error(result.cause.code, result.cause.message), result.cause.status);
});
app.get('/v1/packs/:packId/questions', async c => {
const id = uuid.safeParse(c.req.param('packId')); if (!id.success) return c.json(error('invalid_pack_id', 'A valid pack ID is required'), 400);
const result = await run(() => repository.packQuestions(c.get('identity').id, id.data));
return result.ok ? c.json({ questions: result.value }) : c.json(error(result.cause.code, result.cause.message), result.cause.status);
});
app.get('/v1/packs/:packId/teaser', async c => {
const id = uuid.safeParse(c.req.param('packId')); if (!id.success) return c.json(error('invalid_pack_id', 'A valid pack ID is required'), 400);
const result = await run(() => repository.packTeaser(c.get('identity').id, id.data));
return result.ok ? c.json({ questions: result.value }) : c.json(error(result.cause.code, result.cause.message), result.cause.status);
});
app.get('/v1/matches/:matchId/context', async c => {
const id = uuid.safeParse(c.req.param('matchId')); if (!id.success) return c.json(error('invalid_match_id', 'A valid match ID is required'), 400);
const result = await run(() => repository.matchContext(c.get('identity').id, id.data));
return result.ok ? c.json({ match: result.value }) : c.json(error(result.cause.code, result.cause.message), result.cause.status);
});
app.patch('/v1/messages/:messageId/media-viewed', async c => {
const id = uuid.safeParse(c.req.param('messageId')); const body = viewedBody.safeParse(await c.req.json().catch(() => null));
if (!id.success || !body.success) return c.json(error('invalid_request', 'A valid message ID and expires_at are required'), 400);
const result = await run(() => repository.markMediaViewed(c.get('identity').id, id.data, body.data.expires_at));
return result.ok ? c.json(result.value) : c.json(error(result.cause.code, result.cause.message), result.cause.status);
});
app.get('/v1/me/nudge-status', async c => c.json(await repository.nudgeStatus(c.get('identity').id)));
app.post('/v1/me/nudge', async c => {
const result = await run(() => repository.sendNudge(c.get('identity').id));
return result.ok ? c.json(result.value) : c.json({ ...error(result.cause.code, result.cause.message), ...result.cause.details }, result.cause.status);
});
app.get('/v1/live-draw', async c => { const result = await run(() => repository.getLiveDraw(c.get('identity').id)); return result.ok ? c.json(result.value) : c.json(error(result.cause.code, result.cause.message), result.cause.status); });
app.put('/v1/live-draw', async c => {
const body = liveDrawBody.safeParse(await c.req.json().catch(() => null)); if (!body.success) return c.json(error('invalid_strokes', 'A valid strokes array is required'), 400);
const result = await run(() => repository.putLiveDraw(c.get('identity').id, body.data.strokes, body.data.base_revision));
return result.ok ? c.json(result.value) : c.json({ ...error(result.cause.code, result.cause.message), ...result.cause.details }, result.cause.status);
});
}
+12
View File
@@ -0,0 +1,12 @@
import { bigint, index, jsonb, pgTable, timestamp, uuid } from 'drizzle-orm/pg-core';
import { profiles } from '../../db/schema.js';
import { couples } from '../couples/schema.js';
export const liveDrawSessions = pgTable('live_draw_sessions', {
coupleId: uuid('couple_id').primaryKey().references(() => couples.id, { onDelete: 'cascade' }),
strokes: jsonb('strokes').notNull().default([]),
revision: bigint('revision', { mode: 'number' }).notNull().default(1),
updatedBy: uuid('updated_by').notNull().references(() => profiles.id, { onDelete: 'cascade' }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
}, (table) => [index('live_draw_sessions_updated_at_idx').on(table.updatedAt)]);
+35
View File
@@ -0,0 +1,35 @@
# Billing and redemption
This domain owns RevenueCat webhook persistence and public promotional-code
redemption. Neither route uses the hosted Supabase data plane.
- `POST /webhooks/revenuecat` requires an exact
`Authorization: Bearer <REVENUECAT_WEBHOOK_SECRET>` header. A missing server
secret disables the webhook with `503`; it never becomes unauthenticated.
- `POST /public/v1/redemptions` accepts `{ email, code }` for the public web
redemption form. Validation, remaining-use checks, redemption recording, and
the premium grant run in one database transaction.
Both public endpoints have request-body limits (1 MiB for provider webhooks and
16 KiB for redemption). Webhook authorization is checked before JSON parsing.
Because the API cannot independently authenticate a proxy-supplied client IP,
redemption also uses a database-backed global ceiling of 60 attempts per minute
across all replicas. The production reverse proxy must additionally apply a
per-client-IP limit; forwarded-IP headers must not be trusted from the public
internet unless the proxy strips and rewrites them.
Webhook event insertion, subscription upsert, premium recomputation, and any
premium-pack disabling are atomic. The unique event ID makes provider retries
idempotent. A known event for an unknown user rolls back the event record so a
later retry can succeed after identity reconciliation. Unknown and `TEST` events
are recorded once and acknowledged without changing subscription state.
Wire a `PostgresBillingRepository`, construct `BillingService` with the
server-only `REVENUECAT_WEBHOOK_SECRET`, call `registerBillingRoutes`, and close
the repository during graceful shutdown. The web app should receive the API base
URL via `NEXT_PUBLIC_API_URL`; no server secret belongs in a `NEXT_PUBLIC_*`
variable.
Rate-limit the public redemption endpoint by client IP at the trusted reverse
proxy. The API limits request size and atomically locks codes, but deliberately
does not trust arbitrary forwarded-IP headers on its own.
+5
View File
@@ -0,0 +1,5 @@
export * from './repository.js';
export * from './routes.js';
export * from './service.js';
export * from './types.js';
+212
View File
@@ -0,0 +1,212 @@
import { Pool, type PoolClient, type QueryResultRow } from 'pg';
import { BillingError, type RedemptionResult, type RevenueCatWebhook, type SubscriptionStatus } from './types.js';
interface ProfileRecord extends QueryResultRow {
id: string;
couple_id: string | null;
is_premium: boolean;
}
interface CodeRecord extends QueryResultRow {
id: string;
max_uses: number | null;
current_uses: number;
expires_at: Date | null;
is_active: boolean;
}
export interface WebhookResult {
duplicate: boolean;
handled: boolean;
}
export interface BillingRepository {
processRevenueCatEvent(payload: RevenueCatWebhook, status: SubscriptionStatus | null): Promise<WebhookResult>;
redeem(email: string, code: string): Promise<RedemptionResult>;
close(): Promise<void>;
}
async function transaction<T>(pool: Pool, operation: (client: PoolClient) => Promise<T>): Promise<T> {
const client = await pool.connect();
try {
await client.query('begin');
const result = await operation(client);
await client.query('commit');
return result;
} catch (error) {
await client.query('rollback');
throw error;
} finally {
client.release();
}
}
export class PostgresBillingRepository implements BillingRepository {
private readonly pool: Pool;
constructor(databaseUrl: string) {
this.pool = new Pool({ connectionString: databaseUrl });
}
async processRevenueCatEvent(
payload: RevenueCatWebhook,
status: SubscriptionStatus | null,
): Promise<WebhookResult> {
return transaction(this.pool, async (client) => {
const event = payload.event;
const inserted = await client.query(
`insert into revenuecat_webhook_events (event_id, event_type, app_user_id, payload)
values ($1, $2, $3, $4::jsonb)
on conflict (event_id) do nothing`,
[event.id, event.type, event.app_user_id, JSON.stringify(payload)],
);
if (inserted.rowCount === 0) return { duplicate: true, handled: status !== null };
if (status === null) return { duplicate: false, handled: false };
const profileResult = await client.query<ProfileRecord>(
'select id, couple_id, is_premium from profiles where id = $1 for update',
[event.app_user_id],
);
const profile = profileResult.rows[0];
if (!profile) throw new BillingError('user_not_found', 'User not found', 404);
await client.query(
`insert into subscriptions (
user_id, revenuecat_app_user_id, product_id, status, entitlement_ids,
purchased_at, expires_at, original_transaction_id, store, is_sandbox,
cancel_reason, grace_period_expires_at
) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
on conflict (user_id, original_transaction_id) do update set
revenuecat_app_user_id = excluded.revenuecat_app_user_id,
product_id = excluded.product_id,
status = excluded.status,
entitlement_ids = excluded.entitlement_ids,
purchased_at = excluded.purchased_at,
expires_at = excluded.expires_at,
store = excluded.store,
is_sandbox = excluded.is_sandbox,
cancel_reason = excluded.cancel_reason,
grace_period_expires_at = excluded.grace_period_expires_at,
updated_at = now()`,
[
event.app_user_id,
event.app_user_id,
event.product_id,
status,
event.entitlement_ids,
new Date(event.purchased_at_ms),
event.expiration_at_ms === undefined ? null : new Date(event.expiration_at_ms),
event.original_transaction_id,
event.store,
event.environment !== 'PRODUCTION',
event.cancel_reason ?? null,
event.grace_period_expiration_at_ms === undefined ? null : new Date(event.grace_period_expiration_at_ms),
],
);
const premiumResult = await client.query<{ premium: boolean }>(
`select exists (
select 1 from subscriptions
where user_id = $1 and status = 'active'
and (expires_at is null or expires_at > now())
) as premium`,
[event.app_user_id],
);
const isPremium = premiumResult.rows[0]?.premium ?? false;
await client.query(
'update profiles set is_premium = $2, updated_at = now() where id = $1',
[event.app_user_id, isPremium],
);
if (profile.is_premium && !isPremium && profile.couple_id) {
const partnerPremium = await client.query<{ premium: boolean }>(
`select exists (
select 1 from profiles
where couple_id = $1 and id <> $2 and is_premium = true
) as premium`,
[profile.couple_id, event.app_user_id],
);
if (!partnerPremium.rows[0]?.premium) {
await client.query(
`update couple_packs cp set enabled = false
from question_packs qp
where cp.pack_id = qp.id and qp.is_premium = true
and cp.couple_id = $1 and cp.enabled = true`,
[profile.couple_id],
);
}
}
return { duplicate: false, handled: true };
});
}
async redeem(email: string, code: string): Promise<RedemptionResult> {
return transaction(this.pool, async (client) => {
const allowance = await client.query(
`insert into redemption_rate_limits (bucket, attempts)
values (date_trunc('minute', now()), 1)
on conflict (bucket) do update
set attempts = redemption_rate_limits.attempts + 1
where redemption_rate_limits.attempts < 60
returning attempts`,
);
if (allowance.rowCount === 0) {
throw new BillingError('redemption_rate_limited', 'Too many redemption attempts. Please try again shortly.', 429);
}
await client.query(
`delete from redemption_rate_limits where bucket < date_trunc('minute', now()) - interval '1 day'`,
);
const profileResult = await client.query<ProfileRecord>(
`select id, couple_id, is_premium from profiles
where lower(email) = lower($1)
order by created_at asc
limit 1
for update`,
[email],
);
const profile = profileResult.rows[0];
if (!profile) return { success: false, error: 'No account found with this email address' };
const codeResult = await client.query<CodeRecord>(
`select id, max_uses, current_uses, expires_at, is_active
from redemption_codes where upper(code) = upper($1) for update`,
[code],
);
const redemptionCode = codeResult.rows[0];
if (!redemptionCode) return { success: false, error: 'Invalid redemption code' };
if (!redemptionCode.is_active) return { success: false, error: 'This code is no longer active' };
if (redemptionCode.expires_at && redemptionCode.expires_at < new Date()) {
return { success: false, error: 'This code has expired' };
}
if (redemptionCode.max_uses !== null && redemptionCode.current_uses >= redemptionCode.max_uses) {
return { success: false, error: 'This code has reached its maximum number of uses' };
}
const existing = await client.query(
'select 1 from code_redemptions where code_id = $1 and user_id = $2',
[redemptionCode.id, profile.id],
);
if (existing.rowCount) return { success: false, error: 'You have already redeemed this code' };
await client.query(
'insert into code_redemptions (code_id, user_id) values ($1, $2)',
[redemptionCode.id, profile.id],
);
await client.query(
'update redemption_codes set current_uses = current_uses + 1, updated_at = now() where id = $1',
[redemptionCode.id],
);
await client.query(
'update profiles set is_premium = true, updated_at = now() where id = $1',
[profile.id],
);
return { success: true, message: 'Code redeemed successfully! You now have premium access.' };
});
}
async close(): Promise<void> {
await this.pool.end();
}
}
+49
View File
@@ -0,0 +1,49 @@
import { Hono } from 'hono';
import { bodyLimit } from 'hono/body-limit';
import { z } from 'zod';
import type { AuthIdentity } from '../../auth.js';
import type { BillingService } from './service.js';
import { BillingError } from './types.js';
const redemptionSchema = z.object({
email: z.string().trim().min(1, 'Please enter your email address').max(320),
code: z.string().trim().min(1, 'Please enter a redemption code').max(255),
}).strict();
type ApiApp = Hono<{ Variables: { identity: AuthIdentity } }>;
export function registerBillingRoutes(app: ApiApp, service: BillingService): void {
app.use('/webhooks/revenuecat', bodyLimit({
maxSize: 1024 * 1024,
onError: (c) => c.json({ error: 'Webhook payload is too large' }, 413),
}));
app.post('/webhooks/revenuecat', async (c) => {
try {
service.assertWebhookAuthorization(c.req.header('authorization'));
const rawBody: unknown = await c.req.json().catch(() => null);
return c.json(await service.processWebhook(c.req.header('authorization'), rawBody));
} catch (cause) {
if (cause instanceof BillingError) return c.json({ error: cause.message }, cause.status);
throw cause;
}
});
app.use('/public/v1/redemptions', bodyLimit({
maxSize: 16 * 1024,
onError: (c) => c.json({ success: false, error: 'Request is too large' }, 413),
}));
app.post('/public/v1/redemptions', async (c) => {
try {
const rawBody: unknown = await c.req.json().catch(() => null);
const parsed = redemptionSchema.safeParse(rawBody);
if (!parsed.success) {
return c.json({ success: false, error: parsed.error.issues[0]?.message ?? 'Invalid request' }, 400);
}
const result = await service.redeem(parsed.data.email, parsed.data.code);
return c.json(result, result.success ? 200 : 400);
} catch (cause) {
if (cause instanceof BillingError) return c.json({ success: false, error: cause.message }, cause.status);
throw cause;
}
});
}
+61
View File
@@ -0,0 +1,61 @@
import { timingSafeEqual } from 'node:crypto';
import type { BillingRepository } from './repository.js';
import {
BillingError,
type BillingResult,
type RedemptionResult,
revenueCatWebhookSchema,
type SubscriptionStatus,
} from './types.js';
const statusByEvent: Record<string, SubscriptionStatus> = {
INITIAL_PURCHASE: 'active',
RENEWAL: 'active',
UNCANCELLATION: 'active',
PRODUCT_CHANGE: 'active',
CANCELLATION: 'cancelled',
EXPIRATION: 'expired',
BILLING_ISSUE: 'billing_issue',
SUBSCRIPTION_PAUSED: 'paused',
};
function secureEqual(actual: string, expected: string): boolean {
const actualBytes = Buffer.from(actual);
const expectedBytes = Buffer.from(expected);
return actualBytes.length === expectedBytes.length && timingSafeEqual(actualBytes, expectedBytes);
}
export class BillingService {
constructor(
private readonly repository: BillingRepository,
private readonly revenueCatWebhookSecret: string | undefined,
) {}
assertWebhookAuthorization(authorization: string | undefined): void {
if (!this.revenueCatWebhookSecret) {
throw new BillingError('webhook_unavailable', 'Webhook is not configured', 503);
}
if (!authorization || !secureEqual(authorization, `Bearer ${this.revenueCatWebhookSecret}`)) {
throw new BillingError('unauthorized', 'Unauthorized', 401);
}
}
async processWebhook(authorization: string | undefined, rawPayload: unknown): Promise<BillingResult> {
this.assertWebhookAuthorization(authorization);
const parsed = revenueCatWebhookSchema.safeParse(rawPayload);
if (!parsed.success) throw new BillingError('invalid_payload', 'Invalid webhook payload', 400);
const payload = parsed.data;
const status = statusByEvent[payload.event.type] ?? null;
const result = await this.repository.processRevenueCatEvent(payload, status);
if (result.duplicate) return { success: true, message: 'Duplicate event' };
if (payload.event.type === 'TEST') return { success: true, message: 'Test event received' };
if (!result.handled) return { success: true, message: 'Event type not handled' };
return { success: true };
}
redeem(email: string, code: string): Promise<RedemptionResult> {
return this.repository.redeem(email.trim(), code.trim());
}
}
+43
View File
@@ -0,0 +1,43 @@
import { z } from 'zod';
export const revenueCatWebhookSchema = z.object({
api_version: z.string().min(1),
event: z.object({
type: z.string().min(1),
id: z.string().min(1).max(255),
app_user_id: z.string().uuid(),
original_app_user_id: z.string().min(1),
product_id: z.string().min(1),
entitlement_ids: z.array(z.string()),
purchased_at_ms: z.number().int().nonnegative(),
expiration_at_ms: z.number().int().nonnegative().optional(),
original_transaction_id: z.string().min(1),
store: z.string().min(1),
environment: z.string().min(1),
cancel_reason: z.string().optional(),
grace_period_expiration_at_ms: z.number().int().nonnegative().optional(),
}).passthrough(),
}).passthrough();
export type RevenueCatWebhook = z.infer<typeof revenueCatWebhookSchema>;
export type SubscriptionStatus = 'active' | 'cancelled' | 'expired' | 'billing_issue' | 'paused';
export interface BillingResult {
success: true;
message?: string;
}
export type RedemptionResult =
| { success: true; message: string }
| { success: false; error: string };
export class BillingError extends Error {
constructor(
readonly code: string,
message: string,
readonly status: 400 | 401 | 404 | 429 | 503,
) {
super(message);
this.name = 'BillingError';
}
}
+245
View File
@@ -0,0 +1,245 @@
import { Pool, type QueryResultRow } from 'pg';
export type ChatMediaType = 'image' | 'video' | null;
export type ReportReason = 'harassment' | 'spam' | 'inappropriate_content' | 'other';
export interface ChatMessage {
id: string;
match_id: string;
user_id: string;
content: string | null;
created_at: string;
read_at: string | null;
delivered_at: string | null;
deleted_at: string | null;
media_path: string | null;
media_type: ChatMediaType;
media_expires_at: string | null;
media_expired: boolean;
media_viewed_at: string | null;
version: number;
encrypted_content: string | null;
encryption_iv: string | null;
keys_metadata: Record<string, unknown> | null;
moderation_status: string | null;
flag_reason: string | null;
category: string | null;
}
interface MessageRecord extends QueryResultRow, Omit<ChatMessage, 'created_at' | 'read_at' | 'delivered_at' | 'deleted_at' | 'media_expires_at' | 'media_viewed_at'> {
created_at: Date;
read_at: Date | null;
delivered_at: Date | null;
deleted_at: Date | null;
media_expires_at: Date | null;
media_viewed_at: Date | null;
}
export class ChatError extends Error {
constructor(
public readonly code: string,
message: string,
public readonly status: 400 | 403 | 404 | 409,
) {
super(message);
}
}
export interface ChatRepository {
listMessages(userId: string, matchId: string): Promise<ChatMessage[]>;
sendText(userId: string, matchId: string, content: string): Promise<ChatMessage>;
unreadCounts(userId: string): Promise<{ total: number; by_match: Record<string, number> }>;
markMatchRead(userId: string, matchId: string): Promise<{ updated: number; read_at: string }>;
markDelivered(userId: string, messageId: string): Promise<ChatMessage>;
deleteForSelf(userId: string, messageId: string): Promise<void>;
deleteForEveryone(userId: string, messageId: string): Promise<ChatMessage>;
report(userId: string, messageId: string, reason: ReportReason): Promise<void>;
setTyping(userId: string, matchId: string, ttlMs: number): Promise<void>;
getPartnerTyping(userId: string, matchId: string): Promise<{ typing: boolean; expires_at: string | null }>;
close(): Promise<void>;
}
const columns = `m.id, m.match_id, m.user_id, m.content, m.created_at, m.read_at,
m.delivered_at, m.deleted_at, m.media_path, m.media_type, m.media_expires_at,
m.media_expired, m.media_viewed_at, m.version, m.encrypted_content,
m.encryption_iv, m.keys_metadata, m.moderation_status, m.flag_reason, m.category`;
function iso(value: Date | null): string | null {
return value?.toISOString() ?? null;
}
function toMessage(row: MessageRecord): ChatMessage {
return {
...row,
created_at: row.created_at.toISOString(),
read_at: iso(row.read_at),
delivered_at: iso(row.delivered_at),
deleted_at: iso(row.deleted_at),
media_expires_at: iso(row.media_expires_at),
media_viewed_at: iso(row.media_viewed_at),
};
}
export class PostgresChatRepository implements ChatRepository {
private readonly pool: Pool;
constructor(databaseUrl: string) {
this.pool = new Pool({ connectionString: databaseUrl });
}
private async assertMatchAccess(userId: string, matchId: string): Promise<void> {
const result = await this.pool.query(
`select 1
from profiles p
join matches ma on ma.couple_id = p.couple_id
where p.id = $1 and ma.id = $2`,
[userId, matchId],
);
if (!result.rowCount) throw new ChatError('match_not_found', 'Match not found', 404);
}
private async accessibleMessage(userId: string, messageId: string): Promise<MessageRecord> {
const result = await this.pool.query<MessageRecord>(
`select ${columns}
from messages m
join matches ma on ma.id = m.match_id
join profiles p on p.couple_id = ma.couple_id
where p.id = $1 and m.id = $2`,
[userId, messageId],
);
const row = result.rows[0];
if (!row) throw new ChatError('message_not_found', 'Message not found', 404);
return row;
}
async listMessages(userId: string, matchId: string): Promise<ChatMessage[]> {
await this.assertMatchAccess(userId, matchId);
const result = await this.pool.query<MessageRecord>(
`select ${columns}
from messages m
where m.match_id = $1
and not exists (
select 1 from message_deletions d where d.message_id = m.id and d.user_id = $2
)
order by m.created_at desc, m.id desc
limit 500`,
[matchId, userId],
);
return result.rows.map(toMessage);
}
async sendText(userId: string, matchId: string, content: string): Promise<ChatMessage> {
await this.assertMatchAccess(userId, matchId);
const result = await this.pool.query<MessageRecord>(
`insert into messages (match_id, user_id, content, version)
values ($1, $2, $3, 1)
returning id, match_id, user_id, content, created_at, read_at, delivered_at,
deleted_at, media_path, media_type, media_expires_at, media_expired,
media_viewed_at, version, encrypted_content, encryption_iv, keys_metadata,
moderation_status, flag_reason, category`,
[matchId, userId, content],
);
return toMessage(result.rows[0]!);
}
async unreadCounts(userId: string): Promise<{ total: number; by_match: Record<string, number> }> {
const result = await this.pool.query<{ match_id: string; count: string }>(
`select m.match_id, count(*)::text as count
from messages m
join matches ma on ma.id = m.match_id
join profiles p on p.couple_id = ma.couple_id
where p.id = $1 and m.user_id <> $1 and m.read_at is null
and m.deleted_at is null
and not exists (
select 1 from message_deletions d where d.message_id = m.id and d.user_id = $1
)
group by m.match_id`,
[userId],
);
const by_match = Object.fromEntries(result.rows.map((row) => [row.match_id, Number(row.count)]));
return { total: Object.values(by_match).reduce((sum, count) => sum + count, 0), by_match };
}
async markMatchRead(userId: string, matchId: string): Promise<{ updated: number; read_at: string }> {
await this.assertMatchAccess(userId, matchId);
const readAt = new Date();
const result = await this.pool.query(
`update messages
set delivered_at = coalesce(delivered_at, $3), read_at = $3
where match_id = $1 and user_id <> $2 and read_at is null`,
[matchId, userId, readAt],
);
return { updated: result.rowCount ?? 0, read_at: readAt.toISOString() };
}
async markDelivered(userId: string, messageId: string): Promise<ChatMessage> {
const message = await this.accessibleMessage(userId, messageId);
if (message.user_id === userId) throw new ChatError('invalid_recipient', 'Senders cannot mark their own message delivered', 403);
const result = await this.pool.query<MessageRecord>(
`update messages m set delivered_at = coalesce(m.delivered_at, now()) where m.id = $1 returning ${columns}`,
[messageId],
);
return toMessage(result.rows[0]!);
}
async deleteForSelf(userId: string, messageId: string): Promise<void> {
await this.accessibleMessage(userId, messageId);
await this.pool.query(
`insert into message_deletions (message_id, user_id) values ($1, $2) on conflict do nothing`,
[messageId, userId],
);
}
async deleteForEveryone(userId: string, messageId: string): Promise<ChatMessage> {
const message = await this.accessibleMessage(userId, messageId);
if (message.user_id !== userId) throw new ChatError('not_message_author', 'Only the sender can delete a message for everyone', 403);
const result = await this.pool.query<MessageRecord>(
`update messages m set deleted_at = coalesce(m.deleted_at, now()) where m.id = $1 returning ${columns}`,
[messageId],
);
return toMessage(result.rows[0]!);
}
async report(userId: string, messageId: string, reason: ReportReason): Promise<void> {
const message = await this.accessibleMessage(userId, messageId);
if (message.user_id === userId) throw new ChatError('cannot_report_own_message', 'You cannot report your own message', 403);
try {
await this.pool.query(
`insert into message_reports (message_id, reporter_id, reason) values ($1, $2, $3)`,
[messageId, userId, reason],
);
} catch (cause) {
if (typeof cause === 'object' && cause !== null && 'code' in cause && cause.code === '23505') {
throw new ChatError('already_reported', 'You have already reported this message', 409);
}
throw cause;
}
}
async setTyping(userId: string, matchId: string, ttlMs: number): Promise<void> {
await this.assertMatchAccess(userId, matchId);
await this.pool.query(
`insert into chat_typing_states (match_id, user_id, expires_at)
values ($1, $2, now() + ($3 * interval '1 millisecond'))
on conflict (match_id, user_id) do update set expires_at = excluded.expires_at`,
[matchId, userId, ttlMs],
);
}
async getPartnerTyping(userId: string, matchId: string): Promise<{ typing: boolean; expires_at: string | null }> {
await this.assertMatchAccess(userId, matchId);
const result = await this.pool.query<{ expires_at: Date }>(
`select expires_at from chat_typing_states
where match_id = $1 and user_id <> $2 and expires_at > now()
order by expires_at desc limit 1`,
[matchId, userId],
);
const expiresAt = result.rows[0]?.expires_at ?? null;
return { typing: Boolean(expiresAt), expires_at: iso(expiresAt) };
}
async close(): Promise<void> {
await this.pool.end();
}
}
+93
View File
@@ -0,0 +1,93 @@
import type { ApiErrorResponse } from '@sauci/shared';
import type { Hono } from 'hono';
import { z } from 'zod';
import type { AuthIdentity } from '../../auth.js';
import { ChatError, type ChatRepository } from './repository.js';
type AuthenticatedApp = Hono<{ Variables: { identity: AuthIdentity } }>;
const id = z.string().uuid();
const sendBody = z.object({ content: z.string().trim().min(1).max(10_000) }).strict();
const reportBody = z.object({ reason: z.enum(['harassment', 'spam', 'inappropriate_content', 'other']) }).strict();
function error(code: string, message: string): ApiErrorResponse {
return { error: { code, message } };
}
async function result<T>(operation: () => Promise<T>) {
try {
return { ok: true as const, value: await operation() };
} catch (cause) {
if (cause instanceof ChatError) return { ok: false as const, cause };
throw cause;
}
}
export function registerChatRoutes(app: AuthenticatedApp, repository: ChatRepository): void {
app.get('/v1/chat/unread', async (c) => c.json(await repository.unreadCounts(c.get('identity').id)));
app.get('/v1/matches/:matchId/messages', async (c) => {
const parsedId = id.safeParse(c.req.param('matchId'));
if (!parsedId.success) return c.json(error('invalid_match_id', 'A valid match ID is required'), 400);
const response = await result(() => repository.listMessages(c.get('identity').id, parsedId.data));
return response.ok ? c.json({ messages: response.value }) : c.json(error(response.cause.code, response.cause.message), response.cause.status);
});
app.post('/v1/matches/:matchId/messages', async (c) => {
const parsedId = id.safeParse(c.req.param('matchId'));
const parsedBody = sendBody.safeParse(await c.req.json().catch(() => ({})));
if (!parsedId.success) return c.json(error('invalid_match_id', 'A valid match ID is required'), 400);
if (!parsedBody.success) return c.json(error('invalid_message', 'Message content is required'), 400);
const response = await result(() => repository.sendText(c.get('identity').id, parsedId.data, parsedBody.data.content));
return response.ok ? c.json({ message: response.value }, 201) : c.json(error(response.cause.code, response.cause.message), response.cause.status);
});
app.post('/v1/matches/:matchId/read', async (c) => {
const parsedId = id.safeParse(c.req.param('matchId'));
if (!parsedId.success) return c.json(error('invalid_match_id', 'A valid match ID is required'), 400);
const response = await result(() => repository.markMatchRead(c.get('identity').id, parsedId.data));
return response.ok ? c.json(response.value) : c.json(error(response.cause.code, response.cause.message), response.cause.status);
});
app.put('/v1/matches/:matchId/typing', async (c) => {
const parsedId = id.safeParse(c.req.param('matchId'));
if (!parsedId.success) return c.json(error('invalid_match_id', 'A valid match ID is required'), 400);
const response = await result(() => repository.setTyping(c.get('identity').id, parsedId.data, 4_000));
return response.ok ? c.json({ typing: true }) : c.json(error(response.cause.code, response.cause.message), response.cause.status);
});
app.get('/v1/matches/:matchId/typing', async (c) => {
const parsedId = id.safeParse(c.req.param('matchId'));
if (!parsedId.success) return c.json(error('invalid_match_id', 'A valid match ID is required'), 400);
const response = await result(() => repository.getPartnerTyping(c.get('identity').id, parsedId.data));
return response.ok ? c.json(response.value) : c.json(error(response.cause.code, response.cause.message), response.cause.status);
});
app.post('/v1/messages/:messageId/delivered', async (c) => {
const parsedId = id.safeParse(c.req.param('messageId'));
if (!parsedId.success) return c.json(error('invalid_message_id', 'A valid message ID is required'), 400);
const response = await result(() => repository.markDelivered(c.get('identity').id, parsedId.data));
return response.ok ? c.json({ message: response.value }) : c.json(error(response.cause.code, response.cause.message), response.cause.status);
});
app.delete('/v1/messages/:messageId', async (c) => {
const parsedId = id.safeParse(c.req.param('messageId'));
const scope = c.req.query('scope');
if (!parsedId.success) return c.json(error('invalid_message_id', 'A valid message ID is required'), 400);
if (scope !== 'self' && scope !== 'everyone') return c.json(error('invalid_delete_scope', 'Delete scope must be self or everyone'), 400);
if (scope === 'self') {
const response = await result(() => repository.deleteForSelf(c.get('identity').id, parsedId.data));
return response.ok ? c.json({ deleted: true }) : c.json(error(response.cause.code, response.cause.message), response.cause.status);
}
const response = await result(() => repository.deleteForEveryone(c.get('identity').id, parsedId.data));
return response.ok ? c.json({ deleted: true, message: response.value }) : c.json(error(response.cause.code, response.cause.message), response.cause.status);
});
app.post('/v1/messages/:messageId/reports', async (c) => {
const parsedId = id.safeParse(c.req.param('messageId'));
const parsedBody = reportBody.safeParse(await c.req.json().catch(() => ({})));
if (!parsedId.success) return c.json(error('invalid_message_id', 'A valid message ID is required'), 400);
if (!parsedBody.success) return c.json(error('invalid_report_reason', 'A valid report reason is required'), 400);
const response = await result(() => repository.report(c.get('identity').id, parsedId.data, parsedBody.data.reason));
return response.ok ? c.json({ reported: true }, 201) : c.json(error(response.cause.code, response.cause.message), response.cause.status);
});
}
+49
View File
@@ -0,0 +1,49 @@
import { boolean, index, integer, jsonb, pgTable, primaryKey, text, timestamp, uniqueIndex, uuid } from 'drizzle-orm/pg-core';
import { profiles } from '../../db/schema.js';
// `matches` is owned by the matches vertical (0003). Keeping the FK in SQL
// avoids importing another domain's schema fragment here.
export const messages = pgTable('messages', {
id: uuid('id').primaryKey().defaultRandom(),
matchId: uuid('match_id').notNull(),
userId: uuid('user_id').notNull().references(() => profiles.id, { onDelete: 'cascade' }),
content: text('content'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
readAt: timestamp('read_at', { withTimezone: true }),
deliveredAt: timestamp('delivered_at', { withTimezone: true }),
deletedAt: timestamp('deleted_at', { withTimezone: true }),
mediaPath: text('media_path'),
mediaType: text('media_type'),
mediaExpiresAt: timestamp('media_expires_at', { withTimezone: true }),
mediaExpired: boolean('media_expired').notNull().default(false),
mediaViewedAt: timestamp('media_viewed_at', { withTimezone: true }),
version: integer('version').notNull().default(1),
encryptedContent: text('encrypted_content'),
encryptionIv: text('encryption_iv'),
keysMetadata: jsonb('keys_metadata'),
moderationStatus: text('moderation_status'),
flagReason: text('flag_reason'),
category: text('category'),
}, (table) => [index('messages_match_created_idx').on(table.matchId, table.createdAt)]);
export const messageDeletions = pgTable('message_deletions', {
messageId: uuid('message_id').notNull().references(() => messages.id, { onDelete: 'cascade' }),
userId: uuid('user_id').notNull().references(() => profiles.id, { onDelete: 'cascade' }),
deletedAt: timestamp('deleted_at', { withTimezone: true }).notNull().defaultNow(),
}, (table) => [primaryKey({ columns: [table.messageId, table.userId] })]);
export const messageReports = pgTable('message_reports', {
id: uuid('id').primaryKey().defaultRandom(),
messageId: uuid('message_id').notNull().references(() => messages.id, { onDelete: 'cascade' }),
reporterId: uuid('reporter_id').notNull().references(() => profiles.id, { onDelete: 'cascade' }),
reason: text('reason').notNull(),
status: text('status').notNull().default('pending'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
}, (table) => [uniqueIndex('message_reports_message_reporter_idx').on(table.messageId, table.reporterId)]);
export const chatTypingStates = pgTable('chat_typing_states', {
matchId: uuid('match_id').notNull(),
userId: uuid('user_id').notNull().references(() => profiles.id, { onDelete: 'cascade' }),
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
}, (table) => [primaryKey({ columns: [table.matchId, table.userId] }), index('chat_typing_expires_idx').on(table.expiresAt)]);
Loaded 100 of 294 files, more files were not shown because too many files have changed in this diff. Show more