mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-11-02 02:19:43 +00:00
Update
This commit is contained in:
1 parent
75509b0e45
commit
ef7bc8c90d
16 files changed
+886
-32
No files matched your search
@@ -12,7 +12,6 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emoji-mart/data": "^1.2.1",
|
"@emoji-mart/data": "^1.2.1",
|
||||||
"@emoji-mart/react": "^1.1.1",
|
"@emoji-mart/react": "^1.1.1",
|
||||||
"emoji-mart": "^5.6.0",
|
|
||||||
"@radix-ui/react-avatar": "^1.0.4",
|
"@radix-ui/react-avatar": "^1.0.4",
|
||||||
"@radix-ui/react-dialog": "^1.0.5",
|
"@radix-ui/react-dialog": "^1.0.5",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||||
@@ -28,6 +27,7 @@
|
|||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
"clsx": "^2.1.0",
|
"clsx": "^2.1.0",
|
||||||
"date-fns": "^3.2.0",
|
"date-fns": "^3.2.0",
|
||||||
|
"emoji-mart": "^5.6.0",
|
||||||
"lucide-react": "^0.309.0",
|
"lucide-react": "^0.309.0",
|
||||||
"openai": "^4.24.0",
|
"openai": "^4.24.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { RedemptionCodesPage } from '@/pages/RedemptionCodesPage';
|
|||||||
import { FeedbackPage } from '@/pages/FeedbackPage';
|
import { FeedbackPage } from '@/pages/FeedbackPage';
|
||||||
import { UsageInsightsPage } from '@/pages/UsageInsightsPage';
|
import { UsageInsightsPage } from '@/pages/UsageInsightsPage';
|
||||||
import { AiSettingsPage } from '@/pages/AiSettingsPage';
|
import { AiSettingsPage } from '@/pages/AiSettingsPage';
|
||||||
|
import { AppSettingsPage } from '@/pages/AppSettingsPage';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
@@ -137,6 +138,14 @@ function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/app-settings"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requiredPermission={PERMISSION_KEYS.MANAGE_APP_CONFIG}>
|
||||||
|
<AppSettingsPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
{/* Catch all - redirect to dashboard */}
|
{/* Catch all - redirect to dashboard */}
|
||||||
|
|||||||
@@ -1,23 +1,40 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
import { Outlet } from 'react-router-dom';
|
import { Outlet } from 'react-router-dom';
|
||||||
|
import { Menu } from 'lucide-react';
|
||||||
import { Sidebar } from './Sidebar';
|
import { Sidebar } from './Sidebar';
|
||||||
import { Breadcrumbs } from './Breadcrumbs';
|
import { Breadcrumbs } from './Breadcrumbs';
|
||||||
import { Toaster } from 'sonner';
|
import { Toaster } from 'sonner';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
export function AppLayout() {
|
export function AppLayout() {
|
||||||
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen overflow-hidden bg-background">
|
<div className="flex h-screen overflow-hidden bg-background">
|
||||||
{/* Subtle gradient overlay */}
|
{/* Subtle gradient overlay */}
|
||||||
<div className="fixed inset-0 bg-gradient-to-br from-primary/[0.02] via-transparent to-transparent pointer-events-none" />
|
<div className="fixed inset-0 bg-gradient-to-br from-primary/[0.02] via-transparent to-transparent pointer-events-none" />
|
||||||
|
|
||||||
<Sidebar />
|
<Sidebar
|
||||||
|
mobileOpen={mobileMenuOpen}
|
||||||
|
onMobileClose={() => setMobileMenuOpen(false)}
|
||||||
|
/>
|
||||||
<div className="flex flex-1 flex-col overflow-hidden relative">
|
<div className="flex flex-1 flex-col overflow-hidden relative">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<header className="flex h-16 items-center border-b border-white/5 px-8 bg-card/50 backdrop-blur-sm">
|
<header className="flex h-14 xl:h-16 items-center gap-3 border-b border-white/5 px-4 xl:px-8 bg-card/50 backdrop-blur-sm">
|
||||||
|
{/* Mobile menu button - visible below xl breakpoint */}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="xl:hidden h-9 w-9 text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={() => setMobileMenuOpen(true)}
|
||||||
|
>
|
||||||
|
<Menu className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
<Breadcrumbs />
|
<Breadcrumbs />
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Main content */}
|
{/* Main content */}
|
||||||
<main className="flex-1 overflow-auto p-8">
|
<main className="flex-1 overflow-auto p-4 xl:p-8">
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,11 +13,13 @@ import {
|
|||||||
MessageSquareText,
|
MessageSquareText,
|
||||||
Target,
|
Target,
|
||||||
Bot,
|
Bot,
|
||||||
|
Smartphone,
|
||||||
|
X,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import logoImage from '@/assets/logo.png';
|
import logoImage from '@/assets/logo.png';
|
||||||
|
|
||||||
interface NavItem {
|
interface NavItem {
|
||||||
@@ -70,6 +72,12 @@ const systemNav: NavItem[] = [
|
|||||||
icon: <Bot className="h-5 w-5" />,
|
icon: <Bot className="h-5 w-5" />,
|
||||||
requiredPermission: PERMISSION_KEYS.MANAGE_AI_CONFIG,
|
requiredPermission: PERMISSION_KEYS.MANAGE_AI_CONFIG,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'App Settings',
|
||||||
|
href: '/app-settings',
|
||||||
|
icon: <Smartphone className="h-5 w-5" />,
|
||||||
|
requiredPermission: PERMISSION_KEYS.MANAGE_APP_CONFIG,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: 'Redemption Codes',
|
label: 'Redemption Codes',
|
||||||
href: '/redemption-codes',
|
href: '/redemption-codes',
|
||||||
@@ -84,11 +92,23 @@ const systemNav: NavItem[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export function Sidebar() {
|
interface SidebarProps {
|
||||||
|
mobileOpen?: boolean;
|
||||||
|
onMobileClose?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { user, permissions, logout, hasPermission } = useAuth();
|
const { user, permissions, logout, hasPermission } = useAuth();
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
|
||||||
|
// Close mobile sidebar on route change
|
||||||
|
useEffect(() => {
|
||||||
|
if (mobileOpen && onMobileClose) {
|
||||||
|
onMobileClose();
|
||||||
|
}
|
||||||
|
}, [location.pathname]);
|
||||||
|
|
||||||
const NavLink = ({ item }: { item: NavItem }) => {
|
const NavLink = ({ item }: { item: NavItem }) => {
|
||||||
if (item.requiredPermission && !hasPermission(item.requiredPermission)) return null;
|
if (item.requiredPermission && !hasPermission(item.requiredPermission)) return null;
|
||||||
|
|
||||||
@@ -102,7 +122,7 @@ export function Sidebar() {
|
|||||||
isActive
|
isActive
|
||||||
? "bg-gradient-rose text-white shadow-lg glow-rose-sm"
|
? "bg-gradient-rose text-white shadow-lg glow-rose-sm"
|
||||||
: "text-muted-foreground hover:bg-white/5 hover:text-foreground",
|
: "text-muted-foreground hover:bg-white/5 hover:text-foreground",
|
||||||
collapsed && "justify-center px-2"
|
collapsed && "justify-center px-2 lg:justify-center lg:px-2"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
@@ -134,11 +154,13 @@ export function Sidebar() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const sidebarContent = (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-screen flex-col border-r border-white/5 bg-gradient-to-b from-card to-background transition-all duration-300",
|
"flex h-screen flex-col border-r border-white/5 bg-gradient-to-b from-card to-background transition-all duration-300",
|
||||||
collapsed ? "w-20" : "w-72"
|
// Desktop only: hidden on mobile/tablet, show on desktop
|
||||||
|
"hidden xl:flex",
|
||||||
|
collapsed ? "xl:w-20" : "xl:w-72"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
@@ -236,4 +258,101 @@ export function Sidebar() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Mobile sidebar (overlay)
|
||||||
|
const mobileSidebar = (
|
||||||
|
<>
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-40 bg-black/60 backdrop-blur-sm transition-opacity xl:hidden",
|
||||||
|
mobileOpen ? "opacity-100" : "opacity-0 pointer-events-none"
|
||||||
|
)}
|
||||||
|
onClick={onMobileClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Slide-in sidebar */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-y-0 left-0 z-50 w-72 transform transition-transform duration-300 ease-in-out xl:hidden",
|
||||||
|
mobileOpen ? "translate-x-0" : "-translate-x-full"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex h-full flex-col border-r border-white/5 bg-gradient-to-b from-card to-background">
|
||||||
|
{/* Mobile header with close button */}
|
||||||
|
<div className="flex h-20 items-center justify-between px-4 border-b border-white/5">
|
||||||
|
<Link to="/" className="flex items-center gap-3 group">
|
||||||
|
<div className="relative">
|
||||||
|
<img
|
||||||
|
src={logoImage}
|
||||||
|
alt="Sauci"
|
||||||
|
className="h-10 w-10 object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-xl font-bold tracking-tight text-gradient-sunset">
|
||||||
|
Sauci
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] font-medium uppercase tracking-widest text-muted-foreground">
|
||||||
|
Admin Portal
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={onMobileClose}
|
||||||
|
className="h-10 w-10 rounded-lg text-muted-foreground hover:text-foreground hover:bg-white/5"
|
||||||
|
>
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<div className="flex-1 space-y-6 overflow-auto px-3 py-4">
|
||||||
|
<NavSection title="Content" items={contentNav} />
|
||||||
|
<NavSection title="Users" items={usersNav} />
|
||||||
|
<NavSection title="System" items={systemNav} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator className="bg-white/5" />
|
||||||
|
|
||||||
|
{/* User info */}
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="flex items-center gap-3 rounded-xl p-2 transition-colors hover:bg-white/5">
|
||||||
|
<Avatar className="h-9 w-9 ring-2 ring-primary/20">
|
||||||
|
<AvatarFallback className="bg-gradient-rose text-white font-semibold">
|
||||||
|
{user?.email?.charAt(0).toUpperCase() || 'A'}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
<p className="truncate text-sm font-medium text-foreground">
|
||||||
|
{user?.email}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground capitalize">
|
||||||
|
{permissions?.role?.replace('_', ' ')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="mt-2 w-full justify-start text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
|
||||||
|
onClick={logout}
|
||||||
|
>
|
||||||
|
<LogOut className="h-4 w-4" />
|
||||||
|
<span className="ml-2">Sign Out</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{sidebarContent}
|
||||||
|
{mobileSidebar}
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -30,7 +30,7 @@ export function EmojiPicker({ value, onChange }: EmojiPickerProps) {
|
|||||||
};
|
};
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
const handleEmojiSelect = (emoji: any) => {
|
const handleEmojiSelect = (emoji: { native: string; id: string; name: string }) => {
|
||||||
onChange(emoji.native);
|
onChange(emoji.native);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export const PERMISSION_KEYS = {
|
|||||||
MANAGE_CODES: 'manage_codes',
|
MANAGE_CODES: 'manage_codes',
|
||||||
MANAGE_ADMINS: 'manage_admins',
|
MANAGE_ADMINS: 'manage_admins',
|
||||||
MANAGE_AI_CONFIG: 'manage_ai_config',
|
MANAGE_AI_CONFIG: 'manage_ai_config',
|
||||||
|
MANAGE_APP_CONFIG: 'manage_app_config',
|
||||||
VIEW_AUDIT_LOGS: 'view_audit_logs',
|
VIEW_AUDIT_LOGS: 'view_audit_logs',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
@@ -40,6 +41,7 @@ export const PERMISSION_METADATA: Record<PermissionKey, { label: string; descrip
|
|||||||
[PERMISSION_KEYS.MANAGE_CODES]: { label: 'Can manage redemption codes', description: 'Create and view redemption codes', group: 'system' },
|
[PERMISSION_KEYS.MANAGE_CODES]: { label: 'Can manage redemption codes', description: 'Create and view redemption codes', group: 'system' },
|
||||||
[PERMISSION_KEYS.MANAGE_ADMINS]: { label: 'Can manage admins', description: 'Add/remove admin users and change permissions', group: 'system' },
|
[PERMISSION_KEYS.MANAGE_ADMINS]: { label: 'Can manage admins', description: 'Add/remove admin users and change permissions', group: 'system' },
|
||||||
[PERMISSION_KEYS.MANAGE_AI_CONFIG]: { label: 'Can manage AI settings', description: 'Configure AI models, API keys, and council mode', group: 'system' },
|
[PERMISSION_KEYS.MANAGE_AI_CONFIG]: { label: 'Can manage AI settings', description: 'Configure AI models, API keys, and council mode', group: 'system' },
|
||||||
|
[PERMISSION_KEYS.MANAGE_APP_CONFIG]: { label: 'Can manage app settings', description: 'Configure mobile app behavior settings', group: 'system' },
|
||||||
[PERMISSION_KEYS.VIEW_AUDIT_LOGS]: { label: 'Can view audit logs', description: 'Access audit log history', group: 'system' },
|
[PERMISSION_KEYS.VIEW_AUDIT_LOGS]: { label: 'Can view audit logs', description: 'Access audit log history', group: 'system' },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { supabase } from '@/config';
|
||||||
|
|
||||||
|
export interface AppConfig {
|
||||||
|
id: string;
|
||||||
|
answer_gap_threshold: number;
|
||||||
|
updated_at: string | null;
|
||||||
|
updated_by: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseAppConfigReturn {
|
||||||
|
config: AppConfig | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
refetch: () => Promise<void>;
|
||||||
|
updateConfig: (updates: Partial<AppConfig>) => Promise<{ error: string | null }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache for the config to avoid refetching on every use
|
||||||
|
let cachedConfig: AppConfig | null = null;
|
||||||
|
let cacheTimestamp: number = 0;
|
||||||
|
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
||||||
|
|
||||||
|
export function useAppConfig(): UseAppConfigReturn {
|
||||||
|
const [config, setConfig] = useState<AppConfig | null>(cachedConfig);
|
||||||
|
const [loading, setLoading] = useState(!cachedConfig);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchConfig = useCallback(async () => {
|
||||||
|
// Use cache if valid
|
||||||
|
if (cachedConfig && Date.now() - cacheTimestamp < CACHE_TTL) {
|
||||||
|
setConfig(cachedConfig);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data, error: fetchError } = await supabase
|
||||||
|
.from('app_config')
|
||||||
|
.select('*')
|
||||||
|
.limit(1)
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
if (fetchError) {
|
||||||
|
setError(fetchError.message);
|
||||||
|
setConfig(null);
|
||||||
|
} else if (data) {
|
||||||
|
cachedConfig = data as AppConfig;
|
||||||
|
cacheTimestamp = Date.now();
|
||||||
|
setConfig(data as AppConfig);
|
||||||
|
} else {
|
||||||
|
// No config row exists yet
|
||||||
|
setConfig(null);
|
||||||
|
setError('App config not initialized. Please contact support.');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to fetch app config');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateConfig = useCallback(async (updates: Partial<AppConfig>): Promise<{ error: string | null }> => {
|
||||||
|
if (!config?.id) {
|
||||||
|
return { error: 'No config to update' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { error: updateError } = await supabase
|
||||||
|
.from('app_config')
|
||||||
|
.update(updates)
|
||||||
|
.eq('id', config.id);
|
||||||
|
|
||||||
|
if (updateError) {
|
||||||
|
return { error: updateError.message };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalidate cache and refetch
|
||||||
|
cachedConfig = null;
|
||||||
|
cacheTimestamp = 0;
|
||||||
|
await fetchConfig();
|
||||||
|
|
||||||
|
return { error: null };
|
||||||
|
} catch (err) {
|
||||||
|
return { error: err instanceof Error ? err.message : 'Failed to update app config' };
|
||||||
|
}
|
||||||
|
}, [config?.id, fetchConfig]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchConfig();
|
||||||
|
}, [fetchConfig]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
config,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
refetch: fetchConfig,
|
||||||
|
updateConfig,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the config cache (useful when logging out)
|
||||||
|
*/
|
||||||
|
export function clearAppConfigCache(): void {
|
||||||
|
cachedConfig = null;
|
||||||
|
cacheTimestamp = 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useAppConfig, AppConfig } from '@/hooks/useAppConfig';
|
||||||
|
import { useAuth, PERMISSION_KEYS } from '@/contexts/AuthContext';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||||
|
import { Save, AlertTriangle, RefreshCw, Users } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
|
||||||
|
export function AppSettingsPage() {
|
||||||
|
const { hasPermission, isSuperAdmin } = useAuth();
|
||||||
|
const { config, loading, error, updateConfig, refetch } = useAppConfig();
|
||||||
|
|
||||||
|
// Local form state
|
||||||
|
const [formData, setFormData] = useState<Partial<AppConfig>>({});
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [hasChanges, setHasChanges] = useState(false);
|
||||||
|
|
||||||
|
// Permission check - only super admins or those with MANAGE_APP_CONFIG can access
|
||||||
|
const canManage = isSuperAdmin || hasPermission(PERMISSION_KEYS.MANAGE_APP_CONFIG);
|
||||||
|
|
||||||
|
// Initialize form data when config loads
|
||||||
|
useEffect(() => {
|
||||||
|
if (config) {
|
||||||
|
setFormData({
|
||||||
|
answer_gap_threshold: config.answer_gap_threshold ?? 10,
|
||||||
|
});
|
||||||
|
setHasChanges(false);
|
||||||
|
}
|
||||||
|
}, [config]);
|
||||||
|
|
||||||
|
const handleChange = <K extends keyof AppConfig>(field: K, value: AppConfig[K]) => {
|
||||||
|
setFormData(prev => ({ ...prev, [field]: value }));
|
||||||
|
setHasChanges(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setIsSaving(true);
|
||||||
|
try {
|
||||||
|
const { error: saveError } = await updateConfig(formData);
|
||||||
|
if (saveError) {
|
||||||
|
toast.error(saveError);
|
||||||
|
} else {
|
||||||
|
toast.success('App settings saved successfully');
|
||||||
|
setHasChanges(false);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to save settings');
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
if (config) {
|
||||||
|
setFormData({
|
||||||
|
answer_gap_threshold: config.answer_gap_threshold ?? 10,
|
||||||
|
});
|
||||||
|
setHasChanges(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!canManage) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-[50vh]">
|
||||||
|
<Alert variant="destructive" className="max-w-md">
|
||||||
|
<AlertTriangle className="h-4 w-4" />
|
||||||
|
<AlertTitle>Access Denied</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
You don't have permission to manage app settings. Contact a super admin for access.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">App Settings</h1>
|
||||||
|
<p className="text-muted-foreground">Configure mobile app behavior settings</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-6">
|
||||||
|
<Skeleton className="h-48 w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">App Settings</h1>
|
||||||
|
<p className="text-muted-foreground">Configure mobile app behavior settings</p>
|
||||||
|
</div>
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertTriangle className="h-4 w-4" />
|
||||||
|
<AlertTitle>Error loading settings</AlertTitle>
|
||||||
|
<AlertDescription className="flex items-center gap-4">
|
||||||
|
{error}
|
||||||
|
<Button variant="outline" size="sm" onClick={refetch}>
|
||||||
|
<RefreshCw className="h-4 w-4 mr-2" />
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">App Settings</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Configure mobile app behavior settings
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{hasChanges && (
|
||||||
|
<Button variant="ghost" onClick={handleReset}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button onClick={handleSave} disabled={!hasChanges || isSaving}>
|
||||||
|
<Save className="h-4 w-4 mr-2" />
|
||||||
|
{isSaving ? 'Saving...' : 'Save Changes'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{config?.updated_at && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Last updated: {format(new Date(config.updated_at), 'MMM d, yyyy HH:mm')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Answer Spam Prevention */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Users className="h-5 w-5" />
|
||||||
|
Answer Spam Prevention
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Prevent one partner from getting too far ahead in answering questions
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="threshold">Answer Gap Threshold</Label>
|
||||||
|
<Input
|
||||||
|
id="threshold"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
value={formData.answer_gap_threshold ?? 10}
|
||||||
|
onChange={(e) => handleChange('answer_gap_threshold', parseInt(e.target.value) || 0)}
|
||||||
|
className="max-w-[200px]"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Maximum number of questions a user can answer ahead of their partner.
|
||||||
|
When exceeded, they'll see a "Waiting for your partner" message.
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Set to <strong>0</strong> to disable this feature.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -165,13 +165,13 @@ export function DashboardPage() {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div className="absolute -top-20 -left-20 w-64 h-64 bg-primary/5 rounded-full blur-3xl" />
|
<div className="absolute -top-20 -left-20 w-64 h-64 bg-primary/5 rounded-full blur-3xl hidden xl:block" />
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||||
<h1 className="text-4xl font-bold tracking-tight">
|
<h1 className="text-2xl md:text-3xl xl:text-4xl font-bold tracking-tight">
|
||||||
Dashboard
|
Dashboard
|
||||||
</h1>
|
</h1>
|
||||||
<RealtimeStatusIndicator
|
<RealtimeStatusIndicator
|
||||||
@@ -179,7 +179,7 @@ export function DashboardPage() {
|
|||||||
showLabel
|
showLabel
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-muted-foreground text-lg">
|
<p className="text-muted-foreground text-sm md:text-base">
|
||||||
Welcome back. Here's what's happening with Sauci.
|
Welcome back. Here's what's happening with Sauci.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -224,12 +224,12 @@ export function CategoriesPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-4 md:space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold tracking-tight">Categories</h1>
|
<h1 className="text-2xl md:text-3xl font-bold tracking-tight">Categories</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground text-sm">
|
||||||
Organize your question packs into categories
|
Organize your question packs into categories
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -238,7 +238,8 @@ export function CategoriesPage() {
|
|||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button onClick={form.openCreate}>
|
<Button onClick={form.openCreate}>
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
Add Category
|
<span className="hidden sm:inline">Add Category</span>
|
||||||
|
<span className="sm:hidden">Add</span>
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<Button variant="outline" onClick={() => setAiIdeasOpen(true)}>
|
<Button variant="outline" onClick={() => setAiIdeasOpen(true)}>
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export function PacksPage() {
|
|||||||
description: '',
|
description: '',
|
||||||
icon: '💕',
|
icon: '💕',
|
||||||
is_premium: false,
|
is_premium: false,
|
||||||
is_public: true,
|
is_public: false,
|
||||||
is_explicit: false,
|
is_explicit: false,
|
||||||
},
|
},
|
||||||
(pack) => ({
|
(pack) => ({
|
||||||
@@ -267,7 +267,7 @@ export function PacksPage() {
|
|||||||
description: result.description,
|
description: result.description,
|
||||||
icon: result.icon || '💕',
|
icon: result.icon || '💕',
|
||||||
is_premium: false,
|
is_premium: false,
|
||||||
is_public: true,
|
is_public: false,
|
||||||
is_explicit: false,
|
is_explicit: false,
|
||||||
});
|
});
|
||||||
setAiDialogOpen(false);
|
setAiDialogOpen(false);
|
||||||
|
|||||||
@@ -163,19 +163,19 @@ export function UsersPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-4 md:space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<h1 className="text-3xl font-bold tracking-tight">Users</h1>
|
<h1 className="text-2xl md:text-3xl font-bold tracking-tight">Users</h1>
|
||||||
<RealtimeStatusIndicator status={realtimeStatus} showLabel />
|
<RealtimeStatusIndicator status={realtimeStatus} showLabel />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground text-sm">
|
||||||
{profiles.length} registered user{profiles.length !== 1 ? 's' : ''}
|
{profiles.length} registered user{profiles.length !== 1 ? 's' : ''}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative w-64">
|
<div className="relative w-full sm:w-64">
|
||||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search users..."
|
placeholder="Search users..."
|
||||||
@@ -187,7 +187,7 @@ export function UsersPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Users Table */}
|
{/* Users Table */}
|
||||||
<div className="rounded-md border">
|
<div className="rounded-md border overflow-x-auto">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
@@ -211,17 +211,17 @@ export function UsersPage() {
|
|||||||
filteredProfiles.map((profile) => (
|
filteredProfiles.map((profile) => (
|
||||||
<TableRow key={profile.id}>
|
<TableRow key={profile.id}>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex items-center gap-3">
|
<Link to={`/users/${profile.id}`} className="flex items-center gap-3 hover:opacity-80 transition-opacity">
|
||||||
<Avatar className="h-9 w-9">
|
<Avatar className="h-9 w-9">
|
||||||
<AvatarImage src={profile.avatar_url || undefined} />
|
<AvatarImage src={profile.avatar_url || undefined} />
|
||||||
<AvatarFallback>
|
<AvatarFallback>
|
||||||
{profile.name?.charAt(0).toUpperCase() || 'U'}
|
{profile.name?.charAt(0).toUpperCase() || 'U'}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<span className="font-medium">
|
<span className="font-medium hover:text-primary transition-colors">
|
||||||
{profile.name || 'Unnamed User'}
|
{profile.name || 'Unnamed User'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</Link>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-muted-foreground">
|
<TableCell className="text-muted-foreground">
|
||||||
{profile.email || '—'}
|
{profile.email || '—'}
|
||||||
|
|||||||
Vendored
+34
@@ -9,3 +9,37 @@ interface ImportMetaEnv {
|
|||||||
interface ImportMeta {
|
interface ImportMeta {
|
||||||
readonly env: ImportMetaEnv;
|
readonly env: ImportMetaEnv;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Type declarations for emoji-mart packages
|
||||||
|
declare module '@emoji-mart/data' {
|
||||||
|
const data: Record<string, unknown>;
|
||||||
|
export default data;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '@emoji-mart/react' {
|
||||||
|
import { ComponentType } from 'react';
|
||||||
|
|
||||||
|
interface PickerProps {
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
onEmojiSelect: (emoji: { native: string; id: string; name: string }) => void;
|
||||||
|
theme?: 'light' | 'dark' | 'auto';
|
||||||
|
previewPosition?: 'top' | 'bottom' | 'none';
|
||||||
|
skinTonePosition?: 'preview' | 'search' | 'none';
|
||||||
|
searchPosition?: 'sticky' | 'static' | 'none';
|
||||||
|
navPosition?: 'top' | 'bottom' | 'none';
|
||||||
|
perLine?: number;
|
||||||
|
maxFrequentRows?: number;
|
||||||
|
emojiSize?: number;
|
||||||
|
emojiButtonSize?: number;
|
||||||
|
emojiButtonRadius?: string;
|
||||||
|
emojiButtonColors?: string[];
|
||||||
|
icons?: 'auto' | 'outline' | 'solid';
|
||||||
|
set?: 'native' | 'apple' | 'facebook' | 'google' | 'twitter';
|
||||||
|
locale?: string;
|
||||||
|
autoFocus?: boolean;
|
||||||
|
dynamicWidth?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Picker: ComponentType<PickerProps>;
|
||||||
|
export default Picker;
|
||||||
|
}
|
||||||
@@ -21,6 +21,8 @@ export default function SwipeScreen() {
|
|||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [currentIndex, setCurrentIndex] = useState(0);
|
const [currentIndex, setCurrentIndex] = useState(0);
|
||||||
const [showTutorial, setShowTutorial] = useState(false);
|
const [showTutorial, setShowTutorial] = useState(false);
|
||||||
|
const [isBlocked, setIsBlocked] = useState(false);
|
||||||
|
const [gapInfo, setGapInfo] = useState<{ unanswered: number; threshold: number } | null>(null);
|
||||||
const { enabledPackIds, fetchPacks } = usePacksStore();
|
const { enabledPackIds, fetchPacks } = usePacksStore();
|
||||||
const { partner, couple } = useAuthStore();
|
const { partner, couple } = useAuthStore();
|
||||||
const hasTrackedExhausted = useRef(false);
|
const hasTrackedExhausted = useRef(false);
|
||||||
@@ -31,6 +33,36 @@ export default function SwipeScreen() {
|
|||||||
hasTrackedExhausted.current = false; // Reset when pack changes
|
hasTrackedExhausted.current = false; // Reset when pack changes
|
||||||
}, [packId]);
|
}, [packId]);
|
||||||
|
|
||||||
|
// Check if user is too far ahead of partner (called when questions empty or after swipe)
|
||||||
|
const checkAnswerGap = async (): Promise<boolean> => {
|
||||||
|
if (!couple || !partner) {
|
||||||
|
setIsBlocked(false);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data, error } = await supabase.rpc('get_answer_gap_status');
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error('Error checking answer gap:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data && data.length > 0) {
|
||||||
|
const result = data[0];
|
||||||
|
setIsBlocked(result.is_blocked);
|
||||||
|
setGapInfo({
|
||||||
|
unanswered: result.unanswered_by_partner,
|
||||||
|
threshold: result.threshold
|
||||||
|
});
|
||||||
|
return result.is_blocked;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to check answer gap:', error);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
// Track when all questions are exhausted
|
// Track when all questions are exhausted
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (questions.length > 0 && currentIndex >= questions.length && !hasTrackedExhausted.current) {
|
if (questions.length > 0 && currentIndex >= questions.length && !hasTrackedExhausted.current) {
|
||||||
@@ -83,6 +115,14 @@ export default function SwipeScreen() {
|
|||||||
// Filter out recently skipped questions
|
// Filter out recently skipped questions
|
||||||
const filtered = (data || []).filter((q: any) => !skippedIds.has(q.id));
|
const filtered = (data || []).filter((q: any) => !skippedIds.has(q.id));
|
||||||
|
|
||||||
|
// If no questions returned and user has partner, check if it's due to answer gap
|
||||||
|
if (filtered.length === 0 && partner && !packId) {
|
||||||
|
await checkAnswerGap();
|
||||||
|
} else {
|
||||||
|
// Reset blocked state if we have questions
|
||||||
|
setIsBlocked(false);
|
||||||
|
}
|
||||||
|
|
||||||
const sorted = filtered.sort((a: any, b: any) => {
|
const sorted = filtered.sort((a: any, b: any) => {
|
||||||
let scoreA = Math.random();
|
let scoreA = Math.random();
|
||||||
let scoreB = Math.random();
|
let scoreB = Math.random();
|
||||||
@@ -132,6 +172,8 @@ export default function SwipeScreen() {
|
|||||||
console.error("Submit response error:", error);
|
console.error("Submit response error:", error);
|
||||||
} else {
|
} else {
|
||||||
Events.questionAnswered(answer, question.pack_id);
|
Events.questionAnswered(answer, question.pack_id);
|
||||||
|
// Check if we've hit the answer gap threshold
|
||||||
|
await checkAnswerGap();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to submit response", error);
|
console.error("Failed to submit response", error);
|
||||||
@@ -187,6 +229,42 @@ export default function SwipeScreen() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show "waiting for partner" message when user is too far ahead
|
||||||
|
if (isBlocked && gapInfo) {
|
||||||
|
return (
|
||||||
|
<GradientBackground showAccent>
|
||||||
|
<View style={styles.centerContainer}>
|
||||||
|
<Animated.View
|
||||||
|
entering={FadeInUp.duration(600).springify()}
|
||||||
|
style={styles.emptyContent}
|
||||||
|
>
|
||||||
|
<LinearGradient
|
||||||
|
colors={gradients.warning as [string, string]}
|
||||||
|
style={styles.pairIconContainer}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 1 }}
|
||||||
|
>
|
||||||
|
<Ionicons name="hourglass-outline" size={48} color={colors.text} />
|
||||||
|
</LinearGradient>
|
||||||
|
<Text style={styles.pairTitle}>
|
||||||
|
Waiting for your partner
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.emptySubtitle}>
|
||||||
|
You've answered {gapInfo.unanswered} questions that your partner hasn't seen yet. Give them some time to catch up!
|
||||||
|
</Text>
|
||||||
|
<GlassButton
|
||||||
|
variant="secondary"
|
||||||
|
onPress={checkAnswerGap}
|
||||||
|
style={{ marginTop: spacing.lg }}
|
||||||
|
>
|
||||||
|
Check Again
|
||||||
|
</GlassButton>
|
||||||
|
</Animated.View>
|
||||||
|
</View>
|
||||||
|
</GradientBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Show "no packs enabled" message when not viewing a specific pack and no packs are enabled
|
// Show "no packs enabled" message when not viewing a specific pack and no packs are enabled
|
||||||
if (!packId && enabledPackIds.length === 0) {
|
if (!packId && enabledPackIds.length === 0) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
-- App Configuration table for mobile app settings
|
||||||
|
-- Stores app behavior settings configurable by super admins
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS app_config (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
-- Answer Spam Prevention
|
||||||
|
-- Maximum questions a user can answer ahead of their partner (0 = disabled)
|
||||||
|
answer_gap_threshold integer DEFAULT 10,
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
updated_at timestamptz DEFAULT now(),
|
||||||
|
updated_by uuid REFERENCES auth.users(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Only allow one row in this table (singleton pattern)
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS app_config_singleton ON app_config ((true));
|
||||||
|
|
||||||
|
-- Insert default config row
|
||||||
|
INSERT INTO app_config (id) VALUES (gen_random_uuid())
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- Enable RLS
|
||||||
|
ALTER TABLE app_config ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- All authenticated users can read (needed for mobile app)
|
||||||
|
CREATE POLICY "Authenticated users can read app_config"
|
||||||
|
ON app_config FOR SELECT
|
||||||
|
USING (auth.role() = 'authenticated');
|
||||||
|
|
||||||
|
-- Only super admins can update
|
||||||
|
CREATE POLICY "Super admins can update app_config"
|
||||||
|
ON app_config FOR UPDATE
|
||||||
|
USING (is_super_admin());
|
||||||
|
|
||||||
|
-- Create trigger to auto-update timestamp (reuses existing function)
|
||||||
|
CREATE TRIGGER app_config_updated_at
|
||||||
|
BEFORE UPDATE ON app_config
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_updated_at();
|
||||||
|
|
||||||
|
-- Add comment for documentation
|
||||||
|
COMMENT ON TABLE app_config IS 'Singleton table storing mobile app configuration. Readable by all authenticated users, writable by super admins only.';
|
||||||
|
COMMENT ON COLUMN app_config.answer_gap_threshold IS 'Maximum number of questions a user can answer ahead of their partner. Set to 0 to disable answer spam prevention.';
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- RPC FUNCTION: get_answer_gap_status
|
||||||
|
-- Returns the number of questions user has answered that partner hasn't,
|
||||||
|
-- the configured threshold, and whether the user is blocked.
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION get_answer_gap_status()
|
||||||
|
RETURNS TABLE(
|
||||||
|
unanswered_by_partner integer,
|
||||||
|
threshold integer,
|
||||||
|
is_blocked boolean
|
||||||
|
)
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = 'public'
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_user_id UUID;
|
||||||
|
v_couple_id UUID;
|
||||||
|
v_partner_id UUID;
|
||||||
|
v_gap_count integer;
|
||||||
|
v_threshold integer;
|
||||||
|
BEGIN
|
||||||
|
v_user_id := auth.uid();
|
||||||
|
|
||||||
|
-- Get user's couple_id
|
||||||
|
SELECT p.couple_id INTO v_couple_id
|
||||||
|
FROM profiles p
|
||||||
|
WHERE p.id = v_user_id;
|
||||||
|
|
||||||
|
-- No couple = no blocking needed
|
||||||
|
IF v_couple_id IS NULL THEN
|
||||||
|
RETURN QUERY SELECT 0, 0, false;
|
||||||
|
RETURN;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Get partner's ID
|
||||||
|
SELECT p.id INTO v_partner_id
|
||||||
|
FROM profiles p
|
||||||
|
WHERE p.couple_id = v_couple_id AND p.id != v_user_id
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
-- No partner = no blocking needed
|
||||||
|
IF v_partner_id IS NULL THEN
|
||||||
|
RETURN QUERY SELECT 0, 0, false;
|
||||||
|
RETURN;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Get threshold from app_config
|
||||||
|
SELECT COALESCE(ac.answer_gap_threshold, 10) INTO v_threshold
|
||||||
|
FROM app_config ac
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
-- If threshold is 0, feature is disabled
|
||||||
|
IF v_threshold = 0 THEN
|
||||||
|
RETURN QUERY SELECT 0, 0, false;
|
||||||
|
RETURN;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Count questions in ENABLED packs where:
|
||||||
|
-- - Current user HAS responded
|
||||||
|
-- - Partner has NOT responded
|
||||||
|
WITH enabled_packs AS (
|
||||||
|
-- Get packs that the couple has explicitly enabled
|
||||||
|
SELECT cp.pack_id
|
||||||
|
FROM couple_packs cp
|
||||||
|
WHERE cp.couple_id = v_couple_id AND cp.enabled = true
|
||||||
|
)
|
||||||
|
SELECT COUNT(*)::integer INTO v_gap_count
|
||||||
|
FROM responses r
|
||||||
|
INNER JOIN questions q ON q.id = r.question_id
|
||||||
|
WHERE r.user_id = v_user_id
|
||||||
|
AND r.couple_id = v_couple_id
|
||||||
|
AND (
|
||||||
|
-- Only count if couple has enabled packs AND question is in one of them
|
||||||
|
-- If no packs enabled, don't block (couple hasn't set up yet)
|
||||||
|
EXISTS (SELECT 1 FROM enabled_packs)
|
||||||
|
AND q.pack_id IN (SELECT pack_id FROM enabled_packs)
|
||||||
|
)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM responses pr
|
||||||
|
WHERE pr.question_id = r.question_id
|
||||||
|
AND pr.user_id = v_partner_id
|
||||||
|
);
|
||||||
|
|
||||||
|
RETURN QUERY SELECT v_gap_count, v_threshold, (v_gap_count >= v_threshold);
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
COMMENT ON FUNCTION get_answer_gap_status() IS 'Returns answer gap status for the current user. Used by mobile app to prevent answer spam.';
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
-- Add answer gap blocking to get_recommended_questions
|
||||||
|
-- When user is over the threshold, returns empty (old apps see "All caught up!")
|
||||||
|
-- New apps call get_answer_gap_status to determine blocking reason
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION get_recommended_questions(target_pack_id UUID DEFAULT NULL)
|
||||||
|
RETURNS TABLE(
|
||||||
|
id UUID,
|
||||||
|
text TEXT,
|
||||||
|
partner_text TEXT,
|
||||||
|
is_two_part BOOLEAN,
|
||||||
|
pack_id UUID,
|
||||||
|
intensity INTEGER,
|
||||||
|
partner_answered BOOLEAN,
|
||||||
|
allowed_couple_genders TEXT[],
|
||||||
|
target_user_genders TEXT[]
|
||||||
|
)
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = 'public'
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_user_id UUID;
|
||||||
|
v_couple_id UUID;
|
||||||
|
v_partner_id UUID;
|
||||||
|
v_user_gender TEXT;
|
||||||
|
v_partner_gender TEXT;
|
||||||
|
v_couple_type TEXT;
|
||||||
|
v_has_premium BOOLEAN;
|
||||||
|
v_gap_threshold INTEGER;
|
||||||
|
v_gap_count INTEGER;
|
||||||
|
BEGIN
|
||||||
|
v_user_id := auth.uid();
|
||||||
|
|
||||||
|
SELECT p.couple_id, p.gender INTO v_couple_id, v_user_gender
|
||||||
|
FROM public.profiles p
|
||||||
|
WHERE p.id = v_user_id;
|
||||||
|
|
||||||
|
IF v_couple_id IS NULL THEN
|
||||||
|
RETURN;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
SELECT p.id, p.gender INTO v_partner_id, v_partner_gender
|
||||||
|
FROM public.profiles p
|
||||||
|
WHERE p.couple_id = v_couple_id AND p.id != v_user_id
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
-- Check answer gap threshold (only when not browsing a specific pack)
|
||||||
|
IF v_partner_id IS NOT NULL AND target_pack_id IS NULL THEN
|
||||||
|
-- Get threshold from app_config
|
||||||
|
SELECT COALESCE(ac.answer_gap_threshold, 10) INTO v_gap_threshold
|
||||||
|
FROM app_config ac
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
-- If threshold > 0, check the gap
|
||||||
|
IF v_gap_threshold > 0 THEN
|
||||||
|
-- Count questions in ENABLED packs where user answered but partner hasn't
|
||||||
|
WITH enabled_packs AS (
|
||||||
|
SELECT cp.pack_id
|
||||||
|
FROM couple_packs cp
|
||||||
|
WHERE cp.couple_id = v_couple_id AND cp.enabled = true
|
||||||
|
)
|
||||||
|
SELECT COUNT(*)::integer INTO v_gap_count
|
||||||
|
FROM responses r
|
||||||
|
INNER JOIN questions q ON q.id = r.question_id
|
||||||
|
WHERE r.user_id = v_user_id
|
||||||
|
AND r.couple_id = v_couple_id
|
||||||
|
AND EXISTS (SELECT 1 FROM enabled_packs)
|
||||||
|
AND q.pack_id IN (SELECT ep.pack_id FROM enabled_packs ep)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM responses pr
|
||||||
|
WHERE pr.question_id = r.question_id
|
||||||
|
AND pr.user_id = v_partner_id
|
||||||
|
);
|
||||||
|
|
||||||
|
-- If over threshold, return empty (old apps see "All caught up!")
|
||||||
|
IF v_gap_count >= v_gap_threshold THEN
|
||||||
|
RETURN;
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF v_user_gender IS NOT NULL AND v_partner_gender IS NOT NULL THEN
|
||||||
|
IF v_user_gender < v_partner_gender THEN
|
||||||
|
v_couple_type := v_user_gender || '+' || v_partner_gender;
|
||||||
|
ELSE
|
||||||
|
v_couple_type := v_partner_gender || '+' || v_user_gender;
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
v_has_premium := public.has_premium_access(v_user_id);
|
||||||
|
|
||||||
|
RETURN QUERY
|
||||||
|
WITH user_config_exists AS (
|
||||||
|
SELECT 1 FROM public.couple_packs WHERE couple_id = v_couple_id LIMIT 1
|
||||||
|
),
|
||||||
|
active_packs AS (
|
||||||
|
SELECT target_pack_id AS pack_id
|
||||||
|
WHERE target_pack_id IS NOT NULL
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM public.question_packs qp
|
||||||
|
WHERE qp.id = target_pack_id
|
||||||
|
AND (qp.is_premium = FALSE OR v_has_premium = TRUE)
|
||||||
|
)
|
||||||
|
UNION ALL
|
||||||
|
SELECT cp.pack_id FROM public.couple_packs cp
|
||||||
|
INNER JOIN public.question_packs qp ON qp.id = cp.pack_id
|
||||||
|
WHERE target_pack_id IS NULL
|
||||||
|
AND cp.couple_id = v_couple_id
|
||||||
|
AND cp.enabled = TRUE
|
||||||
|
AND (qp.is_premium = FALSE OR v_has_premium = TRUE)
|
||||||
|
AND EXISTS (SELECT 1 FROM user_config_exists)
|
||||||
|
UNION ALL
|
||||||
|
SELECT p.id AS pack_id FROM public.question_packs p
|
||||||
|
WHERE target_pack_id IS NULL
|
||||||
|
AND p.is_public = TRUE
|
||||||
|
AND (p.is_premium = FALSE OR v_has_premium = TRUE)
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM user_config_exists)
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
q.id,
|
||||||
|
CASE
|
||||||
|
WHEN q.partner_text IS NOT NULL AND EXISTS (
|
||||||
|
SELECT 1 FROM public.responses r
|
||||||
|
WHERE r.question_id = q.id
|
||||||
|
AND r.couple_id = v_couple_id
|
||||||
|
AND r.user_id != v_user_id
|
||||||
|
) THEN q.partner_text
|
||||||
|
ELSE q.text
|
||||||
|
END as text,
|
||||||
|
q.partner_text,
|
||||||
|
q.partner_text IS NOT NULL as is_two_part,
|
||||||
|
q.pack_id,
|
||||||
|
q.intensity,
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM public.responses r
|
||||||
|
WHERE r.question_id = q.id
|
||||||
|
AND r.couple_id = v_couple_id
|
||||||
|
AND r.user_id != v_user_id
|
||||||
|
) AS partner_answered,
|
||||||
|
q.allowed_couple_genders,
|
||||||
|
q.target_user_genders
|
||||||
|
FROM public.questions q
|
||||||
|
WHERE q.pack_id IN (SELECT ap.pack_id FROM active_packs ap)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM public.responses r
|
||||||
|
WHERE r.question_id = q.id
|
||||||
|
AND r.user_id = v_user_id
|
||||||
|
)
|
||||||
|
AND (
|
||||||
|
q.allowed_couple_genders IS NULL
|
||||||
|
OR v_couple_type IS NULL
|
||||||
|
OR v_couple_type = ANY(q.allowed_couple_genders)
|
||||||
|
)
|
||||||
|
AND (
|
||||||
|
q.target_user_genders IS NULL
|
||||||
|
OR v_user_gender = ANY(q.target_user_genders)
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1 FROM public.responses r
|
||||||
|
WHERE r.question_id = q.id
|
||||||
|
AND r.couple_id = v_couple_id
|
||||||
|
AND r.user_id != v_user_id
|
||||||
|
)
|
||||||
|
);
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
COMMENT ON FUNCTION get_recommended_questions(UUID) IS 'Returns recommended questions for user. When user is too far ahead of partner (answer gap threshold exceeded), returns empty. Old apps see "All caught up!", new apps can call get_answer_gap_status to determine blocking reason.';
|
||||||
Reference in new issue
Block a user