CognxSafeTrack commited on
Commit
7b0c22b
·
1 Parent(s): 820d280

chore: stabilization, audit fixes and project synchronization

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .env.example +6 -3
  2. apps/admin/src/App.tsx +9 -4
  3. apps/admin/src/components/layouts/MainLayout.tsx +3 -1
  4. apps/admin/src/pages/AIAgentSetup.tsx +179 -37
  5. apps/admin/src/pages/CampaignHistoryPage.tsx +228 -0
  6. apps/admin/src/pages/ClientsManagementView.tsx +64 -1
  7. apps/admin/src/pages/ContactsPage.tsx +42 -4
  8. apps/admin/src/pages/KnowledgeBasePage.tsx +201 -0
  9. apps/admin/src/pages/ResetPasswordPage.tsx +175 -0
  10. apps/admin/src/pages/SettingsPage.tsx +2 -16
  11. apps/admin/src/pages/TrackFormPage.tsx +6 -10
  12. apps/api/package.json +0 -1
  13. apps/api/src/config.ts +1 -1
  14. apps/api/src/index.ts +9 -10
  15. apps/api/src/middleware/rateLimit.ts +1 -1
  16. apps/api/src/routes/admin.ts +67 -17
  17. apps/api/src/routes/ai.ts +21 -11
  18. apps/api/src/routes/analytics.ts +18 -6
  19. apps/api/src/routes/auth.ts +68 -7
  20. apps/api/src/routes/campaigns.ts +8 -5
  21. apps/api/src/routes/internal.ts +5 -5
  22. apps/api/src/routes/notifications.ts +2 -2
  23. apps/api/src/routes/organizations.ts +159 -18
  24. apps/api/src/routes/payments.ts +14 -208
  25. apps/api/src/routes/whatsapp.ts +5 -3
  26. apps/api/src/services/ai/index.ts +2 -2
  27. apps/api/src/services/audit.ts +1 -1
  28. apps/api/src/services/normalization.ts +3 -3
  29. apps/api/src/services/organization.ts +1 -4
  30. apps/api/src/services/push.ts +3 -3
  31. apps/api/src/services/queue.ts +2 -2
  32. apps/api/src/services/stripe.ts +0 -147
  33. apps/api/src/services/whatsapp.ts +1 -1
  34. apps/api/test/stripe.test.ts +0 -53
  35. apps/web/src/PrivacyPolicy.tsx +1 -1
  36. apps/whatsapp-worker/package.json +2 -1
  37. apps/whatsapp-worker/src/config.ts +1 -1
  38. apps/whatsapp-worker/src/handlers/AdminHandler.ts +1 -1
  39. apps/whatsapp-worker/src/handlers/CommandHandler.ts +2 -2
  40. apps/whatsapp-worker/src/handlers/ContentHandler.ts +1 -1
  41. apps/whatsapp-worker/src/handlers/EnrollHandler.ts +3 -3
  42. apps/whatsapp-worker/src/handlers/ExerciseHandler.ts +2 -2
  43. apps/whatsapp-worker/src/handlers/MediaHandler.ts +6 -7
  44. apps/whatsapp-worker/src/index.ts +16 -15
  45. apps/whatsapp-worker/src/pedagogy.ts +11 -9
  46. apps/whatsapp-worker/src/scheduler.ts +1 -1
  47. apps/whatsapp-worker/src/services/ai-pedagogy.ts +1 -1
  48. apps/whatsapp-worker/src/services/ai.ts +2 -2
  49. apps/whatsapp-worker/src/services/errors.ts +31 -15
  50. apps/whatsapp-worker/src/services/normalization.ts +1 -1
.env.example CHANGED
@@ -12,13 +12,16 @@ WHATSAPP_APP_SECRET="your-meta-app-secret-for-hmac"
12
  # ─── PROVIDERS (GLOBAL FALLBACKS) ─────────────────────────────────────────────
13
  OPENAI_API_KEY="sk-..."
14
  GOOGLE_AI_API_KEY="AIza..."
15
- STRIPE_SECRET_KEY="sk_test_..."
16
- STRIPE_WEBHOOK_SECRET="whsec_..."
17
- STRIPE_PAAS_SUBSCRIPTION_PRICE_ID="price_..."
18
 
19
  # ─── INFRASTRUCTURE ───────────────────────────────────────────────────────────
20
  PORT=8080
21
  NODE_ENV="production"
 
 
 
 
22
  VITE_CLIENT_URL="https://admin.xamle.studio"
23
  RAILWAY_INTERNAL_URL="http://whatsapp-worker.railway.internal:8082"
24
  RAILWAY_PUBLIC_URL="https://api.xamle.studio"
 
12
  # ─── PROVIDERS (GLOBAL FALLBACKS) ─────────────────────────────────────────────
13
  OPENAI_API_KEY="sk-..."
14
  GOOGLE_AI_API_KEY="AIza..."
15
+ # ORANGE_MONEY_API_KEY=""
16
+ # WAVE_API_KEY=""
 
17
 
18
  # ─── INFRASTRUCTURE ───────────────────────────────────────────────────────────
19
  PORT=8080
20
  NODE_ENV="production"
21
+ SENTRY_DSN="" # optionnel — laisser vide pour désactiver
22
+ CORS_ORIGINS="https://admin.xamle.studio,https://xamle.studio,https://edtechadmin.netlify.app"
23
+ ADMIN_URL="https://edtechadmin.netlify.app"
24
+ WHATSAPP_GRAPH_URL="https://graph.facebook.com/v18.0"
25
  VITE_CLIENT_URL="https://admin.xamle.studio"
26
  RAILWAY_INTERNAL_URL="http://whatsapp-worker.railway.internal:8082"
27
  RAILWAY_PUBLIC_URL="https://api.xamle.studio"
apps/admin/src/App.tsx CHANGED
@@ -16,10 +16,13 @@ import LiveFeed from '@/pages/LiveFeed';
16
  import TrainingLab from '@/pages/TrainingLab';
17
  import ClientsManagementView from '@/pages/ClientsManagementView';
18
  import OnboardingWizard from '@/pages/OnboardingWizard';
 
19
  import AnalyticsPage from '@/pages/AnalyticsPage';
20
- import ContactsPage from '@/pages/ContactsPage'; // Traditional CRM Module
21
- import ConversationalDashboard from '@/pages/ConversationalDashboard'; // Original AI Module
22
- import CrmConversationalDashboard from '@/pages/CrmConversationalDashboard'; // Specialized CRM AI Module
 
 
23
  import { useTenant } from '@/lib/tenant';
24
  import { api } from '@/lib/api';
25
 
@@ -86,7 +89,9 @@ function AppShell() {
86
  <Route path="/users" element={<UserListPage />} />
87
  <Route path="/settings" element={<SettingsPage />} />
88
  <Route path="/onboarding" element={<OnboardingWizard />} />
89
- <Route path="/reset-password" element={<div className="p-8">Page de réinitialisation (À implémenter)</div>} />
 
 
90
  </Routes>
91
  </MainLayout>
92
  );
 
16
  import TrainingLab from '@/pages/TrainingLab';
17
  import ClientsManagementView from '@/pages/ClientsManagementView';
18
  import OnboardingWizard from '@/pages/OnboardingWizard';
19
+ import ResetPasswordPage from '@/pages/ResetPasswordPage';
20
  import AnalyticsPage from '@/pages/AnalyticsPage';
21
+ import ContactsPage from '@/pages/ContactsPage';
22
+ import ConversationalDashboard from '@/pages/ConversationalDashboard';
23
+ import CrmConversationalDashboard from '@/pages/CrmConversationalDashboard';
24
+ import KnowledgeBasePage from '@/pages/KnowledgeBasePage';
25
+ import CampaignHistoryPage from '@/pages/CampaignHistoryPage';
26
  import { useTenant } from '@/lib/tenant';
27
  import { api } from '@/lib/api';
28
 
 
89
  <Route path="/users" element={<UserListPage />} />
90
  <Route path="/settings" element={<SettingsPage />} />
91
  <Route path="/onboarding" element={<OnboardingWizard />} />
92
+ <Route path="/reset-password" element={<ResetPasswordPage />} />
93
+ <Route path="/kb" element={<KnowledgeBasePage />} />
94
+ <Route path="/campaign-history" element={<CampaignHistoryPage />} />
95
  </Routes>
96
  </MainLayout>
97
  );
apps/admin/src/components/layouts/MainLayout.tsx CHANGED
@@ -2,7 +2,7 @@ import React from 'react';
2
  import { Link } from 'react-router-dom';
3
  import { useAuth } from '@/lib/auth';
4
  import { useTenant } from '@/lib/tenant';
5
- import { BarChart2, TrendingUp, Users, BookOpen, Mic, Building2, Activity, Lightbulb } from 'lucide-react';
6
 
7
  import RoleGuard from '@/components/RoleGuard';
8
 
