feat(admin-web): Phase 1 — organizations, support, AI budgets, waitlist pages

This commit is contained in:
saravanakumardb1 2026-03-21 17:47:41 -07:00
parent d1d01727e4
commit aa33b12f8c
9 changed files with 1833 additions and 0 deletions

View File

@ -0,0 +1,462 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import {
Coins,
Plus,
MoreHorizontal,
Search,
AlertTriangle,
TrendingUp,
Trash2,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
interface BudgetPolicy {
id: string;
name: string;
scope: string;
period: string;
limitAmount: number;
currentSpend?: number;
alertThresholdPercent: number;
status: string;
createdAt: string;
}
interface BudgetAlert {
id: string;
policyId: string;
policyName?: string;
type: string;
message: string;
currentSpend: number;
limitAmount: number;
createdAt: string;
acknowledged: boolean;
}
function formatCurrency(amount: number) {
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(amount);
}
function formatDate(iso: string) {
return new Date(iso).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
}
async function apiFetch(path: string, opts?: RequestInit) {
const res = await fetch(`/api/ai-budgets/${path}`, {
headers: { 'Content-Type': 'application/json' },
...opts,
});
return res.json();
}
export default function AIBudgetsPage() {
const [policies, setPolicies] = useState<BudgetPolicy[]>([]);
const [alerts, setAlerts] = useState<BudgetAlert[]>([]);
const [loading, setLoading] = useState(true);
const [tab, setTab] = useState<'policies' | 'alerts'>('policies');
const [showCreate, setShowCreate] = useState(false);
const [creating, setCreating] = useState(false);
// Create form
const [newName, setNewName] = useState('');
const [newScope, setNewScope] = useState('global');
const [newPeriod, setNewPeriod] = useState('monthly');
const [newLimit, setNewLimit] = useState('100');
const [newThreshold, setNewThreshold] = useState('80');
const loadData = useCallback(async () => {
setLoading(true);
const [pData, aData] = await Promise.all([apiFetch('policies'), apiFetch('alerts')]);
setPolicies(
Array.isArray(pData?.policies) ? pData.policies : Array.isArray(pData) ? pData : []
);
setAlerts(Array.isArray(aData?.alerts) ? aData.alerts : Array.isArray(aData) ? aData : []);
setLoading(false);
}, []);
useEffect(() => {
void loadData();
}, [loadData]);
async function handleCreate() {
setCreating(true);
await apiFetch('policies', {
method: 'POST',
body: JSON.stringify({
name: newName,
scope: newScope,
period: newPeriod,
limitAmount: parseFloat(newLimit) || 100,
alertThresholdPercent: parseInt(newThreshold) || 80,
}),
});
setCreating(false);
setShowCreate(false);
setNewName('');
setNewScope('global');
setNewPeriod('monthly');
setNewLimit('100');
setNewThreshold('80');
loadData();
}
async function handleDelete(id: string) {
if (!confirm('Delete this budget policy?')) return;
await apiFetch(`policies/${id}`, { method: 'DELETE' });
loadData();
}
const totalSpend = policies.reduce((sum, p) => sum + (p.currentSpend ?? 0), 0);
const totalBudget = policies.reduce((sum, p) => sum + p.limitAmount, 0);
const unacknowledgedAlerts = alerts.filter(a => !a.acknowledged).length;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">AI Budgets & Cost Management</h1>
<p className="text-sm text-muted-foreground">
Monitor AI spend, set budget policies, and manage cost alerts
</p>
</div>
<Button onClick={() => setShowCreate(true)}>
<Plus className="mr-2 h-4 w-4" /> Create Policy
</Button>
</div>
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Total Spend</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{formatCurrency(totalSpend)}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Total Budget
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{formatCurrency(totalBudget)}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Utilization</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-2">
<div className="text-2xl font-bold">
{totalBudget > 0 ? Math.round((totalSpend / totalBudget) * 100) : 0}%
</div>
<TrendingUp className="h-4 w-4 text-muted-foreground" />
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Active Alerts
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-2">
<div className="text-2xl font-bold text-amber-600">{unacknowledgedAlerts}</div>
{unacknowledgedAlerts > 0 && <AlertTriangle className="h-5 w-5 text-amber-500" />}
</div>
</CardContent>
</Card>
</div>
<div className="flex gap-2">
<Button
variant={tab === 'policies' ? 'default' : 'outline'}
size="sm"
onClick={() => setTab('policies')}
>
Policies ({policies.length})
</Button>
<Button
variant={tab === 'alerts' ? 'default' : 'outline'}
size="sm"
onClick={() => setTab('alerts')}
>
Alerts ({alerts.length})
</Button>
</div>
{tab === 'policies' && (
<Card>
<CardContent className="p-0">
{loading ? (
<div className="p-8 text-center text-muted-foreground">Loading...</div>
) : policies.length === 0 ? (
<div className="p-8 text-center text-muted-foreground">
<Coins className="mx-auto mb-2 h-8 w-8 opacity-40" />
No budget policies yet. Create one to start tracking AI spend.
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Policy</TableHead>
<TableHead>Scope</TableHead>
<TableHead>Period</TableHead>
<TableHead>Spend / Limit</TableHead>
<TableHead>Utilization</TableHead>
<TableHead>Status</TableHead>
<TableHead className="w-[50px]" />
</TableRow>
</TableHeader>
<TableBody>
{policies.map(p => {
const util =
p.limitAmount > 0
? Math.round(((p.currentSpend ?? 0) / p.limitAmount) * 100)
: 0;
return (
<TableRow key={p.id}>
<TableCell className="font-medium">{p.name}</TableCell>
<TableCell>
<Badge variant="outline" className="capitalize">
{p.scope}
</Badge>
</TableCell>
<TableCell className="capitalize">{p.period}</TableCell>
<TableCell>
{formatCurrency(p.currentSpend ?? 0)} / {formatCurrency(p.limitAmount)}
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<div className="h-2 w-20 rounded-full bg-gray-100">
<div
className={`h-2 rounded-full ${
util >= 90
? 'bg-red-500'
: util >= 70
? 'bg-amber-500'
: 'bg-emerald-500'
}`}
style={{ width: `${Math.min(util, 100)}%` }}
/>
</div>
<span className="text-xs text-muted-foreground">{util}%</span>
</div>
</TableCell>
<TableCell>
<Badge
variant="outline"
className={
p.status === 'active'
? 'bg-emerald-50 text-emerald-700 border-0'
: 'bg-gray-50 text-gray-600 border-0'
}
>
{p.status}
</Badge>
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => handleDelete(p.id)}
className="text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" /> Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</CardContent>
</Card>
)}
{tab === 'alerts' && (
<Card>
<CardContent className="p-0">
{alerts.length === 0 ? (
<div className="p-8 text-center text-muted-foreground">
<AlertTriangle className="mx-auto mb-2 h-8 w-8 opacity-40" />
No budget alerts.
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Alert</TableHead>
<TableHead>Spend / Limit</TableHead>
<TableHead>Date</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{alerts.map(a => (
<TableRow key={a.id}>
<TableCell>
<div className="font-medium text-sm">{a.message}</div>
<p className="text-xs text-muted-foreground">
{a.policyName || a.policyId}
</p>
</TableCell>
<TableCell>
{formatCurrency(a.currentSpend)} / {formatCurrency(a.limitAmount)}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{formatDate(a.createdAt)}
</TableCell>
<TableCell>
<Badge
variant="outline"
className={
a.acknowledged
? 'bg-gray-50 text-gray-600 border-0'
: 'bg-amber-50 text-amber-700 border-0'
}
>
{a.acknowledged ? 'Acknowledged' : 'Active'}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
)}
{/* Create Policy Dialog */}
<Dialog open={showCreate} onOpenChange={setShowCreate}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Budget Policy</DialogTitle>
<DialogDescription>
Set a spending limit and alert threshold for AI operations.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>Policy Name</Label>
<Input
placeholder="e.g. Monthly GPT-4 Budget"
value={newName}
onChange={e => setNewName(e.target.value)}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Scope</Label>
<Select value={newScope} onValueChange={setNewScope}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="global">Global</SelectItem>
<SelectItem value="product">Per Product</SelectItem>
<SelectItem value="user">Per User</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Period</Label>
<Select value={newPeriod} onValueChange={setNewPeriod}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="daily">Daily</SelectItem>
<SelectItem value="monthly">Monthly</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Limit (USD)</Label>
<Input
type="number"
min="1"
step="0.01"
value={newLimit}
onChange={e => setNewLimit(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Alert Threshold (%)</Label>
<Input
type="number"
min="1"
max="100"
value={newThreshold}
onChange={e => setNewThreshold(e.target.value)}
/>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowCreate(false)}>
Cancel
</Button>
<Button onClick={handleCreate} disabled={creating || !newName.trim()}>
{creating ? 'Creating...' : 'Create Policy'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -0,0 +1,409 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { Building2, Plus, MoreHorizontal, Users, FolderOpen, Search, Trash2 } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet';
interface Org {
id: string;
name: string;
slug: string;
status: string;
memberCount?: number;
workspaceCount?: number;
createdAt: string;
}
interface Member {
id: string;
userId: string;
role: string;
email?: string;
name?: string;
createdAt: string;
}
interface Workspace {
id: string;
name: string;
slug: string;
createdAt: string;
}
function formatDate(iso: string) {
return new Date(iso).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
}
async function apiFetch(path: string, opts?: RequestInit) {
const res = await fetch(`/api/orgs/${path}`, {
headers: { 'Content-Type': 'application/json' },
...opts,
});
return res.json();
}
export default function OrganizationsPage() {
const [orgs, setOrgs] = useState<Org[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [showCreate, setShowCreate] = useState(false);
const [newName, setNewName] = useState('');
const [newSlug, setNewSlug] = useState('');
const [creating, setCreating] = useState(false);
// Detail drawer
const [selectedOrg, setSelectedOrg] = useState<Org | null>(null);
const [members, setMembers] = useState<Member[]>([]);
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
const [detailLoading, setDetailLoading] = useState(false);
const loadOrgs = useCallback(async () => {
setLoading(true);
const qs = search ? `?search=${encodeURIComponent(search)}` : '';
const data = await apiFetch(`list${qs}`);
if (data?.organizations) {
setOrgs(data.organizations);
setTotal(data.total ?? data.organizations.length);
} else if (Array.isArray(data)) {
setOrgs(data);
setTotal(data.length);
}
setLoading(false);
}, [search]);
useEffect(() => {
void loadOrgs();
}, [loadOrgs]);
async function handleCreate() {
setCreating(true);
await apiFetch('create', {
method: 'POST',
body: JSON.stringify({ name: newName, slug: newSlug || undefined }),
});
setCreating(false);
setShowCreate(false);
setNewName('');
setNewSlug('');
loadOrgs();
}
async function handleDelete(id: string) {
if (!confirm('Delete this organization? This cannot be undone.')) return;
await apiFetch(`${id}/delete`, { method: 'DELETE' });
loadOrgs();
if (selectedOrg?.id === id) setSelectedOrg(null);
}
async function openDetail(org: Org) {
setSelectedOrg(org);
setDetailLoading(true);
const [membersData, wsData] = await Promise.all([
apiFetch(`${org.id}/members`),
apiFetch(`${org.id}/workspaces`),
]);
setMembers(
Array.isArray(membersData?.memberships)
? membersData.memberships
: Array.isArray(membersData)
? membersData
: []
);
setWorkspaces(
Array.isArray(wsData?.workspaces) ? wsData.workspaces : Array.isArray(wsData) ? wsData : []
);
setDetailLoading(false);
}
const activeOrgs = orgs.filter(o => o.status === 'active');
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Organizations</h1>
<p className="text-sm text-muted-foreground">
Manage organizations, workspaces, and memberships
</p>
</div>
<Button onClick={() => setShowCreate(true)}>
<Plus className="mr-2 h-4 w-4" /> Create Organization
</Button>
</div>
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Total Organizations
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{total}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Active</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{activeOrgs.length}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Total Members
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{orgs.reduce((sum, o) => sum + (o.memberCount ?? 0), 0)}
</div>
</CardContent>
</Card>
</div>
<div className="flex items-center gap-4">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search organizations..."
value={search}
onChange={e => setSearch(e.target.value)}
className="pl-9"
/>
</div>
</div>
<Card>
<CardContent className="p-0">
{loading ? (
<div className="p-8 text-center text-muted-foreground">Loading...</div>
) : orgs.length === 0 ? (
<div className="p-8 text-center text-muted-foreground">
<Building2 className="mx-auto mb-2 h-8 w-8 opacity-40" />
No organizations yet. Create one to get started.
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Organization</TableHead>
<TableHead>Slug</TableHead>
<TableHead>Members</TableHead>
<TableHead>Workspaces</TableHead>
<TableHead>Status</TableHead>
<TableHead>Created</TableHead>
<TableHead className="w-[50px]" />
</TableRow>
</TableHeader>
<TableBody>
{orgs.map(org => (
<TableRow key={org.id} className="cursor-pointer" onClick={() => openDetail(org)}>
<TableCell className="font-medium">{org.name}</TableCell>
<TableCell>
<code className="text-xs text-muted-foreground">{org.slug}</code>
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Users className="h-3.5 w-3.5 text-muted-foreground" />
{org.memberCount ?? 0}
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<FolderOpen className="h-3.5 w-3.5 text-muted-foreground" />
{org.workspaceCount ?? 0}
</div>
</TableCell>
<TableCell>
<Badge
variant="outline"
className={
org.status === 'active'
? 'bg-emerald-50 text-emerald-700 border-0'
: 'bg-gray-50 text-gray-600 border-0'
}
>
{org.status}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground text-xs">
{formatDate(org.createdAt)}
</TableCell>
<TableCell onClick={e => e.stopPropagation()}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => openDetail(org)}>
View Details
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleDelete(org.id)}
className="text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" /> Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{/* Create Dialog */}
<Dialog open={showCreate} onOpenChange={setShowCreate}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Organization</DialogTitle>
<DialogDescription>
Create a new organization with workspaces and members.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>Name</Label>
<Input
placeholder="e.g. Acme Corp"
value={newName}
onChange={e => setNewName(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Slug (optional auto-generated)</Label>
<Input
placeholder="e.g. acme-corp"
value={newSlug}
onChange={e => setNewSlug(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowCreate(false)}>
Cancel
</Button>
<Button onClick={handleCreate} disabled={creating || !newName.trim()}>
{creating ? 'Creating...' : 'Create'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Detail Sheet */}
<Sheet open={!!selectedOrg} onOpenChange={open => !open && setSelectedOrg(null)}>
<SheetContent className="w-[480px] sm:w-[540px] overflow-auto">
<SheetHeader>
<SheetTitle>{selectedOrg?.name}</SheetTitle>
<SheetDescription>
<code className="text-xs">{selectedOrg?.id}</code>
</SheetDescription>
</SheetHeader>
{detailLoading ? (
<div className="py-12 text-center text-muted-foreground">Loading...</div>
) : (
<div className="space-y-6 mt-6">
<div>
<h3 className="text-sm font-semibold mb-3 flex items-center gap-2">
<Users className="h-4 w-4" /> Members ({members.length})
</h3>
{members.length === 0 ? (
<p className="text-sm text-muted-foreground">No members yet.</p>
) : (
<div className="space-y-2">
{members.map(m => (
<div
key={m.id}
className="flex items-center justify-between rounded-lg border p-3"
>
<div>
<p className="text-sm font-medium">{m.name || m.email || m.userId}</p>
<p className="text-xs text-muted-foreground">{m.email}</p>
</div>
<Badge variant="outline" className="capitalize">
{m.role}
</Badge>
</div>
))}
</div>
)}
</div>
<div>
<h3 className="text-sm font-semibold mb-3 flex items-center gap-2">
<FolderOpen className="h-4 w-4" /> Workspaces ({workspaces.length})
</h3>
{workspaces.length === 0 ? (
<p className="text-sm text-muted-foreground">No workspaces yet.</p>
) : (
<div className="space-y-2">
{workspaces.map(ws => (
<div
key={ws.id}
className="flex items-center justify-between rounded-lg border p-3"
>
<div>
<p className="text-sm font-medium">{ws.name}</p>
<code className="text-xs text-muted-foreground">{ws.slug}</code>
</div>
<span className="text-xs text-muted-foreground">
{formatDate(ws.createdAt)}
</span>
</div>
))}
</div>
)}
</div>
</div>
)}
</SheetContent>
</Sheet>
</div>
);
}

View File

@ -0,0 +1,371 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import {
LifeBuoy,
Plus,
MoreHorizontal,
Search,
AlertTriangle,
CheckCircle2,
Clock,
XCircle,
ArrowUpRight,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
interface SupportCase {
id: string;
title: string;
description: string;
status: string;
priority: string;
requesterUserId: string;
assignedTo?: string;
createdAt: string;
updatedAt: string;
}
const statusConfig: Record<string, { label: string; color: string; icon: typeof Clock }> = {
open: { label: 'Open', color: 'bg-blue-50 text-blue-700', icon: Clock },
in_progress: { label: 'In Progress', color: 'bg-amber-50 text-amber-700', icon: ArrowUpRight },
waiting_on_customer: { label: 'Waiting', color: 'bg-purple-50 text-purple-700', icon: Clock },
resolved: { label: 'Resolved', color: 'bg-emerald-50 text-emerald-700', icon: CheckCircle2 },
closed: { label: 'Closed', color: 'bg-gray-50 text-gray-600', icon: XCircle },
};
const priorityConfig: Record<string, { label: string; color: string }> = {
low: { label: 'Low', color: 'bg-gray-50 text-gray-600' },
medium: { label: 'Medium', color: 'bg-blue-50 text-blue-700' },
high: { label: 'High', color: 'bg-amber-50 text-amber-700' },
critical: { label: 'Critical', color: 'bg-red-50 text-red-700' },
};
function formatDate(iso: string) {
return new Date(iso).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
}
async function apiFetch(path: string, opts?: RequestInit) {
const res = await fetch(`/api/support/${path}`, {
headers: { 'Content-Type': 'application/json' },
...opts,
});
return res.json();
}
export default function SupportCasesPage() {
const [cases, setCases] = useState<SupportCase[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [statusFilter, setStatusFilter] = useState<string>('all');
const [search, setSearch] = useState('');
const [showCreate, setShowCreate] = useState(false);
const [creating, setCreating] = useState(false);
// Create form
const [newTitle, setNewTitle] = useState('');
const [newDescription, setNewDescription] = useState('');
const [newPriority, setNewPriority] = useState('medium');
const loadCases = useCallback(async () => {
setLoading(true);
const params = new URLSearchParams();
if (statusFilter !== 'all') params.set('status', statusFilter);
if (search) params.set('search', search);
const qs = params.toString() ? `?${params.toString()}` : '';
const data = await apiFetch(`cases${qs}`);
if (data?.cases) {
setCases(data.cases);
setTotal(data.total ?? data.cases.length);
} else if (Array.isArray(data)) {
setCases(data);
setTotal(data.length);
}
setLoading(false);
}, [statusFilter, search]);
useEffect(() => {
void loadCases();
}, [loadCases]);
async function handleCreate() {
setCreating(true);
await apiFetch('cases', {
method: 'POST',
body: JSON.stringify({
title: newTitle,
description: newDescription,
priority: newPriority,
}),
});
setCreating(false);
setShowCreate(false);
setNewTitle('');
setNewDescription('');
setNewPriority('medium');
loadCases();
}
async function handleStatusChange(id: string, status: string) {
await apiFetch(`cases/${id}`, {
method: 'PATCH',
body: JSON.stringify({ status }),
});
loadCases();
}
const openCount = cases.filter(c => c.status === 'open' || c.status === 'in_progress').length;
const criticalCount = cases.filter(
c => c.priority === 'critical' && c.status !== 'closed' && c.status !== 'resolved'
).length;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Support Cases</h1>
<p className="text-sm text-muted-foreground">
Manage customer support cases, notes, and escalations
</p>
</div>
<Button onClick={() => setShowCreate(true)}>
<Plus className="mr-2 h-4 w-4" /> New Case
</Button>
</div>
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Total Cases</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{total}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Open</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-blue-600">{openCount}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Critical</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-2">
<div className="text-2xl font-bold text-red-600">{criticalCount}</div>
{criticalCount > 0 && <AlertTriangle className="h-5 w-5 text-red-500" />}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Resolved</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-emerald-600">
{cases.filter(c => c.status === 'resolved' || c.status === 'closed').length}
</div>
</CardContent>
</Card>
</div>
<div className="flex items-center gap-4">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search cases..."
value={search}
onChange={e => setSearch(e.target.value)}
className="pl-9"
/>
</div>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Filter by status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Statuses</SelectItem>
<SelectItem value="open">Open</SelectItem>
<SelectItem value="in_progress">In Progress</SelectItem>
<SelectItem value="waiting_on_customer">Waiting</SelectItem>
<SelectItem value="resolved">Resolved</SelectItem>
<SelectItem value="closed">Closed</SelectItem>
</SelectContent>
</Select>
</div>
<Card>
<CardContent className="p-0">
{loading ? (
<div className="p-8 text-center text-muted-foreground">Loading...</div>
) : cases.length === 0 ? (
<div className="p-8 text-center text-muted-foreground">
<LifeBuoy className="mx-auto mb-2 h-8 w-8 opacity-40" />
No support cases found.
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Title</TableHead>
<TableHead>Priority</TableHead>
<TableHead>Status</TableHead>
<TableHead>Assigned To</TableHead>
<TableHead>Created</TableHead>
<TableHead className="w-[50px]" />
</TableRow>
</TableHeader>
<TableBody>
{cases.map(c => {
const sCfg = statusConfig[c.status] || statusConfig.open;
const pCfg = priorityConfig[c.priority] || priorityConfig.medium;
const StatusIcon = sCfg.icon;
return (
<TableRow key={c.id}>
<TableCell>
<div className="font-medium">{c.title}</div>
<p className="text-xs text-muted-foreground truncate max-w-[300px]">
{c.description}
</p>
</TableCell>
<TableCell>
<Badge className={`${pCfg.color} border-0`}>{pCfg.label}</Badge>
</TableCell>
<TableCell>
<Badge className={`${sCfg.color} border-0 gap-1`}>
<StatusIcon className="h-3 w-3" />
{sCfg.label}
</Badge>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{c.assignedTo || '—'}
</TableCell>
<TableCell className="text-muted-foreground text-xs">
{formatDate(c.createdAt)}
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => handleStatusChange(c.id, 'in_progress')}
>
Mark In Progress
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleStatusChange(c.id, 'resolved')}>
Resolve
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleStatusChange(c.id, 'closed')}>
Close
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{/* Create Dialog */}
<Dialog open={showCreate} onOpenChange={setShowCreate}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Support Case</DialogTitle>
<DialogDescription>Open a new support case for a customer issue.</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>Title</Label>
<Input
placeholder="Brief description of the issue"
value={newTitle}
onChange={e => setNewTitle(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Description</Label>
<Textarea
placeholder="Detailed description..."
value={newDescription}
onChange={e => setNewDescription(e.target.value)}
rows={4}
/>
</div>
<div className="space-y-2">
<Label>Priority</Label>
<Select value={newPriority} onValueChange={setNewPriority}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="low">Low</SelectItem>
<SelectItem value="medium">Medium</SelectItem>
<SelectItem value="high">High</SelectItem>
<SelectItem value="critical">Critical</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowCreate(false)}>
Cancel
</Button>
<Button onClick={handleCreate} disabled={creating || !newTitle.trim()}>
{creating ? 'Creating...' : 'Create Case'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -0,0 +1,356 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import {
ListOrdered,
Plus,
MoreHorizontal,
Search,
Download,
Send,
CheckCircle2,
Clock,
Mail,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
interface WaitlistEntry {
id: string;
email: string;
name?: string;
position: number;
status: string;
referralCode?: string;
referredBy?: string;
invitedAt?: string;
convertedAt?: string;
createdAt: string;
}
const statusConfig: Record<string, { label: string; color: string; icon: typeof Clock }> = {
waiting: { label: 'Waiting', color: 'bg-blue-50 text-blue-700', icon: Clock },
invited: { label: 'Invited', color: 'bg-amber-50 text-amber-700', icon: Mail },
converted: { label: 'Converted', color: 'bg-emerald-50 text-emerald-700', icon: CheckCircle2 },
unsubscribed: { label: 'Unsubscribed', color: 'bg-gray-50 text-gray-600', icon: Clock },
};
function formatDate(iso: string) {
return new Date(iso).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
}
async function apiFetch(path: string, opts?: RequestInit) {
const res = await fetch(`/api/waitlist/${path}`, {
headers: { 'Content-Type': 'application/json' },
...opts,
});
return res.json();
}
export default function WaitlistPage() {
const [entries, setEntries] = useState<WaitlistEntry[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [statusFilter, setStatusFilter] = useState<string>('all');
const [search, setSearch] = useState('');
const [selected, setSelected] = useState<Set<string>>(new Set());
const [inviting, setInviting] = useState(false);
const [exporting, setExporting] = useState(false);
const loadEntries = useCallback(async () => {
setLoading(true);
const params = new URLSearchParams();
if (statusFilter !== 'all') params.set('status', statusFilter);
if (search) params.set('search', search);
const qs = params.toString() ? `?${params.toString()}` : '';
const data = await apiFetch(`list${qs}`);
if (data?.entries) {
setEntries(data.entries);
setTotal(data.total ?? data.entries.length);
} else if (Array.isArray(data)) {
setEntries(data);
setTotal(data.length);
}
setLoading(false);
}, [statusFilter, search]);
useEffect(() => {
void loadEntries();
}, [loadEntries]);
function toggleSelect(id: string) {
setSelected(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
function toggleAll() {
if (selected.size === entries.length) {
setSelected(new Set());
} else {
setSelected(new Set(entries.map(e => e.id)));
}
}
async function handleBatchInvite() {
if (selected.size === 0) return;
setInviting(true);
await apiFetch('invite', {
method: 'POST',
body: JSON.stringify({ entryIds: Array.from(selected) }),
});
setInviting(false);
setSelected(new Set());
loadEntries();
}
async function handleExport() {
setExporting(true);
try {
const res = await fetch('/api/waitlist/export', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
if (res.ok) {
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `waitlist-export-${new Date().toISOString().slice(0, 10)}.csv`;
a.click();
URL.revokeObjectURL(url);
}
} catch {
// ignore
}
setExporting(false);
}
async function handleStatusChange(id: string, status: string) {
await apiFetch(`${id}`, {
method: 'PUT',
body: JSON.stringify({ status }),
});
loadEntries();
}
const waitingCount = entries.filter(e => e.status === 'waiting').length;
const invitedCount = entries.filter(e => e.status === 'invited').length;
const convertedCount = entries.filter(e => e.status === 'converted').length;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Waitlist Management</h1>
<p className="text-sm text-muted-foreground">
Manage pre-launch signups, send invitations, and track conversions
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={handleExport} disabled={exporting}>
<Download className="mr-2 h-4 w-4" />
{exporting ? 'Exporting...' : 'CSV Export'}
</Button>
{selected.size > 0 && (
<Button onClick={handleBatchInvite} disabled={inviting}>
<Send className="mr-2 h-4 w-4" />
{inviting ? 'Inviting...' : `Invite ${selected.size} Selected`}
</Button>
)}
</div>
</div>
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Total Signups
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{total}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Waiting</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-blue-600">{waitingCount}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Invited</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-amber-600">{invitedCount}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Converted</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-2">
<div className="text-2xl font-bold text-emerald-600">{convertedCount}</div>
{total > 0 && (
<span className="text-xs text-muted-foreground">
({Math.round((convertedCount / total) * 100)}%)
</span>
)}
</div>
</CardContent>
</Card>
</div>
<div className="flex items-center gap-4">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search by email or name..."
value={search}
onChange={e => setSearch(e.target.value)}
className="pl-9"
/>
</div>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Filter by status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Statuses</SelectItem>
<SelectItem value="waiting">Waiting</SelectItem>
<SelectItem value="invited">Invited</SelectItem>
<SelectItem value="converted">Converted</SelectItem>
<SelectItem value="unsubscribed">Unsubscribed</SelectItem>
</SelectContent>
</Select>
</div>
<Card>
<CardContent className="p-0">
{loading ? (
<div className="p-8 text-center text-muted-foreground">Loading...</div>
) : entries.length === 0 ? (
<div className="p-8 text-center text-muted-foreground">
<ListOrdered className="mx-auto mb-2 h-8 w-8 opacity-40" />
No waitlist entries found.
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[40px]">
<input
type="checkbox"
checked={selected.size === entries.length && entries.length > 0}
onChange={toggleAll}
className="rounded border-gray-300"
/>
</TableHead>
<TableHead>#</TableHead>
<TableHead>Email</TableHead>
<TableHead>Name</TableHead>
<TableHead>Status</TableHead>
<TableHead>Referral</TableHead>
<TableHead>Signed Up</TableHead>
<TableHead className="w-[50px]" />
</TableRow>
</TableHeader>
<TableBody>
{entries.map(entry => {
const cfg = statusConfig[entry.status] || statusConfig.waiting;
const StatusIcon = cfg.icon;
return (
<TableRow key={entry.id}>
<TableCell onClick={e => e.stopPropagation()}>
<input
type="checkbox"
checked={selected.has(entry.id)}
onChange={() => toggleSelect(entry.id)}
className="rounded border-gray-300"
/>
</TableCell>
<TableCell className="text-muted-foreground font-mono text-xs">
{entry.position}
</TableCell>
<TableCell className="font-medium">{entry.email}</TableCell>
<TableCell className="text-sm">{entry.name || '—'}</TableCell>
<TableCell>
<Badge className={`${cfg.color} border-0 gap-1`}>
<StatusIcon className="h-3 w-3" />
{cfg.label}
</Badge>
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{entry.referralCode ? <code>{entry.referralCode}</code> : '—'}
</TableCell>
<TableCell className="text-muted-foreground text-xs">
{formatDate(entry.createdAt)}
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{entry.status === 'waiting' && (
<DropdownMenuItem
onClick={() => handleStatusChange(entry.id, 'invited')}
>
<Send className="mr-2 h-4 w-4" /> Send Invite
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() => handleStatusChange(entry.id, 'unsubscribed')}
>
Unsubscribe
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,56 @@
/**
* AI Budgets API proxy forwards requests to platform-service.
*
* GET/POST/PATCH/DELETE /api/ai-budgets/* platform-service /api/ai-budgets/*
*/
import { NextRequest, NextResponse } from 'next/server';
import { getCurrentUser } from '@/lib/auth-server';
import { logError } from '@/lib/logger';
const PLATFORM_URL = process.env.PLATFORM_SERVICE_URL || 'http://localhost:4003';
async function proxy(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
try {
const caller = await getCurrentUser(req.headers.get('authorization'));
if (!caller) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { path } = await params;
const targetPath = `/api/ai-budgets/${path.join('/')}`;
const qs = new URL(req.url).search;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'x-request-id': req.headers.get('x-request-id') || crypto.randomUUID(),
'x-user-id': caller.id,
'x-product-id': req.headers.get('x-product-id') || process.env.PRODUCT_ID || 'lysnrai',
};
const fetchOptions: RequestInit = { method: req.method, headers };
if (['POST', 'PUT', 'PATCH'].includes(req.method)) {
fetchOptions.body = await req.text();
}
const res = await fetch(`${PLATFORM_URL}${targetPath}${qs}`, fetchOptions);
const data = await res.json().catch(() => null);
return NextResponse.json(data ?? { error: res.statusText }, { status: res.status });
} catch (error) {
logError('AI Budgets proxy error', error);
return NextResponse.json({ error: 'Service unavailable' }, { status: 502 });
}
}
export async function GET(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
return proxy(req, ctx);
}
export async function POST(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
return proxy(req, ctx);
}
export async function PATCH(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
return proxy(req, ctx);
}
export async function DELETE(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
return proxy(req, ctx);
}

View File

@ -0,0 +1,59 @@
/**
* Organizations API proxy forwards requests to platform-service.
*
* GET/POST/PATCH/DELETE /api/orgs/* platform-service /api/orgs/*
*/
import { NextRequest, NextResponse } from 'next/server';
import { getCurrentUser } from '@/lib/auth-server';
import { logError } from '@/lib/logger';
const PLATFORM_URL = process.env.PLATFORM_SERVICE_URL || 'http://localhost:4003';
async function proxy(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
try {
const caller = await getCurrentUser(req.headers.get('authorization'));
if (!caller) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { path } = await params;
const targetPath = `/api/orgs/${path.join('/')}`;
const qs = new URL(req.url).search;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'x-request-id': req.headers.get('x-request-id') || crypto.randomUUID(),
'x-user-id': caller.id,
'x-product-id': req.headers.get('x-product-id') || process.env.PRODUCT_ID || 'lysnrai',
};
const fetchOptions: RequestInit = { method: req.method, headers };
if (['POST', 'PUT', 'PATCH'].includes(req.method)) {
fetchOptions.body = await req.text();
}
const res = await fetch(`${PLATFORM_URL}${targetPath}${qs}`, fetchOptions);
const data = await res.json().catch(() => null);
return NextResponse.json(data ?? { error: res.statusText }, { status: res.status });
} catch (error) {
logError('Orgs proxy error', error);
return NextResponse.json({ error: 'Service unavailable' }, { status: 502 });
}
}
export async function GET(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
return proxy(req, ctx);
}
export async function POST(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
return proxy(req, ctx);
}
export async function PATCH(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
return proxy(req, ctx);
}
export async function PUT(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
return proxy(req, ctx);
}
export async function DELETE(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
return proxy(req, ctx);
}

View File

@ -0,0 +1,56 @@
/**
* Support Cases API proxy forwards requests to platform-service.
*
* GET/POST/PATCH /api/support/* platform-service /api/support/*
*/
import { NextRequest, NextResponse } from 'next/server';
import { getCurrentUser } from '@/lib/auth-server';
import { logError } from '@/lib/logger';
const PLATFORM_URL = process.env.PLATFORM_SERVICE_URL || 'http://localhost:4003';
async function proxy(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
try {
const caller = await getCurrentUser(req.headers.get('authorization'));
if (!caller) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { path } = await params;
const targetPath = `/api/support/${path.join('/')}`;
const qs = new URL(req.url).search;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'x-request-id': req.headers.get('x-request-id') || crypto.randomUUID(),
'x-user-id': caller.id,
'x-product-id': req.headers.get('x-product-id') || process.env.PRODUCT_ID || 'lysnrai',
};
const fetchOptions: RequestInit = { method: req.method, headers };
if (['POST', 'PUT', 'PATCH'].includes(req.method)) {
fetchOptions.body = await req.text();
}
const res = await fetch(`${PLATFORM_URL}${targetPath}${qs}`, fetchOptions);
const data = await res.json().catch(() => null);
return NextResponse.json(data ?? { error: res.statusText }, { status: res.status });
} catch (error) {
logError('Support proxy error', error);
return NextResponse.json({ error: 'Service unavailable' }, { status: 502 });
}
}
export async function GET(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
return proxy(req, ctx);
}
export async function POST(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
return proxy(req, ctx);
}
export async function PATCH(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
return proxy(req, ctx);
}
export async function DELETE(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
return proxy(req, ctx);
}

View File

@ -0,0 +1,56 @@
/**
* Waitlist API proxy forwards requests to platform-service.
*
* GET/POST/PUT/DELETE /api/waitlist/* platform-service /api/waitlist/*
*/
import { NextRequest, NextResponse } from 'next/server';
import { getCurrentUser } from '@/lib/auth-server';
import { logError } from '@/lib/logger';
const PLATFORM_URL = process.env.PLATFORM_SERVICE_URL || 'http://localhost:4003';
async function proxy(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
try {
const caller = await getCurrentUser(req.headers.get('authorization'));
if (!caller) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { path } = await params;
const targetPath = `/api/waitlist/${path.join('/')}`;
const qs = new URL(req.url).search;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'x-request-id': req.headers.get('x-request-id') || crypto.randomUUID(),
'x-user-id': caller.id,
'x-product-id': req.headers.get('x-product-id') || process.env.PRODUCT_ID || 'lysnrai',
};
const fetchOptions: RequestInit = { method: req.method, headers };
if (['POST', 'PUT', 'PATCH'].includes(req.method)) {
fetchOptions.body = await req.text();
}
const res = await fetch(`${PLATFORM_URL}${targetPath}${qs}`, fetchOptions);
const data = await res.json().catch(() => null);
return NextResponse.json(data ?? { error: res.statusText }, { status: res.status });
} catch (error) {
logError('Waitlist proxy error', error);
return NextResponse.json({ error: 'Service unavailable' }, { status: 502 });
}
}
export async function GET(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
return proxy(req, ctx);
}
export async function POST(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
return proxy(req, ctx);
}
export async function PUT(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
return proxy(req, ctx);
}
export async function DELETE(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
return proxy(req, ctx);
}

View File

@ -39,6 +39,10 @@ import {
FlaskConical,
BrainCircuit,
TrendingDown,
Building2,
LifeBuoy,
Coins,
ListOrdered,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { useAuth } from '@/lib/auth-context';
@ -65,6 +69,10 @@ const navItems = [
{ href: '/flags', label: 'Feature Flags', icon: Settings },
{ href: '/audit', label: 'Audit Log', icon: ScrollText },
{ href: '/actiontrail', label: 'ActionTrail', icon: Crosshair },
{ href: '/organizations', label: 'Organizations', icon: Building2 },
{ href: '/support', label: 'Support Cases', icon: LifeBuoy },
{ href: '/ai-budgets', label: 'AI Budgets', icon: Coins },
{ href: '/waitlist', label: 'Waitlist', icon: ListOrdered },
{ href: '/experiments', label: 'Experiments', icon: FlaskConical },
{ href: '/predictive/at-risk', label: 'Predictive', icon: TrendingDown },
{ href: '/ops', label: 'Mission Control', icon: Activity },