CognxSafeTrack commited on
Commit
6282d86
·
1 Parent(s): de46926

feat: add XAMLÉ Platform Admin super-admin interface

Browse files

When selectedOrgId === 'xamle-admin-org', the admin switches to a full
platform management dashboard instead of the normal org layout.

Backend (apps/api):
- New /v1/super-admin/* routes (SUPER_ADMIN/ADMIN role required):
platform stats, organizations CRUD + suspend + impersonate,
cross-org users + role change, WhatsApp numbers, billing
transactions + manual credits, monitoring health, AI agentic command
- Fixed SQL injection in analytics text-to-SQL (UNION/DROP/comment blocking)
- Removed JWT secret fallback — crashes on startup if JWT_SECRET is missing
- Added ORG_ADMIN role guard to all /v1/admin/* routes

Frontend (apps/admin):
- SuperAdminRouter: nested routes under /platform/*
- SuperAdminLayout: dark sidebar (slate-950/violet), 7 nav items
- PlatformDashboard: 6 KPI cards, system health, active alerts
- OrganizationsManager: searchable table, suspend/edit drawer, create modal
- UsersManager: cross-org table with inline role-change
- WhatsAppNumbers: all registered numbers across orgs
- MonitoringAlerts: DB/Redis/queue health, expiring tokens, low balances
- BillingManager: transaction history, manual credit top-up modal
- AIInsights: NL chat with suggestion chips and confirmation flow

apps/admin/src/App.tsx CHANGED
@@ -31,6 +31,7 @@ import { useTenant } from '@/lib/tenant';
31
  import { api } from '@/lib/api';
32
 
33
  import MainLayout from '@/components/layouts/MainLayout';
 
34
  import { logError } from '@/lib/logger';
35
 
36
  function ProtectedRoute({ children }: { children: React.ReactNode }) {
@@ -41,7 +42,7 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) {
41
 
42
  function AppShell() {
43
  const { token, user } = useAuth();
44
- const { setSelectedOrgId, currentOrg } = useTenant();
45
  const [orgs, setOrgs] = React.useState<any[]>([]);
46
 
47
  const isSuperAdmin = user?.role === 'SUPER_ADMIN' || user?.role === 'ADMIN';
@@ -62,6 +63,10 @@ function AppShell() {
62
 
63
  const isCrmActive = !!currentOrg?.isCrmActive;
64
 
 
 
 
 
65
  return (
66
  <MainLayout isSuperAdmin={isSuperAdmin} orgs={orgs}>
67
  <Routes>
 
31
  import { api } from '@/lib/api';
32
 
33
  import MainLayout from '@/components/layouts/MainLayout';
34
+ import SuperAdminRouter from '@/pages/super-admin/SuperAdminRouter';
35
  import { logError } from '@/lib/logger';
36
 
37
  function ProtectedRoute({ children }: { children: React.ReactNode }) {
 
42
 
43
  function AppShell() {
44
  const { token, user } = useAuth();
45
+ const { selectedOrgId, setSelectedOrgId, currentOrg } = useTenant();
46
  const [orgs, setOrgs] = React.useState<any[]>([]);
47
 
48
  const isSuperAdmin = user?.role === 'SUPER_ADMIN' || user?.role === 'ADMIN';
 
63
 
64
  const isCrmActive = !!currentOrg?.isCrmActive;
65
 
66
+ if (selectedOrgId === 'xamle-admin-org') {
67
+ return <SuperAdminRouter />;
68
+ }
69
+
70
  return (
71
  <MainLayout isSuperAdmin={isSuperAdmin} orgs={orgs}>
72
  <Routes>
apps/admin/src/pages/super-admin/AIInsights.tsx ADDED
@@ -0,0 +1,195 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useRef, useEffect } from 'react';
2
+ import { useAuth } from '@/lib/auth';
3
+ import { api } from '@/lib/api';
4
+ import { useToast } from '@/hooks/useToast';
5
+ import { Bot, Send, Loader2, Check, X } from 'lucide-react';
6
+
7
+ const SUGGESTIONS = [
8
+ 'Montre moi les organisations avec un solde faible',
9
+ 'Quelles sont les statistiques de la plateforme ?',
10
+ 'Liste les alertes actives',
11
+ 'Affiche les 10 dernières organisations',
12
+ ];
13
+
14
+ interface Msg {
15
+ role: 'user' | 'assistant';
16
+ content: string;
17
+ pending?: { action: string; params: Record<string, unknown>; summary: string };
18
+ }
19
+
20
+ export default function AIInsights() {
21
+ const { token } = useAuth();
22
+ const toast = useToast();
23
+ const [messages, setMessages] = useState<Msg[]>([{
24
+ role: 'assistant',
25
+ content: 'Bonjour ! Je suis votre assistant IA pour la gestion de la plateforme XAMLÉ. Posez-moi une question ou donnez-moi une instruction.',
26
+ }]);
27
+ const [input, setInput] = useState('');
28
+ const [loading, setLoading] = useState(false);
29
+ const endRef = useRef<HTMLDivElement>(null);
30
+
31
+ useEffect(() => { endRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages]);
32
+
33
+ async function send(command?: string) {
34
+ const text = command || input.trim();
35
+ if (!text || !token) return;
36
+ setInput('');
37
+ setMessages(prev => [...prev, { role: 'user', content: text }]);
38
+ setLoading(true);
39
+
40
+ try {
41
+ const res = await api.post('/v1/super-admin/ai/command', { command: text }, token);
42
+
43
+ if (res.status === 'pending_confirmation') {
44
+ setMessages(prev => [...prev, {
45
+ role: 'assistant',
46
+ content: res.summary,
47
+ pending: { action: res.action, params: res.params, summary: res.summary },
48
+ }]);
49
+ } else {
50
+ const resultText = formatResult(res.result);
51
+ setMessages(prev => [...prev, { role: 'assistant', content: resultText }]);
52
+ }
53
+ } catch {
54
+ toast.error('Erreur AI');
55
+ setMessages(prev => [...prev, { role: 'assistant', content: 'Désolé, une erreur est survenue.' }]);
56
+ } finally {
57
+ setLoading(false);
58
+ }
59
+ }
60
+
61
+ async function confirm(msg: Msg, confirmed: boolean) {
62
+ if (!msg.pending || !token) return;
63
+ setMessages(prev => prev.map(m => m === msg ? { ...m, pending: undefined } : m));
64
+
65
+ if (!confirmed) {
66
+ setMessages(prev => [...prev, { role: 'assistant', content: 'Action annulée.' }]);
67
+ return;
68
+ }
69
+
70
+ setLoading(true);
71
+ try {
72
+ const res = await api.post('/v1/super-admin/ai/command', {
73
+ command: msg.pending.summary,
74
+ confirm: true,
75
+ pendingAction: { action: msg.pending.action, params: msg.pending.params },
76
+ }, token);
77
+ setMessages(prev => [...prev, { role: 'assistant', content: formatResult(res.result) }]);
78
+ } catch {
79
+ toast.error('Erreur exécution');
80
+ setMessages(prev => [...prev, { role: 'assistant', content: "Erreur lors de l'exécution." }]);
81
+ } finally {
82
+ setLoading(false);
83
+ }
84
+ }
85
+
86
+ function formatResult(result: unknown): string {
87
+ if (!result) return 'Action effectuée.';
88
+ if (typeof result === 'string') return result;
89
+ if (Array.isArray(result)) {
90
+ if (result.length === 0) return 'Aucun résultat trouvé.';
91
+ return result.map((r: any) => {
92
+ if (r.name) return `• **${r.name}** — ${Object.entries(r).filter(([k]) => k !== 'name' && k !== 'id').map(([k, v]) => `${k}: ${v}`).join(', ')}`;
93
+ return JSON.stringify(r);
94
+ }).join('\n');
95
+ }
96
+ if (typeof result === 'object' && result !== null) {
97
+ return Object.entries(result).map(([k, v]) => `**${k}**: ${v}`).join('\n');
98
+ }
99
+ return String(result);
100
+ }
101
+
102
+ function renderContent(content: string) {
103
+ return content.split('\n').map((line, i) => {
104
+ const boldLine = line.replace(/\*\*(.+?)\*\*/g, (_, m) => `<strong>${m}</strong>`);
105
+ return <p key={i} className="text-sm" dangerouslySetInnerHTML={{ __html: boldLine }} />;
106
+ });
107
+ }
108
+
109
+ return (
110
+ <div className="flex flex-col h-[calc(100vh-8rem)]">
111
+ <div className="mb-4">
112
+ <h1 className="text-xl font-bold text-white">AI Insights</h1>
113
+ <p className="text-sm text-slate-400 mt-0.5">Commandes en langage naturel pour gérer la plateforme</p>
114
+ </div>
115
+
116
+ {/* Suggestions */}
117
+ <div className="flex flex-wrap gap-2 mb-4">
118
+ {SUGGESTIONS.map(s => (
119
+ <button
120
+ key={s}
121
+ onClick={() => send(s)}
122
+ className="text-xs px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-full transition-colors border border-slate-700"
123
+ >
124
+ {s}
125
+ </button>
126
+ ))}
127
+ </div>
128
+
129
+ {/* Chat messages */}
130
+ <div className="flex-1 overflow-y-auto space-y-4 bg-slate-900 rounded-xl border border-slate-800 p-4 mb-4">
131
+ {messages.map((msg, i) => (
132
+ <div key={i} className={`flex gap-3 ${msg.role === 'user' ? 'flex-row-reverse' : ''}`}>
133
+ {msg.role === 'assistant' && (
134
+ <div className="w-7 h-7 rounded-full bg-violet-600 flex items-center justify-center shrink-0">
135
+ <Bot className="w-4 h-4 text-white" />
136
+ </div>
137
+ )}
138
+ <div className={`max-w-[80%] space-y-1 ${msg.role === 'user' ? 'items-end flex flex-col' : ''}`}>
139
+ <div className={`rounded-xl px-4 py-2.5 ${msg.role === 'user' ? 'bg-violet-600 text-white' : 'bg-slate-800 text-slate-200'}`}>
140
+ {renderContent(msg.content)}
141
+ </div>
142
+ {msg.pending && (
143
+ <div className="flex gap-2 mt-1">
144
+ <button
145
+ onClick={() => confirm(msg, true)}
146
+ className="flex items-center gap-1.5 px-3 py-1.5 bg-emerald-600 hover:bg-emerald-500 text-white text-xs font-medium rounded-lg transition-colors"
147
+ >
148
+ <Check className="w-3 h-3" />
149
+ Confirmer
150
+ </button>
151
+ <button
152
+ onClick={() => confirm(msg, false)}
153
+ className="flex items-center gap-1.5 px-3 py-1.5 bg-slate-700 hover:bg-slate-600 text-slate-300 text-xs font-medium rounded-lg transition-colors"
154
+ >
155
+ <X className="w-3 h-3" />
156
+ Annuler
157
+ </button>
158
+ </div>
159
+ )}
160
+ </div>
161
+ </div>
162
+ ))}
163
+ {loading && (
164
+ <div className="flex gap-3">
165
+ <div className="w-7 h-7 rounded-full bg-violet-600 flex items-center justify-center shrink-0">
166
+ <Bot className="w-4 h-4 text-white" />
167
+ </div>
168
+ <div className="bg-slate-800 rounded-xl px-4 py-2.5">
169
+ <Loader2 className="w-4 h-4 text-slate-400 animate-spin" />
170
+ </div>
171
+ </div>
172
+ )}
173
+ <div ref={endRef} />
174
+ </div>
175
+
176
+ {/* Input */}
177
+ <form onSubmit={(e) => { e.preventDefault(); send(); }} className="flex gap-2">
178
+ <input
179
+ value={input}
180
+ onChange={e => setInput(e.target.value)}
181
+ placeholder="Tapez une commande... ex: 'Ajoute 500 crédits à l'org XAMLÉ'"
182
+ disabled={loading}
183
+ className="flex-1 bg-slate-900 border border-slate-700 rounded-xl px-4 py-3 text-sm text-white placeholder-slate-500 focus:outline-none focus:border-violet-500 disabled:opacity-50"
184
+ />
185
+ <button
186
+ type="submit"
187
+ disabled={loading || !input.trim()}
188
+ className="px-4 py-3 bg-violet-600 hover:bg-violet-500 disabled:opacity-50 text-white rounded-xl transition-colors"
189
+ >
190
+ {loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Send className="w-4 h-4" />}
191
+ </button>
192
+ </form>
193
+ </div>
194
+ );
195
+ }
apps/admin/src/pages/super-admin/BillingManager.tsx ADDED
@@ -0,0 +1,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect } from 'react';
2
+ import { useAuth } from '@/lib/auth';
3
+ import { api } from '@/lib/api';
4
+ import { useToast } from '@/hooks/useToast';
5
+ import { CreditCard, Plus, X } from 'lucide-react';
6
+
7
+ export default function BillingManager() {
8
+ const { token } = useAuth();
9
+ const toast = useToast();
10
+ const [transactions, setTransactions] = useState<any[]>([]);
11
+ const [total, setTotal] = useState(0);
12
+ const [page, setPage] = useState(1);
13
+ const [loading, setLoading] = useState(true);
14
+ const [showAddCredits, setShowAddCredits] = useState(false);
15
+ const [creditOrgId, setCreditOrgId] = useState('');
16
+ const [creditAmount, setCreditAmount] = useState('');
17
+ const [creditDesc, setCreditDesc] = useState('');
18
+ const [saving, setSaving] = useState(false);
19
+
20
+ const LIMIT = 20;
21
+
22
+ async function load() {
23
+ if (!token) return;
24
+ setLoading(true);
25
+ try {
26
+ const data = await api.get(`/v1/super-admin/billing/transactions?page=${page}&limit=${LIMIT}`, token);
27
+ setTransactions(data.data ?? []);
28
+ setTotal(data.total ?? 0);
29
+ } catch { toast.error('Erreur chargement transactions'); }
30
+ finally { setLoading(false); }
31
+ }
32
+
33
+ useEffect(() => { load(); }, [page, token]);
34
+
35
+ async function handleAddCredits() {
36
+ if (!creditOrgId || !creditAmount) return;
37
+ setSaving(true);
38
+ try {
39
+ const result = await api.post('/v1/super-admin/billing/credits', {
40
+ orgId: creditOrgId,
41
+ amount: parseInt(creditAmount),
42
+ description: creditDesc || undefined,
43
+ }, token);
44
+ toast.success(`${creditAmount} crédits ajoutés. Nouveau solde: ${result.newBalance}`);
45
+ setShowAddCredits(false);
46
+ setCreditOrgId(''); setCreditAmount(''); setCreditDesc('');
47
+ load();
48
+ } catch { toast.error('Erreur ajout de crédits'); }
49
+ finally { setSaving(false); }
50
+ }
51
+
52
+ const TYPE_COLORS: Record<string, string> = {
53
+ TOP_UP_MANUAL: 'text-emerald-400',
54
+ TOP_UP_PAYMENT: 'text-emerald-400',
55
+ DEBIT_AI: 'text-red-400',
56
+ };
57
+
58
+ return (
59
+ <div className="space-y-4">
60
+ <div className="flex items-center justify-between">
61
+ <div>
62
+ <h1 className="text-xl font-bold text-white">Billing</h1>
63
+ <p className="text-sm text-slate-400 mt-0.5">{total} transactions</p>
64
+ </div>
65
+ <button
66
+ onClick={() => setShowAddCredits(true)}
67
+ className="flex items-center gap-2 bg-violet-600 hover:bg-violet-500 text-white text-sm font-medium px-4 py-2 rounded-lg transition-colors"
68
+ >
69
+ <Plus className="w-4 h-4" />
70
+ Ajouter crédits
71
+ </button>
72
+ </div>
73
+
74
+ <div className="bg-slate-900 rounded-xl border border-slate-800 overflow-hidden">
75
+ {loading ? (
76
+ <div className="p-8 text-center text-slate-500 animate-pulse">Chargement...</div>
77
+ ) : transactions.length === 0 ? (
78
+ <div className="p-12 flex flex-col items-center gap-3 text-slate-500">
79
+ <CreditCard className="w-8 h-8" />
80
+ <p>Aucune transaction</p>
81
+ </div>
82
+ ) : (
83
+ <div className="overflow-x-auto">
84
+ <table className="w-full text-sm">
85
+ <thead>
86
+ <tr className="border-b border-slate-800">
87
+ <th className="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">Date</th>
88
+ <th className="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">Organisation</th>
89
+ <th className="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">Type</th>
90
+ <th className="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">Description</th>
91
+ <th className="text-right px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">Montant</th>
92
+ <th className="text-right px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">Solde après</th>
93
+ </tr>
94
+ </thead>
95
+ <tbody className="divide-y divide-slate-800">
96
+ {transactions.map(t => (
97
+ <tr key={t.id} className="hover:bg-slate-800/50 transition-colors">
98
+ <td className="px-4 py-3 text-xs text-slate-400">{new Date(t.createdAt).toLocaleDateString('fr-FR')}</td>
99
+ <td className="px-4 py-3 text-slate-300">{t.organization?.name || '—'}</td>
100
+ <td className="px-4 py-3">
101
+ <span className={`text-xs font-medium ${TYPE_COLORS[t.type] ?? 'text-slate-300'}`}>{t.type}</span>
102
+ </td>
103
+ <td className="px-4 py-3 text-xs text-slate-400 max-w-xs truncate">{t.description || '—'}</td>
104
+ <td className={`px-4 py-3 text-right font-medium text-sm ${t.amount > 0 ? 'text-emerald-400' : 'text-red-400'}`}>
105
+ {t.amount > 0 ? '+' : ''}{t.amount.toLocaleString()}
106
+ </td>
107
+ <td className="px-4 py-3 text-right text-slate-300">{t.balanceAfter?.toLocaleString()}</td>
108
+ </tr>
109
+ ))}
110
+ </tbody>
111
+ </table>
112
+ </div>
113
+ )}
114
+ </div>
115
+
116
+ {total > LIMIT && (
117
+ <div className="flex items-center justify-between text-sm text-slate-400">
118
+ <span>{((page - 1) * LIMIT) + 1}–{Math.min(page * LIMIT, total)} sur {total}</span>
119
+ <div className="flex gap-2">
120
+ <button onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page === 1} className="px-3 py-1.5 bg-slate-900 border border-slate-700 rounded-lg disabled:opacity-40 hover:bg-slate-800 transition-colors">Précédent</button>
121
+ <button onClick={() => setPage(p => p + 1)} disabled={page * LIMIT >= total} className="px-3 py-1.5 bg-slate-900 border border-slate-700 rounded-lg disabled:opacity-40 hover:bg-slate-800 transition-colors">Suivant</button>
122
+ </div>
123
+ </div>
124
+ )}
125
+
126
+ {showAddCredits && (
127
+ <div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center">
128
+ <div className="bg-slate-900 border border-slate-800 rounded-xl p-6 w-96 space-y-4">
129
+ <div className="flex items-center justify-between">
130
+ <h2 className="text-base font-bold text-white">Ajouter des crédits</h2>
131
+ <button onClick={() => setShowAddCredits(false)}><X className="w-4 h-4 text-slate-400" /></button>
132
+ </div>
133
+ <div className="space-y-3">
134
+ <div>
135
+ <label className="text-xs font-medium text-slate-400 uppercase tracking-wider">ID de l'organisation</label>
136
+ <input value={creditOrgId} onChange={e => setCreditOrgId(e.target.value)} placeholder="org-uuid..." className="mt-1.5 w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-sm text-white placeholder-slate-500 focus:outline-none focus:border-violet-500" />
137
+ </div>
138
+ <div>
139
+ <label className="text-xs font-medium text-slate-400 uppercase tracking-wider">Montant (crédits)</label>
140
+ <input type="number" value={creditAmount} onChange={e => setCreditAmount(e.target.value)} placeholder="100" className="mt-1.5 w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-sm text-white placeholder-slate-500 focus:outline-none focus:border-violet-500" />
141
+ </div>
142
+ <div>
143
+ <label className="text-xs font-medium text-slate-400 uppercase tracking-wider">Description (optionnel)</label>
144
+ <input value={creditDesc} onChange={e => setCreditDesc(e.target.value)} placeholder="Rechargement manuel..." className="mt-1.5 w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-sm text-white placeholder-slate-500 focus:outline-none focus:border-violet-500" />
145
+ </div>
146
+ </div>
147
+ <div className="flex gap-2">
148
+ <button onClick={() => setShowAddCredits(false)} className="flex-1 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 text-sm rounded-lg transition-colors">Annuler</button>
149
+ <button onClick={handleAddCredits} disabled={saving || !creditOrgId || !creditAmount} className="flex-1 py-2 bg-violet-600 hover:bg-violet-500 disabled:opacity-50 text-white text-sm font-medium rounded-lg transition-colors">
150
+ {saving ? 'Ajout...' : 'Ajouter'}
151
+ </button>
152
+ </div>
153
+ </div>
154
+ </div>
155
+ )}
156
+ </div>
157
+ );
158
+ }
apps/admin/src/pages/super-admin/MonitoringAlerts.tsx ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect } from 'react';
2
+ import { useAuth } from '@/lib/auth';
3
+ import { api } from '@/lib/api';
4
+ import { useToast } from '@/hooks/useToast';
5
+ import { RefreshCw, AlertTriangle, CheckCircle, XCircle } from 'lucide-react';
6
+
7
+ export default function MonitoringAlerts() {
8
+ const { token } = useAuth();
9
+ const toast = useToast();
10
+ const [health, setHealth] = useState<any>(null);
11
+ const [loading, setLoading] = useState(true);
12
+
13
+ async function load() {
14
+ if (!token) return;
15
+ setLoading(true);
16
+ try {
17
+ const data = await api.get('/v1/super-admin/monitoring/health', token);
18
+ setHealth(data);
19
+ } catch { toast.error('Erreur chargement monitoring'); }
20
+ finally { setLoading(false); }
21
+ }
22
+
23
+ useEffect(() => { load(); }, [token]);
24
+
25
+ return (
26
+ <div className="space-y-6">
27
+ <div className="flex items-center justify-between">
28
+ <div>
29
+ <h1 className="text-xl font-bold text-white">Monitoring & Alertes</h1>
30
+ <p className="text-sm text-slate-400 mt-0.5">État en temps réel du système</p>
31
+ </div>
32
+ <button onClick={load} className="flex items-center gap-2 px-3 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 text-sm rounded-lg transition-colors">
33
+ <RefreshCw className="w-3.5 h-3.5" />
34
+ Actualiser
35
+ </button>
36
+ </div>
37
+
38
+ {loading ? (
39
+ <div className="animate-pulse space-y-4">
40
+ {[1, 2, 3].map(i => <div key={i} className="h-24 bg-slate-900 rounded-xl" />)}
41
+ </div>
42
+ ) : (
43
+ <>
44
+ {/* System health */}
45
+ <div className="bg-slate-900 rounded-xl p-5 border border-slate-800">
46
+ <h2 className="text-sm font-semibold text-white mb-4">Santé système</h2>
47
+ <div className="grid grid-cols-3 gap-4">
48
+ {[
49
+ { label: 'Base de données', ok: health?.db?.ok },
50
+ { label: 'Redis / Cache', ok: health?.redis?.ok },
51
+ { label: 'Queue jobs', ok: (health?.queue?.failed ?? 0) === 0, detail: health?.queue?.failed > 0 ? `${health.queue.failed} échoués` : `${health?.queue?.waiting ?? 0} en attente` },
52
+ ].map(({ label, ok, detail }) => (
53
+ <div key={label} className={`p-4 rounded-lg border ${ok ? 'bg-emerald-950/30 border-emerald-900/50' : 'bg-red-950/30 border-red-900/50'}`}>
54
+ <div className="flex items-center gap-2 mb-1">
55
+ {ok ? <CheckCircle className="w-4 h-4 text-emerald-400" /> : <XCircle className="w-4 h-4 text-red-400" />}
56
+ <span className="text-sm font-medium text-white">{label}</span>
57
+ </div>
58
+ {detail && <div className="text-xs text-slate-400 ml-6">{detail}</div>}
59
+ </div>
60
+ ))}
61
+ </div>
62
+ </div>
63
+
64
+ {/* Token expiry alerts */}
65
+ <div className="bg-slate-900 rounded-xl p-5 border border-slate-800">
66
+ <h2 className="text-sm font-semibold text-white mb-3 flex items-center gap-2">
67
+ <AlertTriangle className="w-4 h-4 text-amber-400" />
68
+ Tokens WhatsApp expirants
69
+ </h2>
70
+ {!health?.tokenExpiries?.length ? (
71
+ <p className="text-sm text-slate-500">Aucun token en risque d'expiration</p>
72
+ ) : (
73
+ <div className="overflow-x-auto">
74
+ <table className="w-full text-sm">
75
+ <thead>
76
+ <tr className="border-b border-slate-800">
77
+ <th className="text-left pb-2 text-xs font-medium text-slate-400">Organisation</th>
78
+ <th className="text-left pb-2 text-xs font-medium text-slate-400">Émis il y a</th>
79
+ </tr>
80
+ </thead>
81
+ <tbody className="divide-y divide-slate-800/50">
82
+ {health.tokenExpiries.map((t: any) => (
83
+ <tr key={t.orgId}>
84
+ <td className="py-2 text-white">{t.orgName}</td>
85
+ <td className={`py-2 text-sm font-medium ${(t.daysOld ?? 0) > 55 ? 'text-red-400' : 'text-amber-400'}`}>
86
+ {t.daysOld ?? '?'} jours
87
+ </td>
88
+ </tr>
89
+ ))}
90
+ </tbody>
91
+ </table>
92
+ </div>
93
+ )}
94
+ </div>
95
+
96
+ {/* Low balance alerts */}
97
+ <div className="bg-slate-900 rounded-xl p-5 border border-slate-800">
98
+ <h2 className="text-sm font-semibold text-white mb-3 flex items-center gap-2">
99
+ <AlertTriangle className="w-4 h-4 text-red-400" />
100
+ Soldes faibles (&lt; 100 crédits)
101
+ </h2>
102
+ {!health?.lowBalanceOrgs?.length ? (
103
+ <p className="text-sm text-slate-500">Aucune organisation avec solde faible</p>
104
+ ) : (
105
+ <div className="space-y-2">
106
+ {health.lowBalanceOrgs.map((o: any) => (
107
+ <div key={o.id} className="flex items-center justify-between bg-slate-800 rounded-lg px-3 py-2">
108
+ <span className="text-sm text-white">{o.name}</span>
109
+ <span className="text-sm font-medium text-red-400">{o.walletBalance} crédits</span>
110
+ </div>
111
+ ))}
112
+ </div>
113
+ )}
114
+ </div>
115
+ </>
116
+ )}
117
+ </div>
118
+ );
119
+ }
apps/admin/src/pages/super-admin/OrganizationsManager.tsx ADDED
@@ -0,0 +1,278 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect } from 'react';
2
+ import { useAuth } from '@/lib/auth';
3
+ import { api } from '@/lib/api';
4
+ import { useToast } from '@/hooks/useToast';
5
+ import { Search, Plus, Pencil, Ban, Check, X } from 'lucide-react';
6
+
7
+ const PLAN_COLORS: Record<string, string> = {
8
+ STARTER: 'bg-slate-700 text-slate-300',
9
+ GROWTH: 'bg-blue-900/60 text-blue-300',
10
+ SCALE: 'bg-violet-900/60 text-violet-300',
11
+ ENTERPRISE: 'bg-amber-900/60 text-amber-300',
12
+ };
13
+
14
+ function StatusBadge({ isHardStopped, status }: { isHardStopped: boolean; status: string }) {
15
+ if (isHardStopped) return <span className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full bg-red-900/60 text-red-300">Suspendu</span>;
16
+ if (status === 'TRIAL') return <span className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full bg-amber-900/60 text-amber-300">Trial</span>;
17
+ return <span className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full bg-emerald-900/60 text-emerald-300">Actif</span>;
18
+ }
19
+
20
+ export default function OrganizationsManager() {
21
+ const { token } = useAuth();
22
+ const toast = useToast();
23
+ const [orgs, setOrgs] = useState<any[]>([]);
24
+ const [total, setTotal] = useState(0);
25
+ const [page, setPage] = useState(1);
26
+ const [search, setSearch] = useState('');
27
+ const [loading, setLoading] = useState(true);
28
+ const [editOrg, setEditOrg] = useState<any>(null);
29
+ const [showCreate, setShowCreate] = useState(false);
30
+ const [newOrgName, setNewOrgName] = useState('');
31
+ const [saving, setSaving] = useState(false);
32
+
33
+ const LIMIT = 20;
34
+
35
+ async function load() {
36
+ if (!token) return;
37
+ setLoading(true);
38
+ try {
39
+ const params = new URLSearchParams({ page: String(page), limit: String(LIMIT) });
40
+ if (search) params.set('search', search);
41
+ const data = await api.get(`/v1/super-admin/organizations?${params}`, token);
42
+ setOrgs(data.data ?? []);
43
+ setTotal(data.total ?? 0);
44
+ } catch { toast.error('Erreur chargement organisations'); }
45
+ finally { setLoading(false); }
46
+ }
47
+
48
+ useEffect(() => { load(); }, [page, token]);
49
+
50
+ async function handleSearch(e: React.FormEvent) {
51
+ e.preventDefault();
52
+ setPage(1);
53
+ load();
54
+ }
55
+
56
+ async function handleSuspend(org: any) {
57
+ try {
58
+ await api.post(`/v1/super-admin/organizations/${org.id}/suspend`, { suspend: !org.isHardStopped }, token);
59
+ toast.success(org.isHardStopped ? 'Organisation réactivée' : 'Organisation suspendue');
60
+ load();
61
+ } catch { toast.error('Erreur modification statut'); }
62
+ }
63
+
64
+ async function handleSaveEdit() {
65
+ if (!editOrg) return;
66
+ setSaving(true);
67
+ try {
68
+ await api.patch(`/v1/super-admin/organizations/${editOrg.id}`, {
69
+ subscriptionPlan: editOrg.subscriptionPlan,
70
+ aiCreditsLimit: editOrg.aiCreditsLimit,
71
+ isCrmActive: editOrg.isCrmActive,
72
+ isEdTechActive: editOrg.isEdTechActive,
73
+ }, token);
74
+ toast.success('Organisation mise à jour');
75
+ setEditOrg(null);
76
+ load();
77
+ } catch { toast.error('Erreur mise à jour'); }
78
+ finally { setSaving(false); }
79
+ }
80
+
81
+ async function handleCreate() {
82
+ if (!newOrgName.trim()) return;
83
+ setSaving(true);
84
+ try {
85
+ await api.post('/v1/super-admin/organizations', { name: newOrgName }, token);
86
+ toast.success('Organisation créée');
87
+ setShowCreate(false);
88
+ setNewOrgName('');
89
+ load();
90
+ } catch { toast.error('Erreur création'); }
91
+ finally { setSaving(false); }
92
+ }
93
+
94
+ return (
95
+ <div className="space-y-4">
96
+ <div className="flex items-center justify-between">
97
+ <div>
98
+ <h1 className="text-xl font-bold text-white">Organisations</h1>
99
+ <p className="text-sm text-slate-400 mt-0.5">{total} organisations au total</p>
100
+ </div>
101
+ <button
102
+ onClick={() => setShowCreate(true)}
103
+ className="flex items-center gap-2 bg-violet-600 hover:bg-violet-500 text-white text-sm font-medium px-4 py-2 rounded-lg transition-colors"
104
+ >
105
+ <Plus className="w-4 h-4" />
106
+ Nouvelle organisation
107
+ </button>
108
+ </div>
109
+
110
+ {/* Search */}
111
+ <form onSubmit={handleSearch} className="flex gap-2">
112
+ <div className="relative flex-1">
113
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
114
+ <input
115
+ value={search}
116
+ onChange={e => setSearch(e.target.value)}
117
+ placeholder="Rechercher une organisation..."
118
+ className="w-full pl-9 pr-4 py-2 bg-slate-900 border border-slate-700 rounded-lg text-sm text-white placeholder-slate-500 focus:outline-none focus:border-violet-500"
119
+ />
120
+ </div>
121
+ <button type="submit" className="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-white text-sm rounded-lg transition-colors">Rechercher</button>
122
+ </form>
123
+
124
+ {/* Table */}
125
+ <div className="bg-slate-900 rounded-xl border border-slate-800 overflow-hidden">
126
+ {loading ? (
127
+ <div className="p-8 text-center text-slate-500 animate-pulse">Chargement...</div>
128
+ ) : orgs.length === 0 ? (
129
+ <div className="p-8 text-center text-slate-500">Aucune organisation trouvée</div>
130
+ ) : (
131
+ <div className="overflow-x-auto">
132
+ <table className="w-full text-sm">
133
+ <thead>
134
+ <tr className="border-b border-slate-800">
135
+ <th className="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">Nom</th>
136
+ <th className="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">Plan</th>
137
+ <th className="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">Statut</th>
138
+ <th className="text-right px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">Utilisateurs</th>
139
+ <th className="text-right px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">Crédits</th>
140
+ <th className="px-4 py-3" />
141
+ </tr>
142
+ </thead>
143
+ <tbody className="divide-y divide-slate-800">
144
+ {orgs.map(org => (
145
+ <tr key={org.id} className="hover:bg-slate-800/50 transition-colors">
146
+ <td className="px-4 py-3">
147
+ <div className="font-medium text-white">{org.name}</div>
148
+ <div className="text-xs text-slate-500 font-mono">{org.id.slice(0, 8)}…</div>
149
+ </td>
150
+ <td className="px-4 py-3">
151
+ <span className={`text-xs px-2 py-0.5 rounded-full ${PLAN_COLORS[org.subscriptionPlan] ?? 'bg-slate-700 text-slate-300'}`}>
152
+ {org.subscriptionPlan}
153
+ </span>
154
+ </td>
155
+ <td className="px-4 py-3">
156
+ <StatusBadge isHardStopped={org.isHardStopped} status={org.subscriptionStatus ?? 'ACTIVE'} />
157
+ </td>
158
+ <td className="px-4 py-3 text-right text-slate-300">{org.userCount}</td>
159
+ <td className="px-4 py-3 text-right text-slate-300">{org.walletBalance?.toLocaleString()}</td>
160
+ <td className="px-4 py-3">
161
+ <div className="flex items-center justify-end gap-1">
162
+ <button
163
+ onClick={() => setEditOrg({ ...org })}
164
+ className="p-1.5 text-slate-400 hover:text-white hover:bg-slate-700 rounded transition-colors"
165
+ title="Modifier"
166
+ >
167
+ <Pencil className="w-3.5 h-3.5" />
168
+ </button>
169
+ <button
170
+ onClick={() => handleSuspend(org)}
171
+ className={`p-1.5 rounded transition-colors ${org.isHardStopped ? 'text-emerald-400 hover:text-emerald-300 hover:bg-slate-700' : 'text-red-400 hover:text-red-300 hover:bg-slate-700'}`}
172
+ title={org.isHardStopped ? 'Réactiver' : 'Suspendre'}
173
+ >
174
+ {org.isHardStopped ? <Check className="w-3.5 h-3.5" /> : <Ban className="w-3.5 h-3.5" />}
175
+ </button>
176
+ </div>
177
+ </td>
178
+ </tr>
179
+ ))}
180
+ </tbody>
181
+ </table>
182
+ </div>
183
+ )}
184
+ </div>
185
+
186
+ {/* Pagination */}
187
+ {total > LIMIT && (
188
+ <div className="flex items-center justify-between text-sm text-slate-400">
189
+ <span>{((page - 1) * LIMIT) + 1}–{Math.min(page * LIMIT, total)} sur {total}</span>
190
+ <div className="flex gap-2">
191
+ <button onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page === 1} className="px-3 py-1.5 bg-slate-900 border border-slate-700 rounded-lg disabled:opacity-40 hover:bg-slate-800 transition-colors">Précédent</button>
192
+ <button onClick={() => setPage(p => p + 1)} disabled={page * LIMIT >= total} className="px-3 py-1.5 bg-slate-900 border border-slate-700 rounded-lg disabled:opacity-40 hover:bg-slate-800 transition-colors">Suivant</button>
193
+ </div>
194
+ </div>
195
+ )}
196
+
197
+ {/* Edit drawer */}
198
+ {editOrg && (
199
+ <div className="fixed inset-0 bg-black/60 z-50 flex justify-end">
200
+ <div className="w-96 bg-slate-900 border-l border-slate-800 h-full overflow-y-auto p-6 space-y-5">
201
+ <div className="flex items-center justify-between">
202
+ <h2 className="text-base font-bold text-white">{editOrg.name}</h2>
203
+ <button onClick={() => setEditOrg(null)} className="p-1.5 text-slate-400 hover:text-white"><X className="w-4 h-4" /></button>
204
+ </div>
205
+ <div className="space-y-4">
206
+ <div>
207
+ <label className="text-xs font-medium text-slate-400 uppercase tracking-wider">Plan</label>
208
+ <select
209
+ value={editOrg.subscriptionPlan}
210
+ onChange={e => setEditOrg({ ...editOrg, subscriptionPlan: e.target.value })}
211
+ className="mt-1.5 w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-violet-500"
212
+ >
213
+ {['STARTER', 'GROWTH', 'SCALE', 'ENTERPRISE'].map(p => <option key={p} value={p}>{p}</option>)}
214
+ </select>
215
+ </div>
216
+ <div>
217
+ <label className="text-xs font-medium text-slate-400 uppercase tracking-wider">Limite crédits AI</label>
218
+ <input
219
+ type="number"
220
+ value={editOrg.aiCreditsLimit}
221
+ onChange={e => setEditOrg({ ...editOrg, aiCreditsLimit: parseInt(e.target.value) })}
222
+ className="mt-1.5 w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-violet-500"
223
+ />
224
+ </div>
225
+ <div className="flex items-center justify-between">
226
+ <span className="text-sm text-slate-300">CRM actif</span>
227
+ <button
228
+ onClick={() => setEditOrg({ ...editOrg, isCrmActive: !editOrg.isCrmActive })}
229
+ className={`w-10 h-5 rounded-full transition-colors relative ${editOrg.isCrmActive ? 'bg-violet-600' : 'bg-slate-700'}`}
230
+ >
231
+ <span className={`absolute top-0.5 w-4 h-4 bg-white rounded-full transition-all ${editOrg.isCrmActive ? 'left-5' : 'left-0.5'}`} />
232
+ </button>
233
+ </div>
234
+ <div className="flex items-center justify-between">
235
+ <span className="text-sm text-slate-300">EdTech actif</span>
236
+ <button
237
+ onClick={() => setEditOrg({ ...editOrg, isEdTechActive: !editOrg.isEdTechActive })}
238
+ className={`w-10 h-5 rounded-full transition-colors relative ${editOrg.isEdTechActive ? 'bg-violet-600' : 'bg-slate-700'}`}
239
+ >
240
+ <span className={`absolute top-0.5 w-4 h-4 bg-white rounded-full transition-all ${editOrg.isEdTechActive ? 'left-5' : 'left-0.5'}`} />
241
+ </button>
242
+ </div>
243
+ </div>
244
+ <button
245
+ onClick={handleSaveEdit}
246
+ disabled={saving}
247
+ className="w-full bg-violet-600 hover:bg-violet-500 disabled:opacity-50 text-white font-medium py-2 rounded-lg text-sm transition-colors"
248
+ >
249
+ {saving ? 'Sauvegarde...' : 'Sauvegarder'}
250
+ </button>
251
+ </div>
252
+ </div>
253
+ )}
254
+
255
+ {/* Create modal */}
256
+ {showCreate && (
257
+ <div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center">
258
+ <div className="bg-slate-900 border border-slate-800 rounded-xl p-6 w-96 space-y-4">
259
+ <h2 className="text-base font-bold text-white">Nouvelle organisation</h2>
260
+ <input
261
+ autoFocus
262
+ value={newOrgName}
263
+ onChange={e => setNewOrgName(e.target.value)}
264
+ placeholder="Nom de l'organisation"
265
+ className="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-sm text-white placeholder-slate-500 focus:outline-none focus:border-violet-500"
266
+ />
267
+ <div className="flex gap-2">
268
+ <button onClick={() => setShowCreate(false)} className="flex-1 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 text-sm rounded-lg transition-colors">Annuler</button>
269
+ <button onClick={handleCreate} disabled={saving || !newOrgName.trim()} className="flex-1 py-2 bg-violet-600 hover:bg-violet-500 disabled:opacity-50 text-white text-sm font-medium rounded-lg transition-colors">
270
+ {saving ? 'Création...' : 'Créer'}
271
+ </button>
272
+ </div>
273
+ </div>
274
+ </div>
275
+ )}
276
+ </div>
277
+ );
278
+ }
apps/admin/src/pages/super-admin/PlatformDashboard.tsx ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect } from 'react';
2
+ import { useAuth } from '@/lib/auth';
3
+ import { api } from '@/lib/api';
4
+ import { Building2, Users, MessageSquare, Layers, AlertTriangle, TrendingUp } from 'lucide-react';
5
+
6
+ interface Stats {
7
+ orgsCount: number;
8
+ activeOrgs: number;
9
+ usersCount: number;
10
+ messagesLast24h: number;
11
+ queueDepth: number;
12
+ queueFailed: number;
13
+ revenueThisMonth: number;
14
+ tokenExpiries?: any[];
15
+ lowBalanceOrgs?: any[];
16
+ }
17
+
18
+ function KPICard({ title, value, icon: Icon, color, sub }: { title: string; value: any; icon: any; color: string; sub?: string }) {
19
+ return (
20
+ <div className="bg-slate-900 rounded-xl p-5 border border-slate-800">
21
+ <div className="flex items-start justify-between">
22
+ <div>
23
+ <p className="text-xs font-medium text-slate-400 uppercase tracking-wider">{title}</p>
24
+ <p className="text-2xl font-bold text-white mt-1">{value?.toLocaleString() ?? '—'}</p>
25
+ {sub && <p className="text-xs text-slate-500 mt-0.5">{sub}</p>}
26
+ </div>
27
+ <div className={`p-2.5 rounded-lg ${color}`}>
28
+ <Icon className="w-4 h-4 text-white" />
29
+ </div>
30
+ </div>
31
+ </div>
32
+ );
33
+ }
34
+
35
+ export default function PlatformDashboard() {
36
+ const { token } = useAuth();
37
+ const [stats, setStats] = useState<Stats | null>(null);
38
+ const [health, setHealth] = useState<any>(null);
39
+ const [loading, setLoading] = useState(true);
40
+
41
+ useEffect(() => {
42
+ if (!token) return;
43
+ Promise.all([
44
+ api.get('/v1/super-admin/platform/stats', token),
45
+ api.get('/v1/super-admin/monitoring/health', token),
46
+ ])
47
+ .then(([s, h]) => { setStats(s); setHealth(h); })
48
+ .finally(() => setLoading(false));
49
+ }, [token]);
50
+
51
+ if (loading) return (
52
+ <div className="space-y-4 animate-pulse">
53
+ <div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
54
+ {[...Array(6)].map((_, i) => <div key={i} className="h-28 bg-slate-900 rounded-xl" />)}
55
+ </div>
56
+ </div>
57
+ );
58
+
59
+ const alerts = [
60
+ ...(health?.tokenExpiries?.map((t: any) => ({ type: 'token', msg: `Token expirant — ${t.orgName} (${t.daysOld}j)` })) ?? []),
61
+ ...(health?.lowBalanceOrgs?.map((o: any) => ({ type: 'balance', msg: `Solde faible — ${o.orgName}: ${o.walletBalance} crédits` })) ?? []),
62
+ ...(health?.queue?.failed > 0 ? [{ type: 'queue', msg: `${health.queue.failed} jobs échoués en queue` }] : []),
63
+ ];
64
+
65
+ return (
66
+ <div className="space-y-6">
67
+ <div>
68
+ <h1 className="text-xl font-bold text-white">Platform Dashboard</h1>
69
+ <p className="text-sm text-slate-400 mt-0.5">Vue globale de toutes les organisations</p>
70
+ </div>
71
+
72
+ <div className="grid grid-cols-2 lg:grid-cols-3 gap-4">
73
+ <KPICard title="Organisations" value={stats?.orgsCount} icon={Building2} color="bg-violet-600" sub={`${stats?.activeOrgs ?? 0} actives`} />
74
+ <KPICard title="Utilisateurs" value={stats?.usersCount} icon={Users} color="bg-blue-600" />
75
+ <KPICard title="Messages / 24h" value={stats?.messagesLast24h} icon={MessageSquare} color="bg-emerald-600" />
76
+ <KPICard title="Queue depth" value={stats?.queueDepth} icon={Layers} color={(stats?.queueDepth ?? 0) > 50 ? 'bg-amber-600' : 'bg-slate-700'} sub={stats?.queueFailed ? `${stats.queueFailed} échoués` : undefined} />
77
+ <KPICard title="Revenus / mois" value={`${(stats?.revenueThisMonth ?? 0).toLocaleString()} cr.`} icon={TrendingUp} color="bg-pink-600" />
78
+ <KPICard title="Alertes" value={alerts.length} icon={AlertTriangle} color={alerts.length > 0 ? 'bg-red-600' : 'bg-slate-700'} />
79
+ </div>
80
+
81
+ {/* System health */}
82
+ <div className="bg-slate-900 rounded-xl p-5 border border-slate-800">
83
+ <h2 className="text-sm font-semibold text-white mb-3">Santé système</h2>
84
+ <div className="flex flex-wrap gap-3">
85
+ {[
86
+ { label: 'Base de données', ok: health?.db?.ok },
87
+ { label: 'Redis', ok: health?.redis?.ok },
88
+ { label: 'Queue', ok: (health?.queue?.failed ?? 0) === 0 },
89
+ ].map(({ label, ok }) => (
90
+ <div key={label} className="flex items-center gap-2 bg-slate-800 rounded-lg px-3 py-2">
91
+ <div className={`w-2 h-2 rounded-full ${ok ? 'bg-green-400' : 'bg-red-400'}`} />
92
+ <span className="text-xs text-slate-300">{label}</span>
93
+ <span className={`text-xs font-medium ${ok ? 'text-green-400' : 'text-red-400'}`}>{ok ? 'OK' : 'KO'}</span>
94
+ </div>
95
+ ))}
96
+ </div>
97
+ </div>
98
+
99
+ {/* Alerts */}
100
+ {alerts.length > 0 && (
101
+ <div className="bg-slate-900 rounded-xl p-5 border border-red-900/50">
102
+ <h2 className="text-sm font-semibold text-red-400 mb-3 flex items-center gap-2">
103
+ <AlertTriangle className="w-4 h-4" />
104
+ Alertes actives ({alerts.length})
105
+ </h2>
106
+ <div className="space-y-2">
107
+ {alerts.map((a, i) => (
108
+ <div key={i} className="flex items-center gap-2 text-sm text-slate-300 bg-slate-800 rounded-lg px-3 py-2">
109
+ <div className="w-1.5 h-1.5 rounded-full bg-red-400 shrink-0" />
110
+ {a.msg}
111
+ </div>
112
+ ))}
113
+ </div>
114
+ </div>
115
+ )}
116
+ </div>
117
+ );
118
+ }
apps/admin/src/pages/super-admin/SuperAdminLayout.tsx ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Outlet, NavLink, useNavigate } from 'react-router-dom';
2
+ import { useAuth } from '@/lib/auth';
3
+ import { useTenant } from '@/lib/tenant';
4
+ import {
5
+ LayoutDashboard, Building2, Users, Phone, Activity,
6
+ CreditCard, Bot, LogOut, ChevronLeft
7
+ } from 'lucide-react';
8
+
9
+ const NAV_ITEMS = [
10
+ { to: '/platform/dashboard', icon: LayoutDashboard, label: 'Dashboard' },
11
+ { to: '/platform/organizations', icon: Building2, label: 'Organisations' },
12
+ { to: '/platform/users', icon: Users, label: 'Utilisateurs' },
13
+ { to: '/platform/whatsapp', icon: Phone, label: 'WhatsApp' },
14
+ { to: '/platform/monitoring', icon: Activity, label: 'Monitoring' },
15
+ { to: '/platform/billing', icon: CreditCard, label: 'Billing' },
16
+ { to: '/platform/ai', icon: Bot, label: 'AI Insights' },
17
+ ];
18
+
19
+ export default function SuperAdminLayout() {
20
+ const { user, logout } = useAuth();
21
+ const { setSelectedOrgId } = useTenant();
22
+ const navigate = useNavigate();
23
+
24
+ function handleExit() {
25
+ setSelectedOrgId(null);
26
+ navigate('/');
27
+ }
28
+
29
+ return (
30
+ <div className="flex h-screen bg-slate-950 text-white overflow-hidden">
31
+ {/* Sidebar */}
32
+ <aside className="w-60 shrink-0 bg-slate-900 border-r border-slate-800 flex flex-col">
33
+ <div className="px-4 py-5 border-b border-slate-800">
34
+ <div className="text-xs font-semibold text-violet-400 uppercase tracking-widest mb-0.5">XAMLÉ</div>
35
+ <div className="text-base font-bold text-white">Platform Admin</div>
36
+ </div>
37
+
38
+ <nav className="flex-1 overflow-y-auto py-3 px-2 space-y-0.5">
39
+ {NAV_ITEMS.map(({ to, icon: Icon, label }) => (
40
+ <NavLink
41
+ key={to}
42
+ to={to}
43
+ className={({ isActive }) =>
44
+ `flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
45
+ isActive
46
+ ? 'bg-violet-600 text-white'
47
+ : 'text-slate-400 hover:text-white hover:bg-slate-800'
48
+ }`
49
+ }
50
+ >
51
+ <Icon className="w-4 h-4 shrink-0" />
52
+ {label}
53
+ </NavLink>
54
+ ))}
55
+ </nav>
56
+
57
+ <div className="p-3 border-t border-slate-800 space-y-1">
58
+ <button
59
+ onClick={handleExit}
60
+ className="flex items-center gap-3 w-full px-3 py-2 rounded-lg text-sm text-slate-400 hover:text-white hover:bg-slate-800 transition-colors"
61
+ >
62
+ <ChevronLeft className="w-4 h-4" />
63
+ Quitter l'admin
64
+ </button>
65
+ <button
66
+ onClick={logout}
67
+ className="flex items-center gap-3 w-full px-3 py-2 rounded-lg text-sm text-red-400 hover:text-red-300 hover:bg-slate-800 transition-colors"
68
+ >
69
+ <LogOut className="w-4 h-4" />
70
+ Déconnexion
71
+ </button>
72
+ </div>
73
+ </aside>
74
+
75
+ {/* Main */}
76
+ <div className="flex-1 flex flex-col min-w-0 overflow-hidden">
77
+ {/* Top bar */}
78
+ <header className="h-14 shrink-0 bg-slate-900 border-b border-slate-800 flex items-center justify-between px-6">
79
+ <div className="text-sm text-slate-400">
80
+ Super-admin · <span className="text-white">{user?.email || user?.name || 'Admin'}</span>
81
+ </div>
82
+ <div className="flex items-center gap-2">
83
+ <div className="w-2 h-2 rounded-full bg-green-400 animate-pulse" />
84
+ <span className="text-xs text-slate-400">Système actif</span>
85
+ </div>
86
+ </header>
87
+
88
+ {/* Page content */}
89
+ <main className="flex-1 overflow-y-auto bg-slate-950">
90
+ <div className="p-6 max-w-screen-xl mx-auto">
91
+ <Outlet />
92
+ </div>
93
+ </main>
94
+ </div>
95
+ </div>
96
+ );
97
+ }
apps/admin/src/pages/super-admin/SuperAdminRouter.tsx ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Routes, Route, Navigate } from 'react-router-dom';
2
+ import SuperAdminLayout from './SuperAdminLayout';
3
+ import PlatformDashboard from './PlatformDashboard';
4
+ import OrganizationsManager from './OrganizationsManager';
5
+ import UsersManager from './UsersManager';
6
+ import WhatsAppNumbers from './WhatsAppNumbers';
7
+ import MonitoringAlerts from './MonitoringAlerts';
8
+ import BillingManager from './BillingManager';
9
+ import AIInsights from './AIInsights';
10
+
11
+ export default function SuperAdminRouter() {
12
+ return (
13
+ <Routes>
14
+ <Route path="/*" element={<SuperAdminLayout />}>
15
+ <Route index element={<Navigate to="/platform/dashboard" replace />} />
16
+ <Route path="platform/dashboard" element={<PlatformDashboard />} />
17
+ <Route path="platform/organizations" element={<OrganizationsManager />} />
18
+ <Route path="platform/users" element={<UsersManager />} />
19
+ <Route path="platform/whatsapp" element={<WhatsAppNumbers />} />
20
+ <Route path="platform/monitoring" element={<MonitoringAlerts />} />
21
+ <Route path="platform/billing" element={<BillingManager />} />
22
+ <Route path="platform/ai" element={<AIInsights />} />
23
+ <Route path="*" element={<Navigate to="/platform/dashboard" replace />} />
24
+ </Route>
25
+ </Routes>
26
+ );
27
+ }
apps/admin/src/pages/super-admin/UsersManager.tsx ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect } from 'react';
2
+ import { useAuth } from '@/lib/auth';
3
+ import { api } from '@/lib/api';
4
+ import { useToast } from '@/hooks/useToast';
5
+ import { Search } from 'lucide-react';
6
+
7
+ const ROLE_COLORS: Record<string, string> = {
8
+ STUDENT: 'bg-slate-700 text-slate-300',
9
+ ORG_MEMBER: 'bg-blue-900/60 text-blue-300',
10
+ ORG_ADMIN: 'bg-violet-900/60 text-violet-300',
11
+ ADMIN: 'bg-amber-900/60 text-amber-300',
12
+ SUPER_ADMIN: 'bg-red-900/60 text-red-300',
13
+ };
14
+
15
+ export default function UsersManager() {
16
+ const { token } = useAuth();
17
+ const toast = useToast();
18
+ const [users, setUsers] = useState<any[]>([]);
19
+ const [total, setTotal] = useState(0);
20
+ const [page, setPage] = useState(1);
21
+ const [search, setSearch] = useState('');
22
+ const [loading, setLoading] = useState(true);
23
+
24
+ const LIMIT = 20;
25
+
26
+ async function load() {
27
+ if (!token) return;
28
+ setLoading(true);
29
+ try {
30
+ const params = new URLSearchParams({ page: String(page), limit: String(LIMIT) });
31
+ if (search) params.set('search', search);
32
+ const data = await api.get(`/v1/super-admin/users?${params}`, token);
33
+ setUsers(data.data ?? []);
34
+ setTotal(data.total ?? 0);
35
+ } catch { toast.error('Erreur chargement utilisateurs'); }
36
+ finally { setLoading(false); }
37
+ }
38
+
39
+ useEffect(() => { load(); }, [page, token]);
40
+
41
+ async function handleSearch(e: React.FormEvent) {
42
+ e.preventDefault();
43
+ setPage(1);
44
+ load();
45
+ }
46
+
47
+ async function handleRoleChange(userId: string, role: string) {
48
+ try {
49
+ await api.patch(`/v1/super-admin/users/${userId}/role`, { role }, token);
50
+ toast.success('Rôle mis à jour');
51
+ load();
52
+ } catch { toast.error('Erreur changement de rôle'); }
53
+ }
54
+
55
+ return (
56
+ <div className="space-y-4">
57
+ <div>
58
+ <h1 className="text-xl font-bold text-white">Utilisateurs</h1>
59
+ <p className="text-sm text-slate-400 mt-0.5">{total} utilisateurs au total</p>
60
+ </div>
61
+
62
+ <form onSubmit={handleSearch} className="flex gap-2">
63
+ <div className="relative flex-1">
64
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
65
+ <input
66
+ value={search}
67
+ onChange={e => setSearch(e.target.value)}
68
+ placeholder="Rechercher par nom ou email..."
69
+ className="w-full pl-9 pr-4 py-2 bg-slate-900 border border-slate-700 rounded-lg text-sm text-white placeholder-slate-500 focus:outline-none focus:border-violet-500"
70
+ />
71
+ </div>
72
+ <button type="submit" className="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-white text-sm rounded-lg transition-colors">Rechercher</button>
73
+ </form>
74
+
75
+ <div className="bg-slate-900 rounded-xl border border-slate-800 overflow-hidden">
76
+ {loading ? (
77
+ <div className="p-8 text-center text-slate-500 animate-pulse">Chargement...</div>
78
+ ) : users.length === 0 ? (
79
+ <div className="p-8 text-center text-slate-500">Aucun utilisateur trouvé</div>
80
+ ) : (
81
+ <div className="overflow-x-auto">
82
+ <table className="w-full text-sm">
83
+ <thead>
84
+ <tr className="border-b border-slate-800">
85
+ <th className="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">Utilisateur</th>
86
+ <th className="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">Organisation</th>
87
+ <th className="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">Rôle</th>
88
+ <th className="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">Créé le</th>
89
+ <th className="px-4 py-3" />
90
+ </tr>
91
+ </thead>
92
+ <tbody className="divide-y divide-slate-800">
93
+ {users.map(user => (
94
+ <tr key={user.id} className="hover:bg-slate-800/50 transition-colors">
95
+ <td className="px-4 py-3">
96
+ <div className="font-medium text-white">{user.name || '—'}</div>
97
+ <div className="text-xs text-slate-500">{user.email || user.phone || '—'}</div>
98
+ </td>
99
+ <td className="px-4 py-3 text-slate-300 text-xs">{user.organization?.name || '—'}</td>
100
+ <td className="px-4 py-3">
101
+ <span className={`text-xs px-2 py-0.5 rounded-full ${ROLE_COLORS[user.role] ?? 'bg-slate-700 text-slate-300'}`}>{user.role}</span>
102
+ </td>
103
+ <td className="px-4 py-3 text-xs text-slate-400">{new Date(user.createdAt).toLocaleDateString('fr-FR')}</td>
104
+ <td className="px-4 py-3">
105
+ <select
106
+ defaultValue={user.role}
107
+ onChange={e => handleRoleChange(user.id, e.target.value)}
108
+ className="text-xs bg-slate-800 border border-slate-700 rounded px-2 py-1 text-slate-300 focus:outline-none focus:border-violet-500"
109
+ >
110
+ {['STUDENT', 'ORG_MEMBER', 'ORG_ADMIN', 'ADMIN', 'SUPER_ADMIN'].map(r => <option key={r} value={r}>{r}</option>)}
111
+ </select>
112
+ </td>
113
+ </tr>
114
+ ))}
115
+ </tbody>
116
+ </table>
117
+ </div>
118
+ )}
119
+ </div>
120
+
121
+ {total > LIMIT && (
122
+ <div className="flex items-center justify-between text-sm text-slate-400">
123
+ <span>{((page - 1) * LIMIT) + 1}–{Math.min(page * LIMIT, total)} sur {total}</span>
124
+ <div className="flex gap-2">
125
+ <button onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page === 1} className="px-3 py-1.5 bg-slate-900 border border-slate-700 rounded-lg disabled:opacity-40 hover:bg-slate-800 transition-colors">Précédent</button>
126
+ <button onClick={() => setPage(p => p + 1)} disabled={page * LIMIT >= total} className="px-3 py-1.5 bg-slate-900 border border-slate-700 rounded-lg disabled:opacity-40 hover:bg-slate-800 transition-colors">Suivant</button>
127
+ </div>
128
+ </div>
129
+ )}
130
+ </div>
131
+ );
132
+ }
apps/admin/src/pages/super-admin/WhatsAppNumbers.tsx ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect } from 'react';
2
+ import { useAuth } from '@/lib/auth';
3
+ import { api } from '@/lib/api';
4
+ import { useToast } from '@/hooks/useToast';
5
+ import { Phone, RefreshCw } from 'lucide-react';
6
+
7
+ export default function WhatsAppNumbers() {
8
+ const { token } = useAuth();
9
+ const toast = useToast();
10
+ const [numbers, setNumbers] = useState<any[]>([]);
11
+ const [loading, setLoading] = useState(true);
12
+
13
+ async function load() {
14
+ if (!token) return;
15
+ setLoading(true);
16
+ try {
17
+ const data = await api.get('/v1/super-admin/whatsapp/numbers', token);
18
+ setNumbers(data.data ?? []);
19
+ } catch { toast.error('Erreur chargement numéros'); }
20
+ finally { setLoading(false); }
21
+ }
22
+
23
+ useEffect(() => { load(); }, [token]);
24
+
25
+ return (
26
+ <div className="space-y-4">
27
+ <div className="flex items-center justify-between">
28
+ <div>
29
+ <h1 className="text-xl font-bold text-white">Numéros WhatsApp</h1>
30
+ <p className="text-sm text-slate-400 mt-0.5">{numbers.length} numéros enregistrés</p>
31
+ </div>
32
+ <button onClick={load} className="flex items-center gap-2 px-3 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 text-sm rounded-lg transition-colors">
33
+ <RefreshCw className="w-3.5 h-3.5" />
34
+ Actualiser
35
+ </button>
36
+ </div>
37
+
38
+ <div className="bg-slate-900 rounded-xl border border-slate-800 overflow-hidden">
39
+ {loading ? (
40
+ <div className="p-8 text-center text-slate-500 animate-pulse">Chargement...</div>
41
+ ) : numbers.length === 0 ? (
42
+ <div className="p-12 flex flex-col items-center gap-3 text-slate-500">
43
+ <Phone className="w-8 h-8" />
44
+ <p>Aucun numéro WhatsApp enregistré</p>
45
+ </div>
46
+ ) : (
47
+ <div className="overflow-x-auto">
48
+ <table className="w-full text-sm">
49
+ <thead>
50
+ <tr className="border-b border-slate-800">
51
+ <th className="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">Numéro</th>
52
+ <th className="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">Organisation</th>
53
+ <th className="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">ID</th>
54
+ <th className="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">Ajouté le</th>
55
+ </tr>
56
+ </thead>
57
+ <tbody className="divide-y divide-slate-800">
58
+ {numbers.map(n => (
59
+ <tr key={n.id} className="hover:bg-slate-800/50 transition-colors">
60
+ <td className="px-4 py-3 font-medium text-white">{n.displayPhone}</td>
61
+ <td className="px-4 py-3 text-slate-300">{n.organization?.name || '—'}</td>
62
+ <td className="px-4 py-3 text-xs font-mono text-slate-500">{n.id}</td>
63
+ <td className="px-4 py-3 text-xs text-slate-400">{new Date(n.createdAt).toLocaleDateString('fr-FR')}</td>
64
+ </tr>
65
+ ))}
66
+ </tbody>
67
+ </table>
68
+ </div>
69
+ )}
70
+ </div>
71
+ </div>
72
+ );
73
+ }
apps/api/src/app.ts CHANGED
@@ -17,6 +17,7 @@ import { notificationRoutes } from './routes/notifications';
17
  import { authRoutes } from './routes/auth';