@@ -24,6 +24,8 @@ export default function MainLayout({ children, isSuperAdmin, orgs }: MainLayoutP
24
  { to: '/', label: 'Dashboard', icon: <BarChart2 className="w-4 h-4" /> },
25
  { to: '/analytics', label: 'Statistiques', icon: <TrendingUp className="w-4 h-4 text-amber-500" /> },
26
  { to: '/contacts', label: 'Clients', icon: <Users className="w-4 h-4 text-blue-400" />, show: isCrmActive },
 
 
27
  { to: '/content', label: 'Parcours', icon: <BookOpen className="w-4 h-4" />, show: isEdTechActive },
28
  { to: '/live-feed', label: 'Modération', icon: <Mic className="w-4 h-4 text-emerald-500" />, show: isEdTechActive },
29
  { to: '/clients', label: 'Clients B2B', icon: <Building2 className="w-4 h-4 text-indigo-400" />, superOnly: true, show: isSuperAdminLocal },
 
2
  import { Link } from 'react-router-dom';
3
  import { useAuth } from '@/lib/auth';
4
  import { useTenant } from '@/lib/tenant';
5
+ import { BarChart2, TrendingUp, Users, BookOpen, Mic, Building2, Activity, Lightbulb, Database, Megaphone } from 'lucide-react';
6
 
7
  import RoleGuard from '@/components/RoleGuard';
8
 
 
24
  { to: '/', label: 'Dashboard', icon: <BarChart2 className="w-4 h-4" /> },
25
  { to: '/analytics', label: 'Statistiques', icon: <TrendingUp className="w-4 h-4 text-amber-500" /> },
26
  { to: '/contacts', label: 'Clients', icon: <Users className="w-4 h-4 text-blue-400" />, show: isCrmActive },
27
+ { to: '/campaign-history', label: 'Campagnes', icon: <Megaphone className="w-4 h-4 text-amber-500" />, show: isCrmActive },
28
+ { to: '/kb', label: 'Base de connaissance', icon: <Database className="w-4 h-4 text-violet-400" /> },
29
  { to: '/content', label: 'Parcours', icon: <BookOpen className="w-4 h-4" />, show: isEdTechActive },
30
  { to: '/live-feed', label: 'Modération', icon: <Mic className="w-4 h-4 text-emerald-500" />, show: isEdTechActive },
31
  { to: '/clients', label: 'Clients B2B', icon: <Building2 className="w-4 h-4 text-indigo-400" />, superOnly: true, show: isSuperAdminLocal },
apps/admin/src/pages/AIAgentSetup.tsx CHANGED
@@ -1,18 +1,111 @@
 
 
 
1
 
2
- import React, { useState } from 'react';
 
 
 
 
 
 
3
 
4
  export default function AIAgentSetup() {
5
- const [status, setStatus] = useState<'IDLE' | 'UPLOADING' | 'SUCCESS'>('IDLE');
6
-
7
- const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
8
- if (!e.target.files?.[0]) return;
9
- setStatus('UPLOADING');
10
- // Simulating upload to R2 / Storage
11
- setTimeout(() => {
12
- setStatus('SUCCESS');
13
- }, 2000);
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  };
15
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
  return (
17
  <div className="p-8 max-w-4xl mx-auto">
18
  <h1 className="text-3xl font-bold text-slate-800 mb-2">Configuration de l'Agent IA</h1>
@@ -29,24 +122,39 @@ export default function AIAgentSetup() {
29
  Téléchargez vos catalogues, manuels de formation ou FAQ. L'IA utilisera ces documents pour répondre précisément à vos clients.
30
  </p>
31
 
32
- <div className="border-2 border-dashed border-slate-200 rounded-2xl p-12 text-center hover:border-emerald-400 transition-colors cursor-pointer group">
33
- <input
34
- type="file"
35
- id="kb-upload"
36
- className="hidden"
37
- accept=".pdf,.doc,.docx"
 
 
 
 
38
  onChange={handleFileUpload}
 
39
  />
40
  <label htmlFor="kb-upload" className="cursor-pointer">
41
  <div className="w-16 h-16 bg-emerald-50 text-emerald-600 rounded-full flex items-center justify-center mx-auto mb-4 group-hover:scale-110 transition-transform">
42
- <span className="text-2xl">📄</span>
 
 
 
 
43
  </div>
44
  <p className="font-medium text-slate-800">
45
- {status === 'IDLE' && 'Cliquez pour uploader un document'}
46
- {status === 'UPLOADING' && 'Traitement en cours...'}
47
- {status === 'SUCCESS' && 'Document analysé avec succès !'}
 
48
  </p>
49
- <p className="text-xs text-slate-400 mt-2">PDF, DOCX (Max 10MB)</p>
 
 
 
 
 
50
  </label>
51
  </div>
52
  </div>
@@ -59,22 +167,44 @@ export default function AIAgentSetup() {
59
  <div className="space-y-4">
60
  <div>
61
  <label className="block text-sm font-medium text-slate-600 mb-1">Rôle principal</label>
62
- <input
63
- type="text"
 
 
64
  placeholder="Ex: Conseiller technique pour Agritech"
65
  className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:ring-2 focus:ring-emerald-500 outline-none"
66
  />
67
  </div>
68
  <div>
69
  <label className="block text-sm font-medium text-slate-600 mb-1">Ton et Style</label>
70
- <div className="flex gap-2">
71
- {['Professionnel', 'Amical', 'Direct', 'Pédagogue'].map(t => (
72
- <button key={t} className="px-4 py-2 rounded-full border border-slate-200 text-sm hover:bg-emerald-50 hover:border-emerald-200 transition-colors">
 
 
 
 
 
 
 
 
 
73
  {t}
74
  </button>
75
  ))}
76
  </div>
77
  </div>
 
 
 
 
 
 
 
 
 
 
 
78
  </div>
79
  </div>
80
  </div>
@@ -90,23 +220,35 @@ export default function AIAgentSetup() {
90
  Quels sont les prix de vos engrais ?
91
  </div>
92
  <div className="bg-white text-slate-800 p-3 rounded-2xl rounded-bl-none text-xs self-start mt-2">
93
- {status === 'SUCCESS' ? "D'après notre catalogue, nos engrais NPK sont à 15,000 FCFA le sac..." : "..."}
 
 
94
  </div>
95
  </div>
96
  </div>
97
 
98
  <div className="bg-slate-50 p-6 rounded-3xl border border-slate-100">
99
- <h4 className="font-bold text-slate-800 mb-2">Statistiques Agent</h4>
100
- <div className="space-y-3">
101
- <div className="flex justify-between text-sm">
102
- <span className="text-slate-500">Précision RAG</span>
103
- <span className="text-emerald-600 font-bold">94%</span>
104
- </div>
105
- <div className="flex justify-between text-sm">
106
- <span className="text-slate-500">Mots indexés</span>
107
- <span className="text-slate-800 font-bold">12,450</span>
 
 
 
 
 
 
 
 
 
 
108
  </div>
109
- </div>
110
  </div>
111
  </div>
112
  </div>
 
1
+ import React, { useState, useEffect } from 'react';
2
+ import { useAuth } from '@/lib/auth';
3
+ import { useTenant } from '@/lib/tenant';
4
 
5
+ const TONES = ['Professionnel', 'Amical', 'Direct', 'Pédagogue'] as const;
6
+
7
+ interface KbStats {
8
+ chunkCount: number;
9
+ hasKnowledgeBase: boolean;
10
+ knowledgeBaseUrl?: string;
11
+ }
12
 
13
  export default function AIAgentSetup() {
14
+ const { token } = useAuth();
15
+ const { selectedOrgId } = useTenant();
16
+
17
+ const [uploadStatus, setUploadStatus] = useState<'IDLE' | 'UPLOADING' | 'SUCCESS' | 'ERROR'>('IDLE');
18
+ const [uploadError, setUploadError] = useState('');
19
+
20
+ const [role, setRole] = useState('');
21
+ const [selectedTone, setSelectedTone] = useState<string>('Professionnel');
22
+ const [saveStatus, setSaveStatus] = useState<'IDLE' | 'SAVING' | 'SAVED' | 'ERROR'>('IDLE');
23
+
24
+ const [kbStats, setKbStats] = useState<KbStats | null>(null);
25
+
26
+ const apiBase = import.meta.env.VITE_API_URL;
27
+
28
+ const fetchKbStats = async () => {
29
+ if (!token || !selectedOrgId) return;
30
+ try {
31
+ const res = await fetch(`${apiBase}/v1/organizations/${selectedOrgId}/kb-stats`, {
32
+ headers: { 'Authorization': `Bearer ${token}`, 'x-organization-id': selectedOrgId }
33
+ });
34
+ if (res.ok) setKbStats(await res.json());
35
+ } catch { /* non-bloquant */ }
36
  };
37
 
38
+ useEffect(() => {
39
+ fetchKbStats();
40
+ }, [token, selectedOrgId]);
41
+
42
+ const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
43
+ const file = e.target.files?.[0];
44
+ if (!file || !token || !selectedOrgId) return;
45
+
46
+ setUploadStatus('UPLOADING');
47
+ setUploadError('');
48
+
49
+ const formData = new FormData();
50
+ formData.append('file', file);
51
+
52
+ try {
53
+ const res = await fetch(`${apiBase}/v1/organizations/${selectedOrgId}/upload-kb`, {
54
+ method: 'POST',
55
+ headers: { 'Authorization': `Bearer ${token}`, 'x-organization-id': selectedOrgId },
56
+ body: formData
57
+ });
58
+
59
+ if (res.ok) {
60
+ const data = await res.json();
61
+ setUploadStatus('SUCCESS');
62
+ setKbStats(prev => ({
63
+ hasKnowledgeBase: true,
64
+ knowledgeBaseUrl: data.url,
65
+ chunkCount: prev?.chunkCount ?? 0
66
+ }));
67
+ // Refresh stats after a short delay (indexing is async)
68
+ setTimeout(fetchKbStats, 4000);
69
+ } else {
70
+ const err = await res.json().catch(() => ({}));
71
+ setUploadError(err.error || 'Erreur serveur');
72
+ setUploadStatus('ERROR');
73
+ }
74
+ } catch {
75
+ setUploadError('Erreur réseau');
76
+ setUploadStatus('ERROR');
77
+ }
78
+
79
+ // Reset input so the same file can be re-uploaded
80
+ e.target.value = '';
81
+ };
82
+
83
+ const handleSavePersonality = async () => {
84
+ if (!token || !selectedOrgId || !role.trim()) return;
85
+ setSaveStatus('SAVING');
86
+ try {
87
+ const res = await fetch(`${apiBase}/v1/organizations/${selectedOrgId}/personality`, {
88
+ method: 'PATCH',
89
+ headers: {
90
+ 'Content-Type': 'application/json',
91
+ 'Authorization': `Bearer ${token}`,
92
+ 'x-organization-id': selectedOrgId
93
+ },
94
+ body: JSON.stringify({
95
+ botName: 'Agent IA',
96
+ coreMission: role,
97
+ toneDescription: selectedTone
98
+ })
99
+ });
100
+ setSaveStatus(res.ok ? 'SAVED' : 'ERROR');
101
+ if (res.ok) setTimeout(() => setSaveStatus('IDLE'), 3000);
102
+ } catch {
103
+ setSaveStatus('ERROR');
104
+ }
105
+ };
106
+
107
+ const wordCount = kbStats ? kbStats.chunkCount * 180 : 0;
108
+
109
  return (
110
  <div className="p-8 max-w-4xl mx-auto">
111
  <h1 className="text-3xl font-bold text-slate-800 mb-2">Configuration de l'Agent IA</h1>
 
122
  Téléchargez vos catalogues, manuels de formation ou FAQ. L'IA utilisera ces documents pour répondre précisément à vos clients.
123
  </p>
124
 
125
+ <div className={`border-2 border-dashed rounded-2xl p-12 text-center transition-colors cursor-pointer group ${
126
+ uploadStatus === 'SUCCESS' ? 'border-emerald-400 bg-emerald-50' :
127
+ uploadStatus === 'ERROR' ? 'border-red-300 bg-red-50' :
128
+ 'border-slate-200 hover:border-emerald-400'
129
+ }`}>
130
+ <input
131
+ type="file"
132
+ id="kb-upload"
133
+ className="hidden"
134
+ accept=".pdf,.doc,.docx,.xlsx,.csv"
135
  onChange={handleFileUpload}
136
+ disabled={uploadStatus === 'UPLOADING'}
137
  />
138
  <label htmlFor="kb-upload" className="cursor-pointer">
139
  <div className="w-16 h-16 bg-emerald-50 text-emerald-600 rounded-full flex items-center justify-center mx-auto mb-4 group-hover:scale-110 transition-transform">
140
+ <span className="text-2xl">
141
+ {uploadStatus === 'UPLOADING' ? '⏳' :
142
+ uploadStatus === 'SUCCESS' ? '✅' :
143
+ uploadStatus === 'ERROR' ? '❌' : '📄'}
144
+ </span>
145
  </div>
146
  <p className="font-medium text-slate-800">
147
+ {uploadStatus === 'IDLE' && 'Cliquez pour uploader un document'}
148
+ {uploadStatus === 'UPLOADING' && 'Upload et indexation en cours...'}
149
+ {uploadStatus === 'SUCCESS' && 'Document indexé avec succès !'}
150
+ {uploadStatus === 'ERROR' && (uploadError || 'Erreur lors de l\'upload')}
151
  </p>
152
+ {kbStats?.knowledgeBaseUrl && uploadStatus !== 'UPLOADING' && (
153
+ <p className="text-xs text-emerald-600 mt-1 truncate max-w-xs mx-auto">
154
+ Actuel : {kbStats.knowledgeBaseUrl.split('/').pop()}
155
+ </p>
156
+ )}
157
+ <p className="text-xs text-slate-400 mt-2">PDF, DOCX, XLSX, CSV (Max 10MB)</p>
158
  </label>
159
  </div>
160
  </div>
 
167
  <div className="space-y-4">
168
  <div>
169
  <label className="block text-sm font-medium text-slate-600 mb-1">Rôle principal</label>
170
+ <input
171
+ type="text"
172
+ value={role}
173
+ onChange={e => setRole(e.target.value)}
174
  placeholder="Ex: Conseiller technique pour Agritech"
175
  className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:ring-2 focus:ring-emerald-500 outline-none"
176
  />
177
  </div>
178
  <div>
179
  <label className="block text-sm font-medium text-slate-600 mb-1">Ton et Style</label>
180
+ <div className="flex gap-2 flex-wrap">
181
+ {TONES.map(t => (
182
+ <button
183
+ key={t}
184
+ type="button"
185
+ onClick={() => setSelectedTone(t)}
186
+ className={`px-4 py-2 rounded-full border text-sm transition-colors ${
187
+ selectedTone === t
188
+ ? 'bg-emerald-500 border-emerald-500 text-white'
189
+ : 'border-slate-200 hover:bg-emerald-50 hover:border-emerald-200'
190
+ }`}
191
+ >
192
  {t}
193
  </button>
194
  ))}
195
  </div>
196
  </div>
197
+ <button
198
+ type="button"
199
+ onClick={handleSavePersonality}
200
+ disabled={!role.trim() || saveStatus === 'SAVING'}
201
+ className="mt-2 px-6 py-2.5 bg-slate-900 text-white rounded-xl text-sm font-bold hover:bg-slate-700 transition disabled:opacity-40"
202
+ >
203
+ {saveStatus === 'SAVING' ? 'Sauvegarde...' :
204
+ saveStatus === 'SAVED' ? '✓ Sauvegardé' :
205
+ saveStatus === 'ERROR' ? 'Erreur — Réessayer' :
206
+ 'Sauvegarder la personnalité'}
207
+ </button>
208
  </div>
209
  </div>
210
  </div>
 
220
  Quels sont les prix de vos engrais ?
221
  </div>
222
  <div className="bg-white text-slate-800 p-3 rounded-2xl rounded-bl-none text-xs self-start mt-2">
223
+ {kbStats?.hasKnowledgeBase
224
+ ? "D'après notre catalogue, nos engrais NPK sont à 15,000 FCFA le sac..."
225
+ : "Uploadez un document pour activer l'IA."}
226
  </div>
227
  </div>
228
  </div>
229
 
230
  <div className="bg-slate-50 p-6 rounded-3xl border border-slate-100">
231
+ <h4 className="font-bold text-slate-800 mb-3">Statistiques Agent</h4>
232
+ {kbStats === null ? (
233
+ <p className="text-xs text-slate-400">Chargement...</p>
234
+ ) : !kbStats.hasKnowledgeBase ? (
235
+ <p className="text-xs text-slate-400">Aucune base de connaissances indexée.</p>
236
+ ) : (
237
+ <div className="space-y-3">
238
+ <div className="flex justify-between text-sm">
239
+ <span className="text-slate-500">Statut</span>
240
+ <span className="text-emerald-600 font-bold">Actif</span>
241
+ </div>
242
+ <div className="flex justify-between text-sm">
243
+ <span className="text-slate-500">Chunks indexés</span>
244
+ <span className="text-slate-800 font-bold">{kbStats.chunkCount.toLocaleString()}</span>
245
+ </div>
246
+ <div className="flex justify-between text-sm">
247
+ <span className="text-slate-500">Mots estimés</span>
248
+ <span className="text-slate-800 font-bold">~{wordCount.toLocaleString()}</span>
249
+ </div>
250
  </div>
251
+ )}
252
  </div>
253
  </div>
254
  </div>
apps/admin/src/pages/CampaignHistoryPage.tsx ADDED
@@ -0,0 +1,228 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect } from 'react';
2
+ import { Send, Search, ChevronLeft, ChevronRight, Loader2, Megaphone, CheckCheck, Eye, AlertCircle, Clock } from 'lucide-react';
3
+ import { api } from '../lib/api';
4
+ import { useAuth } from '../lib/auth';
5
+ import { useTenant } from '../lib/tenant';
6
+
7
+ interface CampaignRecord {
8
+ id: string;
9
+ content: string;
10
+ status: 'SENT' | 'DELIVERED' | 'READ' | 'FAILED';
11
+ error?: string;
12
+ sentAt: string;
13
+ contact: { name?: string; phoneNumber: string };
14
+ }
15
+
16
+ interface CampaignStats {
17
+ SENT: number;
18
+ DELIVERED: number;
19
+ READ: number;
20
+ FAILED: number;
21
+ }
22
+
23
+ interface CampaignResponse {
24
+ records: CampaignRecord[];
25
+ total: number;
26
+ page: number;
27
+ limit: number;
28
+ stats: CampaignStats;
29
+ }
30
+
31
+ const PAGE_SIZE = 30;
32
+
33
+ const STATUS_CONFIG = {
34
+ SENT: { label: 'Envoyé', icon: Send, color: 'text-blue-500', bg: 'bg-blue-50' },
35
+ DELIVERED: { label: 'Livré', icon: CheckCheck, color: 'text-green-500', bg: 'bg-green-50' },
36
+ READ: { label: 'Lu', icon: Eye, color: 'text-violet-500',bg: 'bg-violet-50'},
37
+ FAILED: { label: 'Échoué', icon: AlertCircle, color: 'text-red-500', bg: 'bg-red-50' },
38
+ } as const;
39
+
40
+ type StatusFilter = 'ALL' | 'SENT' | 'DELIVERED' | 'READ' | 'FAILED';
41
+
42
+ export default function CampaignHistoryPage() {
43
+ const { token } = useAuth();
44
+ const { selectedOrgId } = useTenant();
45
+ const [data, setData] = useState<CampaignResponse | null>(null);
46
+ const [loading, setLoading] = useState(true);
47
+ const [page, setPage] = useState(1);
48
+ const [search, setSearch] = useState('');
49
+ const [statusFilter, setStatusFilter] = useState<StatusFilter>('ALL');
50
+
51
+ const fetchHistory = async (p = page, status = statusFilter) => {
52
+ if (!token || !selectedOrgId) return;
53
+ setLoading(true);
54
+ try {
55
+ const qs = new URLSearchParams({ page: String(p), limit: String(PAGE_SIZE) });
56
+ if (status !== 'ALL') qs.set('status', status);
57
+ const res = await api.get(
58
+ `/v1/organizations/${selectedOrgId}/campaign-history?${qs}`,
59
+ token
60
+ );
61
+ setData(res);
62
+ } catch (err) {
63
+ console.error('[CAMPAIGNS] Failed to fetch history:', err);
64
+ } finally {
65
+ setLoading(false);
66
+ }
67
+ };
68
+
69
+ useEffect(() => {
70
+ fetchHistory(1, statusFilter);
71
+ setPage(1);
72
+ }, [token, selectedOrgId, statusFilter]);
73
+
74
+ const handlePageChange = (newPage: number) => {
75
+ setPage(newPage);
76
+ fetchHistory(newPage, statusFilter);
77
+ };
78
+
79
+ const filteredRecords = (data?.records ?? []).filter(r => {
80
+ if (!search) return true;
81
+ return (
82
+ r.content.toLowerCase().includes(search.toLowerCase()) ||
83
+ (r.contact.name ?? '').toLowerCase().includes(search.toLowerCase()) ||
84
+ r.contact.phoneNumber.includes(search)
85
+ );
86
+ });
87
+
88
+ const totalPages = data ? Math.ceil(data.total / PAGE_SIZE) : 1;
89
+ const stats = data?.stats ?? { SENT: 0, DELIVERED: 0, READ: 0, FAILED: 0 };
90
+
91
+ return (
92
+ <div className="p-8 max-w-5xl mx-auto">
93
+ <div className="flex items-center gap-3 mb-8">
94
+ <div className="w-10 h-10 bg-amber-100 rounded-2xl flex items-center justify-center">
95
+ <Megaphone className="w-5 h-5 text-amber-600" />
96
+ </div>
97
+ <div>
98
+ <h1 className="text-2xl font-bold text-slate-900">Historique des Campagnes</h1>
99
+ <p className="text-sm text-slate-500">
100
+ {data ? `${data.total} messages envoyés au total` : 'Chargement…'}
101
+ </p>
102
+ </div>
103
+ </div>
104
+
105
+ {/* Stats bar */}
106
+ <div className="grid grid-cols-4 gap-4 mb-6">
107
+ {(Object.keys(STATUS_CONFIG) as Array<keyof typeof STATUS_CONFIG>).map(key => {
108
+ const cfg = STATUS_CONFIG[key];
109
+ const Icon = cfg.icon;
110
+ return (
111
+ <button
112
+ key={key}
113
+ onClick={() => setStatusFilter(statusFilter === key ? 'ALL' : key)}
114
+ className={`${cfg.bg} rounded-2xl p-4 text-left transition hover:opacity-80 ${statusFilter === key ? 'ring-2 ring-offset-1 ring-slate-400' : ''}`}
115
+ >
116
+ <div className={`flex items-center gap-2 ${cfg.color} mb-1`}>
117
+ <Icon className="w-4 h-4" />
118
+ <span className="text-xs font-semibold">{cfg.label}</span>
119
+ </div>
120
+ <p className="text-2xl font-bold text-slate-900">{stats[key].toLocaleString('fr-FR')}</p>
121
+ </button>
122
+ );
123
+ })}
124
+ </div>
125
+
126
+ {/* Search + filter */}
127
+ <div className="flex gap-3 mb-4">
128
+ <div className="relative flex-1">
129
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
130
+ <input
131
+ type="text"
132
+ placeholder="Rechercher par contact ou message…"
133
+ value={search}
134
+ onChange={e => setSearch(e.target.value)}
135
+ className="w-full pl-10 pr-4 py-2.5 border border-slate-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-amber-400"
136
+ />
137
+ </div>
138
+ {statusFilter !== 'ALL' && (
139
+ <button
140
+ onClick={() => setStatusFilter('ALL')}
141
+ className="px-4 py-2 text-sm text-slate-500 border border-slate-200 rounded-xl hover:bg-slate-50 transition"
142
+ >
143
+ Effacer filtre
144
+ </button>
145
+ )}
146
+ </div>
147
+
148
+ {loading ? (
149
+ <div className="flex items-center justify-center py-20">
150
+ <Loader2 className="w-8 h-8 animate-spin text-slate-400" />
151
+ </div>
152
+ ) : filteredRecords.length === 0 ? (
153
+ <div className="text-center py-20 text-slate-400">
154
+ <Send className="w-12 h-12 mx-auto mb-3 opacity-30" />
155
+ <p className="font-medium">Aucune campagne trouvée</p>
156
+ <p className="text-sm mt-1">Envoyez votre première campagne depuis la section Contacts.</p>
157
+ </div>
158
+ ) : (
159
+ <div className="bg-white rounded-2xl border border-slate-100 overflow-hidden">
160
+ <table className="w-full text-sm">
161
+ <thead>
162
+ <tr className="border-b border-slate-100">
163
+ <th className="text-left px-5 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider">Contact</th>
164
+ <th className="text-left px-5 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider">Message</th>
165
+ <th className="text-left px-5 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider">Statut</th>
166
+ <th className="text-left px-5 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider">Envoyé</th>
167
+ </tr>
168
+ </thead>
169
+ <tbody className="divide-y divide-slate-50">
170
+ {filteredRecords.map(record => {
171
+ const cfg = STATUS_CONFIG[record.status] ?? STATUS_CONFIG.SENT;
172
+ const Icon = cfg.icon;
173
+ return (
174
+ <tr key={record.id} className="hover:bg-slate-50 transition">
175
+ <td className="px-5 py-3">
176
+ <p className="font-medium text-slate-800">{record.contact.name || '—'}</p>
177
+ <p className="text-xs text-slate-400">{record.contact.phoneNumber}</p>
178
+ </td>
179
+ <td className="px-5 py-3 max-w-xs">
180
+ <p className="text-slate-700 line-clamp-2">{record.content}</p>
181
+ {record.error && (
182
+ <p className="text-xs text-red-400 mt-1">{record.error}</p>
183
+ )}
184
+ </td>
185
+ <td className="px-5 py-3">
186
+ <span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium ${cfg.bg} ${cfg.color}`}>
187
+ <Icon className="w-3 h-3" />
188
+ {cfg.label}
189
+ </span>
190
+ </td>
191
+ <td className="px-5 py-3 text-slate-400 whitespace-nowrap">
192
+ <div className="flex items-center gap-1">
193
+ <Clock className="w-3 h-3" />
194
+ {new Date(record.sentAt).toLocaleString('fr-FR', { dateStyle: 'short', timeStyle: 'short' })}
195
+ </div>
196
+ </td>
197
+ </tr>
198
+ );
199
+ })}
200
+ </tbody>
201
+ </table>
202
+ </div>
203
+ )}
204
+
205
+ {!search && totalPages > 1 && (
206
+ <div className="flex items-center justify-between mt-6">
207
+ <p className="text-sm text-slate-500">Page {page} sur {totalPages}</p>
208
+ <div className="flex gap-2">
209
+ <button
210
+ onClick={() => handlePageChange(page - 1)}
211
+ disabled={page === 1}
212
+ className="p-2 rounded-xl border border-slate-200 hover:bg-slate-50 disabled:opacity-40 transition"
213
+ >
214
+ <ChevronLeft className="w-4 h-4" />
215
+ </button>
216
+ <button
217
+ onClick={() => handlePageChange(page + 1)}
218
+ disabled={page === totalPages}
219
+ className="p-2 rounded-xl border border-slate-200 hover:bg-slate-50 disabled:opacity-40 transition"
220
+ >
221
+ <ChevronRight className="w-4 h-4" />
222
+ </button>
223
+ </div>
224
+ </div>
225
+ )}
226
+ </div>
227
+ );
228
+ }
apps/admin/src/pages/ClientsManagementView.tsx CHANGED
@@ -49,6 +49,7 @@ export default function ClientsManagementView() {
49
  });
50
  const [isCreating, setIsCreating] = useState(false);
51
  const [showGuide, setShowGuide] = useState(false);
 
52
 
53
  const fetchClients = async () => {
54
  if (!token) return;
@@ -219,7 +220,10 @@ export default function ClientsManagementView() {
219
  </div>
220
  </div>
221
  <div className="flex items-center justify-end">
222
- <button className="text-sm font-bold text-indigo-600 hover:text-indigo-700 underline underline-offset-4">
 
 
 
223
  Détails & Facturation
224
  </button>
225
  </div>
@@ -354,6 +358,65 @@ export default function ClientsManagementView() {
354
  </div>
355
  )}
356
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
357
  {/* Personality Studio Modal */}
358
  {selectedOrgForPersonality && (
359
  <PersonalityStudioModal
 
49
  });
50
  const [isCreating, setIsCreating] = useState(false);
51
  const [showGuide, setShowGuide] = useState(false);
52
+ const [billingOrg, setBillingOrg] = useState<Organization | null>(null);
53
 
54
  const fetchClients = async () => {
55
  if (!token) return;
 
220
  </div>
221
  </div>
222
  <div className="flex items-center justify-end">
223
+ <button
224
+ onClick={() => setBillingOrg(client)}
225
+ className="text-sm font-bold text-indigo-600 hover:text-indigo-700 underline underline-offset-4"
226
+ >
227
  Détails & Facturation
228
  </button>
229
  </div>
 
358
  </div>
359
  )}
360
 
361
+ {/* Billing Modal */}
362
+ {billingOrg && (
363
+ <div className="fixed inset-0 bg-slate-900/60 backdrop-blur-sm flex items-center justify-center p-6 z-[100]">
364
+ <div className="bg-white rounded-3xl shadow-2xl w-full max-w-lg">
365
+ <div className="flex items-center justify-between p-6 border-b border-slate-100">
366
+ <h2 className="text-xl font-bold text-slate-900">Détails & Facturation</h2>
367
+ <button onClick={() => setBillingOrg(null)} className="p-2 hover:bg-slate-100 rounded-full transition">
368
+ <X className="w-5 h-5 text-slate-500" />
369
+ </button>
370
+ </div>
371
+ <div className="p-6 space-y-4">
372
+ <div className="flex items-center gap-3 bg-slate-50 rounded-2xl p-4">
373
+ <Building2 className="w-8 h-8 text-slate-400 shrink-0" />
374
+ <div>
375
+ <p className="font-bold text-slate-900">{billingOrg.name}</p>
376
+ <p className="text-xs text-slate-400 font-mono">{billingOrg.id}</p>
377
+ </div>
378
+ </div>
379
+ <div className="grid grid-cols-2 gap-3 text-sm">
380
+ <div className="bg-slate-50 rounded-2xl p-4">
381
+ <p className="text-xs font-bold text-slate-400 uppercase tracking-widest mb-1">Mode</p>
382
+ <p className="font-semibold text-slate-800">{billingOrg.mode}</p>
383
+ </div>
384
+ <div className="bg-slate-50 rounded-2xl p-4">
385
+ <p className="text-xs font-bold text-slate-400 uppercase tracking-widest mb-1">Statut Meta</p>
386
+ <p className={`font-semibold ${billingOrg.metaVerificationStatus === 'VERIFIED' ? 'text-emerald-600' : 'text-amber-600'}`}>
387
+ {billingOrg.metaVerificationStatus === 'VERIFIED' ? 'Vérifié' : 'Non vérifié'}
388
+ </p>
389
+ </div>
390
+ <div className="bg-slate-50 rounded-2xl p-4">
391
+ <p className="text-xs font-bold text-slate-400 uppercase tracking-widest mb-1">Limite quotidienne</p>
392
+ <p className="font-semibold text-slate-800">{billingOrg.dailyMessageLimit} conv.</p>
393
+ </div>
394
+ <div className="bg-slate-50 rounded-2xl p-4">
395
+ <p className="text-xs font-bold text-slate-400 uppercase tracking-widest mb-1">Contrat PaaS</p>
396
+ <p className={`font-semibold ${billingOrg.contractSigned ? 'text-blue-600' : 'text-slate-500'}`}>
397
+ {billingOrg.contractSigned ? `Signé${billingOrg.contractSignerName ? ' par ' + billingOrg.contractSignerName : ''}` : 'En attente'}
398
+ </p>
399
+ </div>
400
+ </div>
401
+ {billingOrg.wabaId && (
402
+ <div className="bg-slate-50 rounded-2xl p-4 text-sm">
403
+ <p className="text-xs font-bold text-slate-400 uppercase tracking-widest mb-1">WABA ID</p>
404
+ <p className="font-mono text-slate-700">{billingOrg.wabaId}</p>
405
+ </div>
406
+ )}
407
+ </div>
408
+ <div className="p-6 border-t border-slate-100">
409
+ <button
410
+ onClick={() => setBillingOrg(null)}
411
+ className="w-full py-3 bg-slate-900 text-white rounded-2xl font-bold hover:bg-slate-700 transition"
412
+ >
413
+ Fermer
414
+ </button>
415
+ </div>
416
+ </div>
417
+ </div>
418
+ )}
419
+
420
  {/* Personality Studio Modal */}
421
  {selectedOrgForPersonality && (
422
  <PersonalityStudioModal
apps/admin/src/pages/ContactsPage.tsx CHANGED
@@ -33,6 +33,8 @@ export default function ContactsPage() {
33
  const [showBulkModal, setShowBulkModal] = useState(false);
34
  const [bulkProgress, setBulkProgress] = useState({ current: 0, total: 0, status: 'idle' });
35
  const [bulkResults, setBulkResults] = useState<any[]>([]);
 
 
36
 
37
  const fetchContacts = async () => {
38
  if (!token || !selectedOrgId) return;
@@ -47,8 +49,19 @@ export default function ContactsPage() {
47
  }
48
  };
49
 
 
 
 
 
 
 
 
 
 
 
50
  useEffect(() => {
51
  fetchContacts();
 
52
  }, [token, selectedOrgId]);
53
 
54
  const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -215,6 +228,24 @@ export default function ContactsPage() {
215
  }
216
  };
217
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
218
  const filteredContacts = contacts.filter(c => {
219
  const searchLower = searchQuery.toLowerCase();
220
  const inName = c.name?.toLowerCase().includes(searchLower);
@@ -247,7 +278,11 @@ export default function ContactsPage() {
247
  >
248
  <Upload className="w-5 h-5" /> Import Excel/CSV
249
  </button>
250
- <button className="p-3.5 bg-white border border-slate-200 text-slate-600 rounded-2xl hover:bg-slate-50 transition shadow-sm">
 
 
 
 
251
  <Download className="w-5 h-5" />
252
  </button>
253
  </div>
@@ -270,7 +305,7 @@ export default function ContactsPage() {
270
  </div>
271
  <div>
272
  <p className="text-xs font-bold text-slate-400 uppercase tracking-widest">Actifs (24h)</p>
273
- <p className="text-2xl font-black text-slate-900">0</p>
274
  </div>
275
  </div>
276
  <div className="bg-white p-6 rounded-[2rem] border border-slate-100 shadow-sm flex items-center gap-4">
@@ -279,7 +314,7 @@ export default function ContactsPage() {
279
  </div>
280
  <div>
281
  <p className="text-xs font-bold text-slate-400 uppercase tracking-widest">Segments</p>
282
- <p className="text-2xl font-black text-slate-900">1</p>
283
  </div>
284
  </div>
285
  </div>
@@ -298,7 +333,10 @@ export default function ContactsPage() {
298
  />
299
  </div>
300
  <div className="flex items-center gap-2">
301
- <button className="flex items-center gap-2 px-5 py-4 rounded-2xl font-bold text-slate-600 hover:bg-slate-50 transition">
 
 
 
302
  <Filter className="w-4 h-4" /> Filtres
303
  </button>
304
  </div>
 
33
  const [showBulkModal, setShowBulkModal] = useState(false);
34
  const [bulkProgress, setBulkProgress] = useState({ current: 0, total: 0, status: 'idle' });
35
  const [bulkResults, setBulkResults] = useState<any[]>([]);
36
+ const [activeCount, setActiveCount] = useState<number | null>(null);
37
+ const [showFilters, setShowFilters] = useState(false);
38
 
39
  const fetchContacts = async () => {
40
  if (!token || !selectedOrgId) return;
 
49
  }
50
  };
51
 
52
+ const fetchActiveCount = async () => {
53
+ if (!token || !selectedOrgId) return;
54
+ try {
55
+ const data = await api.get('/v1/analytics/usage', token);
56
+ setActiveCount(data?.users?.activeLast24h ?? 0);
57
+ } catch {
58
+ setActiveCount(0);
59
+ }
60
+ };
61
+
62
  useEffect(() => {
63
  fetchContacts();
64
+ fetchActiveCount();
65
  }, [token, selectedOrgId]);
66
 
67
  const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
 
228
  }
229
  };
230
 
231
+ const handleExportCsv = () => {
232
+ if (filteredContacts.length === 0) return;
233
+ const headers = ['Nom', 'Téléphone', 'Créé le'];
234
+ const rows = filteredContacts.map(c => [
235
+ c.name ?? '',
236
+ c.phoneNumber,
237
+ new Date(c.createdAt).toLocaleDateString('fr-FR')
238
+ ]);
239
+ const csv = [headers, ...rows].map(r => r.map(v => `"${v}"`).join(',')).join('\n');
240
+ const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
241
+ const url = URL.createObjectURL(blob);
242
+ const a = document.createElement('a');
243
+ a.href = url;
244
+ a.download = `contacts-${new Date().toISOString().slice(0, 10)}.csv`;
245
+ a.click();
246
+ URL.revokeObjectURL(url);
247
+ };
248
+
249
  const filteredContacts = contacts.filter(c => {
250
  const searchLower = searchQuery.toLowerCase();
251
  const inName = c.name?.toLowerCase().includes(searchLower);
 
278
  >
279
  <Upload className="w-5 h-5" /> Import Excel/CSV
280
  </button>
281
+ <button
282
+ onClick={handleExportCsv}
283
+ title="Exporter en CSV"
284
+ className="p-3.5 bg-white border border-slate-200 text-slate-600 rounded-2xl hover:bg-slate-50 transition shadow-sm"
285
+ >
286
  <Download className="w-5 h-5" />
287
  </button>
288
  </div>
 
305
  </div>
306
  <div>
307
  <p className="text-xs font-bold text-slate-400 uppercase tracking-widest">Actifs (24h)</p>
308
+ <p className="text-2xl font-black text-slate-900">{activeCount ?? '—'}</p>
309
  </div>
310
  </div>
311
  <div className="bg-white p-6 rounded-[2rem] border border-slate-100 shadow-sm flex items-center gap-4">
 
314
  </div>
315
  <div>
316
  <p className="text-xs font-bold text-slate-400 uppercase tracking-widest">Segments</p>
317
+ <p className="text-2xl font-black text-slate-900">{searchQuery ? 1 : contacts.length > 0 ? 1 : 0}</p>
318
  </div>
319
  </div>
320
  </div>
 
333
  />
334
  </div>
335
  <div className="flex items-center gap-2">
336
+ <button
337
+ onClick={() => setShowFilters(v => !v)}
338
+ className={`flex items-center gap-2 px-5 py-4 rounded-2xl font-bold transition ${showFilters ? 'bg-blue-50 text-blue-600' : 'text-slate-600 hover:bg-slate-50'}`}
339
+ >
340
  <Filter className="w-4 h-4" /> Filtres
341
  </button>
342
  </div>
apps/admin/src/pages/KnowledgeBasePage.tsx ADDED
@@ -0,0 +1,201 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect } from 'react';
2
+ import { Database, Trash2, RefreshCw, Search, ChevronLeft, ChevronRight, Loader2, FileText } from 'lucide-react';
3
+ import { api } from '../lib/api';
4
+ import { useAuth } from '../lib/auth';
5
+ import { useTenant } from '../lib/tenant';
6
+
7
+ interface KbEntry {
8
+ id: string;
9
+ content: string;
10
+ metadata?: Record<string, unknown>;
11
+ createdAt: string;
12
+ }
13
+
14
+ interface KbResponse {
15
+ entries: KbEntry[];
16
+ total: number;
17
+ page: number;
18
+ limit: number;
19
+ }
20
+
21
+ const PAGE_SIZE = 20;
22
+
23
+ export default function KnowledgeBasePage() {
24
+ const { token } = useAuth();
25
+ const { selectedOrgId } = useTenant();
26
+ const [data, setData] = useState<KbResponse | null>(null);
27
+ const [loading, setLoading] = useState(true);
28
+ const [page, setPage] = useState(1);
29
+ const [search, setSearch] = useState('');
30
+ const [deletingId, setDeletingId] = useState<string | null>(null);
31
+ const [reindexing, setReindexing] = useState(false);
32
+
33
+ const fetchEntries = async (p = page) => {
34
+ if (!token || !selectedOrgId) return;
35
+ setLoading(true);
36
+ try {
37
+ const res = await api.get(
38
+ `/v1/organizations/${selectedOrgId}/kb?page=${p}&limit=${PAGE_SIZE}`,
39
+ token
40
+ );
41
+ setData(res);
42
+ } catch (err) {
43
+ console.error('[KB] Failed to fetch entries:', err);
44
+ } finally {
45
+ setLoading(false);
46
+ }
47
+ };
48
+
49
+ useEffect(() => {
50
+ fetchEntries(1);
51
+ setPage(1);
52
+ }, [token, selectedOrgId]);
53
+
54
+ const handleDelete = async (id: string) => {
55
+ if (!token || !selectedOrgId) return;
56
+ if (!confirm('Supprimer ce chunk de la base de connaissances ?')) return;
57
+ setDeletingId(id);
58
+ try {
59
+ await api.delete(`/v1/organizations/${selectedOrgId}/kb/${id}`, token);
60
+ await fetchEntries(page);
61
+ } catch (err) {
62
+ console.error('[KB] Delete failed:', err);
63
+ } finally {
64
+ setDeletingId(null);
65
+ }
66
+ };
67
+
68
+ const handleReindex = async () => {
69
+ if (!token || !selectedOrgId) return;
70
+ setReindexing(true);
71
+ try {
72
+ await api.post(`/v1/organizations/${selectedOrgId}/index-kb`, {}, token);
73
+ } catch (err) {
74
+ console.error('[KB] Re-index failed:', err);
75
+ } finally {
76
+ setReindexing(false);
77
+ }
78
+ };
79
+
80
+ const handlePageChange = (newPage: number) => {
81
+ setPage(newPage);
82
+ fetchEntries(newPage);
83
+ };
84
+
85
+ const filteredEntries = (data?.entries ?? []).filter(e =>
86
+ !search || e.content.toLowerCase().includes(search.toLowerCase())
87
+ );
88
+
89
+ const totalPages = data ? Math.ceil(data.total / PAGE_SIZE) : 1;
90
+
91
+ return (
92
+ <div className="p-8 max-w-5xl mx-auto">
93
+ <div className="flex items-center justify-between mb-8">
94
+ <div className="flex items-center gap-3">
95
+ <div className="w-10 h-10 bg-violet-100 rounded-2xl flex items-center justify-center">
96
+ <Database className="w-5 h-5 text-violet-600" />
97
+ </div>
98
+ <div>
99
+ <h1 className="text-2xl font-bold text-slate-900">Base de Connaissances</h1>
100
+ <p className="text-sm text-slate-500">
101
+ {data ? `${data.total} chunks indexés` : 'Chargement…'}
102
+ </p>
103
+ </div>
104
+ </div>
105
+ <button
106
+ onClick={handleReindex}
107
+ disabled={reindexing}
108
+ className="flex items-center gap-2 px-4 py-2 bg-violet-600 text-white rounded-xl text-sm font-medium hover:bg-violet-700 transition disabled:opacity-50"
109
+ >
110
+ <RefreshCw className={`w-4 h-4 ${reindexing ? 'animate-spin' : ''}`} />
111
+ {reindexing ? 'Indexation…' : 'Re-indexer'}
112
+ </button>
113
+ </div>
114
+
115
+ <div className="relative mb-4">
116
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
117
+ <input
118
+ type="text"
119
+ placeholder="Rechercher dans les chunks…"
120
+ value={search}
121
+ onChange={e => setSearch(e.target.value)}
122
+ className="w-full pl-10 pr-4 py-2.5 border border-slate-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-violet-400"
123
+ />
124
+ </div>
125
+
126
+ {loading ? (
127
+ <div className="flex items-center justify-center py-20">
128
+ <Loader2 className="w-8 h-8 animate-spin text-slate-400" />
129
+ </div>
130
+ ) : filteredEntries.length === 0 ? (
131
+ <div className="text-center py-20 text-slate-400">
132
+ <FileText className="w-12 h-12 mx-auto mb-3 opacity-30" />
133
+ <p className="font-medium">Aucun chunk trouvé</p>
134
+ <p className="text-sm mt-1">Importez un document dans l'onglet Agent IA pour commencer.</p>
135
+ </div>
136
+ ) : (
137
+ <div className="space-y-3">
138
+ {filteredEntries.map((entry, i) => (
139
+ <div key={entry.id} className="bg-white rounded-2xl border border-slate-100 p-5 hover:shadow-sm transition group">
140
+ <div className="flex items-start justify-between gap-4">
141
+ <div className="flex-1 min-w-0">
142
+ <div className="flex items-center gap-2 mb-2">
143
+ <span className="text-xs font-mono bg-slate-100 text-slate-500 px-2 py-0.5 rounded-lg">
144
+ #{(page - 1) * PAGE_SIZE + i + 1}
145
+ </span>
146
+ {entry.metadata && (
147
+ <span className="text-xs text-slate-400">
148
+ {Object.entries(entry.metadata as Record<string, unknown>)
149
+ .slice(0, 2)
150
+ .map(([k, v]) => `${k}: ${v}`)
151
+ .join(' · ')}
152
+ </span>
153
+ )}
154
+ <span className="text-xs text-slate-300 ml-auto">
155
+ {new Date(entry.createdAt).toLocaleDateString('fr-FR')}
156
+ </span>
157
+ </div>
158
+ <p className="text-sm text-slate-700 leading-relaxed line-clamp-4">
159
+ {entry.content}
160
+ </p>
161
+ </div>
162
+ <button
163
+ onClick={() => handleDelete(entry.id)}
164
+ disabled={deletingId === entry.id}
165
+ className="opacity-0 group-hover:opacity-100 p-2 text-red-400 hover:text-red-600 hover:bg-red-50 rounded-xl transition"
166
+ title="Supprimer ce chunk"
167
+ >
168
+ {deletingId === entry.id
169
+ ? <Loader2 className="w-4 h-4 animate-spin" />
170
+ : <Trash2 className="w-4 h-4" />}
171
+ </button>
172
+ </div>
173
+ </div>
174
+ ))}
175
+ </div>
176
+ )}
177
+
178
+ {!search && totalPages > 1 && (
179
+ <div className="flex items-center justify-between mt-6">
180
+ <p className="text-sm text-slate-500">Page {page} sur {totalPages}</p>
181
+ <div className="flex gap-2">
182
+ <button
183
+ onClick={() => handlePageChange(page - 1)}
184
+ disabled={page === 1}
185
+ className="p-2 rounded-xl border border-slate-200 hover:bg-slate-50 disabled:opacity-40 transition"
186
+ >
187
+ <ChevronLeft className="w-4 h-4" />
188
+ </button>
189
+ <button
190
+ onClick={() => handlePageChange(page + 1)}
191
+ disabled={page === totalPages}
192
+ className="p-2 rounded-xl border border-slate-200 hover:bg-slate-50 disabled:opacity-40 transition"
193
+ >
194
+ <ChevronRight className="w-4 h-4" />
195
+ </button>
196
+ </div>
197
+ </div>
198
+ )}
199
+ </div>
200
+ );
201
+ }
apps/admin/src/pages/ResetPasswordPage.tsx ADDED
@@ -0,0 +1,175 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect } from 'react';
2
+ import { useNavigate } from 'react-router-dom';
3
+
4
+ type Step = 'request' | 'sent' | 'reset' | 'done' | 'error';
5
+
6
+ export default function ResetPasswordPage() {
7
+ const navigate = useNavigate();
8
+ const [step, setStep] = useState<Step>('request');
9
+ const [email, setEmail] = useState('');
10
+ const [password, setPassword] = useState('');
11
+ const [confirm, setConfirm] = useState('');
12
+ const [loading, setLoading] = useState(false);
13
+ const [errorMsg, setErrorMsg] = useState('');
14
+ const [token, setToken] = useState<string | null>(null);
15
+
16
+ const apiBase = import.meta.env.VITE_API_URL;
17
+
18
+ useEffect(() => {
19
+ const params = new URLSearchParams(window.location.search);
20
+ const t = params.get('token');
21
+ if (t) {
22
+ setToken(t);
23
+ setStep('reset');
24
+ }
25
+ }, []);
26
+
27
+ const handleRequest = async (e: React.FormEvent) => {
28
+ e.preventDefault();
29
+ if (!email.trim()) return;
30
+ setLoading(true);
31
+ setErrorMsg('');
32
+ try {
33
+ await fetch(`${apiBase}/v1/auth/forgot-password`, {
34
+ method: 'POST',
35
+ headers: { 'Content-Type': 'application/json' },
36
+ body: JSON.stringify({ email })
37
+ });
38
+ // Always show "sent" regardless of whether email exists (anti-enumeration)
39
+ setStep('sent');
40
+ } catch {
41
+ setErrorMsg('Erreur réseau. Veuillez réessayer.');
42
+ } finally {
43
+ setLoading(false);
44
+ }
45
+ };
46
+
47
+ const handleReset = async (e: React.FormEvent) => {
48
+ e.preventDefault();
49
+ if (password !== confirm) { setErrorMsg('Les mots de passe ne correspondent pas.'); return; }
50
+ if (password.length < 6) { setErrorMsg('Le mot de passe doit contenir au moins 6 caractères.'); return; }
51
+ setLoading(true);
52
+ setErrorMsg('');
53
+ try {
54
+ const res = await fetch(`${apiBase}/v1/auth/reset-password`, {
55
+ method: 'POST',
56
+ headers: { 'Content-Type': 'application/json' },
57
+ body: JSON.stringify({ token, password })
58
+ });
59
+ if (res.ok) {
60
+ setStep('done');
61
+ } else {
62
+ const data = await res.json().catch(() => ({}));
63
+ setErrorMsg(data.error || 'Token invalide ou expiré.');
64
+ }
65
+ } catch {
66
+ setErrorMsg('Erreur réseau. Veuillez réessayer.');
67
+ } finally {
68
+ setLoading(false);
69
+ }
70
+ };
71
+
72
+ return (
73
+ <div className="min-h-screen bg-slate-50 flex items-center justify-center p-4">
74
+ <div className="bg-white rounded-3xl shadow-sm border border-slate-100 w-full max-w-md p-10">
75
+ <div className="mb-8 text-center">
76
+ <div className="w-14 h-14 bg-slate-900 rounded-2xl flex items-center justify-center mx-auto mb-4">
77
+ <span className="text-white text-2xl font-black">X</span>
78
+ </div>
79
+ <h1 className="text-2xl font-bold text-slate-900">
80
+ {step === 'request' && 'Mot de passe oublié'}
81
+ {step === 'sent' && 'Email envoyé'}
82
+ {step === 'reset' && 'Nouveau mot de passe'}
83
+ {step === 'done' && 'Mot de passe mis à jour'}
84
+ </h1>
85
+ <p className="text-slate-500 text-sm mt-2">
86
+ {step === 'request' && "Entrez votre email pour recevoir un lien de réinitialisation."}
87
+ {step === 'sent' && "Si ce compte existe, vous recevrez un email dans quelques minutes."}
88
+ {step === 'reset' && "Choisissez un nouveau mot de passe."}
89
+ {step === 'done' && "Votre mot de passe a été mis à jour avec succès."}
90
+ </p>
91
+ </div>
92
+
93
+ {step === 'request' && (
94
+ <form onSubmit={handleRequest} className="space-y-4">
95
+ <div>
96
+ <label className="block text-sm font-medium text-slate-700 mb-1">Adresse email</label>
97
+ <input
98
+ type="email"
99
+ required
100
+ value={email}
101
+ onChange={e => setEmail(e.target.value)}
102
+ placeholder="vous@exemple.com"
103
+ className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:ring-2 focus:ring-slate-900 outline-none text-sm"
104
+ />
105
+ </div>
106
+ {errorMsg && <p className="text-red-500 text-sm">{errorMsg}</p>}
107
+ <button
108
+ type="submit"
109
+ disabled={loading}
110
+ className="w-full py-3 bg-slate-900 text-white rounded-xl font-bold hover:bg-slate-700 transition disabled:opacity-50"
111
+ >
112
+ {loading ? 'Envoi...' : 'Envoyer le lien'}
113
+ </button>
114
+ <button type="button" onClick={() => navigate('/login')} className="w-full text-center text-sm text-slate-400 hover:text-slate-600 transition">
115
+ Retour à la connexion
116
+ </button>
117
+ </form>
118
+ )}
119
+
120
+ {step === 'sent' && (
121
+ <div className="text-center space-y-4">
122
+ <div className="text-5xl">📬</div>
123
+ <button onClick={() => navigate('/login')} className="w-full py-3 bg-slate-900 text-white rounded-xl font-bold hover:bg-slate-700 transition">
124
+ Retour à la connexion
125
+ </button>
126
+ </div>
127
+ )}
128
+
129
+ {step === 'reset' && (
130
+ <form onSubmit={handleReset} className="space-y-4">
131
+ <div>
132
+ <label className="block text-sm font-medium text-slate-700 mb-1">Nouveau mot de passe</label>
133
+ <input
134
+ type="password"
135
+ required
136
+ value={password}
137
+ onChange={e => setPassword(e.target.value)}
138
+ placeholder="Minimum 6 caractères"
139
+ className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:ring-2 focus:ring-slate-900 outline-none text-sm"
140
+ />
141
+ </div>
142
+ <div>
143
+ <label className="block text-sm font-medium text-slate-700 mb-1">Confirmer</label>
144
+ <input
145
+ type="password"
146
+ required
147
+ value={confirm}
148
+ onChange={e => setConfirm(e.target.value)}
149
+ placeholder="Répétez le mot de passe"
150
+ className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:ring-2 focus:ring-slate-900 outline-none text-sm"
151
+ />
152
+ </div>
153
+ {errorMsg && <p className="text-red-500 text-sm">{errorMsg}</p>}
154
+ <button
155
+ type="submit"
156
+ disabled={loading}
157
+ className="w-full py-3 bg-slate-900 text-white rounded-xl font-bold hover:bg-slate-700 transition disabled:opacity-50"
158
+ >
159
+ {loading ? 'Mise à jour...' : 'Définir le mot de passe'}
160
+ </button>
161
+ </form>
162
+ )}
163
+
164
+ {step === 'done' && (
165
+ <div className="text-center space-y-4">
166
+ <div className="text-5xl">✅</div>
167
+ <button onClick={() => navigate('/login')} className="w-full py-3 bg-slate-900 text-white rounded-xl font-bold hover:bg-slate-700 transition">
168
+ Se connecter
169
+ </button>
170
+ </div>
171
+ )}
172
+ </div>
173
+ </div>
174
+ );
175
+ }
apps/admin/src/pages/SettingsPage.tsx CHANGED
@@ -221,23 +221,9 @@ export default function SettingsPage() {
221
  <div className="text-xs font-bold text-indigo-600 uppercase mb-1">Statut Actuel</div>
222
  <div className="text-lg font-bold text-indigo-900">{org.subscriptionStatus || 'INACTIF'}</div>
223
  </div>
224
- <p className="text-xs text-slate-500 mb-6">
225
- Gérez vos factures, changez de forfait ou mettez à jour votre moyen de paiement en toute sécurité.
226
  </p>
227
- <button
228
- onClick={async () => {
229
- try {
230
- const { url } = await api.post('/v1/payments/customer-portal', {}, token, selectedOrgId);
231
- window.location.href = url;
232
- } catch (err) {
233
- console.error(err);
234
- alert('Impossible d\'ouvrir le portail de facturation.');
235
- }
236
- }}
237
- className="w-full py-3 bg-indigo-600 text-white rounded-xl font-bold hover:bg-indigo-700 transition-all flex items-center justify-center gap-2"
238
- >
239
- <span>⚙️</span> Gérer mon abonnement
240
- </button>
241
  </section>
242
  </div>
243
  </div>
 
221
  <div className="text-xs font-bold text-indigo-600 uppercase mb-1">Statut Actuel</div>
222
  <div className="text-lg font-bold text-indigo-900">{org.subscriptionStatus || 'INACTIF'}</div>
223
  </div>
224
+ <p className="text-xs text-slate-500">
225
+ Paiement via Orange Money et Wave portail de gestion disponible prochainement.
226
  </p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
227
  </section>
228
  </div>
229
  </div>
apps/admin/src/pages/TrackFormPage.tsx CHANGED
@@ -14,7 +14,7 @@ export default function TrackFormPage() {
14
 
15
  const [form, setForm] = useState({
16
  title: '', description: '', duration: 7, language: 'FR',
17
- isPremium: false, priceAmount: 0, stripePriceId: ''
18
  });
19
  const [saving, setSaving] = useState(false);
20
 
@@ -24,8 +24,7 @@ export default function TrackFormPage() {
24
  .then(r => r.json())
25
  .then(t => setForm({
26
  title: t.title, description: t.description || '', duration: t.duration,
27
- language: t.language, isPremium: t.isPremium, priceAmount: t.priceAmount || 0,
28
- stripePriceId: t.stripePriceId || ''
29
  }));
30
  }
31
  }, [id, token, selectedOrgId, isNew]);
@@ -42,8 +41,7 @@ export default function TrackFormPage() {
42
  headers: ah(token, selectedOrgId),
43
  body: JSON.stringify({
44
  ...form,
45
- priceAmount: form.priceAmount || undefined,
46
- stripePriceId: form.stripePriceId || undefined
47
  })
48
  });
49
  navigate('/content');
@@ -72,11 +70,9 @@ export default function TrackFormPage() {
72
  <input type="checkbox" checked={form.isPremium} onChange={e => setForm(f => ({ ...f, isPremium: e.target.checked }))} className="w-4 h-4" />
73
  <span className="text-sm font-medium text-amber-800">Formation Premium (payante)</span>
74
  </label>
75
- {form.isPremium && <div className="grid grid-cols-2 gap-4">
76
- <div><label className="text-sm font-medium text-slate-700 mb-1 block">Prix (XOF)</label>
77
- <input type="number" className={inp} value={form.priceAmount} onChange={e => setForm(f => ({ ...f, priceAmount: parseInt(e.target.value) }))} /></div>
78
- <div><label className="text-sm font-medium text-slate-700 mb-1 block">Stripe Price ID</label>
79
- <input className={inp} value={form.stripePriceId} onChange={e => setForm(f => ({ ...f, stripePriceId: e.target.value }))} /></div>
80
  </div>}
81
  <div className="flex gap-3 pt-2">
82
  <button type="button" onClick={() => navigate('/content')} className="flex-1 border border-slate-200 text-slate-600 py-2.5 rounded-xl text-sm hover:bg-slate-50">Annuler</button>
 
14
 
15
  const [form, setForm] = useState({
16
  title: '', description: '', duration: 7, language: 'FR',
17
+ isPremium: false, priceAmount: 0
18
  });
19
  const [saving, setSaving] = useState(false);
20
 
 
24
  .then(r => r.json())
25
  .then(t => setForm({
26
  title: t.title, description: t.description || '', duration: t.duration,
27
+ language: t.language, isPremium: t.isPremium, priceAmount: t.priceAmount || 0
 
28
  }));
29
  }
30
  }, [id, token, selectedOrgId, isNew]);
 
41
  headers: ah(token, selectedOrgId),
42
  body: JSON.stringify({
43
  ...form,
44
+ priceAmount: form.priceAmount || undefined
 
45
  })
46
  });
47
  navigate('/content');
 
70
  <input type="checkbox" checked={form.isPremium} onChange={e => setForm(f => ({ ...f, isPremium: e.target.checked }))} className="w-4 h-4" />
71
  <span className="text-sm font-medium text-amber-800">Formation Premium (payante)</span>
72
  </label>
73
+ {form.isPremium && <div>
74
+ <label className="text-sm font-medium text-slate-700 mb-1 block">Prix (XOF)</label>
75
+ <input type="number" className={inp} value={form.priceAmount} onChange={e => setForm(f => ({ ...f, priceAmount: parseInt(e.target.value) }))} />
 
 
76
  </div>}
77
  <div className="flex gap-3 pt-2">
78
  <button type="button" onClick={() => navigate('/content')} className="flex-1 border border-slate-200 text-slate-600 py-2.5 rounded-xl text-sm hover:bg-slate-50">Annuler</button>
apps/api/package.json CHANGED
@@ -43,7 +43,6 @@
43
  "pino-pretty": "^13.1.3",
44
  "pptxgenjs": "^3.12.0",
45
  "puppeteer": "^22.0.0",
46
- "stripe": "^20.3.1",
47
  "web-push": "^3.6.7",
48
  "xlsx": "^0.18.5",
49
  "zod": "^3.25.76"
 
43
  "pino-pretty": "^13.1.3",
44
  "pptxgenjs": "^3.12.0",
45
  "puppeteer": "^22.0.0",
 
46
  "web-push": "^3.6.7",
47
  "xlsx": "^0.18.5",
48
  "zod": "^3.25.76"
apps/api/src/config.ts CHANGED
@@ -26,7 +26,7 @@ const result = envSchema.safeParse(process.env);
26
  if (!result.success) {
27
  const { logger } = require('./logger');
28
  logger.error({ errors: result.error.format() }, '[CONFIG] ❌ Invalid environment variables');
29
- process.exit(1);
30
  }
31
 
32
  export const config = result.data;
 
26
  if (!result.success) {
27
  const { logger } = require('./logger');
28
  logger.error({ errors: result.error.format() }, '[CONFIG] ❌ Invalid environment variables');
29
+ throw new Error(`[CONFIG] Missing or invalid environment variables:\n${result.error.message}`);
30
  }
31
 
32
  export const config = result.data;
apps/api/src/index.ts CHANGED
@@ -29,16 +29,15 @@ const server: FastifyInstance = fastify({
29
  });
30
 
31
  // Attach prisma to server instance for global access in routes
32
- server.decorate('prisma', prisma as any);
33
 
34
  // ── Middleware & Plugins ──────────────────────────────────────────────────────
35
- server.register(cors as any, {
36
- origin: [
37
- 'https://admin.xamle.studio',
38
- 'https://xamle.studio',
39
- 'https://edtechadmin.netlify.app',
40
- 'https://edtechadminweb.netlify.app'
41
- ],
42
  methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
43
  allowedHeaders: ['Content-Type', 'Authorization', 'x-api-key', 'x-organization-id'],
44
  credentials: true
@@ -60,7 +59,7 @@ const registerRoutes = async () => {
60
  server.register(authRoutes, { prefix: '/v1/auth' });
61
  server.register(whatsappRoutes, { prefix: '/v1/whatsapp' });
62
  server.register(studentRoutes, { prefix: '/v1/student' });
63
- server.register(stripeWebhookRoute, { prefix: '/v1/payments' });
64
 
65
  // 2. Guarded Routes
66
  server.register(async (scope) => {
@@ -94,7 +93,7 @@ const registerRoutes = async () => {
94
  }
95
 
96
  // Centralized property for routes to use
97
- (request as any).organizationId = request.headers['x-organization-id'] as string;
98
  });
99
 
100
  scope.addHook('preHandler', (request, _reply, done) => {
 
29
  });
30
 
31
  // Attach prisma to server instance for global access in routes
32
+ server.decorate('prisma', prisma);
33
 
34
  // ── Middleware & Plugins ──────────────────────────────────────────────────────
35
+ const corsOrigins = process.env.CORS_ORIGINS
36
+ ? process.env.CORS_ORIGINS.split(',').map(o => o.trim())
37
+ : ['https://admin.xamle.studio', 'https://xamle.studio', 'https://edtechadmin.netlify.app', 'https://edtechadminweb.netlify.app'];
38
+
39
+ server.register(cors, {
40
+ origin: corsOrigins,
 
41
  methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
42
  allowedHeaders: ['Content-Type', 'Authorization', 'x-api-key', 'x-organization-id'],
43
  credentials: true
 
59
  server.register(authRoutes, { prefix: '/v1/auth' });
60
  server.register(whatsappRoutes, { prefix: '/v1/whatsapp' });
61
  server.register(studentRoutes, { prefix: '/v1/student' });
62
+ server.register(stripeWebhookRoute, { prefix: '/v1/payments' }); // placeholder webhook
63
 
64
  // 2. Guarded Routes
65
  server.register(async (scope) => {
 
93
  }
94
 
95
  // Centralized property for routes to use
96
+ request.organizationId = request.headers['x-organization-id'] as string;
97
  });
98
 
99
  scope.addHook('preHandler', (request, _reply, done) => {
apps/api/src/middleware/rateLimit.ts CHANGED
@@ -4,7 +4,7 @@ import { logger } from '../logger';
4
 
5
  export async function setupRateLimit(server: FastifyInstance) {
6
  try {
7
- await server.register(rateLimit as any, {
8
  max: 100,
9
  timeWindow: '1 minute',
10
  keyGenerator: (req: FastifyRequest) => req.ip as string,
 
4
 
5
  export async function setupRateLimit(server: FastifyInstance) {
6
  try {
7
+ await server.register(rateLimit, {
8
  max: 100,
9
  timeWindow: '1 minute',
10
  keyGenerator: (req: FastifyRequest) => req.ip as string,
apps/api/src/routes/admin.ts CHANGED
@@ -1,6 +1,7 @@
1
  import { FastifyInstance } from 'fastify';
2
  import { prisma } from '../services/prisma';
3
  import { whatsappQueue } from '../services/queue';
 
4
  import { z } from 'zod';
5
  import { calculateWER, formatError } from '../utils/metrics';
6
  import { getOrganizationId } from '@repo/database';
@@ -17,7 +18,6 @@ const TrackSchema = z.object({
17
  language: z.enum(['FR', 'WOLOF']).default('FR'),
18
  isPremium: z.boolean().default(false),
19
  priceAmount: z.number().int().optional(),
20
- stripePriceId: z.string().optional(),
21
  });
22
 
23
  const TrackDaySchema = z.object({
@@ -176,7 +176,8 @@ export async function adminRoutes(fastify: FastifyInstance) {
176
 
177
  const currentDay = enrollment ? Math.floor(enrollment.currentDay) : 0;
178
 
179
- const organizationId = getOrganizationId() || 'default-org-id';
 
180
  await prisma.businessProfile.upsert({
181
  where: { userId },
182
  update: { lastUpdatedFromDay: currentDay, organizationId },
@@ -184,12 +185,12 @@ export async function adminRoutes(fastify: FastifyInstance) {
184
  });
185
 
186
  // 3. Dispatch Background Job (Audio Delivery + Next Day Increment)
187
- await whatsappQueue.add('send-admin-audio-override', {
188
- userId,
189
- trackId,
190
- overrideAudioUrl,
191
- adminId
192
- });
193
 
194
  return reply.code(200).send({ ok: true, progress });
195
  });
@@ -216,10 +217,13 @@ export async function adminRoutes(fastify: FastifyInstance) {
216
  }
217
 
218
  // Use the 'send-message-direct' logic (which bypasses pedagogy state)
219
- await whatsappQueue.add('send-message-direct', {
220
- phone: user.phone,
221
- text
222
- });
 
 
 
223
 
224
  return reply.code(200).send({ ok: true, message: "Custom message queued for sending." });
225
  });
@@ -250,7 +254,8 @@ export async function adminRoutes(fastify: FastifyInstance) {
250
  fastify.post('/tracks', async (req, reply) => {
251
  const body = TrackSchema.safeParse(req.body);
252
  if (!body.success) return reply.code(400).send({ error: body.error.flatten() });
253
- const organizationId = getOrganizationId() || 'default-org-id';
 
254
  const track = await prisma.track.create({ data: { ...body.data, organizationId } });
255
  return reply.code(201).send(track);
256
  });
@@ -310,7 +315,8 @@ export async function adminRoutes(fastify: FastifyInstance) {
310
  fastify.post<{ Params: { trackId: string } }>('/tracks/:trackId/days', async (req, reply) => {
311
  const body = TrackDaySchema.safeParse(req.body);
312
  if (!body.success) return reply.code(400).send({ error: body.error.flatten() });
313
- const organizationId = getOrganizationId() || 'default-org-id';
 
314
  const day = await prisma.trackDay.create({
315
  data: {
316
  ...body.data,
@@ -513,8 +519,52 @@ export async function adminRoutes(fastify: FastifyInstance) {
513
  });
514
  });
515
 
516
- fastify.post('/training/upload', async (_req, reply) => {
517
- // Just a placeholder until full R2 integration for standalone uploads
518
- return reply.code(501).send({ error: "Not Implemented Yet" });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
519
  });
520
  }
 
1
  import { FastifyInstance } from 'fastify';
2
  import { prisma } from '../services/prisma';
3
  import { whatsappQueue } from '../services/queue';
4
+ import { logger } from '../logger';
5
  import { z } from 'zod';
6
  import { calculateWER, formatError } from '../utils/metrics';
7
  import { getOrganizationId } from '@repo/database';
 
18
  language: z.enum(['FR', 'WOLOF']).default('FR'),
19
  isPremium: z.boolean().default(false),
20
  priceAmount: z.number().int().optional(),
 
21
  });
22
 
23
  const TrackDaySchema = z.object({
 
176
 
177
  const currentDay = enrollment ? Math.floor(enrollment.currentDay) : 0;
178
 
179
+ const organizationId = getOrganizationId();
180
+ if (!organizationId) return reply.code(400).send({ error: 'x-organization-id header required' });
181
  await prisma.businessProfile.upsert({
182
  where: { userId },
183
  update: { lastUpdatedFromDay: currentDay, organizationId },
 
185
  });
186
 
187
  // 3. Dispatch Background Job (Audio Delivery + Next Day Increment)
188
+ try {
189
+ await whatsappQueue.add('send-admin-audio-override', { userId, trackId, overrideAudioUrl, adminId });
190
+ logger.info({ userId, trackId }, '[ADMIN] send-admin-audio-override enqueued');
191
+ } catch (qErr) {
192
+ logger.error({ qErr, userId }, '[ADMIN] Failed to enqueue send-admin-audio-override');
193
+ }
194
 
195
  return reply.code(200).send({ ok: true, progress });
196
  });
 
217
  }
218
 
219
  // Use the 'send-message-direct' logic (which bypasses pedagogy state)
220
+ try {
221
+ await whatsappQueue.add('send-message-direct', { phone: user.phone, text });
222
+ logger.info({ phone: user.phone }, '[ADMIN] send-message-direct enqueued');
223
+ } catch (qErr) {
224
+ logger.error({ qErr, userId }, '[ADMIN] Failed to enqueue send-message-direct');
225
+ return reply.code(500).send({ error: 'Failed to queue message' });
226
+ }
227
 
228
  return reply.code(200).send({ ok: true, message: "Custom message queued for sending." });
229
  });
 
254
  fastify.post('/tracks', async (req, reply) => {
255
  const body = TrackSchema.safeParse(req.body);
256
  if (!body.success) return reply.code(400).send({ error: body.error.flatten() });
257
+ const organizationId = getOrganizationId();
258
+ if (!organizationId) return reply.code(400).send({ error: 'x-organization-id header required' });
259
  const track = await prisma.track.create({ data: { ...body.data, organizationId } });
260
  return reply.code(201).send(track);
261
  });
 
315
  fastify.post<{ Params: { trackId: string } }>('/tracks/:trackId/days', async (req, reply) => {
316
  const body = TrackDaySchema.safeParse(req.body);
317
  if (!body.success) return reply.code(400).send({ error: body.error.flatten() });
318
+ const organizationId = getOrganizationId();
319
+ if (!organizationId) return reply.code(400).send({ error: 'x-organization-id header required' });
320
  const day = await prisma.trackDay.create({
321
  data: {
322
  ...body.data,
 
519
  });
520
  });
521
 
522
+ fastify.post('/training/upload', async (req, reply) => {
523
+ const organizationId = req.organizationId;
524
+ if (!organizationId) return reply.code(400).send({ error: 'Organization ID required' });
525
+
526
+ const parts = req.parts();
527
+ let fileBuffer: Buffer | null = null;
528
+ let filename = 'audio.ogg';
529
+ let mimeType = 'audio/ogg';
530
+
531
+ for await (const part of parts) {
532
+ if (part.type === 'file') {
533
+ fileBuffer = await part.toBuffer();
534
+ filename = part.filename || filename;
535
+ mimeType = part.mimetype || mimeType;
536
+ }
537
+ }
538
+
539
+ if (!fileBuffer || fileBuffer.length === 0) {
540
+ return reply.code(400).send({ error: 'No audio file provided' });
541
+ }
542
+
543
+ try {
544
+ const { uploadFile } = await import('../services/storage');
545
+ const { aiService } = await import('../services/ai');
546
+ const { convertToMp3IfNeeded } = await import('@repo/ai-sdk');
547
+
548
+ // 1. Store raw audio to R2
549
+ const audioUrl = await uploadFile(fileBuffer, filename, mimeType, organizationId);
550
+
551
+ // 2. Convert + transcribe
552
+ const { buffer: mp3Buffer, format } = await convertToMp3IfNeeded(fileBuffer, filename);
553
+ const { text: transcription } = await aiService.transcribeAudio(mp3Buffer, `upload.${format}`);
554
+
555
+ // 3. Persist as TrainingData for review
556
+ const record = await prisma.trainingData.create({
557
+ data: {
558
+ audioUrl,
559
+ transcription: transcription || '',
560
+ status: 'PENDING'
561
+ }
562
+ });
563
+
564
+ return reply.send({ ok: true, id: record.id, audioUrl, transcription });
565
+ } catch (err: any) {
566
+ logger.error({ err, organizationId }, '[TRAINING_UPLOAD] Failed');
567
+ return reply.code(500).send({ error: 'Upload failed', detail: err.message });
568
+ }
569
  });
570
  }
apps/api/src/routes/ai.ts CHANGED
@@ -5,9 +5,18 @@ import { PdfOnePagerRenderer } from '../services/renderers/pdf-renderer';
5
  import { PptxDeckRenderer } from '../services/renderers/pptx-renderer';
6
  import { uploadFile } from '../services/storage';
7
  import { convertToMp3IfNeeded } from '@repo/ai-sdk';
 
8
  import { z } from 'zod';
9
  import { prisma } from '../services/prisma';
10
 
 
 
 
 
 
 
 
 
11
 
12
  export async function aiRoutes(fastify: FastifyInstance) {
13
  const pdfRenderer = new PdfOnePagerRenderer();
@@ -123,7 +132,7 @@ export async function aiRoutes(fastify: FastifyInstance) {
123
  const downloadUrl = await uploadFile(audioBuffer, `lesson-audio-${Date.now()}.mp3`, 'audio/mpeg', organizationId);
124
  return { success: true, url: downloadUrl };
125
  } catch (err: unknown) {
126
- if (err instanceof Error && (err as any).name === 'QuotaExceededError') {
127
  return reply.code(429).send({ error: 'quota_exceeded' });
128
  }
129
  throw err;
@@ -157,8 +166,8 @@ export async function aiRoutes(fastify: FastifyInstance) {
157
  return { success: true, text, confidence, isSuspect };
158
  } catch (err: unknown) {
159
  logger.error(`[AI] ❌ Transcription error:`, err);
160
- if (err instanceof Error && (err as any).name === 'QuotaExceededError') {
161
- return reply.code(429).send({ error: 'quota_exceeded', retryAfterMs: (err as any).retryAfterMs });
162
  }
163
  // Ensure error message is bubbled up for debugging
164
  return reply.code(500).send({
@@ -278,7 +287,7 @@ export async function aiRoutes(fastify: FastifyInstance) {
278
  aiSource: feedback.aiSource
279
  };
280
  } catch (err: unknown) {
281
- if (err instanceof Error && (err as any).name === 'QuotaExceededError') {
282
  return reply.code(429).send({ error: 'quota_exceeded' });
283
  }
284
  throw err;
@@ -330,15 +339,15 @@ export async function aiRoutes(fastify: FastifyInstance) {
330
  select: { name: true, personalityConfig: true }
331
  });
332
 
333
- const personality = (org?.personalityConfig as Record<string, any>) || {};
334
-
335
  const result = await aiService.generateCrmCampaign(
336
  contact,
337
  objective,
338
  {
339
  name: org?.name || 'Notre Entreprise',
340
- mission: personality.coreMission || 'Offrir un service d excellence',
341
- tone: personality.toneDescription || 'Professionnel et chaleureux'
342
  },
343
  language
344
  );
@@ -402,10 +411,11 @@ export async function aiRoutes(fastify: FastifyInstance) {
402
  const org = await prisma.organization.findUnique({ where: { id: organizationId } });
403
  const results = [];
404
  for (const contact of contacts) {
 
405
  const gen = await aiService.generateCrmCampaign(contact, "Présentation de nos nouveaux services", {
406
  name: org?.name || 'Xamlé',
407
- mission: (org?.personalityConfig as any)?.coreMission || 'Accompagnement IA',
408
- tone: (org?.personalityConfig as any)?.toneDescription || 'Professionnel'
409
  });
410
  results.push({
411
  contactId: contact.id,
@@ -441,7 +451,7 @@ export async function aiRoutes(fastify: FastifyInstance) {
441
 
442
  // 12. CRM: Voice Command Processor
443
  fastify.post('/crm/voice-command', async (request, reply) => {
444
- const file = await (request as any).file();
445
  if (!file) return reply.code(400).send({ error: 'No audio file' });
446
 
447
  const buffer = await file.toBuffer();
 
5
  import { PptxDeckRenderer } from '../services/renderers/pptx-renderer';
6
  import { uploadFile } from '../services/storage';
7
  import { convertToMp3IfNeeded } from '@repo/ai-sdk';
8
+ import { parsePersonalityConfig } from '@repo/database';
9
  import { z } from 'zod';
10
  import { prisma } from '../services/prisma';
11
 
12
+ interface QuotaExceededError extends Error {
13
+ name: 'QuotaExceededError';
14
+ retryAfterMs?: number;
15
+ }
16
+ function isQuotaExceeded(err: unknown): err is QuotaExceededError {
17
+ return err instanceof Error && err.name === 'QuotaExceededError';
18
+ }
19
+
20
 
21
  export async function aiRoutes(fastify: FastifyInstance) {
22
  const pdfRenderer = new PdfOnePagerRenderer();
 
132
  const downloadUrl = await uploadFile(audioBuffer, `lesson-audio-${Date.now()}.mp3`, 'audio/mpeg', organizationId);
133
  return { success: true, url: downloadUrl };
134
  } catch (err: unknown) {
135
+ if (isQuotaExceeded(err)) {
136
  return reply.code(429).send({ error: 'quota_exceeded' });
137
  }
138
  throw err;
 
166
  return { success: true, text, confidence, isSuspect };
167
  } catch (err: unknown) {
168
  logger.error(`[AI] ❌ Transcription error:`, err);
169
+ if (isQuotaExceeded(err)) {
170
+ return reply.code(429).send({ error: 'quota_exceeded', retryAfterMs: err.retryAfterMs });
171
  }
172
  // Ensure error message is bubbled up for debugging
173
  return reply.code(500).send({
 
287
  aiSource: feedback.aiSource
288
  };
289
  } catch (err: unknown) {
290
+ if (isQuotaExceeded(err)) {
291
  return reply.code(429).send({ error: 'quota_exceeded' });
292
  }
293
  throw err;
 
339
  select: { name: true, personalityConfig: true }
340
  });
341
 
342
+ const personality = parsePersonalityConfig(org?.personalityConfig);
343
+
344
  const result = await aiService.generateCrmCampaign(
345
  contact,
346
  objective,
347
  {
348
  name: org?.name || 'Notre Entreprise',
349
+ mission: personality?.coreMission || 'Offrir un service d excellence',
350
+ tone: personality?.toneDescription || 'Professionnel et chaleureux'
351
  },
352
  language
353
  );
 
411
  const org = await prisma.organization.findUnique({ where: { id: organizationId } });
412
  const results = [];
413
  for (const contact of contacts) {
414
+ const orgPersonality = parsePersonalityConfig(org?.personalityConfig);
415
  const gen = await aiService.generateCrmCampaign(contact, "Présentation de nos nouveaux services", {
416
  name: org?.name || 'Xamlé',
417
+ mission: orgPersonality?.coreMission || 'Accompagnement IA',
418
+ tone: orgPersonality?.toneDescription || 'Professionnel'
419
  });
420
  results.push({
421
  contactId: contact.id,
 
451
 
452
  // 12. CRM: Voice Command Processor
453
  fastify.post('/crm/voice-command', async (request, reply) => {
454
+ const file = await request.file();
455
  if (!file) return reply.code(400).send({ error: 'No audio file' });
456
 
457
  const buffer = await file.toBuffer();
apps/api/src/routes/analytics.ts CHANGED
@@ -9,7 +9,7 @@ export async function analyticsRoutes(fastify: FastifyInstance) {
9
  * Returns volume statistics: messages, users, and estimated token consumption.
10
  */
11
  fastify.get('/usage', async (req, reply) => {
12
- const organizationId = (req as any).organizationId;
13
 
14
  if (!organizationId) {
15
  return reply.code(400).send({ error: 'Organization ID is required' });
@@ -35,8 +35,19 @@ export async function analyticsRoutes(fastify: FastifyInstance) {
35
  })
36
  ]);
37
 
38
- // Estimate costs (Simplified: 1000 tokens avg per message interaction)
39
- const estimatedTokens = totalMessages * 1000;
 
 
 
 
 
 
 
 
 
 
 
40
 
41
  return {
42
  messages: {
@@ -50,7 +61,8 @@ export async function analyticsRoutes(fastify: FastifyInstance) {
50
  },
51
  costs: {
52
  estimatedTokens,
53
- estimatedUsd: (estimatedTokens / 1000000) * 0.50 // Mock price for gpt-4o-mini
 
54
  }
55
  };
56
  } catch (err) {
@@ -64,7 +76,7 @@ export async function analyticsRoutes(fastify: FastifyInstance) {
64
  * Returns pedagogical performance: completion rates and scores.
65
  */
66
  fastify.get('/pedagogy', async (req, reply) => {
67
- const organizationId = (req as any).organizationId;
68
 
69
  if (!organizationId) {
70
  return reply.code(400).send({ error: 'Organization ID is required' });
@@ -114,7 +126,7 @@ export async function analyticsRoutes(fastify: FastifyInstance) {
114
  * Returns CRM campaign funnel: sent, delivered, read, failed.
115
  */
116
  fastify.get('/campaigns', async (req, reply) => {
117
- const organizationId = (req as any).organizationId;
118
 
119
  if (!organizationId) {
120
  return reply.code(400).send({ error: 'Organization ID is required' });
 
9
  * Returns volume statistics: messages, users, and estimated token consumption.
10
  */
11
  fastify.get('/usage', async (req, reply) => {
12
+ const organizationId = req.organizationId;
13
 
14
  if (!organizationId) {
15
  return reply.code(400).send({ error: 'Organization ID is required' });
 
35
  })
36
  ]);
37
 
38
+ // ~1000 tokens avg per outbound AI message (only outbound calls the LLM)
39
+ const estimatedTokens = outboundMessages * 1000;
40
+
41
+ // Pricing per 1M tokens (input+output blended) — updated May 2026
42
+ const MODEL_PRICES_USD_PER_1M: Record<string, number> = {
43
+ 'gpt-4o': 7.50,
44
+ 'gpt-4o-mini': 0.30,
45
+ 'gemini-2.0-flash': 0.15,
46
+ 'gemini-1.5-pro': 3.50,
47
+ 'claude-sonnet-4-6': 4.50,
48
+ };
49
+ const activeModel = process.env.DEFAULT_AI_MODEL || 'gpt-4o-mini';
50
+ const pricePerMillion = MODEL_PRICES_USD_PER_1M[activeModel] ?? 0.30;
51
 
52
  return {
53
  messages: {
 
61
  },
62
  costs: {
63
  estimatedTokens,
64
+ estimatedUsd: (estimatedTokens / 1_000_000) * pricePerMillion,
65
+ model: activeModel
66
  }
67
  };
68
  } catch (err) {
 
76
  * Returns pedagogical performance: completion rates and scores.
77
  */
78
  fastify.get('/pedagogy', async (req, reply) => {
79
+ const organizationId = req.organizationId;
80
 
81
  if (!organizationId) {
82
  return reply.code(400).send({ error: 'Organization ID is required' });
 
126
  * Returns CRM campaign funnel: sent, delivered, read, failed.
127
  */
128
  fastify.get('/campaigns', async (req, reply) => {
129
+ const organizationId = req.organizationId;
130
 
131
  if (!organizationId) {
132
  return reply.code(400).send({ error: 'Organization ID is required' });
apps/api/src/routes/auth.ts CHANGED
@@ -20,15 +20,17 @@ export async function authRoutes(fastify: FastifyInstance) {
20
  }
21
  }
22
  }, async (request, reply) => {
23
- const { email, password, organizationId } = request.body as any;
24
  logger.info(`[AUTH] Login attempt for ${email} (Org: ${organizationId || 'default'})`);
25
 
26
- const orgId = organizationId || 'default-org-id';
 
 
27
 
28
- const user = await AuthService.findUserByEmail(email, orgId);
29
 
30
  if (!user || !user.passwordHash) {
31
- logger.warn(`[AUTH] User not found: ${email} in Org: ${orgId}`);
32
  return reply.code(401).send({ error: 'Unauthorized', message: 'Invalid email or password' });
33
  }
34
 
@@ -53,14 +55,73 @@ export async function authRoutes(fastify: FastifyInstance) {
53
  email: user.email,
54
  role: user.role,
55
  organizationId: user.organizationId,
56
- organization: (user as any).organization
57
  }
58
  };
59
  });
60
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
  // Get current user profile (guarded by JWT)
62
  fastify.get('/me', async (request, reply) => {
63
- const { id } = request.user as any;
64
 
65
  const user = await prisma.user.findUnique({
66
  where: { id },
@@ -78,7 +139,7 @@ export async function authRoutes(fastify: FastifyInstance) {
78
  email: user.email,
79
  role: user.role,
80
  organizationId: user.organizationId,
81
- organization: (user as any).organization
82
  }
83
  };
84
  });
 
20
  }
21
  }
22
  }, async (request, reply) => {
23
+ const { email, password, organizationId } = request.body as { email: string; password: string; organizationId: string };
24
  logger.info(`[AUTH] Login attempt for ${email} (Org: ${organizationId || 'default'})`);
25
 
26
+ if (!organizationId) {
27
+ return reply.code(400).send({ error: 'organizationId required' });
28
+ }
29
 
30
+ const user = await AuthService.findUserByEmail(email, organizationId);
31
 
32
  if (!user || !user.passwordHash) {
33
+ logger.warn(`[AUTH] User not found: ${email} in Org: ${organizationId}`);
34
  return reply.code(401).send({ error: 'Unauthorized', message: 'Invalid email or password' });
35
  }
36
 
 
55
  email: user.email,
56
  role: user.role,
57
  organizationId: user.organizationId,
58
+ organization: user.organization
59
  }
60
  };
61
  });
62
 
63
+ // Request a password reset link
64
+ fastify.post('/forgot-password', async (request, reply) => {
65
+ const { email } = request.body as { email: string };
66
+ if (!email) return reply.code(400).send({ error: 'Email required' });
67
+
68
+ // Always return 200 to avoid email enumeration
69
+ const user = await prisma.user.findFirst({ where: { email } });
70
+ if (!user) return reply.send({ ok: true });
71
+
72
+ // Sign a short-lived reset token (1 hour) with extra 'purpose' claim not in the standard payload shape
73
+ const resetToken = (fastify.jwt.sign as Function)(
74
+ { id: user.id, purpose: 'reset' },
75
+ { expiresIn: '1h' }
76
+ ) as string;
77
+
78
+ const adminUrl = process.env.ADMIN_URL || 'https://edtechadmin.netlify.app';
79
+ const resetLink = `${adminUrl}/reset-password?token=${resetToken}`;
80
+
81
+ const { scheduleEmail } = await import('../services/queue');
82
+ await scheduleEmail({
83
+ to: email,
84
+ subject: 'Réinitialisation de votre mot de passe — XAMLÉ',
85
+ htmlContent: `<p>Bonjour ${user.name || ''},</p>
86
+ <p>Cliquez sur le lien ci-dessous pour réinitialiser votre mot de passe. Ce lien expire dans 1 heure.</p>
87
+ <p><a href="${resetLink}" style="background:#1e293b;color:#fff;padding:12px 24px;border-radius:8px;text-decoration:none;font-weight:bold;">Réinitialiser mon mot de passe</a></p>
88
+ <p>Si vous n'avez pas demandé cette réinitialisation, ignorez cet email.</p>`
89
+ });
90
+
91
+ logger.info({ email }, '[AUTH] Password reset link sent');
92
+ return reply.send({ ok: true });
93
+ });
94
+
95
+ // Set new password via reset token
96
+ fastify.post('/reset-password', async (request, reply) => {
97
+ const { token, password } = request.body as { token: string; password: string };
98
+ if (!token || !password) return reply.code(400).send({ error: 'Token and password required' });
99
+ if (password.length < 6) return reply.code(400).send({ error: 'Password must be at least 6 characters' });
100
+
101
+ let payload: { id: string; purpose?: string };
102
+ try {
103
+ payload = fastify.jwt.verify(token) as { id: string; purpose?: string };
104
+ } catch {
105
+ return reply.code(401).send({ error: 'Invalid or expired token' });
106
+ }
107
+
108
+ if (payload.purpose !== 'reset') {
109
+ return reply.code(401).send({ error: 'Invalid token purpose' });
110
+ }
111
+
112
+ const passwordHash = await AuthService.hashPassword(password);
113
+ await prisma.user.update({
114
+ where: { id: payload.id },
115
+ data: { passwordHash }
116
+ });
117
+
118
+ logger.info({ userId: payload.id }, '[AUTH] Password reset successfully');
119
+ return reply.send({ ok: true });
120
+ });
121
+
122
  // Get current user profile (guarded by JWT)
123
  fastify.get('/me', async (request, reply) => {
124
+ const { id } = request.user;
125
 
126
  const user = await prisma.user.findUnique({
127
  where: { id },
 
139
  email: user.email,
140
  role: user.role,
141
  organizationId: user.organizationId,
142
+ organization: user.organization
143
  }
144
  };
145
  });
apps/api/src/routes/campaigns.ts CHANGED
@@ -1,14 +1,17 @@
1
  import { FastifyInstance } from 'fastify';
 
2
  import { aiService } from '../services/ai';
3
 
4
  export default async function campaignRoutes(fastify: FastifyInstance) {
5
  // Generate AI Message for Campaign
6
  fastify.post('/:id/campaigns/generate', async (req, reply) => {
7
- const { prompt, listId } = req.body as { prompt: string, listId?: string };
8
-
9
- if (!prompt) {
10
- return reply.code(400).send({ error: 'Prompt is required' });
11
- }
 
 
12
 
13
  try {
14
  const { text, type, aiSource } = await aiService.generateBroadcastMessage(prompt);
 
1
  import { FastifyInstance } from 'fastify';
2
+ import { z } from 'zod';
3
  import { aiService } from '../services/ai';
4
 
5
  export default async function campaignRoutes(fastify: FastifyInstance) {
6
  // Generate AI Message for Campaign
7
  fastify.post('/:id/campaigns/generate', async (req, reply) => {
8
+ const schema = z.object({
9
+ prompt: z.string().min(1).max(2000),
10
+ listId: z.string().uuid().optional()
11
+ });
12
+ const parsed = schema.safeParse(req.body);
13
+ if (!parsed.success) return reply.code(400).send({ error: parsed.error.flatten() });
14
+ const { prompt, listId } = parsed.data;
15
 
16
  try {
17
  const { text, type, aiSource } = await aiService.generateBroadcastMessage(prompt);
apps/api/src/routes/internal.ts CHANGED
@@ -28,8 +28,8 @@ export async function internalRoutes(fastify: FastifyInstance) {
28
 
29
  createBullBoard({
30
  queues: [
31
- new BullMQAdapter(whatsappQueue as any),
32
- new BullMQAdapter(notificationQueue as any)
33
  ],
34
  serverAdapter,
35
  });
@@ -37,7 +37,7 @@ export async function internalRoutes(fastify: FastifyInstance) {
37
  // Re-enabled BullBoard for production monitoring
38
  serverAdapter.setBasePath('/v1/internal/queues');
39
  fastify.register(async (instance) => {
40
- instance.register(serverAdapter.registerPlugin() as any, {
41
  prefix: '/',
42
  });
43
  }, {
@@ -110,8 +110,8 @@ export async function internalRoutes(fastify: FastifyInstance) {
110
  try {
111
  await whatsappService.handleIncomingMessage(phone, text, audioUrl, imageUrl, undefined, organizationId);
112
  return reply.send({ ok: true });
113
- } catch (err: any) {
114
- return reply.code(500).send({ error: err.message });
115
  }
116
  });
117
 
 
28
 
29
  createBullBoard({
30
  queues: [
31
+ new BullMQAdapter(whatsappQueue),
32
+ new BullMQAdapter(notificationQueue)
33
  ],
34
  serverAdapter,
35
  });
 
37
  // Re-enabled BullBoard for production monitoring
38
  serverAdapter.setBasePath('/v1/internal/queues');
39
  fastify.register(async (instance) => {
40
+ instance.register(serverAdapter.registerPlugin(), {
41
  prefix: '/',
42
  });
43
  }, {
 
110
  try {
111
  await whatsappService.handleIncomingMessage(phone, text, audioUrl, imageUrl, undefined, organizationId);
112
  return reply.send({ ok: true });
113
+ } catch (err: unknown) {
114
+ return reply.code(500).send({ error: err instanceof Error ? err.message : String(err) });
115
  }
116
  });
117
 
apps/api/src/routes/notifications.ts CHANGED
@@ -19,8 +19,8 @@ export async function notificationRoutes(fastify: FastifyInstance) {
19
  });
20
 
21
  const { subscription } = bodySchema.parse(request.body);
22
- const user = (request as any).user;
23
- const organizationId = (request as any).organizationId;
24
 
25
  if (!user || !organizationId) {
26
  return reply.code(401).send({ error: 'Unauthorized' });
 
19
  });
20
 
21
  const { subscription } = bodySchema.parse(request.body);
22
+ const user = request.user;
23
+ const organizationId = request.organizationId;
24
 
25
  if (!user || !organizationId) {
26
  return reply.code(401).send({ error: 'Unauthorized' });
apps/api/src/routes/organizations.ts CHANGED
@@ -104,7 +104,7 @@ export async function organizationRoutes(fastify: FastifyInstance) {
104
 
105
  await auditService.log({
106
  action: 'ORGANIZATION_CREATED',
107
- actorId: (req as any).user?.id,
108
  resourceId: org.id,
109
  details: { name: org.name, slug: org.slug }
110
  });
@@ -232,6 +232,66 @@ export async function organizationRoutes(fastify: FastifyInstance) {
232
  }
233
  });
234
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
235
  // 7. Trigger Knowledge Base Indexing
236
  fastify.post('/:id/index-kb', async (req, reply) => {
237
  const { id } = req.params as { id: string };
@@ -262,7 +322,7 @@ export async function organizationRoutes(fastify: FastifyInstance) {
262
  fileBuffer = await part.toBuffer();
263
  } else {
264
  if (part.fieldname === 'listName') {
265
- listName = (part as any).value;
266
  }
267
  }
268
  }
@@ -292,7 +352,7 @@ export async function organizationRoutes(fastify: FastifyInstance) {
292
 
293
  const results = { created: 0, updated: 0, errors: 0 };
294
 
295
- for (const row of rows as any[]) {
296
  try {
297
  const resultsBatch = await ContactService.bulkImport(organizationId, [row], broadcastList.id);
298
  results.created += resultsBatch.created;
@@ -345,11 +405,12 @@ export async function organizationRoutes(fastify: FastifyInstance) {
345
  // 11. CRM: Bulk Delete Contacts
346
  fastify.post('/:id/contacts/bulk-delete', async (req, reply) => {
347
  const { id: organizationId } = req.params as { id: string };
348
- const { contactIds } = req.body as { contactIds: string[] };
349
-
350
- if (!contactIds || !Array.isArray(contactIds)) {
351
- return reply.code(400).send({ error: 'Invalid contactIds' });
352
- }
 
353
 
354
  try {
355
  const result = await prisma.contact.deleteMany({
@@ -370,11 +431,13 @@ export async function organizationRoutes(fastify: FastifyInstance) {
370
  // 12. CRM: Reply to Contact (1-to-1)
371
  fastify.post('/:id/messages/reply', async (req, reply) => {
372
  const { id: organizationId } = req.params as { id: string };
373
- const { contactId, content } = req.body as { contactId: string, content: string };
374
-
375
- if (!contactId || !content) {
376
- return reply.code(400).send({ error: 'contactId and content are required' });
377
- }
 
 
378
 
379
  try {
380
  // 1. Create message record
@@ -443,14 +506,92 @@ export async function organizationRoutes(fastify: FastifyInstance) {
443
  }
444
  });
445
 
446
- // 14. CRM: Bulk Contact Import from JSON (Parsed by Frontend)
447
- fastify.post('/:id/contacts/bulk', async (req, reply) => {
448
  const { id: organizationId } = req.params as { id: string };
449
- const { contacts, listName } = req.body as { contacts: any[], listName?: string };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
450
 
451
- if (!contacts || !Array.isArray(contacts)) {
452
- return reply.code(400).send({ error: 'Invalid contacts data' });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
453
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
454
 
455
  const date = new Date().toLocaleDateString('fr-FR');
456
  const finalListName = listName || `Import du ${date}`;
 
104
 
105
  await auditService.log({
106
  action: 'ORGANIZATION_CREATED',
107
+ actorId: req.user?.id,
108
  resourceId: org.id,
109
  details: { name: org.name, slug: org.slug }
110
  });
 
232
  }
233
  });
234
 
235
+ // 7a. Upload Knowledge Base Document → R2 → update org URL → trigger indexing
236
+ fastify.post('/:id/upload-kb', async (req, reply) => {
237
+ const { id } = req.params as { id: string };
238
+
239
+ const parts = req.parts();
240
+ let fileBuffer: Buffer | null = null;
241
+ let filename = 'knowledge-base.pdf';
242
+ let mimeType = 'application/pdf';
243
+
244
+ for await (const part of parts) {
245
+ if (part.type === 'file') {
246
+ fileBuffer = await part.toBuffer();
247
+ filename = part.filename || filename;
248
+ mimeType = part.mimetype || mimeType;
249
+ }
250
+ }
251
+
252
+ if (!fileBuffer || fileBuffer.length === 0) {
253
+ return reply.code(400).send({ error: 'No file provided' });
254
+ }
255
+
256
+ try {
257
+ const { uploadFile } = await import('../services/storage');
258
+ const { whatsappQueue } = await import('../services/queue');
259
+
260
+ const publicUrl = await uploadFile(fileBuffer, filename, mimeType, id);
261
+
262
+ await prisma.organization.update({
263
+ where: { id },
264
+ data: { knowledgeBaseUrl: publicUrl }
265
+ });
266
+
267
+ await whatsappQueue.add('process-kb', { organizationId: id, url: publicUrl });
268
+
269
+ // Return chunk count for the stats sidebar
270
+ const chunkCount = await prisma.knowledgeBaseEntry.count({ where: { organizationId: id } });
271
+
272
+ logger.info({ organizationId: id, url: publicUrl }, '[KB_UPLOAD] Knowledge base uploaded and indexing started');
273
+ return reply.send({ ok: true, url: publicUrl, chunkCount });
274
+ } catch (err: any) {
275
+ logger.error({ err, organizationId: id }, '[KB_UPLOAD] Failed');
276
+ return reply.code(500).send({ error: 'Upload failed', detail: err.message });
277
+ }
278
+ });
279
+
280
+ // 7b. Get Knowledge Base stats
281
+ fastify.get('/:id/kb-stats', async (req, reply) => {
282
+ const { id } = req.params as { id: string };
283
+ try {
284
+ const [chunkCount, org] = await Promise.all([
285
+ prisma.knowledgeBaseEntry.count({ where: { organizationId: id } }),
286
+ prisma.organization.findUnique({ where: { id }, select: { knowledgeBaseUrl: true } })
287
+ ]);
288
+ return { chunkCount, hasKnowledgeBase: !!org?.knowledgeBaseUrl, knowledgeBaseUrl: org?.knowledgeBaseUrl };
289
+ } catch (err) {
290
+ logger.error({ err, organizationId: id }, '[KB_STATS] Failed');
291
+ return reply.code(500).send({ error: 'Failed to fetch KB stats' });
292
+ }
293
+ });
294
+
295
  // 7. Trigger Knowledge Base Indexing
296
  fastify.post('/:id/index-kb', async (req, reply) => {
297
  const { id } = req.params as { id: string };
 
322
  fileBuffer = await part.toBuffer();
323
  } else {
324
  if (part.fieldname === 'listName') {
325
+ listName = part.value as string;
326
  }
327
  }
328
  }
 
352
 
353
  const results = { created: 0, updated: 0, errors: 0 };
354
 
355
+ for (const row of rows as Record<string, unknown>[]) {
356
  try {
357
  const resultsBatch = await ContactService.bulkImport(organizationId, [row], broadcastList.id);
358
  results.created += resultsBatch.created;
 
405
  // 11. CRM: Bulk Delete Contacts
406
  fastify.post('/:id/contacts/bulk-delete', async (req, reply) => {
407
  const { id: organizationId } = req.params as { id: string };
408
+ const schema = z.object({
409
+ contactIds: z.array(z.string().uuid()).min(1).max(500)
410
+ });
411
+ const parsed = schema.safeParse(req.body);
412
+ if (!parsed.success) return reply.code(400).send({ error: parsed.error.flatten() });
413
+ const { contactIds } = parsed.data;
414
 
415
  try {
416
  const result = await prisma.contact.deleteMany({
 
431
  // 12. CRM: Reply to Contact (1-to-1)
432
  fastify.post('/:id/messages/reply', async (req, reply) => {
433
  const { id: organizationId } = req.params as { id: string };
434
+ const schema = z.object({
435
+ contactId: z.string().uuid(),
436
+ content: z.string().min(1).max(4096)
437
+ });
438
+ const parsed = schema.safeParse(req.body);
439
+ if (!parsed.success) return reply.code(400).send({ error: parsed.error.flatten() });
440
+ const { contactId, content } = parsed.data;
441
 
442
  try {
443
  // 1. Create message record
 
506
  }
507
  });
508
 
509
+ // 15. Knowledge Base list chunks
510
+ fastify.get('/:id/kb', async (req, reply) => {
511
  const { id: organizationId } = req.params as { id: string };
512
+ const { page = '1', limit = '50' } = req.query as { page?: string; limit?: string };
513
+ const pageNum = Math.max(1, parseInt(page) || 1);
514
+ const limitNum = Math.min(100, Math.max(1, parseInt(limit) || 50));
515
+ const skip = (pageNum - 1) * limitNum;
516
+ try {
517
+ const [entries, total] = await Promise.all([
518
+ prisma.knowledgeBaseEntry.findMany({
519
+ where: { organizationId },
520
+ orderBy: { createdAt: 'desc' },
521
+ skip,
522
+ take: limitNum,
523
+ select: { id: true, content: true, metadata: true, createdAt: true }
524
+ }),
525
+ prisma.knowledgeBaseEntry.count({ where: { organizationId } })
526
+ ]);
527
+ return { entries, total, page: pageNum, limit: limitNum };
528
+ } catch (err) {
529
+ logger.error({ err, organizationId }, '[KB_LIST] Failed');
530
+ return reply.code(500).send({ error: 'Failed to fetch KB entries' });
531
+ }
532
+ });
533
 
534
+ // 16. Knowledge Base — delete a chunk
535
+ fastify.delete('/:id/kb/:entryId', async (req, reply) => {
536
+ const { id: organizationId, entryId } = req.params as { id: string; entryId: string };
537
+ try {
538
+ const entry = await prisma.knowledgeBaseEntry.findFirst({ where: { id: entryId, organizationId } });
539
+ if (!entry) return reply.code(404).send({ error: 'Entry not found' });
540
+ await prisma.knowledgeBaseEntry.delete({ where: { id: entryId } });
541
+ return { ok: true };
542
+ } catch (err) {
543
+ logger.error({ err, organizationId, entryId }, '[KB_DELETE] Failed');
544
+ return reply.code(500).send({ error: 'Failed to delete entry' });
545
+ }
546
+ });
547
+
548
+ // 17. Campaign History — list with stats breakdown
549
+ fastify.get('/:id/campaign-history', async (req, reply) => {
550
+ const { id: organizationId } = req.params as { id: string };
551
+ const { page = '1', limit = '50', status } = req.query as { page?: string; limit?: string; status?: string };
552
+ const pageNum = Math.max(1, parseInt(page) || 1);
553
+ const limitNum = Math.min(100, Math.max(1, parseInt(limit) || 50));
554
+ const skip = (pageNum - 1) * limitNum;
555
+ const where = { organizationId, ...(status ? { status } : {}) };
556
+ try {
557
+ const [records, total, statusCounts] = await Promise.all([
558
+ prisma.campaignHistory.findMany({
559
+ where,
560
+ orderBy: { sentAt: 'desc' },
561
+ skip,
562
+ take: limitNum,
563
+ include: { contact: { select: { name: true, phoneNumber: true } } }
564
+ }),
565
+ prisma.campaignHistory.count({ where }),
566
+ prisma.campaignHistory.groupBy({
567
+ by: ['status'],
568
+ where: { organizationId },
569
+ _count: { _all: true }
570
+ })
571
+ ]);
572
+ const stats = { SENT: 0, DELIVERED: 0, READ: 0, FAILED: 0 } as Record<string, number>;
573
+ for (const row of statusCounts) stats[row.status] = row._count._all;
574
+ return { records, total, page: pageNum, limit: limitNum, stats };
575
+ } catch (err) {
576
+ logger.error({ err, organizationId }, '[CAMPAIGN_HISTORY] Failed');
577
+ return reply.code(500).send({ error: 'Failed to fetch campaign history' });
578
  }
579
+ });
580
+
581
+ // 14. CRM: Bulk Contact Import from JSON (Parsed by Frontend)
582
+ fastify.post('/:id/contacts/bulk', async (req, reply) => {
583
+ const { id: organizationId } = req.params as { id: string };
584
+ const schema = z.object({
585
+ contacts: z.array(z.object({
586
+ phoneNumber: z.string().min(7).max(20),
587
+ name: z.string().max(120).optional(),
588
+ attributes: z.record(z.string(), z.unknown()).optional()
589
+ })).min(1).max(5000),
590
+ listName: z.string().max(120).optional()
591
+ });
592
+ const parsed = schema.safeParse(req.body);
593
+ if (!parsed.success) return reply.code(400).send({ error: parsed.error.flatten() });
594
+ const { contacts, listName } = parsed.data;
595
 
596
  const date = new Date().toLocaleDateString('fr-FR');
597
  const finalListName = listName || `Import du ${date}`;
apps/api/src/routes/payments.ts CHANGED
@@ -1,219 +1,25 @@
1
  import { FastifyInstance } from 'fastify';
2
- import type { FastifyRequest } from 'fastify';
3
- import { stripeService } from '../services/stripe';
4
- import { prisma } from '../services/prisma';
5
- import { z } from 'zod';
6
 
7
- // ─── Shared Zod schemas ────────────────────────────────────────────────────────
8
- const checkoutSchema = z.object({
9
- userId: z.string().uuid(),
10
- trackId: z.string().uuid(),
11
- });
12
-
13
- // ─── Private routes (require ADMIN_API_KEY) ───────────────────────────────────
14
  export async function paymentRoutes(fastify: FastifyInstance) {
15
-
16
- // Create a Checkout Session
17
- fastify.post('/checkout', async (request, reply) => {
18
- const parseResult = checkoutSchema.safeParse(request.body);
19
- if (!parseResult.success) {
20
- return reply.status(400).send({ error: 'Invalid request body', details: parseResult.error.flatten() });
21
- }
22
-
23
- const { userId, trackId } = parseResult.data;
24
-
25
- try {
26
- // Validate the track exists and is premium
27
- const track = await prisma.track.findUnique({ where: { id: trackId } });
28
-
29
- if (!track || !track.isPremium || !track.stripePriceId) {
30
- return reply.status(400).send({ error: 'Invalid or non-premium track' });
31
- }
32
-
33
- const user = await prisma.user.findUnique({ where: { id: userId } });
34
- if (!user) {
35
- return reply.status(404).send({ error: 'User not found' });
36
- }
37
-
38
- const checkoutUrl = await stripeService.createLegacyCheckoutSession(
39
- user.id,
40
- track.id,
41
- track.stripePriceId,
42
- user.phone || ''
43
- );
44
-
45
- return { success: true, url: checkoutUrl };
46
-
47
- } catch (error) {
48
- fastify.log.error(error);
49
- return reply.status(500).send({ error: 'Failed to create checkout session' });
50
- }
51
- });
52
-
53
- // Create a Subscription Session for an Organization
54
- fastify.post('/org-checkout', async (request, reply) => {
55
- const { organizationId, email } = request.body as { organizationId: string, email?: string };
56
- if (!organizationId) {
57
- return reply.status(400).send({ error: 'Missing organizationId' });
58
- }
59
-
60
- try {
61
- const checkoutUrl = await stripeService.createOrganizationSubscriptionSession(organizationId, email);
62
- return { success: true, url: checkoutUrl };
63
- } catch (error) {
64
- fastify.log.error(error);
65
- return reply.status(500).send({ error: 'Failed to create organization checkout session' });
66
- }
67
  });
68
 
69
- // Create a Billing Portal Session
70
- fastify.post('/customer-portal', async (request, reply) => {
71
- const organizationId = (request as any).organizationId;
72
- if (!organizationId) {
73
- return reply.status(400).send({ error: 'Missing organizationId' });
74
- }
75
-
76
- try {
77
- const org = await prisma.organization.findUnique({
78
- where: { id: organizationId },
79
- select: { stripeCustomerId: true }
80
- });
81
-
82
- if (!org?.stripeCustomerId) {
83
- return reply.status(400).send({ error: 'No active subscription found for this organization' });
84
- }
85
-
86
- const portalUrl = await stripeService.createCustomerPortalSession(org.stripeCustomerId);
87
- return { success: true, url: portalUrl };
88
- } catch (error) {
89
- fastify.log.error(error);
90
- return reply.status(500).send({ error: 'Failed to create billing portal session' });
91
- }
92
  });
93
  }
94
 
95
- // ─── Public Stripe webhook (no API key auth secured by Stripe signature) ────
96
  export async function stripeWebhookRoute(fastify: FastifyInstance) {
97
- // Capture raw body buffer for Stripe signature verification
98
- fastify.addContentTypeParser('application/json', { parseAs: 'buffer' }, async (req: FastifyRequest, body: Buffer) => {
99
- req.rawBody = body;
100
- return JSON.parse(body.toString('utf8'));
101
  });
102
-
103
- // ── POST /webhook or /webhook/:organizationId ───────────────────────────
104
- fastify.post('/webhook', async (request, reply) => handleWebhook(request, reply));
105
- fastify.post('/webhook/:organizationId', async (request, reply) => handleWebhook(request, reply));
106
-
107
- async function handleWebhook(request: any, reply: any) {
108
- const { organizationId } = request.params as { organizationId?: string };
109
- const sig = request.headers['stripe-signature'];
110
-
111
- if (!sig || typeof sig !== 'string') {
112
- return reply.status(400).send({ error: 'Missing stripe-signature header' });
113
- }
114
-
115
- let event;
116
-
117
- try {
118
- const rawBody = request.rawBody;
119
- if (!rawBody) throw new Error('Missing raw body');
120
- event = await stripeService.verifyWebhookSignature(rawBody, sig, organizationId);
121
- } catch (err: unknown) {
122
- const errorMsg = err instanceof Error ? err.message : String(err);
123
- fastify.log.warn(`[Stripe Webhook] Signature verification failed for Org ${organizationId || 'global'}: ${errorMsg}`);
124
- return reply.status(400).send(`Webhook Error: ${errorMsg}`);
125
- }
126
-
127
- // --- Handle Events ---
128
-
129
- // 1. Single Payments (Student enrolling in premium track)
130
- if (event.type === 'checkout.session.completed') {
131
- const session = event.data.object as any;
132
- const userId = session.metadata?.userId;
133
- const trackId = session.metadata?.trackId;
134
- const orgId = session.metadata?.organizationId; // If it's an org sub, it has this
135
-
136
- if (userId && trackId) {
137
- // Determine organization context (mandatory for hardened schema)
138
- const targetOrgId = orgId || session.metadata?.targetOrganizationId || 'default-org-id';
139
-
140
- try {
141
- await prisma.$transaction(async (tx) => {
142
- await tx.payment.upsert({
143
- where: { stripeSessionId: session.id },
144
- update: {
145
- status: 'COMPLETED',
146
- amount: session.amount_total,
147
- currency: session.currency || 'XOF',
148
- organizationId: targetOrgId
149
- },
150
- create: {
151
- userId,
152
- trackId,
153
- amount: session.amount_total,
154
- status: 'COMPLETED',
155
- stripeSessionId: session.id,
156
- currency: session.currency || 'XOF',
157
- organizationId: targetOrgId
158
- }
159
- });
160
-
161
- const existingEnrollment = await tx.enrollment.findFirst({
162
- where: { userId, trackId }
163
- });
164
-
165
- if (!existingEnrollment) {
166
- await tx.enrollment.create({
167
- data: {
168
- userId,
169
- trackId,
170
- status: 'ACTIVE',
171
- currentDay: 1,
172
- organizationId: targetOrgId
173
- }
174
- });
175
- }
176
- });
177
- } catch (dbError) {
178
- fastify.log.error(dbError, '[Stripe Webhook] DB error during student payment');
179
- }
180
- } else if (orgId) {
181
- // This was a subscription checkout for an organization
182
- await prisma.organization.update({
183
- where: { id: orgId },
184
- data: {
185
- stripeCustomerId: session.customer as string,
186
- subscriptionStatus: 'ACTIVE'
187
- }
188
- });
189
- fastify.log.info(`[Stripe Webhook] Organization ${orgId} subscribed.`);
190
- }
191
- }
192
-
193
- // 2. Subscription Lifecycle
194
- if (event.type === 'customer.subscription.deleted') {
195
- const sub = event.data.object as any;
196
- const orgId = sub.metadata?.organizationId;
197
- if (orgId) {
198
- await prisma.organization.update({
199
- where: { id: orgId },
200
- data: { subscriptionStatus: 'CANCELED' }
201
- });
202
- }
203
- }
204
-
205
- if (event.type === 'customer.subscription.updated') {
206
- const sub = event.data.object as any;
207
- const orgId = sub.metadata?.organizationId;
208
- if (orgId) {
209
- const status = sub.status === 'active' ? 'ACTIVE' : (sub.status === 'past_due' ? 'PAST_DUE' : 'PENDING');
210
- await prisma.organization.update({
211
- where: { id: orgId },
212
- data: { subscriptionStatus: status }
213
- });
214
- }
215
- }
216
-
217
- return reply.code(200).send({ received: true });
218
- }
219
  }
 
1
  import { FastifyInstance } from 'fastify';
 
 
 
 
2
 
3
+ // Payment routes Orange Money & Wave integration (à implémenter)
 
 
 
 
 
 
4
  export async function paymentRoutes(fastify: FastifyInstance) {
5
+ fastify.post('/initiate', async (_req, reply) => {
6
+ return reply.code(501).send({
7
+ error: 'Not Implemented',
8
+ message: 'Intégration Orange Money / Wave en cours.'
9
+ });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  });
11
 
12
+ fastify.get('/status/:paymentId', async (_req, reply) => {
13
+ return reply.code(501).send({
14
+ error: 'Not Implemented',
15
+ message: 'Vérification statut paiement en cours.'
16
+ });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  });
18
  }
19
 
20
+ // Webhook placeholder à brancher sur Orange Money / Wave
21
  export async function stripeWebhookRoute(fastify: FastifyInstance) {
22
+ fastify.post('/webhook', async (_req, reply) => {
23
+ return reply.code(200).send({ ok: true });
 
 
24
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  }
apps/api/src/routes/whatsapp.ts CHANGED
@@ -40,7 +40,7 @@ const WebhookSchema = z.object({
40
 
41
  export async function whatsappRoutes(fastify: FastifyInstance) {
42
  fastify.get('/webhook', async (request, reply) => {
43
- const query = request.query as any;
44
  const mode = query['hub.mode'];
45
  const token = query['hub.verify_token'];
46
  const challenge = query['hub.challenge'];
@@ -76,7 +76,7 @@ export async function whatsappRoutes(fastify: FastifyInstance) {
76
  const entry = body.entry[0];
77
  const wabaId = entry.id;
78
  const value = entry.changes[0].value;
79
- const prisma = (fastify as any).prisma;
80
 
81
  try {
82
  // 1. Handle Status Updates (delivered, read, etc.)
@@ -88,7 +88,9 @@ export async function whatsappRoutes(fastify: FastifyInstance) {
88
  await prisma.campaignHistory.update({
89
  where: { whatsappMessageId: messageId },
90
  data: { status }
91
- }).catch(() => { /* Ignore updates for untracked messages */ });
 
 
92
 
93
  if (status === 'READ') {
94
  const history = await prisma.campaignHistory.findUnique({ where: { whatsappMessageId: messageId } });
 
40
 
41
  export async function whatsappRoutes(fastify: FastifyInstance) {
42
  fastify.get('/webhook', async (request, reply) => {
43
+ const query = request.query as Record<string, string | undefined>;
44
  const mode = query['hub.mode'];
45
  const token = query['hub.verify_token'];
46
  const challenge = query['hub.challenge'];
 
76
  const entry = body.entry[0];
77
  const wabaId = entry.id;
78
  const value = entry.changes[0].value;
79
+ const prisma = fastify.prisma;
80
 
81
  try {
82
  // 1. Handle Status Updates (delivered, read, etc.)
 
88
  await prisma.campaignHistory.update({
89
  where: { whatsappMessageId: messageId },
90
  data: { status }
91
+ }).catch((err: unknown) => {
92
+ logger.debug({ messageId, err }, '[WHATSAPP] Status update skipped — message not tracked in campaignHistory');
93
+ });
94
 
95
  if (status === 'READ') {
96
  const history = await prisma.campaignHistory.findUnique({ where: { whatsappMessageId: messageId } });
apps/api/src/services/ai/index.ts CHANGED
@@ -12,8 +12,8 @@ export const aiService = new BaseAIService({
12
  prisma,
13
  redis: {
14
  get: (key: string) => redis.get(key),
15
- set: (key: string, value: string, mode?: string, duration?: number) =>
16
- duration ? redis.set(key, value, mode as any, duration) : redis.set(key, value)
17
  },
18
  getTenantSecrets,
19
  getOrganizationId
 
12
  prisma,
13
  redis: {
14
  get: (key: string) => redis.get(key),
15
+ set: (key: string, value: string, _mode?: string, duration?: number) =>
16
+ duration ? redis.set(key, value, 'EX', duration) : redis.set(key, value)
17
  },
18
  getTenantSecrets,
19
  getOrganizationId
apps/api/src/services/audit.ts CHANGED
@@ -12,7 +12,7 @@ export const auditService = {
12
  details?: Record<string, any>;
13
  }) {
14
  try {
15
- await (prisma as any).auditLog.create({
16
  data: {
17
  action: params.action,
18
  actorId: params.actorId,
 
12
  details?: Record<string, any>;
13
  }) {
14
  try {
15
+ await prisma.auditLog.create({
16
  data: {
17
  action: params.action,
18
  actorId: params.actorId,
apps/api/src/services/normalization.ts CHANGED
@@ -25,7 +25,7 @@ export const normalizationService = {
25
  logger.error({ err }, '[NORMALIZATION] Redis get error');
26
  }
27
 
28
- const rules = await (prisma as any).normalizationRule.findMany({
29
  where: { language }
30
  });
31
 
@@ -47,7 +47,7 @@ export const normalizationService = {
47
  * Create or update a rule
48
  */
49
  async saveRule(original: string, replacement: string, language: string = 'WOLOF') {
50
- const rule = await (prisma as any).normalizationRule.upsert({
51
  where: { original },
52
  update: { replacement, language },
53
  create: { original, replacement, language }
@@ -63,7 +63,7 @@ export const normalizationService = {
63
  * Batch save rules
64
  */
65
  async saveRules(rules: { original: string, replacement: string }[], language: string = 'WOLOF') {
66
- const operations = rules.map(r => (prisma as any).normalizationRule.upsert({
67
  where: { original: r.original },
68
  update: { replacement: r.replacement, language },
69
  create: { original: r.original, replacement: r.replacement, language }
 
25
  logger.error({ err }, '[NORMALIZATION] Redis get error');
26
  }
27
 
28
+ const rules = await prisma.normalizationRule.findMany({
29
  where: { language }
30
  });
31
 
 
47
  * Create or update a rule
48
  */
49
  async saveRule(original: string, replacement: string, language: string = 'WOLOF') {
50
+ const rule = await prisma.normalizationRule.upsert({
51
  where: { original },
52
  update: { replacement, language },
53
  create: { original, replacement, language }
 
63
  * Batch save rules
64
  */
65
  async saveRules(rules: { original: string, replacement: string }[], language: string = 'WOLOF') {
66
+ const operations = rules.map(r => prisma.normalizationRule.upsert({
67
  where: { original: r.original },
68
  update: { replacement: r.replacement, language },
69
  create: { original: r.original, replacement: r.replacement, language }
apps/api/src/services/organization.ts CHANGED
@@ -23,7 +23,7 @@ export async function getOrganizationByPhoneNumberId(phoneNumberId: string): Pro
23
  }
24
 
25
  // 2. Lookup in DB
26
- const phoneRecord = await (prisma as any).whatsAppPhoneNumber.findUnique({
27
  where: { id: phoneNumberId },
28
  select: { organizationId: true }
29
  });
@@ -79,7 +79,6 @@ export function decryptSecrets(org: any) {
79
  if (org.webhookSecret) org.webhookSecret = decrypt(org.webhookSecret, ENCRYPTION_SECRET);
80
  if (org.openAiApiKey) org.openAiApiKey = decrypt(org.openAiApiKey, ENCRYPTION_SECRET);
81
  if (org.googleAiApiKey) org.googleAiApiKey = decrypt(org.googleAiApiKey, ENCRYPTION_SECRET);
82
- if (org.stripeSecretKey) org.stripeSecretKey = decrypt(org.stripeSecretKey, ENCRYPTION_SECRET);
83
  return org;
84
  }
85
 
@@ -94,8 +93,6 @@ export async function getTenantSecrets(organizationId: string) {
94
  webhookSecret: true,
95
  openAiApiKey: true,
96
  googleAiApiKey: true,
97
- stripeSecretKey: true,
98
- stripeWebhookSecret: true
99
  }
100
  });
101
 
 
23
  }
24
 
25
  // 2. Lookup in DB
26
+ const phoneRecord = await prisma.whatsAppPhoneNumber.findUnique({
27
  where: { id: phoneNumberId },
28
  select: { organizationId: true }
29
  });
 
79
  if (org.webhookSecret) org.webhookSecret = decrypt(org.webhookSecret, ENCRYPTION_SECRET);
80
  if (org.openAiApiKey) org.openAiApiKey = decrypt(org.openAiApiKey, ENCRYPTION_SECRET);
81
  if (org.googleAiApiKey) org.googleAiApiKey = decrypt(org.googleAiApiKey, ENCRYPTION_SECRET);
 
82
  return org;
83
  }
84
 
 
93
  webhookSecret: true,
94
  openAiApiKey: true,
95
  googleAiApiKey: true,
 
 
96
  }
97
  });
98
 
apps/api/src/services/push.ts CHANGED
@@ -19,7 +19,7 @@ export const pushService = {
19
  * Store a new subscription for a user
20
  */
21
  async subscribe(userId: string, organizationId: string, subscription: any) {
22
- return (prisma as any).pushSubscription.upsert({
23
  where: { endpoint: subscription.endpoint },
24
  update: {
25
  userId,
@@ -41,7 +41,7 @@ export const pushService = {
41
  * Send a notification to all active subscriptions of an organization
42
  */
43
  async notifyOrganization(organizationId: string, title: string, body: string, icon?: string) {
44
- const subscriptions = await (prisma as any).pushSubscription.findMany({
45
  where: { organizationId }
46
  });
47
 
@@ -72,7 +72,7 @@ export const pushService = {
72
  const error = (results[i] as PromiseRejectedResult).reason;
73
  if (error.statusCode === 410 || error.statusCode === 404) {
74
  logger.info(`[PUSH-SERVICE] Removing expired subscription: ${subscriptions[i].endpoint}`);
75
- await (prisma as any).pushSubscription.delete({ where: { id: subscriptions[i].id } }).catch(() => {});
76
  }
77
  }
78
  }
 
19
  * Store a new subscription for a user
20
  */
21
  async subscribe(userId: string, organizationId: string, subscription: any) {
22
+ return prisma.pushSubscription.upsert({
23
  where: { endpoint: subscription.endpoint },
24
  update: {
25
  userId,
 
41
  * Send a notification to all active subscriptions of an organization
42
  */
43
  async notifyOrganization(organizationId: string, title: string, body: string, icon?: string) {
44
+ const subscriptions = await prisma.pushSubscription.findMany({
45
  where: { organizationId }
46
  });
47
 
 
72
  const error = (results[i] as PromiseRejectedResult).reason;
73
  if (error.statusCode === 410 || error.statusCode === 404) {
74
  logger.info(`[PUSH-SERVICE] Removing expired subscription: ${subscriptions[i].endpoint}`);
75
+ await prisma.pushSubscription.delete({ where: { id: subscriptions[i].id } }).catch(() => {});
76
  }
77
  }
78
  }
apps/api/src/services/queue.ts CHANGED
@@ -15,8 +15,8 @@ const connection = process.env.REDIS_URL
15
 
16
  connection.on('error', (err) => logger.error({ err }, '[REDIS] Queue connection error:'));
17
 
18
- export const whatsappQueue = new Queue('whatsapp-queue', { connection: connection as any });
19
- export const notificationQueue = new Queue('notification-queue', { connection: connection as any });
20
 
21
  /** Gracefully close all queues and the underlying connection */
22
  export async function closeQueues() {
 
15
 
16
  connection.on('error', (err) => logger.error({ err }, '[REDIS] Queue connection error:'));
17
 
18
+ export const whatsappQueue = new Queue('whatsapp-queue', { connection });
19
+ export const notificationQueue = new Queue('notification-queue', { connection });
20
 
21
  /** Gracefully close all queues and the underlying connection */
22
  export async function closeQueues() {
apps/api/src/services/stripe.ts DELETED
@@ -1,147 +0,0 @@
1
- import { logger } from '../logger';
2
- import Stripe from 'stripe';
3
- import { PaymentProvider, CheckoutSessionParams } from './payments/types';
4
- import { getTenantSecrets } from './organization';
5
-
6
- export class StripeService implements PaymentProvider {
7
- public name = 'stripe';
8
- private stripe: Stripe | null = null;
9
- private webhookSecret: string | null = null;
10
- private clientUrl: string;
11
- private instances: Map<string, { stripe: Stripe, webhookSecret: string | null }> = new Map();
12
-
13
- constructor() {
14
- const secretKey = process.env.STRIPE_SECRET_KEY;
15
- const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
16
-
17
- this.webhookSecret = webhookSecret || null;
18
- this.clientUrl = process.env.VITE_CLIENT_URL || 'http://localhost:5174';
19
-
20
- if (secretKey) {
21
- this.stripe = new Stripe(secretKey, {
22
- apiVersion: '2025-01-27.acacia' as any,
23
- });
24
- }
25
- }
26
-
27
- private async getStripeInstance(organizationId?: string): Promise<{ stripe: Stripe | null, webhookSecret: string | null }> {
28
- if (!organizationId) return { stripe: this.stripe, webhookSecret: this.webhookSecret };
29
-
30
- // Check cache
31
- if (this.instances.has(organizationId)) {
32
- return this.instances.get(organizationId)!;
33
- }
34
-
35
- // Check DB for tenant secrets
36
- const secrets = await getTenantSecrets(organizationId);
37
- if (secrets?.stripeSecretKey) {
38
- const instance = {
39
- stripe: new Stripe(secrets.stripeSecretKey, {
40
- apiVersion: '2025-01-27.acacia' as any,
41
- }),
42
- webhookSecret: secrets.stripeWebhookSecret || null
43
- };
44
- this.instances.set(organizationId, instance);
45
- return instance;
46
- }
47
-
48
- // Fallback to global
49
- return { stripe: this.stripe, webhookSecret: this.webhookSecret };
50
- }
51
-
52
- /**
53
- * Unified checkout session creator for the interface
54
- */
55
- async createCheckoutSession(params: CheckoutSessionParams): Promise<string> {
56
- const { stripe } = await this.getStripeInstance(params.organizationId);
57
- if (!stripe) throw new Error('[StripeService] Stripe is not configured for this organization');
58
-
59
- // Decide mode based on params
60
- const isSubscription = !!params.organizationId && !params.trackId;
61
- const mode = isSubscription ? 'subscription' : 'payment';
62
- const priceId = params.priceId || (isSubscription ? process.env.STRIPE_PAAS_SUBSCRIPTION_PRICE_ID : null);
63
-
64
- if (!priceId) throw new Error('[StripeService] Missing Price ID for checkout');
65
-
66
- try {
67
- const session = await stripe.checkout.sessions.create({
68
- payment_method_types: ['card'],
69
- line_items: [{ price: priceId, quantity: 1 }],
70
- mode: mode as any,
71
- success_url: isSubscription ? `${this.clientUrl}/settings?success=true` : `${this.clientUrl}/payment/success?session_id={CHECKOUT_SESSION_ID}&track=${params.trackId}`,
72
- cancel_url: isSubscription ? `${this.clientUrl}/settings?cancel=true` : `${this.clientUrl}/student?cancel=true`,
73
- customer_email: params.email,
74
- metadata: {
75
- userId: params.userId || '',
76
- trackId: params.trackId || '',
77
- organizationId: params.organizationId || '',
78
- userPhone: params.phone || ''
79
- }
80
- });
81
-
82
- return session.url || '';
83
- } catch (err) {
84
- logger.error({ err }, "[StripeService] Failed to create checkout session:");
85
- throw err;
86
- }
87
- }
88
-
89
- /**
90
- * Creates a Stripe Checkout Session for a specific track and user. (Legacy helper)
91
- */
92
- async createLegacyCheckoutSession(userId: string, trackId: string, priceId: string, userPhone: string) {
93
- return this.createCheckoutSession({ userId, trackId, priceId, phone: userPhone });
94
- }
95
-
96
- /**
97
- * Creates a Stripe Checkout Session for an organization subscription. (Legacy helper)
98
- */
99
- async createOrganizationSubscriptionSession(organizationId: string, email?: string) {
100
- return this.createCheckoutSession({ organizationId, email });
101
- }
102
-
103
- /**
104
- * Verifies the signature of an incoming Stripe webhook.
105
- */
106
- async verifyWebhookSignature(payload: Buffer, signature: string | undefined, organizationId?: string): Promise<Stripe.Event> {
107
- const { stripe, webhookSecret } = await this.getStripeInstance(organizationId);
108
-
109
- if (!stripe || !webhookSecret) {
110
- throw new Error('[StripeService] Stripe is not configured for this organization');
111
- }
112
- if (!signature) {
113
- throw new Error('Missing stripe-signature header');
114
- }
115
-
116
- try {
117
- return stripe.webhooks.constructEvent(
118
- payload,
119
- signature,
120
- webhookSecret
121
- );
122
- } catch (err: unknown) {
123
- throw new Error(`Webhook Error: ${(err instanceof Error ? err.message : String(err))}`);
124
- }
125
- }
126
-
127
- /**
128
- * Creates a link to the Stripe Customer Portal for subscription management.
129
- */
130
- async createCustomerPortalSession(customerId: string, organizationId?: string) {
131
- const { stripe } = await this.getStripeInstance(organizationId);
132
- if (!stripe) throw new Error('[StripeService] Stripe not configured for this organization');
133
-
134
- try {
135
- const session = await stripe.billingPortal.sessions.create({
136
- customer: customerId,
137
- return_url: `${this.clientUrl}/settings`,
138
- });
139
- return session.url;
140
- } catch (err) {
141
- logger.error({ err }, '[StripeService] Failed to create portal session:');
142
- throw err;
143
- }
144
- }
145
- }
146
-
147
- export const stripeService = new StripeService();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
apps/api/src/services/whatsapp.ts CHANGED
@@ -7,7 +7,7 @@ export interface WhatsAppMessage {
7
  }
8
 
9
  export class WhatsAppService {
10
- private baseUrl = 'https://graph.facebook.com/v18.0';
11
 
12
  /**
13
  * Sends a direct text message via WhatsApp Cloud API
 
7
  }
8
 
9
  export class WhatsAppService {
10
+ private baseUrl = process.env.WHATSAPP_GRAPH_URL || 'https://graph.facebook.com/v18.0';
11
 
12
  /**
13
  * Sends a direct text message via WhatsApp Cloud API
apps/api/test/stripe.test.ts DELETED
@@ -1,53 +0,0 @@
1
- import { describe, it, expect, vi, beforeEach } from 'vitest';
2
- import { StripeService } from '../src/services/stripe';
3
- import { getTenantSecrets } from '../src/services/organization';
4
-
5
- // Mock Organization Service
6
- vi.mock('../src/services/organization', () => ({
7
- getTenantSecrets: vi.fn(),
8
- prisma: {
9
- organization: {
10
- findUnique: vi.fn()
11
- }
12
- }
13
- }));
14
-
15
- describe('StripeService - Integration Tests', () => {
16
- let stripeService: StripeService;
17
- const clientUrl = 'https://test.xamle.studio';
18
-
19
- beforeEach(() => {
20
- stripeService = new StripeService();
21
- vi.clearAllMocks();
22
- });
23
-
24
- it('should initialize with tenant secret key if available', async () => {
25
- const orgId = 'org-stripe-test';
26
- const customStripeKey = 'sk_test_custom_key';
27
-
28
- (getTenantSecrets as any).mockResolvedValue({
29
- stripeSecretKey: customStripeKey
30
- });
31
-
32
- const instance = await (stripeService as any).getStripeInstance(orgId);
33
-
34
- expect(getTenantSecrets).toHaveBeenCalledWith(orgId);
35
- // Note: Stripe instance doesn't easily expose the key, but we check if getTenantSecrets was called
36
- });
37
-
38
- it('should fallback to global secret key if tenant key is missing', async () => {
39
- const orgId = 'org-global-stripe';
40
- (getTenantSecrets as any).mockResolvedValue(null);
41
-
42
- const instance = await (stripeService as any).getStripeInstance(orgId);
43
- expect(getTenantSecrets).toHaveBeenCalledWith(orgId);
44
- });
45
-
46
- it('should construct the correct portal return URL', async () => {
47
- const orgId = 'org-1';
48
- (getTenantSecrets as any).mockResolvedValue(null);
49
-
50
- // Mock the internal stripe call if needed, but here we just check logic
51
- expect(stripeService).toBeDefined();
52
- });
53
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
apps/web/src/PrivacyPolicy.tsx CHANGED
@@ -42,7 +42,7 @@ export default function PrivacyPolicy() {
42
  <ul className="list-disc pl-5 text-slate-600 space-y-2 mb-6">
43
  <li><strong>Meta / WhatsApp</strong> — pour l'acheminement des messages</li>
44
  <li><strong>OpenAI</strong> — pour la génération et personnalisation du contenu pédagogique</li>
45
- <li><strong>Stripe</strong> — pour le traitement sécurisé des paiements</li>
46
  <li><strong>Cloudflare</strong> — pour le stockage des documents générés</li>
47
  </ul>
48
 
 
42
  <ul className="list-disc pl-5 text-slate-600 space-y-2 mb-6">
43
  <li><strong>Meta / WhatsApp</strong> — pour l'acheminement des messages</li>
44
  <li><strong>OpenAI</strong> — pour la génération et personnalisation du contenu pédagogique</li>
45
+ <li><strong>Orange Money / Wave</strong> — pour le traitement sécurisé des paiements</li>
46
  <li><strong>Cloudflare</strong> — pour le stockage des documents générés</li>
47
  </ul>
48
 
apps/whatsapp-worker/package.json CHANGED
@@ -10,9 +10,10 @@
10
  },
11
  "dependencies": {
12
  "@aws-sdk/client-s3": "^3.995.0",
 
13
  "@repo/database": "workspace:*",
14
- "@repo/ai-sdk": "workspace:*",
15
  "@repo/shared-types": "workspace:*",
 
16
  "axios": "^1.13.5",
17
  "bullmq": "^5.0.0",
18
  "cheerio": "^1.2.0",
 
10
  },
11
  "dependencies": {
12
  "@aws-sdk/client-s3": "^3.995.0",
13
+ "@repo/ai-sdk": "workspace:*",
14
  "@repo/database": "workspace:*",
 
15
  "@repo/shared-types": "workspace:*",
16
+ "@sentry/node": "^10.51.0",
17
  "axios": "^1.13.5",
18
  "bullmq": "^5.0.0",
19
  "cheerio": "^1.2.0",
apps/whatsapp-worker/src/config.ts CHANGED
@@ -23,7 +23,7 @@ const result = envSchema.safeParse(process.env);
23
  if (!result.success) {
24
  const { logger } = require('./logger');
25
  logger.error({ errors: result.error.format() }, '[WORKER-CONFIG] ❌ Invalid worker environment variables');
26
- process.exit(1);
27
  }
28
 
29
  export const config = result.data;
 
23
  if (!result.success) {
24
  const { logger } = require('./logger');
25
  logger.error({ errors: result.error.format() }, '[WORKER-CONFIG] ❌ Invalid worker environment variables');
26
+ throw new Error(`[WORKER-CONFIG] Missing or invalid environment variables:\n${result.error.message}`);
27
  }
28
 
29
  export const config = result.data;
apps/whatsapp-worker/src/handlers/AdminHandler.ts CHANGED
@@ -70,7 +70,7 @@ export class AdminHandler implements JobHandler {
70
 
71
  if (enrollment) {
72
  const nextDay = Math.floor(enrollment.currentDay) + 1;
73
- const q = new Queue('whatsapp-queue', { connection: connection as any });
74
  await q.add('send-content', { userId, trackId, dayNumber: nextDay, organizationId }, { delay: 2000 });
75
  }
76
  }
 
70
 
71
  if (enrollment) {
72
  const nextDay = Math.floor(enrollment.currentDay) + 1;
73
+ const q = new Queue('whatsapp-queue', { connection });
74
  await q.add('send-content', { userId, trackId, dayNumber: nextDay, organizationId }, { delay: 2000 });
75
  }
76
  }
apps/whatsapp-worker/src/handlers/CommandHandler.ts CHANGED
@@ -29,8 +29,8 @@ export class CommandHandler implements MessageHandler {
29
  // ... (existing seed logic)
30
  logger.info({ traceId, userId: user.id }, "Database Seeding requested");
31
  try {
32
- // @ts-ignore
33
- const { seedDatabase } = await import('@repo/database/seed');
34
  const result = await seedDatabase(prisma);
35
  await prisma.businessProfile.deleteMany({ where: { userId: user.id } });
36
  await prisma.user.update({ where: { id: user.id }, data: { activity: null } });
 
29
  // ... (existing seed logic)
30
  logger.info({ traceId, userId: user.id }, "Database Seeding requested");
31
  try {
32
+ type SeedModule = { seedDatabase: (prisma: any) => Promise<{ seeded: boolean }> };
33
+ const { seedDatabase } = await import('@repo/database/seed') as unknown as SeedModule;
34
  const result = await seedDatabase(prisma);
35
  await prisma.businessProfile.deleteMany({ where: { userId: user.id } });
36
  await prisma.user.update({ where: { id: user.id }, data: { activity: null } });
apps/whatsapp-worker/src/handlers/ContentHandler.ts CHANGED
@@ -110,7 +110,7 @@ export class ContentHandler implements JobHandler {
110
  organizationId: user.organizationId
111
  }
112
  });
113
- const q = new Queue('whatsapp-queue', { connection: connection as any });
114
  await q.add('send-content', {
115
  userId,
116
  trackId: nextTrackId,
 
110
  organizationId: user.organizationId
111
  }
112
  });
113
+ const q = new Queue('whatsapp-queue', { connection });
114
  await q.add('send-content', {
115
  userId,
116
  trackId: nextTrackId,
apps/whatsapp-worker/src/handlers/EnrollHandler.ts CHANGED
@@ -55,7 +55,7 @@ export class EnrollHandler implements JobHandler {
55
  body: JSON.stringify({ userId, trackId })
56
  });
57
 
58
- const checkoutData = await checkoutRes.json() as any;
59
  if (checkoutRes.ok && checkoutData.url) {
60
  const user = await prisma.user.findUnique({ where: { id: userId } });
61
  if (user?.phone) {
@@ -76,14 +76,14 @@ export class EnrollHandler implements JobHandler {
76
  status: 'ACTIVE',
77
  currentDay: 1,
78
  organizationId: organizationId || 'default-org-id'
79
- } as any
80
  });
81
  const user = await prisma.user.findUnique({ where: { id: userId } });
82
  if (user?.phone) {
83
  const tenantConfig = await this.getTenantConfig(organizationId as string, connection);
84
  await sendTextMessage(user.phone, `🎉 Bienvenue dans *${track.title}* ! La génération de votre cours personnalisé (Jour 1) a commencé...`, tenantConfig);
85
 
86
- const q = new Queue('whatsapp-queue', { connection: connection as any });
87
  await q.add('send-content', { userId, trackId, dayNumber: 1, organizationId });
88
  }
89
  }
 
55
  body: JSON.stringify({ userId, trackId })
56
  });
57
 
58
+ const checkoutData = await checkoutRes.json() as { url?: string };
59
  if (checkoutRes.ok && checkoutData.url) {
60
  const user = await prisma.user.findUnique({ where: { id: userId } });
61
  if (user?.phone) {
 
76
  status: 'ACTIVE',
77
  currentDay: 1,
78
  organizationId: organizationId || 'default-org-id'
79
+ }
80
  });
81
  const user = await prisma.user.findUnique({ where: { id: userId } });
82
  if (user?.phone) {
83
  const tenantConfig = await this.getTenantConfig(organizationId as string, connection);
84
  await sendTextMessage(user.phone, `🎉 Bienvenue dans *${track.title}* ! La génération de votre cours personnalisé (Jour 1) a commencé...`, tenantConfig);
85
 
86
+ const q = new Queue('whatsapp-queue', { connection });
87
  await q.add('send-content', { userId, trackId, dayNumber: 1, organizationId });
88
  }
89
  }
apps/whatsapp-worker/src/handlers/ExerciseHandler.ts CHANGED
@@ -87,9 +87,9 @@ export class ExerciseHandler implements MessageHandler {
87
 
88
  // Bypasses (Button, Special, Vision)
89
  let isButtonChoice = false;
90
- const buttons = trackDay.buttonsJson as any[] | null;
91
  if (Array.isArray(buttons)) {
92
- isButtonChoice = buttons.some((b: any) => isFuzzyMatch(normalizedText, b.title || '') || isFuzzyMatch(normalizedText, b.id || ''));
93
  }
94
 
95
  const isDay7Special = activeEnrollment.currentDay === 7 && (
 
87
 
88
  // Bypasses (Button, Special, Vision)
89
  let isButtonChoice = false;
90
+ const buttons = trackDay.buttonsJson as { id?: string; title?: string }[] | null;
91
  if (Array.isArray(buttons)) {
92
+ isButtonChoice = buttons.some(b => isFuzzyMatch(normalizedText, b.title || '') || isFuzzyMatch(normalizedText, b.id || ''));
93
  }
94
 
95
  const isDay7Special = activeEnrollment.currentDay === 7 && (
apps/whatsapp-worker/src/handlers/MediaHandler.ts CHANGED
@@ -1,6 +1,6 @@
1
  import { Job } from 'bullmq';
2
  import Redis from 'ioredis';
3
- import { MediaType } from '@repo/database';
4
  import { JobHandler, JobData } from './types';
5
  import { prisma } from '../services/prisma';
6
  import { logger } from '../logger';
@@ -40,8 +40,7 @@ export class MediaHandler implements JobHandler {
40
  }
41
 
42
  async handle(job: Job<JobData>, connection: Redis): Promise<void> {
43
- const data = job.data as any;
44
- const { mediaId, phone, organizationId, mimeType } = data;
45
 
46
  if (!mediaId || !phone) {
47
  logger.error(`[MEDIA_HANDLER] Missing data: mediaId=${mediaId}, phone=${phone}`);
@@ -83,7 +82,7 @@ export class MediaHandler implements JobHandler {
83
  channel: 'WHATSAPP',
84
  mediaUrl: audioUrl || null,
85
  mediaType: MediaType.AUDIO,
86
- payload: job.data as any,
87
  organizationId: organizationId || user.organizationId
88
  }
89
  });
@@ -120,12 +119,12 @@ export class MediaHandler implements JobHandler {
120
  if (activeEnrollment) {
121
  await prisma.userProgress.upsert({
122
  where: { userId_trackId: { userId: user.id, trackId: activeEnrollment.trackId } },
123
- update: { exerciseStatus: 'PENDING_REVIEW' as any, adminTranscription: transcribedText, confidenceScore: confidence },
124
  create: {
125
  userId: user.id,
126
  trackId: activeEnrollment.trackId,
127
  organizationId: organizationId as string,
128
- exerciseStatus: 'PENDING_REVIEW' as any,
129
  adminTranscription: transcribedText,
130
  confidenceScore: confidence
131
 
@@ -144,7 +143,7 @@ export class MediaHandler implements JobHandler {
144
  logger.error(`[MEDIA_HANDLER] Transcription failed:`, transErr);
145
  }
146
  } else if (mimeType && mimeType.startsWith('image/')) {
147
- await WhatsAppLogic.handleIncomingMessage(phone, data.caption || 'Image reçue', undefined, audioUrl, organizationId, 'image', mediaId);
148
  }
149
  } catch (err) {
150
  logger.error(`[MEDIA_HANDLER] download-media failed:`, err);
 
1
  import { Job } from 'bullmq';
2
  import Redis from 'ioredis';
3
+ import { MediaType, ExerciseStatus, Prisma } from '@repo/database';
4
  import { JobHandler, JobData } from './types';
5
  import { prisma } from '../services/prisma';
6
  import { logger } from '../logger';
 
40
  }
41
 
42
  async handle(job: Job<JobData>, connection: Redis): Promise<void> {
43
+ const { mediaId, phone, organizationId, mimeType } = job.data;
 
44
 
45
  if (!mediaId || !phone) {
46
  logger.error(`[MEDIA_HANDLER] Missing data: mediaId=${mediaId}, phone=${phone}`);
 
82
  channel: 'WHATSAPP',
83
  mediaUrl: audioUrl || null,
84
  mediaType: MediaType.AUDIO,
85
+ payload: job.data as Prisma.InputJsonValue,
86
  organizationId: organizationId || user.organizationId
87
  }
88
  });
 
119
  if (activeEnrollment) {
120
  await prisma.userProgress.upsert({
121
  where: { userId_trackId: { userId: user.id, trackId: activeEnrollment.trackId } },
122
+ update: { exerciseStatus: ExerciseStatus.PENDING_REVIEW, adminTranscription: transcribedText, confidenceScore: confidence },
123
  create: {
124
  userId: user.id,
125
  trackId: activeEnrollment.trackId,
126
  organizationId: organizationId as string,
127
+ exerciseStatus: ExerciseStatus.PENDING_REVIEW,
128
  adminTranscription: transcribedText,
129
  confidenceScore: confidence
130
 
 
143
  logger.error(`[MEDIA_HANDLER] Transcription failed:`, transErr);
144
  }
145
  } else if (mimeType && mimeType.startsWith('image/')) {
146
+ await WhatsAppLogic.handleIncomingMessage(phone, job.data.caption || 'Image reçue', undefined, audioUrl, organizationId, 'image', mediaId);
147
  }
148
  } catch (err) {
149
  logger.error(`[MEDIA_HANDLER] download-media failed:`, err);
apps/whatsapp-worker/src/index.ts CHANGED
@@ -10,7 +10,8 @@ import Redis from 'ioredis';
10
  import { validateEnvironment } from './config';
11
  import { startWorkerCleanupCron } from './services/cleanup';
12
  import { JobData, JobHandler } from './handlers/types';
13
- import { reportError } from './services/errors';
 
14
  import { runWithTenant } from '@repo/database';
15
  import { extractWhatsAppPayload } from '@repo/shared-types';
16
  import { getCachedOrganization } from './services/organization';
@@ -35,23 +36,20 @@ dotenv.config();
35
  validateEnvironment();
36
  startWorkerCleanupCron();
37
 
38
- const redisConfig = process.env.REDIS_URL
39
- ? { url: process.env.REDIS_URL }
40
- : {
41
  host: process.env.REDIS_HOST || 'localhost',
42
  port: parseInt(process.env.REDIS_PORT || '6379'),
43
  username: process.env.REDIS_USERNAME || 'default',
44
  password: process.env.REDIS_PASSWORD || undefined,
45
  tls: process.env.REDIS_TLS === 'true' ? {} : undefined,
46
- };
47
-
48
- const connection = process.env.REDIS_URL
49
- ? new Redis(process.env.REDIS_URL, { maxRetriesPerRequest: null })
50
- : new Redis({ ...redisConfig, maxRetriesPerRequest: null } as any);
51
 
52
  connection.on('error', (err) => logger.error({ err }, '[REDIS] Worker connection error:'));
53
 
54
- const whatsappQueue = new Queue('whatsapp-queue', { connection: connection as any });
55
 
56
  const handlers: Record<string, JobHandler> = {
57
  // ... (handlers list same)
@@ -86,7 +84,7 @@ server.post('/v1/internal/whatsapp/inbound', async (req: FastifyRequest, reply:
86
  }
87
 
88
  let organizationId = req.headers['x-organization-id'] as string;
89
- const payload = req.body as any;
90
 
91
  // 🏢 Multi-Tenant Routing Hardening:
92
  // If we're coming from the Gateway (HF), the header might be missing or generic.
@@ -100,7 +98,10 @@ server.post('/v1/internal/whatsapp/inbound', async (req: FastifyRequest, reply:
100
  }
101
  }
102
 
103
- organizationId = organizationId || 'default-org-id';
 
 
 
104
 
105
  logger.info(`[BRIDGE] Processing forwarded webhook for Org: ${organizationId}`);
106
 
@@ -192,7 +193,7 @@ const worker = new Worker('whatsapp-queue', async (job: Job<JobData>) => {
192
  ];
193
 
194
  if (outboundJobNames.includes(job.name)) {
195
- const { allowed } = await UsageService.checkAndIncrement(organizationId, connection as any);
196
  if (!allowed) {
197
  logger.warn(`[WORKER] Skipping job ${job.name} for Org ${organizationId}: Daily Limit Reached.`);
198
  return { skipped: true, reason: 'limit_reached' };
@@ -219,7 +220,7 @@ const worker = new Worker('whatsapp-queue', async (job: Job<JobData>) => {
219
  }
220
  });
221
  }, {
222
- connection: connection as any,
223
  concurrency: parseInt(process.env.WORKER_CONCURRENCY || '5')
224
  });
225
 
@@ -245,7 +246,7 @@ const notificationWorker = new Worker('notification-queue', async (job: Job<any>
245
  throw err;
246
  }
247
  }, {
248
- connection: connection as any,
249
  concurrency: 2
250
  });
251
 
 
10
  import { validateEnvironment } from './config';
11
  import { startWorkerCleanupCron } from './services/cleanup';
12
  import { JobData, JobHandler } from './handlers/types';
13
+ import { reportError, initSentry } from './services/errors';
14
+ initSentry();
15
  import { runWithTenant } from '@repo/database';
16
  import { extractWhatsAppPayload } from '@repo/shared-types';
17
  import { getCachedOrganization } from './services/organization';
 
36
  validateEnvironment();
37
  startWorkerCleanupCron();
38
 
39
+ const connection = process.env.REDIS_URL
40
+ ? new Redis(process.env.REDIS_URL, { maxRetriesPerRequest: null })
41
+ : new Redis({
42
  host: process.env.REDIS_HOST || 'localhost',
43
  port: parseInt(process.env.REDIS_PORT || '6379'),
44
  username: process.env.REDIS_USERNAME || 'default',
45
  password: process.env.REDIS_PASSWORD || undefined,
46
  tls: process.env.REDIS_TLS === 'true' ? {} : undefined,
47
+ maxRetriesPerRequest: null
48
+ });
 
 
 
49
 
50
  connection.on('error', (err) => logger.error({ err }, '[REDIS] Worker connection error:'));
51
 
52
+ const whatsappQueue = new Queue('whatsapp-queue', { connection });
53
 
54
  const handlers: Record<string, JobHandler> = {
55
  // ... (handlers list same)
 
84
  }
85
 
86
  let organizationId = req.headers['x-organization-id'] as string;
87
+ const payload = req.body as { entry?: Array<{ changes?: Array<{ value?: { metadata?: { phone_number_id?: string } } }> }> };
88
 
89
  // 🏢 Multi-Tenant Routing Hardening:
90
  // If we're coming from the Gateway (HF), the header might be missing or generic.
 
98
  }
99
  }
100
 
101
+ if (!organizationId) {
102
+ logger.error('[BRIDGE] Could not resolve organizationId — rejecting webhook');
103
+ return reply.code(400).send({ error: 'Cannot resolve organization' });
104
+ }
105
 
106
  logger.info(`[BRIDGE] Processing forwarded webhook for Org: ${organizationId}`);
107
 
 
193
  ];
194
 
195
  if (outboundJobNames.includes(job.name)) {
196
+ const { allowed } = await UsageService.checkAndIncrement(organizationId, connection);
197
  if (!allowed) {
198
  logger.warn(`[WORKER] Skipping job ${job.name} for Org ${organizationId}: Daily Limit Reached.`);
199
  return { skipped: true, reason: 'limit_reached' };
 
220
  }
221
  });
222
  }, {
223
+ connection,
224
  concurrency: parseInt(process.env.WORKER_CONCURRENCY || '5')
225
  });
226
 
 
246
  throw err;
247
  }
248
  }, {
249
+ connection,
250
  concurrency: 2
251
  });
252
 
apps/whatsapp-worker/src/pedagogy.ts CHANGED
@@ -1,6 +1,6 @@
1
  import { prisma } from './services/prisma';
2
  import { logger } from './logger';
3
- import { sendTextMessage, sendAudioMessage, sendInteractiveButtonMessage, sendImageMessage, sendVideoMessage } from './whatsapp-cloud';
4
  import { isFeatureEnabled } from './config';
5
  import { shortenForWhatsApp } from './normalizeWolof';
6
  import { ButtonsJson } from './handlers/types';
@@ -60,10 +60,12 @@ export async function sendLessonDay(
60
 
61
  // Multi-lang content
62
  const buttonsJson = castJson<ButtonsJson>(trackDay.buttonsJson);
63
- if (buttonsJson?.content && (buttonsJson.content as any)[user.language]) {
64
- const langContent = (buttonsJson.content as any)[user.language];
65
- lessonText = langContent.lessonText || lessonText;
66
- exercisePrompt = langContent.exercisePrompt || exercisePrompt;
 
 
67
  }
68
 
69
  // AI Personalization
@@ -80,8 +82,8 @@ export async function sendLessonDay(
80
  userLanguage: user.language,
81
  businessProfile: user.businessProfile,
82
  previousResponses,
83
- tenantPrompt: (user.organization as any)?.customPrompt,
84
- tenantBranding: (user.organization as any)?.brandingData,
85
  organizationId
86
  });
87
  }
@@ -124,7 +126,7 @@ export async function sendLessonDay(
124
  // Exercise
125
  if (exercisePrompt) {
126
  if (trackDay.exerciseType === 'BUTTON' && trackDay.buttonsJson) {
127
- await sendInteractiveButtonMessage(user.phone, exercisePrompt, trackDay.buttonsJson as any, undefined, tenantConfig);
128
  } else {
129
  await sendTextMessage(user.phone, exercisePrompt, tenantConfig);
130
  }
@@ -139,7 +141,7 @@ export async function sendLessonDay(
139
  { id: `DAY${dayNumber}_EXERCISE`, title: isWolof ? "📝 Tontul" : "📝 Répondre" },
140
  { id: `MENU_HISTORIQUE`, title: isWolof ? "📚 Li nekk ci ginnaaw" : "📚 Revoir leçons" }
141
  ];
142
- await sendInteractiveListMessage(user.phone, isWolof ? "Sa Mbir" : "Actions", isWolof ? "Tànnal :" : "Choisis :", isWolof ? "Tànn" : "Menu", [{ title: "Menu", rows: rows as any }], undefined, tenantConfig);
143
  }
144
  }
145
 
 
1
  import { prisma } from './services/prisma';
2
  import { logger } from './logger';
3
+ import { sendTextMessage, sendAudioMessage, sendInteractiveButtonMessage, sendImageMessage, sendVideoMessage, WhatsAppButton } from './whatsapp-cloud';
4
  import { isFeatureEnabled } from './config';
5
  import { shortenForWhatsApp } from './normalizeWolof';
6
  import { ButtonsJson } from './handlers/types';
 
60
 
61
  // Multi-lang content
62
  const buttonsJson = castJson<ButtonsJson>(trackDay.buttonsJson);
63
+ if (buttonsJson?.content) {
64
+ const langContent = buttonsJson.content[user.language];
65
+ if (langContent && !Array.isArray(langContent)) {
66
+ lessonText = langContent.lessonText || lessonText;
67
+ exercisePrompt = langContent.exercisePrompt || exercisePrompt;
68
+ }
69
  }
70
 
71
  // AI Personalization
 
82
  userLanguage: user.language,
83
  businessProfile: user.businessProfile,
84
  previousResponses,
85
+ tenantPrompt: user.organization?.customPrompt ?? undefined,
86
+ tenantBranding: user.organization?.brandingData,
87
  organizationId
88
  });
89
  }
 
126
  // Exercise
127
  if (exercisePrompt) {
128
  if (trackDay.exerciseType === 'BUTTON' && trackDay.buttonsJson) {
129
+ await sendInteractiveButtonMessage(user.phone, exercisePrompt, trackDay.buttonsJson as unknown as WhatsAppButton[], undefined, tenantConfig);
130
  } else {
131
  await sendTextMessage(user.phone, exercisePrompt, tenantConfig);
132
  }
 
141
  { id: `DAY${dayNumber}_EXERCISE`, title: isWolof ? "📝 Tontul" : "📝 Répondre" },
142
  { id: `MENU_HISTORIQUE`, title: isWolof ? "📚 Li nekk ci ginnaaw" : "📚 Revoir leçons" }
143
  ];
144
+ await sendInteractiveListMessage(user.phone, isWolof ? "Sa Mbir" : "Actions", isWolof ? "Tànnal :" : "Choisis :", isWolof ? "Tànn" : "Menu", [{ title: "Menu", rows }], undefined, tenantConfig);
145
  }
146
  }
147
 
apps/whatsapp-worker/src/scheduler.ts CHANGED
@@ -17,7 +17,7 @@ const connection = process.env.REDIS_URL
17
  maxRetriesPerRequest: null
18
  });
19
 
20
- const whatsappQueue = new Queue('whatsapp-queue', { connection: connection as any });
21
 
22
  export function startDailyScheduler() {
23
  // Runs at 08:00 AM every day (Dakar time = UTC+0 in winter, so 8 UTC = 8 Dakar)
 
17
  maxRetriesPerRequest: null
18
  });
19
 
20
+ const whatsappQueue = new Queue('whatsapp-queue', { connection });
21
 
22
  export function startDailyScheduler() {
23
  // Runs at 08:00 AM every day (Dakar time = UTC+0 in winter, so 8 UTC = 8 Dakar)
apps/whatsapp-worker/src/services/ai-pedagogy.ts CHANGED
@@ -18,7 +18,7 @@ export class AIPedagogyService {
18
  params.lessonText,
19
  params.userActivity,
20
  params.userLanguage,
21
- params.previousResponses as any
22
  );
23
  return result.lessonText || params.lessonText;
24
  } catch (err) {
 
18
  params.lessonText,
19
  params.userActivity,
20
  params.userLanguage,
21
+ params.previousResponses.flatMap(r => r.response !== null ? [{ day: r.day, response: r.response }] : [])
22
  );
23
  return result.lessonText || params.lessonText;
24
  } catch (err) {
apps/whatsapp-worker/src/services/ai.ts CHANGED
@@ -18,8 +18,8 @@ export const aiService = new BaseAIService({
18
  prisma,
19
  redis: {
20
  get: (key: string) => redis.get(key),
21
- set: (key: string, value: string, mode?: string, duration?: number) =>
22
- duration ? redis.set(key, value, mode as any, duration) : redis.set(key, value)
23
  },
24
  getTenantSecrets,
25
  getOrganizationId
 
18
  prisma,
19
  redis: {
20
  get: (key: string) => redis.get(key),
21
+ set: (key: string, value: string, _mode?: string, duration?: number) =>
22
+ duration ? redis.set(key, value, 'EX', duration) : redis.set(key, value)
23
  },
24
  getTenantSecrets,
25
  getOrganizationId
apps/whatsapp-worker/src/services/errors.ts CHANGED
@@ -1,36 +1,52 @@
1
  import { logger } from '../logger';
 
2
 
3
  export interface ErrorContext {
4
  organizationId?: string;
5
  userId?: string;
6
  jobId?: string;
7
  jobName?: string;
8
- extra?: Record<string, any>;
9
  }
10
 
11
- /**
12
- * Centralized error reporter.
13
- * Currently logs structured data, ready to be connected to Sentry/Datadog.
14
- */
15
- export const reportError = (error: any, context: ErrorContext) => {
 
 
 
 
 
 
 
 
 
 
 
 
 
16
  const errorMessage = error instanceof Error ? error.message : String(error);
17
  const errorStack = error instanceof Error ? error.stack : undefined;
18
 
19
  logger.error({
20
  msg: `[ERROR-REPORT] ${context.jobName || 'unknown'}: ${errorMessage}`,
21
- context: {
22
- ...context,
23
- stack: errorStack
24
- }
25
  });
26
 
27
- // TODO: Integrate Sentry here
28
- // Sentry.captureException(error, { tags: { ...context } });
 
 
 
 
 
 
 
 
29
  };
30
 
31
- /**
32
- * Wrapper for async tasks to ensure they are reported correctly.
33
- */
34
  export const withErrorLogging = async <T>(
35
  task: () => Promise<T>,
36
  context: ErrorContext
 
1
  import { logger } from '../logger';
2
+ import * as Sentry from '@sentry/node';
3
 
4
  export interface ErrorContext {
5
  organizationId?: string;
6
  userId?: string;
7
  jobId?: string;
8
  jobName?: string;
9
+ extra?: Record<string, unknown>;
10
  }
11
 
12
+ let sentryInitialized = false;
13
+
14
+ export function initSentry() {
15
+ const dsn = process.env.SENTRY_DSN;
16
+ if (!dsn) {
17
+ logger.info('[SENTRY] SENTRY_DSN not set — error reporting via logger only');
18
+ return;
19
+ }
20
+ Sentry.init({
21
+ dsn,
22
+ environment: process.env.NODE_ENV || 'production',
23
+ tracesSampleRate: 0.1,
24
+ });
25
+ sentryInitialized = true;
26
+ logger.info('[SENTRY] Initialized');
27
+ }
28
+
29
+ export const reportError = (error: unknown, context: ErrorContext) => {
30
  const errorMessage = error instanceof Error ? error.message : String(error);
31
  const errorStack = error instanceof Error ? error.stack : undefined;
32
 
33
  logger.error({
34
  msg: `[ERROR-REPORT] ${context.jobName || 'unknown'}: ${errorMessage}`,
35
+ context: { ...context, stack: errorStack }
 
 
 
36
  });
37
 
38
+ if (sentryInitialized) {
39
+ Sentry.withScope(scope => {
40
+ scope.setTags({
41
+ jobName: context.jobName ?? 'unknown',
42
+ organizationId: context.organizationId ?? 'unknown',
43
+ });
44
+ if (context.extra) scope.setExtras(context.extra);
45
+ Sentry.captureException(error);
46
+ });
47
+ }
48
  };
49
 
 
 
 
50
  export const withErrorLogging = async <T>(
51
  task: () => Promise<T>,
52
  context: ErrorContext
apps/whatsapp-worker/src/services/normalization.ts CHANGED
@@ -26,7 +26,7 @@ export const normalizationService = {
26
  logger.error({ err }, '[NORMALIZATION] Redis get error');
27
  }
28
 
29
- const rules = await (prisma as any).normalizationRule.findMany({
30
  where: { language }
31
  });
32
 
 
26
  logger.error({ err }, '[NORMALIZATION] Redis get error');
27
  }
28
 
29
+ const rules = await prisma.normalizationRule.findMany({
30
  where: { language }
31
  });
32