CognxSafeTrack commited on
Commit ·
7b0c22b
1
Parent(s): 820d280
chore: stabilization, audit fixes and project synchronization
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .env.example +6 -3
- apps/admin/src/App.tsx +9 -4
- apps/admin/src/components/layouts/MainLayout.tsx +3 -1
- apps/admin/src/pages/AIAgentSetup.tsx +179 -37
- apps/admin/src/pages/CampaignHistoryPage.tsx +228 -0
- apps/admin/src/pages/ClientsManagementView.tsx +64 -1
- apps/admin/src/pages/ContactsPage.tsx +42 -4
- apps/admin/src/pages/KnowledgeBasePage.tsx +201 -0
- apps/admin/src/pages/ResetPasswordPage.tsx +175 -0
- apps/admin/src/pages/SettingsPage.tsx +2 -16
- apps/admin/src/pages/TrackFormPage.tsx +6 -10
- apps/api/package.json +0 -1
- apps/api/src/config.ts +1 -1
- apps/api/src/index.ts +9 -10
- apps/api/src/middleware/rateLimit.ts +1 -1
- apps/api/src/routes/admin.ts +67 -17
- apps/api/src/routes/ai.ts +21 -11
- apps/api/src/routes/analytics.ts +18 -6
- apps/api/src/routes/auth.ts +68 -7
- apps/api/src/routes/campaigns.ts +8 -5
- apps/api/src/routes/internal.ts +5 -5
- apps/api/src/routes/notifications.ts +2 -2
- apps/api/src/routes/organizations.ts +159 -18
- apps/api/src/routes/payments.ts +14 -208
- apps/api/src/routes/whatsapp.ts +5 -3
- apps/api/src/services/ai/index.ts +2 -2
- apps/api/src/services/audit.ts +1 -1
- apps/api/src/services/normalization.ts +3 -3
- apps/api/src/services/organization.ts +1 -4
- apps/api/src/services/push.ts +3 -3
- apps/api/src/services/queue.ts +2 -2
- apps/api/src/services/stripe.ts +0 -147
- apps/api/src/services/whatsapp.ts +1 -1
- apps/api/test/stripe.test.ts +0 -53
- apps/web/src/PrivacyPolicy.tsx +1 -1
- apps/whatsapp-worker/package.json +2 -1
- apps/whatsapp-worker/src/config.ts +1 -1
- apps/whatsapp-worker/src/handlers/AdminHandler.ts +1 -1
- apps/whatsapp-worker/src/handlers/CommandHandler.ts +2 -2
- apps/whatsapp-worker/src/handlers/ContentHandler.ts +1 -1
- apps/whatsapp-worker/src/handlers/EnrollHandler.ts +3 -3
- apps/whatsapp-worker/src/handlers/ExerciseHandler.ts +2 -2
- apps/whatsapp-worker/src/handlers/MediaHandler.ts +6 -7
- apps/whatsapp-worker/src/index.ts +16 -15
- apps/whatsapp-worker/src/pedagogy.ts +11 -9
- apps/whatsapp-worker/src/scheduler.ts +1 -1
- apps/whatsapp-worker/src/services/ai-pedagogy.ts +1 -1
- apps/whatsapp-worker/src/services/ai.ts +2 -2
- apps/whatsapp-worker/src/services/errors.ts +31 -15
- 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 |
-
|
| 16 |
-
|
| 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';
|
| 21 |
-
import ConversationalDashboard from '@/pages/ConversationalDashboard';
|
| 22 |
-
import CrmConversationalDashboard from '@/pages/CrmConversationalDashboard';
|
|
|
|
|
|
|
| 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={<
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
|
| 4 |
export default function AIAgentSetup() {
|
| 5 |
-
const
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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">
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
</div>
|
| 44 |
<p className="font-medium text-slate-800">
|
| 45 |
-
{
|
| 46 |
-
{
|
| 47 |
-
{
|
|
|
|
| 48 |
</p>
|
| 49 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
{
|
| 72 |
-
<button
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
{
|
|
|
|
|
|
|
| 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-
|
| 100 |
-
|
| 101 |
-
<
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
<div className="
|
| 106 |
-
<
|
| 107 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
</div>
|
| 109 |
-
|
| 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
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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">
|
| 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
|
|
|
|
|
|
|
|
|
|
| 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
|
| 225 |
-
|
| 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
|
| 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
|
| 76 |
-
<
|
| 77 |
-
|
| 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 |
-
|
| 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
|
| 33 |
|
| 34 |
// ── Middleware & Plugins ──────────────────────────────────────────────────────
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 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 |
-
|
| 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
|
| 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()
|
|
|
|
| 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 |
-
|
| 188 |
-
userId,
|
| 189 |
-
trackId,
|
| 190 |
-
|
| 191 |
-
|
| 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 |
-
|
| 220 |
-
phone: user.phone,
|
| 221 |
-
|
| 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()
|
|
|
|
| 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()
|
|
|
|
| 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 (
|
| 517 |
-
|
| 518 |
-
return reply.code(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 (
|
| 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 (
|
| 161 |
-
return reply.code(429).send({ error: 'quota_exceeded', 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 (
|
| 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
|
| 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:
|
| 408 |
-
tone:
|
| 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
|
| 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 =
|
| 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 |
-
//
|
| 39 |
-
const estimatedTokens =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
|
| 41 |
return {
|
| 42 |
messages: {
|
|
@@ -50,7 +61,8 @@ export async function analyticsRoutes(fastify: FastifyInstance) {
|
|
| 50 |
},
|
| 51 |
costs: {
|
| 52 |
estimatedTokens,
|
| 53 |
-
estimatedUsd: (estimatedTokens /
|
|
|
|
| 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 =
|
| 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 =
|
| 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
|
| 24 |
logger.info(`[AUTH] Login attempt for ${email} (Org: ${organizationId || 'default'})`);
|
| 25 |
|
| 26 |
-
|
|
|
|
|
|
|
| 27 |
|
| 28 |
-
const user = await AuthService.findUserByEmail(email,
|
| 29 |
|
| 30 |
if (!user || !user.passwordHash) {
|
| 31 |
-
logger.warn(`[AUTH] User not found: ${email} in Org: ${
|
| 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:
|
| 57 |
}
|
| 58 |
};
|
| 59 |
});
|
| 60 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
// Get current user profile (guarded by JWT)
|
| 62 |
fastify.get('/me', async (request, reply) => {
|
| 63 |
-
const { id } = request.user
|
| 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:
|
| 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
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 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
|
| 32 |
-
new BullMQAdapter(notificationQueue
|
| 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()
|
| 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:
|
| 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 =
|
| 23 |
-
const 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:
|
| 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 =
|
| 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
|
| 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
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 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
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
|
|
|
|
|
|
| 378 |
|
| 379 |
try {
|
| 380 |
// 1. Create message record
|
|
@@ -443,14 +506,92 @@ export async function organizationRoutes(fastify: FastifyInstance) {
|
|
| 443 |
}
|
| 444 |
});
|
| 445 |
|
| 446 |
-
//
|
| 447 |
-
fastify.
|
| 448 |
const { id: organizationId } = req.params as { id: string };
|
| 449 |
-
const {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 450 |
|
| 451 |
-
|
| 452 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
//
|
| 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 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 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 |
-
//
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 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 |
-
//
|
| 96 |
export async function stripeWebhookRoute(fastify: FastifyInstance) {
|
| 97 |
-
/
|
| 98 |
-
|
| 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
|
| 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 =
|
| 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(() => {
|
|
|
|
|
|
|
| 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,
|
| 16 |
-
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
|
| 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
|
| 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
|
| 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 =>
|
| 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
|
| 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
|
| 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
|
| 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
|
| 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
|
| 19 |
-
export const notificationQueue = new Queue('notification-queue', { connection
|
| 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>
|
| 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 |
-
|
| 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
|
| 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 |
-
|
| 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
|
| 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
|
| 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 |
-
}
|
| 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 |
}
|
|
|
|
| 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
|
| 91 |
if (Array.isArray(buttons)) {
|
| 92 |
-
isButtonChoice = buttons.some(
|
| 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
|
| 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
|
| 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:
|
| 124 |
create: {
|
| 125 |
userId: user.id,
|
| 126 |
trackId: activeEnrollment.trackId,
|
| 127 |
organizationId: organizationId as string,
|
| 128 |
-
exerciseStatus:
|
| 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
|
| 39 |
-
?
|
| 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
|
| 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
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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
|
| 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
|
| 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
|
| 64 |
-
const langContent =
|
| 65 |
-
|
| 66 |
-
|
|
|
|
|
|
|
| 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:
|
| 84 |
-
tenantBranding:
|
| 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
|
| 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
|
| 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
|
| 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
|
| 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,
|
| 22 |
-
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,
|
| 9 |
}
|
| 10 |
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 28 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
|