18
  import campaignRoutes from './routes/campaigns';
19
  import { internalRoutes } from './routes/internal';
 
20
  import { setupErrorHandler } from './utils/errors';
21
  import { injectTenantConfig } from './middleware/tenant';
22
  import { validateApiKey } from './middleware/validateApiKey';
@@ -47,7 +48,10 @@ export async function buildApp() {
47
  });
48
 
49
  await server.register(jwt, {
50
- secret: process.env.JWT_SECRET || 'super-secret-dev-key'
 
 
 
51
  });
52
 
53
  await server.register(rateLimit, {
@@ -91,6 +95,7 @@ export async function buildApp() {
91
  scope.register(billingRoutes, { prefix: '/v1/billing' });
92
  scope.register(notificationRoutes, { prefix: '/v1/notifications' });
93
  scope.register(campaignRoutes, { prefix: '/v1/organizations' });
 
94
  });
95
 
96
  server.register(whatsappRoutes, { prefix: '/v1/whatsapp' });
 
17
  import { authRoutes } from './routes/auth';
18
  import campaignRoutes from './routes/campaigns';
19
  import { internalRoutes } from './routes/internal';
20
+ import { superAdminRoutes } from './routes/super-admin';
21
  import { setupErrorHandler } from './utils/errors';
22
  import { injectTenantConfig } from './middleware/tenant';
23
  import { validateApiKey } from './middleware/validateApiKey';
 
48
  });
49
 
50
  await server.register(jwt, {
51
+ secret: (() => {
52
+ if (!process.env.JWT_SECRET) throw new Error('JWT_SECRET env var is required');
53
+ return process.env.JWT_SECRET;
54
+ })()
55
  });
56
 
57
  await server.register(rateLimit, {
 
95
  scope.register(billingRoutes, { prefix: '/v1/billing' });
96
  scope.register(notificationRoutes, { prefix: '/v1/notifications' });
97
  scope.register(campaignRoutes, { prefix: '/v1/organizations' });
98
+ scope.register(superAdminRoutes, { prefix: '/v1/super-admin' });
99
  });
100
 
101
  server.register(whatsappRoutes, { prefix: '/v1/whatsapp' });
apps/api/src/routes/admin.ts CHANGED
@@ -56,6 +56,13 @@ const PaginationSchema = z.object({
56
 
57
  export async function adminRoutes(fastify: FastifyInstance) {
58
 
 
 
 
 
 
 
 
59
  // ── Dashboard Stats ────────────────────────────────────────────────────────
60
  fastify.get('/stats', async (req, reply) => {
61
  const organizationId = req.organizationId;
 
56
 
57
  export async function adminRoutes(fastify: FastifyInstance) {
58
 
59
+ fastify.addHook('preHandler', async (request, reply) => {
60
+ const role = (request as any).user?.role;
61
+ if (!role || !['ORG_ADMIN', 'ADMIN', 'SUPER_ADMIN'].includes(role)) {
62
+ return reply.code(403).send({ error: 'Admin access required' });
63
+ }
64
+ });
65
+
66
  // ── Dashboard Stats ────────────────────────────────────────────────────────
67
  fastify.get('/stats', async (req, reply) => {
68
  const organizationId = req.organizationId;
apps/api/src/routes/analytics.ts CHANGED
@@ -241,12 +241,17 @@ Règles IMPÉRATIVES:
241
  return reply.code(503).send({ error: 'AI service unavailable' });
242
  }
243
 
244
- // Safety: only allow SELECT — reject anything else
245
  const normalized = sql.replace(/\s+/g, ' ').trim().toUpperCase();
246
  if (!normalized.startsWith('SELECT')) {
247
  logger.warn({ sql }, '[TEXT-SQL] Non-SELECT query rejected');
248
  return reply.code(400).send({ error: 'Only SELECT queries are allowed', generatedSql: sql });
249
  }
 
 
 
 
 
250
 
251
  // Inject correct organizationId (replace placeholder used by LLM)
252
  const safeSql = sql
 
241
  return reply.code(503).send({ error: 'AI service unavailable' });
242
  }
243
 
244
+ // Safety: only allow simple SELECT — reject dangerous patterns
245
  const normalized = sql.replace(/\s+/g, ' ').trim().toUpperCase();
246
  if (!normalized.startsWith('SELECT')) {
247
  logger.warn({ sql }, '[TEXT-SQL] Non-SELECT query rejected');
248
  return reply.code(400).send({ error: 'Only SELECT queries are allowed', generatedSql: sql });
249
  }
250
+ const DANGEROUS_PATTERNS = [/\bUNION\b/, /\bINSERT\b/, /\bUPDATE\b/, /\bDELETE\b/, /\bDROP\b/, /\bEXEC\b/, /\bEXECUTE\b/, /--/, /\/\*/, /;\s*SELECT/i];
251
+ if (DANGEROUS_PATTERNS.some(p => p.test(normalized))) {
252
+ logger.warn({ sql }, '[TEXT-SQL] Dangerous pattern detected — query rejected');
253
+ return reply.code(400).send({ error: 'Query contains disallowed patterns' });
254
+ }
255
 
256
  // Inject correct organizationId (replace placeholder used by LLM)
257
  const safeSql = sql
apps/api/src/routes/super-admin.ts ADDED
@@ -0,0 +1,528 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { FastifyInstance } from 'fastify';
2
+ import { prisma } from '../services/prisma';
3
+ import { logger } from '../logger';
4
+ import { z } from 'zod';
5
+ import { redis } from '../lib/redis';
6
+ import { whatsappQueue } from '../services/queue';
7
+
8
+ export async function superAdminRoutes(fastify: FastifyInstance) {
9
+
10
+ fastify.addHook('preHandler', async (request: any, reply) => {
11
+ const role = request.user?.role;
12
+ if (role !== 'SUPER_ADMIN' && role !== 'ADMIN') {
13
+ return reply.code(403).send({ error: 'Super admin access required' });
14
+ }
15
+ });
16
+
17
+ // ── Platform Stats ────────────────────────────────────────────────────────
18
+ fastify.get('/platform/stats', async (_req, reply) => {
19
+ try {
20
+ const since24h = new Date(Date.now() - 24 * 60 * 60 * 1000);
21
+ const since30d = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
22
+
23
+ const [orgsCount, usersCount, messagesLast24h, revenueAgg, queueCounts] = await Promise.all([
24
+ prisma.organization.count(),
25
+ prisma.user.count({ where: { deletedAt: null } }),
26
+ prisma.message.count({ where: { createdAt: { gte: since24h } } }),
27
+ prisma.walletTransaction.aggregate({
28
+ where: { type: 'TOP_UP_MANUAL', createdAt: { gte: since30d } },
29
+ _sum: { amount: true }
30
+ }),
31
+ Promise.all([
32
+ whatsappQueue.getWaitingCount(),
33
+ whatsappQueue.getFailedCount(),
34
+ whatsappQueue.getActiveCount(),
35
+ ]).catch(() => [0, 0, 0]),
36
+ ]);
37
+
38
+ const activeOrgs = await prisma.organization.count({
39
+ where: { isHardStopped: false }
40
+ });
41
+
42
+ return {
43
+ orgsCount,
44
+ activeOrgs,
45
+ usersCount,
46
+ messagesLast24h,
47
+ queueDepth: Array.isArray(queueCounts) ? (queueCounts[0] + queueCounts[2]) : 0,
48
+ queueFailed: Array.isArray(queueCounts) ? queueCounts[1] : 0,
49
+ revenueThisMonth: revenueAgg._sum.amount ?? 0,
50
+ };
51
+ } catch (err) {
52
+ logger.error({ err }, '[SUPER_ADMIN] platform/stats failed');
53
+ return reply.code(500).send({ error: 'Failed to fetch platform stats' });
54
+ }
55
+ });
56
+
57
+ // ── Organizations ─────────────────────────────────────────────────────────
58
+ fastify.get('/organizations', async (req, reply) => {
59
+ const QuerySchema = z.object({
60
+ page: z.coerce.number().int().positive().default(1),
61
+ limit: z.coerce.number().int().positive().max(100).default(20),
62
+ search: z.string().optional(),
63
+ plan: z.string().optional(),
64
+ });
65
+ const parsed = QuerySchema.safeParse(req.query);
66
+ if (!parsed.success) return reply.code(400).send({ error: 'Invalid query params' });
67
+
68
+ const { page, limit, search, plan } = parsed.data;
69
+ const where: any = {};
70
+ if (search) where.name = { contains: search, mode: 'insensitive' };
71
+ if (plan) where.subscriptionPlan = plan;
72
+
73
+ try {
74
+ const [orgs, total] = await Promise.all([
75
+ prisma.organization.findMany({
76
+ where,
77
+ select: {
78
+ id: true, name: true, wabaId: true, walletBalance: true,
79
+ subscriptionPlan: true, subscriptionStatus: true, isHardStopped: true,
80
+ createdAt: true, mode: true, isCrmActive: true, isEdTechActive: true,
81
+ _count: { select: { users: true } }
82
+ },
83
+ orderBy: { createdAt: 'desc' },
84
+ skip: (page - 1) * limit,
85
+ take: limit,
86
+ }),
87
+ prisma.organization.count({ where }),
88
+ ]);
89
+ return { data: orgs.map(o => ({ ...o, userCount: o._count.users })), total, page, limit };
90
+ } catch (err) {
91
+ logger.error({ err }, '[SUPER_ADMIN] organizations list failed');
92
+ return reply.code(500).send({ error: 'Failed to list organizations' });
93
+ }
94
+ });
95
+
96
+ fastify.post('/organizations', async (req, reply) => {
97
+ const Schema = z.object({
98
+ name: z.string().min(1),
99
+ subscriptionPlan: z.enum(['STARTER', 'GROWTH', 'SCALE', 'ENTERPRISE']).default('STARTER'),
100
+ aiCreditsLimit: z.number().int().positive().default(500),
101
+ isCrmActive: z.boolean().default(false),
102
+ isEdTechActive: z.boolean().default(true),
103
+ });
104
+ const body = Schema.safeParse(req.body);
105
+ if (!body.success) return reply.code(400).send({ error: body.error.flatten() });
106
+
107
+ try {
108
+ const org = await prisma.organization.create({ data: body.data });
109
+ return reply.code(201).send(org);
110
+ } catch (err: any) {
111
+ logger.error({ err }, '[SUPER_ADMIN] org create failed');
112
+ return reply.code(500).send({ error: 'Failed to create organization' });
113
+ }
114
+ });
115
+
116
+ fastify.patch<{ Params: { id: string } }>('/organizations/:id', async (req, reply) => {
117
+ const Schema = z.object({
118
+ name: z.string().min(1).optional(),
119
+ subscriptionPlan: z.enum(['STARTER', 'GROWTH', 'SCALE', 'ENTERPRISE']).optional(),
120
+ subscriptionStatus: z.string().optional(),
121
+ aiCreditsLimit: z.number().int().positive().optional(),
122
+ isHardStopped: z.boolean().optional(),
123
+ isCrmActive: z.boolean().optional(),
124
+ isEdTechActive: z.boolean().optional(),
125
+ walletBalance: z.number().int().optional(),
126
+ });
127
+ const body = Schema.safeParse(req.body);
128
+ if (!body.success) return reply.code(400).send({ error: body.error.flatten() });
129
+
130
+ try {
131
+ const org = await prisma.organization.update({
132
+ where: { id: req.params.id },
133
+ data: body.data,
134
+ });
135
+ return org;
136
+ } catch (err: any) {
137
+ logger.error({ err, orgId: req.params.id }, '[SUPER_ADMIN] org update failed');
138
+ if (err.code === 'P2025') return reply.code(404).send({ error: 'Organization not found' });
139
+ return reply.code(500).send({ error: 'Failed to update organization' });
140
+ }
141
+ });
142
+
143
+ // Suspend / activate shortcut
144
+ fastify.post<{ Params: { id: string } }>('/organizations/:id/suspend', async (req, reply) => {
145
+ const Schema = z.object({ suspend: z.boolean() });
146
+ const body = Schema.safeParse(req.body);
147
+ if (!body.success) return reply.code(400).send({ error: body.error.flatten() });
148
+ try {
149
+ await prisma.organization.update({
150
+ where: { id: req.params.id },
151
+ data: { isHardStopped: body.data.suspend, subscriptionStatus: body.data.suspend ? 'SUSPENDED' : 'ACTIVE' }
152
+ });
153
+ return { ok: true };
154
+ } catch (err: any) {
155
+ if (err.code === 'P2025') return reply.code(404).send({ error: 'Organization not found' });
156
+ return reply.code(500).send({ error: 'Failed to suspend organization' });
157
+ }
158
+ });
159
+
160
+ // Impersonation — returns a short-lived JWT scoped to the target org
161
+ fastify.post<{ Params: { id: string } }>('/organizations/:id/impersonate', async (req, reply) => {
162
+ try {
163
+ const org = await prisma.organization.findUnique({ where: { id: req.params.id }, select: { id: true, name: true } });
164
+ if (!org) return reply.code(404).send({ error: 'Organization not found' });
165
+
166
+ const impersonateToken = (fastify as any).jwt.sign(
167
+ { id: (req as any).user.id, role: 'ORG_ADMIN', organizationId: org.id, impersonating: true },
168
+ { expiresIn: '1h' }
169
+ );
170
+ return { token: impersonateToken, orgName: org.name };
171
+ } catch (err) {
172
+ logger.error({ err }, '[SUPER_ADMIN] impersonate failed');
173
+ return reply.code(500).send({ error: 'Impersonation failed' });
174
+ }
175
+ });
176
+
177
+ // ── Users (cross-org) ─────────────────────────────────────────────────────
178
+ fastify.get('/users', async (req, reply) => {
179
+ const QuerySchema = z.object({
180
+ page: z.coerce.number().int().positive().default(1),
181
+ limit: z.coerce.number().int().positive().max(100).default(20),
182
+ orgId: z.string().optional(),
183
+ role: z.string().optional(),
184
+ search: z.string().optional(),
185
+ });
186
+ const parsed = QuerySchema.safeParse(req.query);
187
+ if (!parsed.success) return reply.code(400).send({ error: 'Invalid query params' });
188
+
189
+ const { page, limit, orgId, role, search } = parsed.data;
190
+ const where: any = { deletedAt: null };
191
+ if (orgId) where.organizationId = orgId;
192
+ if (role) where.role = role;
193
+ if (search) where.OR = [
194
+ { name: { contains: search, mode: 'insensitive' } },
195
+ { email: { contains: search, mode: 'insensitive' } },
196
+ ];
197
+
198
+ try {
199
+ const [users, total] = await Promise.all([
200
+ prisma.user.findMany({
201
+ where,
202
+ select: { id: true, name: true, email: true, phone: true, role: true, organizationId: true, createdAt: true, lastActivityAt: true,
203
+ organization: { select: { name: true } }
204
+ },
205
+ orderBy: { createdAt: 'desc' },
206
+ skip: (page - 1) * limit,
207
+ take: limit,
208
+ }),
209
+ prisma.user.count({ where }),
210
+ ]);
211
+ return { data: users, total, page, limit };
212
+ } catch (err) {
213
+ logger.error({ err }, '[SUPER_ADMIN] users list failed');
214
+ return reply.code(500).send({ error: 'Failed to list users' });
215
+ }
216
+ });
217
+
218
+ fastify.patch<{ Params: { userId: string } }>('/users/:userId/role', async (req, reply) => {
219
+ const Schema = z.object({ role: z.enum(['STUDENT', 'ORG_MEMBER', 'ORG_ADMIN', 'ADMIN', 'SUPER_ADMIN']) });
220
+ const body = Schema.safeParse(req.body);
221
+ if (!body.success) return reply.code(400).send({ error: body.error.flatten() });
222
+ try {
223
+ const user = await prisma.user.update({ where: { id: req.params.userId }, data: { role: body.data.role } });
224
+ return { ok: true, role: user.role };
225
+ } catch (err: any) {
226
+ if (err.code === 'P2025') return reply.code(404).send({ error: 'User not found' });
227
+ return reply.code(500).send({ error: 'Failed to update role' });
228
+ }
229
+ });
230
+
231
+ // ── WhatsApp Numbers (cross-org) ──────────────────────────────────────────
232
+ fastify.get('/whatsapp/numbers', async (_req, reply) => {
233
+ try {
234
+ const numbers = await prisma.whatsAppPhoneNumber.findMany({
235
+ include: { organization: { select: { id: true, name: true } } },
236
+ orderBy: { createdAt: 'desc' },
237
+ });
238
+ return { data: numbers };
239
+ } catch (err) {
240
+ logger.error({ err }, '[SUPER_ADMIN] whatsapp numbers failed');
241
+ return reply.code(500).send({ error: 'Failed to list phone numbers' });
242
+ }
243
+ });
244
+
245
+ // ── Billing ───────────────────────────────────────────────────────────────
246
+ fastify.get('/billing/transactions', async (req, reply) => {
247
+ const QuerySchema = z.object({
248
+ page: z.coerce.number().int().positive().default(1),
249
+ limit: z.coerce.number().int().positive().max(100).default(20),
250
+ orgId: z.string().optional(),
251
+ });
252
+ const parsed = QuerySchema.safeParse(req.query);
253
+ if (!parsed.success) return reply.code(400).send({ error: 'Invalid query params' });
254
+
255
+ const { page, limit, orgId } = parsed.data;
256
+ const where: any = {};
257
+ if (orgId) where.organizationId = orgId;
258
+
259
+ try {
260
+ const [txns, total] = await Promise.all([
261
+ prisma.walletTransaction.findMany({
262
+ where,
263
+ include: { organization: { select: { id: true, name: true } } },
264
+ orderBy: { createdAt: 'desc' },
265
+ skip: (page - 1) * limit,
266
+ take: limit,
267
+ }),
268
+ prisma.walletTransaction.count({ where }),
269
+ ]);
270
+ return { data: txns, total, page, limit };
271
+ } catch (err) {
272
+ logger.error({ err }, '[SUPER_ADMIN] billing transactions failed');
273
+ return reply.code(500).send({ error: 'Failed to list transactions' });
274
+ }
275
+ });
276
+
277
+ fastify.post('/billing/credits', async (req, reply) => {
278
+ const Schema = z.object({
279
+ orgId: z.string(),
280
+ amount: z.number().int().positive(),
281
+ description: z.string().optional(),
282
+ });
283
+ const body = Schema.safeParse(req.body);
284
+ if (!body.success) return reply.code(400).send({ error: body.error.flatten() });
285
+
286
+ try {
287
+ const org = await prisma.organization.findUnique({ where: { id: body.data.orgId }, select: { walletBalance: true } });
288
+ if (!org) return reply.code(404).send({ error: 'Organization not found' });
289
+
290
+ const newBalance = org.walletBalance + body.data.amount;
291
+ const [updatedOrg, txn] = await prisma.$transaction([
292
+ prisma.organization.update({ where: { id: body.data.orgId }, data: { walletBalance: newBalance } }),
293
+ prisma.walletTransaction.create({
294
+ data: {
295
+ organizationId: body.data.orgId,
296
+ amount: body.data.amount,
297
+ balanceAfter: newBalance,
298
+ type: 'TOP_UP_MANUAL',
299
+ description: body.data.description || 'Manual credit by super-admin',
300
+ actorId: (req as any).user?.id,
301
+ }
302
+ })
303
+ ]);
304
+ return { ok: true, newBalance: updatedOrg.walletBalance, transactionId: txn.id };
305
+ } catch (err) {
306
+ logger.error({ err }, '[SUPER_ADMIN] add credits failed');
307
+ return reply.code(500).send({ error: 'Failed to add credits' });
308
+ }
309
+ });
310
+
311
+ // ── Monitoring ─────────────────────────────────────────────��──────────────
312
+ fastify.get('/monitoring/health', async (_req, reply) => {
313
+ try {
314
+ const [dbPing, redisPing, queueWaiting, queueFailed, queueActive] = await Promise.all([
315
+ prisma.$queryRaw`SELECT 1`.then(() => true).catch(() => false),
316
+ redis.ping().then(() => true).catch(() => false),
317
+ whatsappQueue.getWaitingCount().catch(() => -1),
318
+ whatsappQueue.getFailedCount().catch(() => -1),
319
+ whatsappQueue.getActiveCount().catch(() => -1),
320
+ ]);
321
+
322
+ // Token expiry check — find orgs with systemUserTokenIssuedAt older than 50 days
323
+ const tokenExpiryThreshold = new Date(Date.now() - 50 * 24 * 60 * 60 * 1000);
324
+ const expiringTokenOrgs = await prisma.organization.findMany({
325
+ where: {
326
+ systemUserTokenIssuedAt: { lte: tokenExpiryThreshold },
327
+ systemUserToken: { not: null }
328
+ },
329
+ select: { id: true, name: true, systemUserTokenIssuedAt: true }
330
+ });
331
+
332
+ // Low wallet balance
333
+ const lowBalanceOrgs = await prisma.organization.findMany({
334
+ where: { walletBalance: { lt: 100 }, isHardStopped: false },
335
+ select: { id: true, name: true, walletBalance: true }
336
+ });
337
+
338
+ return {
339
+ db: { ok: dbPing },
340
+ redis: { ok: redisPing },
341
+ queue: { waiting: queueWaiting, failed: queueFailed, active: queueActive },
342
+ tokenExpiries: expiringTokenOrgs.map(o => ({
343
+ orgId: o.id,
344
+ orgName: o.name,
345
+ issuedAt: o.systemUserTokenIssuedAt,
346
+ daysOld: o.systemUserTokenIssuedAt
347
+ ? Math.floor((Date.now() - o.systemUserTokenIssuedAt.getTime()) / (24 * 60 * 60 * 1000))
348
+ : null
349
+ })),
350
+ lowBalanceOrgs,
351
+ };
352
+ } catch (err) {
353
+ logger.error({ err }, '[SUPER_ADMIN] monitoring failed');
354
+ return reply.code(500).send({ error: 'Failed to fetch monitoring data' });
355
+ }
356
+ });
357
+
358
+ // ── AI Agentic Command ────────────────────────────────────────────────────
359
+ fastify.post('/ai/command', async (req, reply) => {
360
+ const Schema = z.object({
361
+ command: z.string().min(1),
362
+ confirm: z.boolean().default(false),
363
+ pendingAction: z.object({
364
+ action: z.string(),
365
+ params: z.record(z.unknown()),
366
+ }).optional(),
367
+ });
368
+ const body = Schema.safeParse(req.body);
369
+ if (!body.success) return reply.code(400).send({ error: body.error.flatten() });
370
+
371
+ const { command, confirm, pendingAction } = body.data;
372
+
373
+ // If confirming a pending action
374
+ if (confirm && pendingAction) {
375
+ return executeSuperAdminAction(pendingAction.action, pendingAction.params, req, reply);
376
+ }
377
+
378
+ // Parse command with AI
379
+ const { aiService } = await import('../services/ai');
380
+ const { z: zod } = await import('zod');
381
+
382
+ const CommandSchema = zod.object({
383
+ action: zod.enum(['QUERY_STATS', 'LIST_ORGS', 'SUSPEND_ORG', 'ACTIVATE_ORG', 'ADD_CREDITS', 'LIST_LOW_BALANCE', 'LIST_ALERTS', 'LIST_USERS']),
384
+ params: zod.record(zod.unknown()).default({}),
385
+ confirmation_required: zod.boolean(),
386
+ human_summary: zod.string(),
387
+ });
388
+
389
+ try {
390
+ const systemPrompt = `You are a super-admin AI assistant for the XAMLÉ platform. Parse the admin's command and return a structured action.
391
+ Available actions:
392
+ - QUERY_STATS: Get platform statistics (no params needed)
393
+ - LIST_ORGS: List organizations (params: search?, plan?)
394
+ - SUSPEND_ORG: Suspend an organization (params: orgName or orgId) — confirmation required
395
+ - ACTIVATE_ORG: Reactivate a suspended org (params: orgName or orgId) — confirmation required
396
+ - ADD_CREDITS: Add wallet credits to an org (params: orgName or orgId, amount) — confirmation required
397
+ - LIST_LOW_BALANCE: List orgs with low wallet balance
398
+ - LIST_ALERTS: List monitoring alerts
399
+ - LIST_USERS: List users (params: orgName?, role?)
400
+
401
+ Always extract organization names and amounts from the command. Set confirmation_required=true for destructive or financial actions.`;
402
+
403
+ const { data: parsed } = await aiService.generateStructuredData(
404
+ `${systemPrompt}\n\nAdmin command: "${command}"`,
405
+ CommandSchema,
406
+ 0.1
407
+ );
408
+
409
+ if (parsed.confirmation_required) {
410
+ return {
411
+ status: 'pending_confirmation',
412
+ summary: parsed.human_summary,
413
+ action: parsed.action,
414
+ params: parsed.params,
415
+ };
416
+ }
417
+
418
+ return executeSuperAdminAction(parsed.action, parsed.params ?? {}, req, reply);
419
+ } catch (err) {
420
+ logger.error({ err }, '[SUPER_ADMIN] AI command failed');
421
+ return reply.code(503).send({ error: 'AI service unavailable' });
422
+ }
423
+ });
424
+ }
425
+
426
+ async function executeSuperAdminAction(action: string, params: Record<string, unknown>, req: any, reply: any) {
427
+ try {
428
+ switch (action) {
429
+ case 'QUERY_STATS': {
430
+ const since24h = new Date(Date.now() - 24 * 60 * 60 * 1000);
431
+ const [orgsCount, usersCount, messagesLast24h] = await Promise.all([
432
+ prisma.organization.count(),
433
+ prisma.user.count({ where: { deletedAt: null } }),
434
+ prisma.message.count({ where: { createdAt: { gte: since24h } } }),
435
+ ]);
436
+ return { status: 'success', result: { orgsCount, usersCount, messagesLast24h } };
437
+ }
438
+ case 'LIST_ORGS': {
439
+ const orgs = await prisma.organization.findMany({
440
+ where: params.search ? { name: { contains: params.search as string, mode: 'insensitive' } } : undefined,
441
+ select: { id: true, name: true, subscriptionPlan: true, walletBalance: true, isHardStopped: true },
442
+ take: 20,
443
+ orderBy: { createdAt: 'desc' },
444
+ });
445
+ return { status: 'success', result: orgs };
446
+ }
447
+ case 'LIST_LOW_BALANCE': {
448
+ const orgs = await prisma.organization.findMany({
449
+ where: { walletBalance: { lt: 100 } },
450
+ select: { id: true, name: true, walletBalance: true },
451
+ orderBy: { walletBalance: 'asc' },
452
+ });
453
+ return { status: 'success', result: orgs };
454
+ }
455
+ case 'LIST_ALERTS': {
456
+ const tokenThreshold = new Date(Date.now() - 50 * 24 * 60 * 60 * 1000);
457
+ const [expiringTokens, lowBalance] = await Promise.all([
458
+ prisma.organization.findMany({
459
+ where: { systemUserTokenIssuedAt: { lte: tokenThreshold }, systemUserToken: { not: null } },
460
+ select: { id: true, name: true, systemUserTokenIssuedAt: true },
461
+ }),
462
+ prisma.organization.findMany({
463
+ where: { walletBalance: { lt: 100 } },
464
+ select: { id: true, name: true, walletBalance: true },
465
+ }),
466
+ ]);
467
+ return { status: 'success', result: { expiringTokens, lowBalance } };
468
+ }
469
+ case 'SUSPEND_ORG':
470
+ case 'ACTIVATE_ORG': {
471
+ const suspend = action === 'SUSPEND_ORG';
472
+ const orgSearch = (params.orgName || params.orgId) as string;
473
+ const org = await prisma.organization.findFirst({
474
+ where: params.orgId
475
+ ? { id: params.orgId as string }
476
+ : { name: { contains: orgSearch, mode: 'insensitive' } }
477
+ });
478
+ if (!org) return reply.code(404).send({ error: `Organization not found: ${orgSearch}` });
479
+ await prisma.organization.update({
480
+ where: { id: org.id },
481
+ data: { isHardStopped: suspend, subscriptionStatus: suspend ? 'SUSPENDED' : 'ACTIVE' }
482
+ });
483
+ return { status: 'success', result: { orgId: org.id, orgName: org.name, action: suspend ? 'suspended' : 'activated' } };
484
+ }
485
+ case 'ADD_CREDITS': {
486
+ const orgSearch = (params.orgName || params.orgId) as string;
487
+ const amount = typeof params.amount === 'number' ? params.amount : parseInt(params.amount as string, 10);
488
+ if (!amount || amount <= 0) return reply.code(400).send({ error: 'Invalid amount' });
489
+ const org = await prisma.organization.findFirst({
490
+ where: params.orgId
491
+ ? { id: params.orgId as string }
492
+ : { name: { contains: orgSearch, mode: 'insensitive' } }
493
+ });
494
+ if (!org) return reply.code(404).send({ error: `Organization not found: ${orgSearch}` });
495
+ const newBalance = org.walletBalance + amount;
496
+ await prisma.$transaction([
497
+ prisma.organization.update({ where: { id: org.id }, data: { walletBalance: newBalance } }),
498
+ prisma.walletTransaction.create({
499
+ data: {
500
+ organizationId: org.id,
501
+ amount,
502
+ balanceAfter: newBalance,
503
+ type: 'TOP_UP_MANUAL',
504
+ description: `Super-admin AI command: add ${amount} credits`,
505
+ actorId: req.user?.id,
506
+ }
507
+ })
508
+ ]);
509
+ return { status: 'success', result: { orgId: org.id, orgName: org.name, amount, newBalance } };
510
+ }
511
+ case 'LIST_USERS': {
512
+ const where: any = { deletedAt: null };
513
+ if (params.role) where.role = params.role;
514
+ if (params.orgName) {
515
+ const org = await prisma.organization.findFirst({ where: { name: { contains: params.orgName as string, mode: 'insensitive' } } });
516
+ if (org) where.organizationId = org.id;
517
+ }
518
+ const users = await prisma.user.findMany({ where, select: { id: true, name: true, email: true, role: true, organizationId: true }, take: 20 });
519
+ return { status: 'success', result: users };
520
+ }
521
+ default:
522
+ return reply.code(400).send({ error: `Unknown action: ${action}` });
523
+ }
524
+ } catch (err) {
525
+ logger.error({ err, action }, '[SUPER_ADMIN] action execution failed');
526
+ return reply.code(500).send({ error: 'Action execution failed' });
527
+ }
528
+ }