CognxSafeTrack Claude Sonnet 4.6 commited on
Commit ·
b43e552
1
Parent(s): 83f9d2d
feat: push notifications, CRM analytics, Fastify v5 upgrades, schema updates
Browse files- Add web push notification service and admin endpoints
- Add CRM analytics component and conversational dashboard
- Extend Contacts page with campaign management
- Update AI and whatsapp routes (Fastify v5 compat, webhook fixes)
- Extend Prisma schema with new models and indexes
- Add CRM campaign prompt template
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- apps/admin/public/sw.js +39 -0
- apps/admin/src/App.tsx +6 -3
- apps/admin/src/components/CRMAnalytics.tsx +152 -0
- apps/admin/src/lib/notifications.ts +66 -0
- apps/admin/src/pages/ContactsPage.tsx +431 -11
- apps/admin/src/pages/ConversationalDashboard.tsx +352 -0
- apps/api/package.json +2 -0
- apps/api/src/index.ts +2 -0
- apps/api/src/routes/ai.ts +205 -0
- apps/api/src/routes/analytics.ts +56 -0
- apps/api/src/routes/internal.ts +4 -4
- apps/api/src/routes/notifications.ts +41 -0
- apps/api/src/routes/organizations.ts +37 -0
- apps/api/src/routes/whatsapp.ts +101 -133
- apps/api/src/services/ai/index.ts +73 -0
- apps/api/src/services/push.ts +80 -0
- apps/api/src/services/whatsapp.ts +102 -27
- packages/database/prisma/schema.prisma +51 -0
- packages/prompts/src/templates/crm-campaign.md +32 -0
apps/admin/public/sw.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
* Service Worker for Push Notifications
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
self.addEventListener('push', function(event) {
|
| 6 |
+
if (event.data) {
|
| 7 |
+
const data = event.data.json();
|
| 8 |
+
|
| 9 |
+
const options = {
|
| 10 |
+
body: data.body,
|
| 11 |
+
icon: data.icon || '/logo.png',
|
| 12 |
+
badge: '/badge.png',
|
| 13 |
+
vibrate: [100, 50, 100],
|
| 14 |
+
data: {
|
| 15 |
+
dateOfArrival: Date.now(),
|
| 16 |
+
primaryKey: '1'
|
| 17 |
+
},
|
| 18 |
+
actions: [
|
| 19 |
+
{ action: 'explore', title: 'Ouvrir le Dashboard', icon: '/check-mark.png' },
|
| 20 |
+
{ action: 'close', title: 'Fermer', icon: '/x-mark.png' },
|
| 21 |
+
]
|
| 22 |
+
};
|
| 23 |
+
|
| 24 |
+
event.waitUntil(
|
| 25 |
+
self.registration.showNotification(data.title, options)
|
| 26 |
+
);
|
| 27 |
+
}
|
| 28 |
+
});
|
| 29 |
+
|
| 30 |
+
self.addEventListener('notificationclick', function(event) {
|
| 31 |
+
event.notification.close();
|
| 32 |
+
|
| 33 |
+
if (event.action === 'explore' || !event.action) {
|
| 34 |
+
// Open the dashboard
|
| 35 |
+
event.waitUntil(
|
| 36 |
+
clients.openWindow('/')
|
| 37 |
+
);
|
| 38 |
+
}
|
| 39 |
+
});
|
apps/admin/src/App.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
import React from 'react';
|
| 2 |
import { BrowserRouter as Router, Routes, Route, Link, Navigate } from 'react-router-dom';
|
| 3 |
-
import { Users, BookOpen, Lightbulb, BarChart2, Mic, Activity, Building2, TrendingUp } from 'lucide-react';
|
| 4 |
|
| 5 |
import { AuthProvider, useAuth } from './lib/auth';
|
| 6 |
import { TenantProvider } from './lib/tenant';
|
|
@@ -18,7 +18,8 @@ import TrainingLab from './pages/TrainingLab';
|
|
| 18 |
import ClientsManagementView from './pages/ClientsManagementView';
|
| 19 |
import OnboardingWizard from './pages/OnboardingWizard';
|
| 20 |
import AnalyticsPage from './pages/AnalyticsPage';
|
| 21 |
-
import ContactsPage from '@/pages/ContactsPage'; // CRM Module
|
|
|
|
| 22 |
import { useTenant } from './lib/tenant';
|
| 23 |
import { api } from './lib/api';
|
| 24 |
|
|
@@ -54,7 +55,8 @@ function AppShell() {
|
|
| 54 |
const allNavItems = [
|
| 55 |
{ to: '/', label: 'Dashboard', icon: <BarChart2 className="w-4 h-4" /> },
|
| 56 |
{ to: '/analytics', label: 'Statistiques', icon: <TrendingUp className="w-4 h-4 text-amber-500" /> },
|
| 57 |
-
{ to: '/
|
|
|
|
| 58 |
{ to: '/content', label: 'Parcours', icon: <BookOpen className="w-4 h-4" />, show: !isCrmMode },
|
| 59 |
{ to: '/live-feed', label: 'Modération', icon: <Mic className="w-4 h-4 text-emerald-500" /> },
|
| 60 |
{ to: '/clients', label: 'Clients B2B', icon: <Building2 className="w-4 h-4 text-indigo-400" />, superOnly: true },
|
|
@@ -128,6 +130,7 @@ function AppShell() {
|
|
| 128 |
<Routes>
|
| 129 |
<Route path="/" element={<DashboardPage />} />
|
| 130 |
<Route path="/analytics" element={<AnalyticsPage />} />
|
|
|
|
| 131 |
<Route path="/contacts" element={<ContactsPage />} />
|
| 132 |
<Route path="/content" element={<TrackListPage />} />
|
| 133 |
<Route path="/content/new" element={<TrackFormPage />} />
|
|
|
|
| 1 |
import React from 'react';
|
| 2 |
import { BrowserRouter as Router, Routes, Route, Link, Navigate } from 'react-router-dom';
|
| 3 |
+
import { Users, BookOpen, Lightbulb, BarChart2, Mic, Activity, Building2, TrendingUp, Sparkles } from 'lucide-react';
|
| 4 |
|
| 5 |
import { AuthProvider, useAuth } from './lib/auth';
|
| 6 |
import { TenantProvider } from './lib/tenant';
|
|
|
|
| 18 |
import ClientsManagementView from './pages/ClientsManagementView';
|
| 19 |
import OnboardingWizard from './pages/OnboardingWizard';
|
| 20 |
import AnalyticsPage from './pages/AnalyticsPage';
|
| 21 |
+
import ContactsPage from '@/pages/ContactsPage'; // Traditional CRM Module
|
| 22 |
+
import ConversationalDashboard from './pages/ConversationalDashboard'; // New AI-First Module
|
| 23 |
import { useTenant } from './lib/tenant';
|
| 24 |
import { api } from './lib/api';
|
| 25 |
|
|
|
|
| 55 |
const allNavItems = [
|
| 56 |
{ to: '/', label: 'Dashboard', icon: <BarChart2 className="w-4 h-4" /> },
|
| 57 |
{ to: '/analytics', label: 'Statistiques', icon: <TrendingUp className="w-4 h-4 text-amber-500" /> },
|
| 58 |
+
{ to: '/crm', label: 'Assistant CRM', icon: <Sparkles className="w-4 h-4 text-indigo-400" />, show: isCrmMode },
|
| 59 |
+
{ to: '/contacts', label: 'Base Contacts', icon: <Users className="w-4 h-4 text-blue-400" />, show: isCrmMode },
|
| 60 |
{ to: '/content', label: 'Parcours', icon: <BookOpen className="w-4 h-4" />, show: !isCrmMode },
|
| 61 |
{ to: '/live-feed', label: 'Modération', icon: <Mic className="w-4 h-4 text-emerald-500" /> },
|
| 62 |
{ to: '/clients', label: 'Clients B2B', icon: <Building2 className="w-4 h-4 text-indigo-400" />, superOnly: true },
|
|
|
|
| 130 |
<Routes>
|
| 131 |
<Route path="/" element={<DashboardPage />} />
|
| 132 |
<Route path="/analytics" element={<AnalyticsPage />} />
|
| 133 |
+
<Route path="/crm" element={<ConversationalDashboard />} />
|
| 134 |
<Route path="/contacts" element={<ContactsPage />} />
|
| 135 |
<Route path="/content" element={<TrackListPage />} />
|
| 136 |
<Route path="/content/new" element={<TrackFormPage />} />
|
apps/admin/src/components/CRMAnalytics.tsx
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect, useState } from 'react';
|
| 2 |
+
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell } from 'recharts';
|
| 3 |
+
import { TrendingUp, Users, CheckCircle2, Eye, AlertCircle } from 'lucide-react';
|
| 4 |
+
import { useAuth } from '../lib/auth';
|
| 5 |
+
import { useTenant } from '../lib/tenant';
|
| 6 |
+
|
| 7 |
+
interface AnalyticsData {
|
| 8 |
+
summary: {
|
| 9 |
+
total: number;
|
| 10 |
+
sent: number;
|
| 11 |
+
delivered: number;
|
| 12 |
+
read: number;
|
| 13 |
+
failed: number;
|
| 14 |
+
deliveryRate: number;
|
| 15 |
+
readRate: number;
|
| 16 |
+
};
|
| 17 |
+
funnel: Array<{ name: string; value: number; fill: string }>;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
export const CRMAnalytics = () => {
|
| 21 |
+
const { token } = useAuth();
|
| 22 |
+
const { selectedOrgId } = useTenant();
|
| 23 |
+
const [data, setData] = useState<AnalyticsData | null>(null);
|
| 24 |
+
const [loading, setLoading] = useState(true);
|
| 25 |
+
|
| 26 |
+
useEffect(() => {
|
| 27 |
+
const fetchAnalytics = async () => {
|
| 28 |
+
if (!token || !selectedOrgId) return;
|
| 29 |
+
try {
|
| 30 |
+
const res = await fetch(`${import.meta.env.VITE_API_URL}/v1/analytics/campaigns`, {
|
| 31 |
+
headers: {
|
| 32 |
+
'Authorization': `Bearer ${token}`,
|
| 33 |
+
'x-organization-id': selectedOrgId
|
| 34 |
+
}
|
| 35 |
+
});
|
| 36 |
+
if (res.ok) {
|
| 37 |
+
const result = await res.json();
|
| 38 |
+
setData(result);
|
| 39 |
+
}
|
| 40 |
+
} catch (err) {
|
| 41 |
+
console.error("Failed to fetch analytics:", err);
|
| 42 |
+
} finally {
|
| 43 |
+
setLoading(false);
|
| 44 |
+
}
|
| 45 |
+
};
|
| 46 |
+
|
| 47 |
+
fetchAnalytics();
|
| 48 |
+
}, [token, selectedOrgId]);
|
| 49 |
+
|
| 50 |
+
if (loading) return (
|
| 51 |
+
<div className="flex items-center justify-center p-12 bg-white rounded-[2rem] border border-slate-100 shadow-sm animate-pulse">
|
| 52 |
+
<div className="text-slate-400 font-medium">Chargement des données...</div>
|
| 53 |
+
</div>
|
| 54 |
+
);
|
| 55 |
+
|
| 56 |
+
if (!data) return null;
|
| 57 |
+
|
| 58 |
+
return (
|
| 59 |
+
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-700">
|
| 60 |
+
{/* KPI Cards */}
|
| 61 |
+
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
| 62 |
+
<div className="p-5 bg-white border border-slate-100 rounded-3xl shadow-sm hover:shadow-md transition-shadow">
|
| 63 |
+
<div className="w-10 h-10 bg-indigo-50 rounded-xl flex items-center justify-center mb-3 text-indigo-600">
|
| 64 |
+
<Users className="w-5 h-5" />
|
| 65 |
+
</div>
|
| 66 |
+
<p className="text-[10px] font-black text-slate-400 uppercase tracking-wider">Total</p>
|
| 67 |
+
<p className="text-2xl font-black text-slate-900">{data.summary.total}</p>
|
| 68 |
+
</div>
|
| 69 |
+
|
| 70 |
+
<div className="p-5 bg-white border border-slate-100 rounded-3xl shadow-sm hover:shadow-md transition-shadow">
|
| 71 |
+
<div className="w-10 h-10 bg-emerald-50 rounded-xl flex items-center justify-center mb-3 text-emerald-600">
|
| 72 |
+
<CheckCircle2 className="w-5 h-5" />
|
| 73 |
+
</div>
|
| 74 |
+
<p className="text-[10px] font-black text-slate-400 uppercase tracking-wider">Délivré</p>
|
| 75 |
+
<p className="text-2xl font-black text-slate-900">{data.summary.deliveryRate.toFixed(1)}%</p>
|
| 76 |
+
</div>
|
| 77 |
+
|
| 78 |
+
<div className="p-5 bg-white border border-slate-100 rounded-3xl shadow-sm hover:shadow-md transition-shadow">
|
| 79 |
+
<div className="w-10 h-10 bg-pink-50 rounded-xl flex items-center justify-center mb-3 text-pink-600">
|
| 80 |
+
<Eye className="w-5 h-5" />
|
| 81 |
+
</div>
|
| 82 |
+
<p className="text-[10px] font-black text-slate-400 uppercase tracking-wider">Lecture</p>
|
| 83 |
+
<p className="text-2xl font-black text-slate-900">{data.summary.readRate.toFixed(1)}%</p>
|
| 84 |
+
</div>
|
| 85 |
+
|
| 86 |
+
<div className="p-5 bg-white border border-slate-100 rounded-3xl shadow-sm hover:shadow-md transition-shadow">
|
| 87 |
+
<div className="w-10 h-10 bg-amber-50 rounded-xl flex items-center justify-center mb-3 text-amber-600">
|
| 88 |
+
<TrendingUp className="w-5 h-5" />
|
| 89 |
+
</div>
|
| 90 |
+
<p className="text-[10px] font-black text-slate-400 uppercase tracking-wider">Engagement</p>
|
| 91 |
+
<p className="text-2xl font-black text-slate-900">Elevé</p>
|
| 92 |
+
</div>
|
| 93 |
+
</div>
|
| 94 |
+
|
| 95 |
+
{/* Funnel Chart */}
|
| 96 |
+
<div className="bg-white border border-slate-100 rounded-[2.5rem] p-8 shadow-sm">
|
| 97 |
+
<div className="flex items-center justify-between mb-8">
|
| 98 |
+
<div>
|
| 99 |
+
<h4 className="font-black text-slate-900 text-lg">Entonnoir de Campagne</h4>
|
| 100 |
+
<p className="text-xs text-slate-500 font-medium">Répartition des statuts de distribution</p>
|
| 101 |
+
</div>
|
| 102 |
+
{data.summary.failed > 0 && (
|
| 103 |
+
<div className="flex items-center gap-2 px-3 py-1 bg-red-50 text-red-600 rounded-full text-[10px] font-bold">
|
| 104 |
+
<AlertCircle className="w-3 h-3" />
|
| 105 |
+
{data.summary.failed} ÉCHECS
|
| 106 |
+
</div>
|
| 107 |
+
)}
|
| 108 |
+
</div>
|
| 109 |
+
|
| 110 |
+
<div style={{ width: '100%', minHeight: '300px' }}>
|
| 111 |
+
<ResponsiveContainer width="100%" height={300}>
|
| 112 |
+
<BarChart data={data.funnel} margin={{ top: 20, right: 30, left: 0, bottom: 0 }}>
|
| 113 |
+
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f1f5f9" />
|
| 114 |
+
<XAxis
|
| 115 |
+
dataKey="name"
|
| 116 |
+
axisLine={false}
|
| 117 |
+
tickLine={false}
|
| 118 |
+
tick={{ fill: '#94a3b8', fontSize: 12, fontWeight: 700 }}
|
| 119 |
+
dy={10}
|
| 120 |
+
/>
|
| 121 |
+
<YAxis hide />
|
| 122 |
+
<Tooltip
|
| 123 |
+
cursor={{ fill: 'transparent' }}
|
| 124 |
+
content={({ active, payload }) => {
|
| 125 |
+
if (active && payload && payload.length) {
|
| 126 |
+
return (
|
| 127 |
+
<div className="bg-slate-900 text-white p-3 rounded-2xl shadow-xl border border-slate-800 animate-in zoom-in-95 duration-200">
|
| 128 |
+
<p className="text-[10px] font-black uppercase mb-1 opacity-60">{payload[0].payload.name}</p>
|
| 129 |
+
<p className="text-sm font-black">{payload[0].value} Messages</p>
|
| 130 |
+
</div>
|
| 131 |
+
);
|
| 132 |
+
}
|
| 133 |
+
return null;
|
| 134 |
+
}}
|
| 135 |
+
/>
|
| 136 |
+
<Bar
|
| 137 |
+
dataKey="value"
|
| 138 |
+
radius={[12, 12, 12, 12]}
|
| 139 |
+
barSize={60}
|
| 140 |
+
animationDuration={1500}
|
| 141 |
+
>
|
| 142 |
+
{data.funnel.map((entry, index) => (
|
| 143 |
+
<Cell key={`cell-${index}`} fill={entry.fill} />
|
| 144 |
+
))}
|
| 145 |
+
</Bar>
|
| 146 |
+
</BarChart>
|
| 147 |
+
</ResponsiveContainer>
|
| 148 |
+
</div>
|
| 149 |
+
</div>
|
| 150 |
+
</div>
|
| 151 |
+
);
|
| 152 |
+
};
|
apps/admin/src/lib/notifications.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Push Notification Helper
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
function urlBase64ToUint8Array(base64String: string) {
|
| 6 |
+
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
|
| 7 |
+
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
|
| 8 |
+
const rawData = window.atob(base64);
|
| 9 |
+
const outputArray = new Uint8Array(rawData.length);
|
| 10 |
+
for (let i = 0; i < rawData.length; ++i) {
|
| 11 |
+
outputArray[i] = rawData.charCodeAt(i);
|
| 12 |
+
}
|
| 13 |
+
return outputArray;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
export const setupNotifications = async (token: string, orgId: string) => {
|
| 17 |
+
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
|
| 18 |
+
console.warn('Push notifications are not supported in this browser.');
|
| 19 |
+
return;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
try {
|
| 23 |
+
// 1. Register Service Worker
|
| 24 |
+
const registration = await navigator.serviceWorker.register('/sw.js');
|
| 25 |
+
console.log('Service Worker registered');
|
| 26 |
+
|
| 27 |
+
// 2. Request Permission
|
| 28 |
+
const permission = await Notification.requestPermission();
|
| 29 |
+
if (permission !== 'granted') {
|
| 30 |
+
console.warn('Notification permission denied');
|
| 31 |
+
return;
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
// 3. Get VAPID public key from server
|
| 35 |
+
const keyRes = await fetch(`${import.meta.env.VITE_API_URL}/v1/notifications/vapid-key`, {
|
| 36 |
+
headers: { 'Authorization': `Bearer ${token}`, 'x-organization-id': orgId }
|
| 37 |
+
});
|
| 38 |
+
const { publicKey } = await keyRes.json();
|
| 39 |
+
|
| 40 |
+
if (!publicKey) {
|
| 41 |
+
console.warn('VAPID public key missing from server');
|
| 42 |
+
return;
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
// 4. Subscribe to Push
|
| 46 |
+
const subscription = await registration.pushManager.subscribe({
|
| 47 |
+
userVisibleOnly: true,
|
| 48 |
+
applicationServerKey: urlBase64ToUint8Array(publicKey)
|
| 49 |
+
});
|
| 50 |
+
|
| 51 |
+
// 5. Send subscription to server
|
| 52 |
+
await fetch(`${import.meta.env.VITE_API_URL}/v1/notifications/subscribe`, {
|
| 53 |
+
method: 'POST',
|
| 54 |
+
headers: {
|
| 55 |
+
'Content-Type': 'application/json',
|
| 56 |
+
'Authorization': `Bearer ${token}`,
|
| 57 |
+
'x-organization-id': orgId
|
| 58 |
+
},
|
| 59 |
+
body: JSON.stringify({ subscription })
|
| 60 |
+
});
|
| 61 |
+
|
| 62 |
+
console.log('Successfully subscribed to push notifications');
|
| 63 |
+
} catch (err) {
|
| 64 |
+
console.error('Failed to setup push notifications:', err);
|
| 65 |
+
}
|
| 66 |
+
};
|
apps/admin/src/pages/ContactsPage.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
import { useState, useEffect } from 'react';
|
| 2 |
-
import { Users, Upload, Search, Download, Trash2, Filter, Loader2, FileSpreadsheet, CheckCircle2 } from 'lucide-react';
|
| 3 |
import { api } from '../lib/api';
|
| 4 |
import { useAuth } from '../lib/auth';
|
| 5 |
import { useTenant } from '../lib/tenant';
|
|
@@ -22,6 +22,18 @@ export default function ContactsPage() {
|
|
| 22 |
const [uploading, setUploading] = useState(false);
|
| 23 |
const [importStats, setImportStats] = useState<any>(null);
|
| 24 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
const fetchContacts = async () => {
|
| 26 |
if (!token || !selectedOrgId) return;
|
| 27 |
setLoading(true);
|
|
@@ -72,10 +84,149 @@ export default function ContactsPage() {
|
|
| 72 |
}
|
| 73 |
};
|
| 74 |
|
| 75 |
-
const
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
|
| 80 |
return (
|
| 81 |
<div className="p-8 max-w-7xl mx-auto">
|
|
@@ -157,6 +308,14 @@ export default function ContactsPage() {
|
|
| 157 |
<table className="w-full text-left">
|
| 158 |
<thead>
|
| 159 |
<tr className="bg-slate-50/50">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
<th className="px-8 py-5 text-xs font-bold text-slate-400 uppercase tracking-widest">Client</th>
|
| 161 |
<th className="px-8 py-5 text-xs font-bold text-slate-400 uppercase tracking-widest">Téléphone</th>
|
| 162 |
<th className="px-8 py-5 text-xs font-bold text-slate-400 uppercase tracking-widest">Attributs</th>
|
|
@@ -166,14 +325,14 @@ export default function ContactsPage() {
|
|
| 166 |
<tbody className="divide-y divide-slate-50">
|
| 167 |
{loading ? (
|
| 168 |
<tr>
|
| 169 |
-
<td colSpan={
|
| 170 |
<Loader2 className="w-10 h-10 animate-spin text-blue-600 mx-auto mb-4" />
|
| 171 |
<p className="font-bold text-slate-400">Chargement de la base de données...</p>
|
| 172 |
</td>
|
| 173 |
</tr>
|
| 174 |
) : filteredContacts.length === 0 ? (
|
| 175 |
<tr>
|
| 176 |
-
<td colSpan={
|
| 177 |
<div className="w-20 h-20 bg-slate-50 rounded-[2rem] flex items-center justify-center mx-auto mb-6">
|
| 178 |
<Users className="w-10 h-10 text-slate-200" />
|
| 179 |
</div>
|
|
@@ -182,7 +341,15 @@ export default function ContactsPage() {
|
|
| 182 |
</td>
|
| 183 |
</tr>
|
| 184 |
) : filteredContacts.map(contact => (
|
| 185 |
-
<tr key={contact.id} className=
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 186 |
<td className="px-8 py-6">
|
| 187 |
<div className="flex items-center gap-4">
|
| 188 |
<div className="w-10 h-10 bg-slate-100 rounded-xl flex items-center justify-center font-bold text-slate-600 uppercase">
|
|
@@ -212,9 +379,20 @@ export default function ContactsPage() {
|
|
| 212 |
</div>
|
| 213 |
</td>
|
| 214 |
<td className="px-8 py-6 text-right">
|
| 215 |
-
<
|
| 216 |
-
<
|
| 217 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 218 |
</td>
|
| 219 |
</tr>
|
| 220 |
))}
|
|
@@ -301,6 +479,248 @@ export default function ContactsPage() {
|
|
| 301 |
</div>
|
| 302 |
</div>
|
| 303 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 304 |
</div>
|
| 305 |
);
|
| 306 |
}
|
|
|
|
| 1 |
import { useState, useEffect } from 'react';
|
| 2 |
+
import { Users, Upload, Search, Download, Trash2, Filter, Loader2, FileSpreadsheet, CheckCircle2, Sparkles, BrainCircuit, Send, Copy, RefreshCw } from 'lucide-react';
|
| 3 |
import { api } from '../lib/api';
|
| 4 |
import { useAuth } from '../lib/auth';
|
| 5 |
import { useTenant } from '../lib/tenant';
|
|
|
|
| 22 |
const [uploading, setUploading] = useState(false);
|
| 23 |
const [importStats, setImportStats] = useState<any>(null);
|
| 24 |
|
| 25 |
+
// AI Campaign State
|
| 26 |
+
const [selectedContactForAI, setSelectedContactForAI] = useState<Contact | null>(null);
|
| 27 |
+
const [generatingAI, setGeneratingAI] = useState(false);
|
| 28 |
+
const [aiResult, setAiResult] = useState<{ personalizedMessage: string; reasoning: string; aiSource: string } | null>(null);
|
| 29 |
+
const [campaignObjective, setCampaignObjective] = useState("Proposer nos nouveaux services de formation IA");
|
| 30 |
+
|
| 31 |
+
// Bulk Selection & Generation State
|
| 32 |
+
const [selectedContactIds, setSelectedContactIds] = useState<string[]>([]);
|
| 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;
|
| 39 |
setLoading(true);
|
|
|
|
| 84 |
}
|
| 85 |
};
|
| 86 |
|
| 87 |
+
const handleGenerateAI = async (contact: Contact) => {
|
| 88 |
+
if (!token || !selectedOrgId) return;
|
| 89 |
+
setSelectedContactForAI(contact);
|
| 90 |
+
setGeneratingAI(true);
|
| 91 |
+
setAiResult(null);
|
| 92 |
+
|
| 93 |
+
try {
|
| 94 |
+
const res = await fetch(`${import.meta.env.VITE_API_URL}/v1/ai/crm/generate-campaign`, {
|
| 95 |
+
method: 'POST',
|
| 96 |
+
headers: {
|
| 97 |
+
'Content-Type': 'application/json',
|
| 98 |
+
'Authorization': `Bearer ${token}`,
|
| 99 |
+
'x-organization-id': selectedOrgId
|
| 100 |
+
},
|
| 101 |
+
body: JSON.stringify({
|
| 102 |
+
contact,
|
| 103 |
+
objective: campaignObjective
|
| 104 |
+
})
|
| 105 |
+
});
|
| 106 |
+
|
| 107 |
+
if (res.ok) {
|
| 108 |
+
const data = await res.json();
|
| 109 |
+
setAiResult(data);
|
| 110 |
+
} else {
|
| 111 |
+
alert("Échec de la génération IA.");
|
| 112 |
+
}
|
| 113 |
+
} catch (error) {
|
| 114 |
+
console.error("AI Generation failed:", error);
|
| 115 |
+
} finally {
|
| 116 |
+
setGeneratingAI(false);
|
| 117 |
+
}
|
| 118 |
+
};
|
| 119 |
+
|
| 120 |
+
const handleBulkGenerate = async () => {
|
| 121 |
+
if (!token || !selectedOrgId || selectedContactIds.length === 0) return;
|
| 122 |
+
|
| 123 |
+
setShowBulkModal(true);
|
| 124 |
+
setBulkProgress({ current: 0, total: selectedContactIds.length, status: 'processing' });
|
| 125 |
+
setBulkResults([]);
|
| 126 |
+
|
| 127 |
+
const results = [];
|
| 128 |
+
const selectedContacts = contacts.filter(c => selectedContactIds.includes(c.id));
|
| 129 |
+
|
| 130 |
+
for (let i = 0; i < selectedContacts.length; i++) {
|
| 131 |
+
const contact = selectedContacts[i];
|
| 132 |
+
setBulkProgress(prev => ({ ...prev, current: i + 1 }));
|
| 133 |
+
|
| 134 |
+
try {
|
| 135 |
+
const res = await fetch(`${import.meta.env.VITE_API_URL}/v1/ai/crm/generate-campaign`, {
|
| 136 |
+
method: 'POST',
|
| 137 |
+
headers: {
|
| 138 |
+
'Content-Type': 'application/json',
|
| 139 |
+
'Authorization': `Bearer ${token}`,
|
| 140 |
+
'x-organization-id': selectedOrgId
|
| 141 |
+
},
|
| 142 |
+
body: JSON.stringify({ contact, objective: campaignObjective })
|
| 143 |
+
});
|
| 144 |
+
|
| 145 |
+
if (res.ok) {
|
| 146 |
+
const data = await res.json();
|
| 147 |
+
results.push({ contact, ...data });
|
| 148 |
+
} else {
|
| 149 |
+
results.push({ contact, error: true, personalizedMessage: "Erreur de génération" });
|
| 150 |
+
}
|
| 151 |
+
} catch (err) {
|
| 152 |
+
results.push({ contact, error: true, personalizedMessage: "Erreur critique" });
|
| 153 |
+
}
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
setBulkResults(results);
|
| 157 |
+
setBulkProgress(prev => ({ ...prev, status: 'completed' }));
|
| 158 |
+
};
|
| 159 |
+
|
| 160 |
+
const toggleSelectAll = () => {
|
| 161 |
+
if (selectedContactIds.length === filteredContacts.length) {
|
| 162 |
+
setSelectedContactIds([]);
|
| 163 |
+
} else {
|
| 164 |
+
setSelectedContactIds(filteredContacts.map(c => c.id));
|
| 165 |
+
}
|
| 166 |
+
};
|
| 167 |
+
|
| 168 |
+
const toggleSelectContact = (id: string) => {
|
| 169 |
+
setSelectedContactIds(prev =>
|
| 170 |
+
prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id]
|
| 171 |
+
);
|
| 172 |
+
};
|
| 173 |
+
|
| 174 |
+
const handleDeleteContact = async (id: string) => {
|
| 175 |
+
if (!token || !selectedOrgId) return;
|
| 176 |
+
if (!confirm("Voulez-vous vraiment supprimer ce contact ?")) return;
|
| 177 |
+
|
| 178 |
+
try {
|
| 179 |
+
const res = await api.delete(`/v1/organizations/${selectedOrgId}/contacts/${id}`, token);
|
| 180 |
+
if (res.ok || res.success || res) { // api.delete might return true/ok depending on implementation
|
| 181 |
+
setContacts(prev => prev.filter(c => c.id !== id));
|
| 182 |
+
setSelectedContactIds(prev => prev.filter(i => i !== id));
|
| 183 |
+
}
|
| 184 |
+
} catch (error) {
|
| 185 |
+
console.error("Delete failed:", error);
|
| 186 |
+
alert("Erreur lors de la suppression.");
|
| 187 |
+
}
|
| 188 |
+
};
|
| 189 |
+
|
| 190 |
+
const handleBulkDelete = async () => {
|
| 191 |
+
if (!token || !selectedOrgId || selectedContactIds.length === 0) return;
|
| 192 |
+
if (!confirm(`Voulez-vous vraiment supprimer ${selectedContactIds.length} contacts ?`)) return;
|
| 193 |
+
|
| 194 |
+
try {
|
| 195 |
+
const res = await fetch(`${import.meta.env.VITE_API_URL}/v1/organizations/${selectedOrgId}/contacts/bulk-delete`, {
|
| 196 |
+
method: 'POST',
|
| 197 |
+
headers: {
|
| 198 |
+
'Content-Type': 'application/json',
|
| 199 |
+
'Authorization': `Bearer ${token}`,
|
| 200 |
+
'x-organization-id': selectedOrgId
|
| 201 |
+
},
|
| 202 |
+
body: JSON.stringify({ contactIds: selectedContactIds })
|
| 203 |
+
});
|
| 204 |
+
|
| 205 |
+
if (res.ok) {
|
| 206 |
+
setContacts(prev => prev.filter(c => !selectedContactIds.includes(c.id)));
|
| 207 |
+
setSelectedContactIds([]);
|
| 208 |
+
alert("Contacts supprimés avec succès.");
|
| 209 |
+
} else {
|
| 210 |
+
alert("Erreur lors de la suppression groupée.");
|
| 211 |
+
}
|
| 212 |
+
} catch (error) {
|
| 213 |
+
console.error("Bulk delete failed:", error);
|
| 214 |
+
alert("Erreur lors de la suppression groupée.");
|
| 215 |
+
}
|
| 216 |
+
};
|
| 217 |
+
|
| 218 |
+
const filteredContacts = contacts.filter(c => {
|
| 219 |
+
const searchLower = searchQuery.toLowerCase();
|
| 220 |
+
const inName = c.name?.toLowerCase().includes(searchLower);
|
| 221 |
+
const inPhone = c.phoneNumber.includes(searchQuery);
|
| 222 |
+
|
| 223 |
+
// Search in dynamic attributes
|
| 224 |
+
const inAttributes = c.attributes && Object.values(c.attributes).some(val =>
|
| 225 |
+
String(val).toLowerCase().includes(searchLower)
|
| 226 |
+
);
|
| 227 |
+
|
| 228 |
+
return inName || inPhone || inAttributes;
|
| 229 |
+
});
|
| 230 |
|
| 231 |
return (
|
| 232 |
<div className="p-8 max-w-7xl mx-auto">
|
|
|
|
| 308 |
<table className="w-full text-left">
|
| 309 |
<thead>
|
| 310 |
<tr className="bg-slate-50/50">
|
| 311 |
+
<th className="px-8 py-5 w-10">
|
| 312 |
+
<input
|
| 313 |
+
type="checkbox"
|
| 314 |
+
className="w-5 h-5 rounded border-slate-300 text-blue-600 focus:ring-blue-500"
|
| 315 |
+
checked={selectedContactIds.length > 0 && selectedContactIds.length === filteredContacts.length}
|
| 316 |
+
onChange={toggleSelectAll}
|
| 317 |
+
/>
|
| 318 |
+
</th>
|
| 319 |
<th className="px-8 py-5 text-xs font-bold text-slate-400 uppercase tracking-widest">Client</th>
|
| 320 |
<th className="px-8 py-5 text-xs font-bold text-slate-400 uppercase tracking-widest">Téléphone</th>
|
| 321 |
<th className="px-8 py-5 text-xs font-bold text-slate-400 uppercase tracking-widest">Attributs</th>
|
|
|
|
| 325 |
<tbody className="divide-y divide-slate-50">
|
| 326 |
{loading ? (
|
| 327 |
<tr>
|
| 328 |
+
<td colSpan={5} className="px-8 py-20 text-center">
|
| 329 |
<Loader2 className="w-10 h-10 animate-spin text-blue-600 mx-auto mb-4" />
|
| 330 |
<p className="font-bold text-slate-400">Chargement de la base de données...</p>
|
| 331 |
</td>
|
| 332 |
</tr>
|
| 333 |
) : filteredContacts.length === 0 ? (
|
| 334 |
<tr>
|
| 335 |
+
<td colSpan={5} className="px-8 py-20 text-center">
|
| 336 |
<div className="w-20 h-20 bg-slate-50 rounded-[2rem] flex items-center justify-center mx-auto mb-6">
|
| 337 |
<Users className="w-10 h-10 text-slate-200" />
|
| 338 |
</div>
|
|
|
|
| 341 |
</td>
|
| 342 |
</tr>
|
| 343 |
) : filteredContacts.map(contact => (
|
| 344 |
+
<tr key={contact.id} className={`hover:bg-slate-50/50 transition ${selectedContactIds.includes(contact.id) ? 'bg-blue-50/30' : ''}`}>
|
| 345 |
+
<td className="px-8 py-6">
|
| 346 |
+
<input
|
| 347 |
+
type="checkbox"
|
| 348 |
+
className="w-5 h-5 rounded border-slate-300 text-blue-600 focus:ring-blue-500"
|
| 349 |
+
checked={selectedContactIds.includes(contact.id)}
|
| 350 |
+
onChange={() => toggleSelectContact(contact.id)}
|
| 351 |
+
/>
|
| 352 |
+
</td>
|
| 353 |
<td className="px-8 py-6">
|
| 354 |
<div className="flex items-center gap-4">
|
| 355 |
<div className="w-10 h-10 bg-slate-100 rounded-xl flex items-center justify-center font-bold text-slate-600 uppercase">
|
|
|
|
| 379 |
</div>
|
| 380 |
</td>
|
| 381 |
<td className="px-8 py-6 text-right">
|
| 382 |
+
<div className="flex items-center justify-end gap-2">
|
| 383 |
+
<button
|
| 384 |
+
onClick={() => handleGenerateAI(contact)}
|
| 385 |
+
className="flex items-center gap-2 bg-indigo-50 text-indigo-600 px-4 py-2 rounded-xl font-bold hover:bg-indigo-100 transition"
|
| 386 |
+
>
|
| 387 |
+
<Sparkles className="w-4 h-4" /> IA
|
| 388 |
+
</button>
|
| 389 |
+
<button
|
| 390 |
+
onClick={() => handleDeleteContact(contact.id)}
|
| 391 |
+
className="p-2 text-slate-400 hover:text-red-500 transition"
|
| 392 |
+
>
|
| 393 |
+
<Trash2 className="w-5 h-5" />
|
| 394 |
+
</button>
|
| 395 |
+
</div>
|
| 396 |
</td>
|
| 397 |
</tr>
|
| 398 |
))}
|
|
|
|
| 479 |
</div>
|
| 480 |
</div>
|
| 481 |
)}
|
| 482 |
+
|
| 483 |
+
{/* AI Generation Modal */}
|
| 484 |
+
{selectedContactForAI && (
|
| 485 |
+
<div className="fixed inset-0 bg-slate-900/60 backdrop-blur-md flex items-center justify-center p-6 z-50">
|
| 486 |
+
<div className="bg-white rounded-[2.5rem] shadow-2xl w-full max-w-2xl overflow-hidden animate-in fade-in zoom-in duration-300">
|
| 487 |
+
{/* Modal Header */}
|
| 488 |
+
<div className="bg-indigo-600 p-8 text-white flex items-center justify-between">
|
| 489 |
+
<div className="flex items-center gap-4">
|
| 490 |
+
<div className="w-12 h-12 bg-white/20 rounded-2xl flex items-center justify-center">
|
| 491 |
+
<Sparkles className="w-6 h-6" />
|
| 492 |
+
</div>
|
| 493 |
+
<div>
|
| 494 |
+
<h3 className="text-xl font-black">AI Campaign Studio</h3>
|
| 495 |
+
<p className="text-indigo-100 text-sm font-medium">Génération pour {selectedContactForAI.name || 'Inconnu'}</p>
|
| 496 |
+
</div>
|
| 497 |
+
</div>
|
| 498 |
+
<button onClick={() => setSelectedContactForAI(null)} className="p-2 hover:bg-white/10 rounded-full transition">
|
| 499 |
+
<Trash2 className="w-6 h-6" />
|
| 500 |
+
</button>
|
| 501 |
+
</div>
|
| 502 |
+
|
| 503 |
+
<div className="p-8">
|
| 504 |
+
{generatingAI ? (
|
| 505 |
+
<div className="py-20 text-center">
|
| 506 |
+
<Loader2 className="w-16 h-16 animate-spin text-indigo-600 mx-auto mb-6" />
|
| 507 |
+
<h4 className="text-xl font-bold text-slate-900 mb-2">L'IA analyse le contact...</h4>
|
| 508 |
+
<p className="text-slate-500 font-medium">Personnalisation du message en cours.</p>
|
| 509 |
+
</div>
|
| 510 |
+
) : aiResult ? (
|
| 511 |
+
<div className="space-y-6">
|
| 512 |
+
{/* Objective Field */}
|
| 513 |
+
<div>
|
| 514 |
+
<label className="text-xs font-bold text-slate-400 uppercase mb-2 block tracking-widest">Objectif de la campagne</label>
|
| 515 |
+
<div className="flex gap-2">
|
| 516 |
+
<input
|
| 517 |
+
className="flex-1 bg-slate-50 border-none rounded-xl p-3 font-medium text-slate-700 focus:ring-2 focus:ring-indigo-100"
|
| 518 |
+
value={campaignObjective}
|
| 519 |
+
onChange={(e) => setCampaignObjective(e.target.value)}
|
| 520 |
+
/>
|
| 521 |
+
<button
|
| 522 |
+
onClick={() => handleGenerateAI(selectedContactForAI)}
|
| 523 |
+
className="p-3 bg-indigo-50 text-indigo-600 rounded-xl hover:bg-indigo-100 transition"
|
| 524 |
+
title="Régénérer"
|
| 525 |
+
>
|
| 526 |
+
<RefreshCw className="w-5 h-5" />
|
| 527 |
+
</button>
|
| 528 |
+
</div>
|
| 529 |
+
</div>
|
| 530 |
+
|
| 531 |
+
{/* Reasoning */}
|
| 532 |
+
<div className="p-5 bg-amber-50 rounded-[1.5rem] border border-amber-100 flex gap-4">
|
| 533 |
+
<div className="w-10 h-10 bg-amber-100 rounded-xl flex items-center justify-center flex-shrink-0">
|
| 534 |
+
<BrainCircuit className="w-5 h-5 text-amber-600" />
|
| 535 |
+
</div>
|
| 536 |
+
<div>
|
| 537 |
+
<p className="text-xs font-bold text-amber-700 uppercase mb-1">Raisonnement de l'IA ({aiResult.aiSource})</p>
|
| 538 |
+
<p className="text-sm text-amber-900 font-medium leading-relaxed">{aiResult.reasoning}</p>
|
| 539 |
+
</div>
|
| 540 |
+
</div>
|
| 541 |
+
|
| 542 |
+
{/* Generated Message */}
|
| 543 |
+
<div className="relative group">
|
| 544 |
+
<label className="text-xs font-bold text-slate-400 uppercase mb-2 block tracking-widest">Message Personnalisé (WhatsApp)</label>
|
| 545 |
+
<textarea
|
| 546 |
+
rows={6}
|
| 547 |
+
className="w-full bg-slate-50 border-none rounded-[1.5rem] p-6 font-medium text-slate-800 leading-relaxed focus:ring-4 focus:ring-indigo-50 transition"
|
| 548 |
+
value={aiResult.personalizedMessage}
|
| 549 |
+
onChange={(e) => setAiResult({...aiResult, personalizedMessage: e.target.value})}
|
| 550 |
+
/>
|
| 551 |
+
<button className="absolute top-10 right-4 p-2 bg-white text-slate-400 rounded-lg hover:text-indigo-600 shadow-sm border border-slate-100 opacity-0 group-hover:opacity-100 transition">
|
| 552 |
+
<Copy className="w-4 h-4" />
|
| 553 |
+
</button>
|
| 554 |
+
</div>
|
| 555 |
+
|
| 556 |
+
<div className="flex gap-3">
|
| 557 |
+
<button
|
| 558 |
+
onClick={() => {
|
| 559 |
+
const url = `https://wa.me/${selectedContactForAI.phoneNumber}?text=${encodeURIComponent(aiResult.personalizedMessage)}`;
|
| 560 |
+
window.open(url, '_blank');
|
| 561 |
+
}}
|
| 562 |
+
className="flex-1 flex items-center justify-center gap-3 bg-indigo-600 text-white py-4 rounded-[1.5rem] font-bold hover:bg-indigo-700 transition shadow-xl shadow-indigo-100"
|
| 563 |
+
>
|
| 564 |
+
<Send className="w-5 h-5" /> Envoyer via WhatsApp
|
| 565 |
+
</button>
|
| 566 |
+
<button
|
| 567 |
+
onClick={() => setSelectedContactForAI(null)}
|
| 568 |
+
className="px-8 bg-slate-100 text-slate-600 rounded-[1.5rem] font-bold hover:bg-slate-200 transition"
|
| 569 |
+
>
|
| 570 |
+
Annuler
|
| 571 |
+
</button>
|
| 572 |
+
</div>
|
| 573 |
+
</div>
|
| 574 |
+
) : (
|
| 575 |
+
<div className="text-center py-10">
|
| 576 |
+
<button
|
| 577 |
+
onClick={() => handleGenerateAI(selectedContactForAI)}
|
| 578 |
+
className="bg-indigo-600 text-white px-8 py-4 rounded-2xl font-bold hover:bg-indigo-700 transition"
|
| 579 |
+
>
|
| 580 |
+
Lancer la génération
|
| 581 |
+
</button>
|
| 582 |
+
</div>
|
| 583 |
+
)}
|
| 584 |
+
</div>
|
| 585 |
+
</div>
|
| 586 |
+
</div>
|
| 587 |
+
)}
|
| 588 |
+
|
| 589 |
+
{/* Bulk Action Bar */}
|
| 590 |
+
{selectedContactIds.length > 0 && (
|
| 591 |
+
<div className="fixed bottom-10 left-1/2 -translate-x-1/2 bg-slate-900 text-white px-8 py-5 rounded-[2rem] shadow-2xl flex items-center gap-8 z-40 animate-in slide-in-from-bottom-10 duration-500">
|
| 592 |
+
<div className="flex items-center gap-3 pr-8 border-r border-slate-700">
|
| 593 |
+
<div className="w-10 h-10 bg-blue-600 rounded-xl flex items-center justify-center font-black text-sm">
|
| 594 |
+
{selectedContactIds.length}
|
| 595 |
+
</div>
|
| 596 |
+
<p className="font-bold text-sm text-slate-300">Contacts sélectionnés</p>
|
| 597 |
+
</div>
|
| 598 |
+
|
| 599 |
+
<div className="flex items-center gap-4">
|
| 600 |
+
<button
|
| 601 |
+
className="flex items-center gap-2 bg-indigo-600 hover:bg-indigo-700 text-white px-6 py-3 rounded-xl font-bold transition shadow-lg shadow-indigo-900/20"
|
| 602 |
+
onClick={handleBulkGenerate}
|
| 603 |
+
>
|
| 604 |
+
<Sparkles className="w-5 h-5" /> Générer Campagne IA
|
| 605 |
+
</button>
|
| 606 |
+
<button
|
| 607 |
+
onClick={handleBulkDelete}
|
| 608 |
+
className="flex items-center gap-2 bg-slate-800 hover:bg-red-900/40 text-slate-300 hover:text-red-400 px-6 py-3 rounded-xl font-bold transition"
|
| 609 |
+
>
|
| 610 |
+
<Trash2 className="w-5 h-5" /> Supprimer
|
| 611 |
+
</button>
|
| 612 |
+
<button
|
| 613 |
+
onClick={() => setSelectedContactIds([])}
|
| 614 |
+
className="text-slate-500 hover:text-white font-bold text-sm transition ml-4"
|
| 615 |
+
>
|
| 616 |
+
Annuler
|
| 617 |
+
</button>
|
| 618 |
+
</div>
|
| 619 |
+
</div>
|
| 620 |
+
)}
|
| 621 |
+
|
| 622 |
+
{/* Bulk Generation Modal */}
|
| 623 |
+
{showBulkModal && (
|
| 624 |
+
<div className="fixed inset-0 bg-slate-900/80 backdrop-blur-xl flex items-center justify-center p-6 z-50">
|
| 625 |
+
<div className="bg-white rounded-[3rem] shadow-2xl w-full max-w-4xl max-h-[85vh] flex flex-col overflow-hidden animate-in zoom-in-95 duration-300">
|
| 626 |
+
{/* Header */}
|
| 627 |
+
<div className="p-8 border-b border-slate-100 flex items-center justify-between bg-slate-50/50">
|
| 628 |
+
<div className="flex items-center gap-4">
|
| 629 |
+
<div className="w-12 h-12 bg-indigo-600 rounded-2xl flex items-center justify-center text-white shadow-lg shadow-indigo-200">
|
| 630 |
+
<BrainCircuit className="w-6 h-6" />
|
| 631 |
+
</div>
|
| 632 |
+
<div>
|
| 633 |
+
<h3 className="text-2xl font-black text-slate-900">Génération de Campagne</h3>
|
| 634 |
+
<p className="text-slate-500 font-medium">{bulkProgress.current} / {bulkProgress.total} contacts traités</p>
|
| 635 |
+
</div>
|
| 636 |
+
</div>
|
| 637 |
+
{bulkProgress.status === 'completed' && (
|
| 638 |
+
<button
|
| 639 |
+
onClick={() => setShowBulkModal(false)}
|
| 640 |
+
className="p-3 hover:bg-slate-200 rounded-full transition text-slate-400 hover:text-slate-900"
|
| 641 |
+
>
|
| 642 |
+
<Trash2 className="w-6 h-6" />
|
| 643 |
+
</button>
|
| 644 |
+
)}
|
| 645 |
+
</div>
|
| 646 |
+
|
| 647 |
+
{/* Progress Bar */}
|
| 648 |
+
<div className="h-2 bg-slate-100 w-full overflow-hidden">
|
| 649 |
+
<div
|
| 650 |
+
className="h-full bg-indigo-600 transition-all duration-500 shadow-[0_0_20px_rgba(79,70,229,0.5)]"
|
| 651 |
+
style={{ width: `${(bulkProgress.current / bulkProgress.total) * 100}%` }}
|
| 652 |
+
/>
|
| 653 |
+
</div>
|
| 654 |
+
|
| 655 |
+
{/* Results Content */}
|
| 656 |
+
<div className="flex-1 overflow-y-auto p-8 space-y-4">
|
| 657 |
+
{bulkResults.length === 0 && bulkProgress.status === 'processing' && (
|
| 658 |
+
<div className="py-20 text-center space-y-6">
|
| 659 |
+
<Loader2 className="w-16 h-16 animate-spin text-indigo-600 mx-auto" />
|
| 660 |
+
<p className="text-xl font-bold text-slate-900 italic">"L'intelligence artificielle rédige vos messages personnalisés..."</p>
|
| 661 |
+
</div>
|
| 662 |
+
)}
|
| 663 |
+
|
| 664 |
+
{bulkResults.map((result, idx) => (
|
| 665 |
+
<div key={idx} className="p-6 bg-slate-50 rounded-3xl border border-slate-100 flex items-start gap-6 hover:bg-white hover:shadow-xl hover:shadow-slate-100 transition duration-300 group">
|
| 666 |
+
<div className="w-12 h-12 bg-white rounded-2xl flex items-center justify-center font-bold text-slate-400 border border-slate-100 group-hover:border-indigo-200 group-hover:text-indigo-600 transition">
|
| 667 |
+
{idx + 1}
|
| 668 |
+
</div>
|
| 669 |
+
<div className="flex-1">
|
| 670 |
+
<div className="flex items-center justify-between mb-3">
|
| 671 |
+
<h4 className="font-bold text-slate-900">{result.contact.name || 'Inconnu'}</h4>
|
| 672 |
+
<span className="text-xs font-mono text-slate-400">+{result.contact.phoneNumber}</span>
|
| 673 |
+
</div>
|
| 674 |
+
<p className="text-sm text-slate-600 leading-relaxed bg-white p-4 rounded-2xl border border-slate-50">
|
| 675 |
+
{result.personalizedMessage}
|
| 676 |
+
</p>
|
| 677 |
+
</div>
|
| 678 |
+
<div className="flex flex-col gap-2">
|
| 679 |
+
<button
|
| 680 |
+
onClick={() => {
|
| 681 |
+
const url = `https://wa.me/${result.contact.phoneNumber}?text=${encodeURIComponent(result.personalizedMessage)}`;
|
| 682 |
+
window.open(url, '_blank');
|
| 683 |
+
}}
|
| 684 |
+
className="p-3 bg-emerald-50 text-emerald-600 rounded-xl hover:bg-emerald-600 hover:text-white transition shadow-sm"
|
| 685 |
+
title="Envoyer WhatsApp"
|
| 686 |
+
>
|
| 687 |
+
<Send className="w-5 h-5" />
|
| 688 |
+
</button>
|
| 689 |
+
<button
|
| 690 |
+
onClick={() => {
|
| 691 |
+
navigator.clipboard.writeText(result.personalizedMessage);
|
| 692 |
+
alert("Copié !");
|
| 693 |
+
}}
|
| 694 |
+
className="p-3 bg-slate-100 text-slate-400 rounded-xl hover:bg-slate-200 hover:text-slate-900 transition"
|
| 695 |
+
title="Copier"
|
| 696 |
+
>
|
| 697 |
+
<Copy className="w-5 h-5" />
|
| 698 |
+
</button>
|
| 699 |
+
</div>
|
| 700 |
+
</div>
|
| 701 |
+
))}
|
| 702 |
+
</div>
|
| 703 |
+
|
| 704 |
+
{/* Footer */}
|
| 705 |
+
{bulkProgress.status === 'completed' && (
|
| 706 |
+
<div className="p-8 border-t border-slate-100 bg-slate-50/50 flex items-center justify-between">
|
| 707 |
+
<div className="flex items-center gap-2 text-emerald-600 font-bold">
|
| 708 |
+
<CheckCircle2 className="w-5 h-5" />
|
| 709 |
+
Génération terminée avec succès
|
| 710 |
+
</div>
|
| 711 |
+
<div className="flex items-center gap-3">
|
| 712 |
+
<button
|
| 713 |
+
onClick={() => setShowBulkModal(false)}
|
| 714 |
+
className="px-8 py-4 bg-slate-900 text-white rounded-2xl font-bold hover:bg-slate-800 transition shadow-xl"
|
| 715 |
+
>
|
| 716 |
+
Terminer
|
| 717 |
+
</button>
|
| 718 |
+
</div>
|
| 719 |
+
</div>
|
| 720 |
+
)}
|
| 721 |
+
</div>
|
| 722 |
+
</div>
|
| 723 |
+
)}
|
| 724 |
</div>
|
| 725 |
);
|
| 726 |
}
|
apps/admin/src/pages/ConversationalDashboard.tsx
ADDED
|
@@ -0,0 +1,352 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useEffect, useRef } from 'react';
|
| 2 |
+
import { Send, Sparkles, History, Upload, Users, Bot, User, Loader2, Mic } from 'lucide-react';
|
| 3 |
+
// api is not used here for now as we use fetch directly
|
| 4 |
+
import { useAuth } from '../lib/auth';
|
| 5 |
+
import { useTenant } from '../lib/tenant';
|
| 6 |
+
import { CRMAnalytics } from '../components/CRMAnalytics';
|
| 7 |
+
import { setupNotifications } from '../lib/notifications';
|
| 8 |
+
|
| 9 |
+
interface Message {
|
| 10 |
+
id: string;
|
| 11 |
+
role: 'user' | 'assistant';
|
| 12 |
+
content: string;
|
| 13 |
+
type?: 'text' | 'contact_list' | 'campaign_preview' | 'import_dropzone' | 'stats' | 'choice_buttons' | 'campaign_summary';
|
| 14 |
+
data?: any;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
export default function ConversationalDashboard() {
|
| 18 |
+
const { token } = useAuth();
|
| 19 |
+
const { selectedOrgId } = useTenant();
|
| 20 |
+
const [messages, setMessages] = useState<Message[]>([
|
| 21 |
+
{
|
| 22 |
+
id: '1',
|
| 23 |
+
role: 'assistant',
|
| 24 |
+
content: "Bonjour ! Je suis votre assistant CRM IA. Que souhaitez-vous faire aujourd'hui ?",
|
| 25 |
+
type: 'choice_buttons',
|
| 26 |
+
data: [
|
| 27 |
+
{ label: "📊 Voir l'historique", action: "Montre moi l'historique des contacts" },
|
| 28 |
+
{ label: "📤 Importer des clients", action: "Je veux importer de nouveaux contacts" },
|
| 29 |
+
{ label: "✨ Lancer une campagne", action: "Génère une nouvelle campagne marketing" }
|
| 30 |
+
]
|
| 31 |
+
}
|
| 32 |
+
]);
|
| 33 |
+
const [input, setInput] = useState('');
|
| 34 |
+
const [loading, setLoading] = useState(false);
|
| 35 |
+
const [isRecording, setIsRecording] = useState(false);
|
| 36 |
+
const mediaRecorder = useRef<MediaRecorder | null>(null);
|
| 37 |
+
const audioChunks = useRef<Blob[]>([]);
|
| 38 |
+
const messagesEndRef = useRef<HTMLDivElement>(null);
|
| 39 |
+
|
| 40 |
+
const scrollToBottom = () => {
|
| 41 |
+
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
| 42 |
+
};
|
| 43 |
+
|
| 44 |
+
useEffect(() => {
|
| 45 |
+
scrollToBottom();
|
| 46 |
+
}, [messages]);
|
| 47 |
+
|
| 48 |
+
useEffect(() => {
|
| 49 |
+
if (token && selectedOrgId) {
|
| 50 |
+
setupNotifications(token, selectedOrgId);
|
| 51 |
+
}
|
| 52 |
+
}, [token, selectedOrgId]);
|
| 53 |
+
|
| 54 |
+
const handleSendMessage = async (text: string) => {
|
| 55 |
+
if (!text.trim() || !token || !selectedOrgId) return;
|
| 56 |
+
|
| 57 |
+
const userMsg: Message = { id: Date.now().toString(), role: 'user', content: text };
|
| 58 |
+
setMessages(prev => [...prev, userMsg]);
|
| 59 |
+
setInput('');
|
| 60 |
+
setLoading(true);
|
| 61 |
+
|
| 62 |
+
try {
|
| 63 |
+
// This route will be implemented in the next step
|
| 64 |
+
const res = await fetch(`${import.meta.env.VITE_API_URL}/v1/ai/crm/command`, {
|
| 65 |
+
method: 'POST',
|
| 66 |
+
headers: {
|
| 67 |
+
'Content-Type': 'application/json',
|
| 68 |
+
'Authorization': `Bearer ${token}`,
|
| 69 |
+
'x-organization-id': selectedOrgId
|
| 70 |
+
},
|
| 71 |
+
body: JSON.stringify({ query: text })
|
| 72 |
+
});
|
| 73 |
+
|
| 74 |
+
if (res.ok) {
|
| 75 |
+
const data = await res.json();
|
| 76 |
+
const aiMsg: Message = {
|
| 77 |
+
id: (Date.now() + 1).toString(),
|
| 78 |
+
role: 'assistant',
|
| 79 |
+
content: data.message,
|
| 80 |
+
type: data.type,
|
| 81 |
+
data: data.payload
|
| 82 |
+
};
|
| 83 |
+
setMessages(prev => [...prev, aiMsg]);
|
| 84 |
+
} else {
|
| 85 |
+
const errorMsg: Message = {
|
| 86 |
+
id: (Date.now() + 1).toString(),
|
| 87 |
+
role: 'assistant',
|
| 88 |
+
content: "Désolé, je rencontre une difficulté technique pour traiter cette commande. Pouvons-nous réessayer ?",
|
| 89 |
+
type: 'text'
|
| 90 |
+
};
|
| 91 |
+
setMessages(prev => [...prev, errorMsg]);
|
| 92 |
+
}
|
| 93 |
+
} catch (err) {
|
| 94 |
+
console.error("Command failed:", err);
|
| 95 |
+
} finally {
|
| 96 |
+
setLoading(false);
|
| 97 |
+
}
|
| 98 |
+
};
|
| 99 |
+
|
| 100 |
+
const startRecording = async () => {
|
| 101 |
+
try {
|
| 102 |
+
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
| 103 |
+
mediaRecorder.current = new MediaRecorder(stream);
|
| 104 |
+
audioChunks.current = [];
|
| 105 |
+
|
| 106 |
+
mediaRecorder.current.ondataavailable = (e) => {
|
| 107 |
+
if (e.data.size > 0) audioChunks.current.push(e.data);
|
| 108 |
+
};
|
| 109 |
+
|
| 110 |
+
mediaRecorder.current.onstop = async () => {
|
| 111 |
+
const audioBlob = new Blob(audioChunks.current, { type: 'audio/webm' });
|
| 112 |
+
await handleVoiceUpload(audioBlob);
|
| 113 |
+
// Stop all tracks
|
| 114 |
+
stream.getTracks().forEach(track => track.stop());
|
| 115 |
+
};
|
| 116 |
+
|
| 117 |
+
mediaRecorder.current.start();
|
| 118 |
+
setIsRecording(true);
|
| 119 |
+
} catch (err) {
|
| 120 |
+
console.error("Failed to start recording:", err);
|
| 121 |
+
alert("Erreur d'accès au micro.");
|
| 122 |
+
}
|
| 123 |
+
};
|
| 124 |
+
|
| 125 |
+
const stopRecording = () => {
|
| 126 |
+
if (mediaRecorder.current && isRecording) {
|
| 127 |
+
mediaRecorder.current.stop();
|
| 128 |
+
setIsRecording(false);
|
| 129 |
+
}
|
| 130 |
+
};
|
| 131 |
+
|
| 132 |
+
const handleVoiceUpload = async (blob: Blob) => {
|
| 133 |
+
if (!token || !selectedOrgId) return;
|
| 134 |
+
setLoading(true);
|
| 135 |
+
|
| 136 |
+
const formData = new FormData();
|
| 137 |
+
formData.append('audio', blob, 'command.webm');
|
| 138 |
+
|
| 139 |
+
try {
|
| 140 |
+
const res = await fetch(`${import.meta.env.VITE_API_URL}/v1/ai/crm/voice-command`, {
|
| 141 |
+
method: 'POST',
|
| 142 |
+
headers: {
|
| 143 |
+
'Authorization': `Bearer ${token}`,
|
| 144 |
+
'x-organization-id': selectedOrgId
|
| 145 |
+
},
|
| 146 |
+
body: formData
|
| 147 |
+
});
|
| 148 |
+
|
| 149 |
+
if (res.ok) {
|
| 150 |
+
const { transcription } = await res.json();
|
| 151 |
+
if (transcription) {
|
| 152 |
+
handleSendMessage(transcription);
|
| 153 |
+
}
|
| 154 |
+
}
|
| 155 |
+
} catch (err) {
|
| 156 |
+
console.error("Voice upload failed:", err);
|
| 157 |
+
} finally {
|
| 158 |
+
setLoading(false);
|
| 159 |
+
}
|
| 160 |
+
};
|
| 161 |
+
|
| 162 |
+
const handleBulkSend = async (messagesToSend: any[]) => {
|
| 163 |
+
if (!token || !selectedOrgId) return;
|
| 164 |
+
setLoading(true);
|
| 165 |
+
|
| 166 |
+
try {
|
| 167 |
+
const res = await fetch(`${import.meta.env.VITE_API_URL}/v1/ai/crm/send-bulk`, {
|
| 168 |
+
method: 'POST',
|
| 169 |
+
headers: {
|
| 170 |
+
'Content-Type': 'application/json',
|
| 171 |
+
'Authorization': `Bearer ${token}`,
|
| 172 |
+
'x-organization-id': selectedOrgId
|
| 173 |
+
},
|
| 174 |
+
body: JSON.stringify({ messages: messagesToSend })
|
| 175 |
+
});
|
| 176 |
+
|
| 177 |
+
if (res.ok) {
|
| 178 |
+
const data = await res.json();
|
| 179 |
+
const aiMsg: Message = {
|
| 180 |
+
id: Date.now().toString(),
|
| 181 |
+
role: 'assistant',
|
| 182 |
+
content: `🚀 Succès ! ${data.results.sent} messages ont été envoyés avec succès via WhatsApp Cloud API.`,
|
| 183 |
+
type: 'text'
|
| 184 |
+
};
|
| 185 |
+
setMessages(prev => [...prev, aiMsg]);
|
| 186 |
+
}
|
| 187 |
+
} catch (err) {
|
| 188 |
+
console.error("Bulk send failed:", err);
|
| 189 |
+
alert("Erreur lors de l'envoi en masse.");
|
| 190 |
+
} finally {
|
| 191 |
+
setLoading(false);
|
| 192 |
+
}
|
| 193 |
+
};
|
| 194 |
+
|
| 195 |
+
return (
|
| 196 |
+
<div className="flex flex-col h-[calc(100vh-120px)] max-w-4xl mx-auto">
|
| 197 |
+
{/* Chat Area */}
|
| 198 |
+
<div className="flex-1 overflow-y-auto space-y-8 p-6 scrollbar-hide">
|
| 199 |
+
{messages.map((msg) => (
|
| 200 |
+
<div key={msg.id} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'} animate-in slide-in-from-bottom-4 duration-300`}>
|
| 201 |
+
<div className={`max-w-[85%] flex gap-4 ${msg.role === 'user' ? 'flex-row-reverse' : ''}`}>
|
| 202 |
+
<div className={`w-10 h-10 rounded-2xl flex items-center justify-center flex-shrink-0 shadow-sm ${msg.role === 'user' ? 'bg-slate-900 text-white' : 'bg-indigo-600 text-white'}`}>
|
| 203 |
+
{msg.role === 'user' ? <User className="w-5 h-5" /> : <Bot className="w-6 h-6" />}
|
| 204 |
+
</div>
|
| 205 |
+
<div className="space-y-4">
|
| 206 |
+
<div className={`p-5 rounded-[1.5rem] font-medium leading-relaxed shadow-sm ${msg.role === 'user' ? 'bg-indigo-50 text-indigo-900 rounded-tr-none' : 'bg-white border border-slate-100 text-slate-900 rounded-tl-none'}`}>
|
| 207 |
+
{msg.content}
|
| 208 |
+
</div>
|
| 209 |
+
|
| 210 |
+
{/* Dynamic Widgets Based on Type */}
|
| 211 |
+
{msg.type === 'contact_list' && msg.data && (
|
| 212 |
+
<div className="bg-white border border-slate-100 rounded-[2rem] overflow-hidden shadow-xl animate-in zoom-in-95 duration-500 max-w-md">
|
| 213 |
+
<div className="p-4 bg-slate-50 border-b border-slate-100 flex items-center justify-between">
|
| 214 |
+
<span className="text-xs font-black text-slate-400 uppercase tracking-widest">Aperçu de la base</span>
|
| 215 |
+
<Users className="w-4 h-4 text-slate-400" />
|
| 216 |
+
</div>
|
| 217 |
+
<div className="max-h-64 overflow-y-auto divide-y divide-slate-50">
|
| 218 |
+
{msg.data.slice(0, 10).map((c: any) => (
|
| 219 |
+
<div key={c.id} className="p-4 flex items-center justify-between hover:bg-slate-50 transition">
|
| 220 |
+
<div className="flex items-center gap-3">
|
| 221 |
+
<div className="w-8 h-8 bg-indigo-50 rounded-lg flex items-center justify-center text-[10px] font-black text-indigo-600 uppercase">{c.name?.[0] || '?'}</div>
|
| 222 |
+
<div>
|
| 223 |
+
<p className="text-sm font-bold text-slate-900">{c.name || 'Inconnu'}</p>
|
| 224 |
+
<p className="text-[10px] text-slate-400 font-mono">+{c.phoneNumber}</p>
|
| 225 |
+
</div>
|
| 226 |
+
</div>
|
| 227 |
+
</div>
|
| 228 |
+
))}
|
| 229 |
+
{msg.data.length > 10 && (
|
| 230 |
+
<div className="p-3 text-center text-[10px] font-bold text-slate-400 uppercase bg-slate-50/50">
|
| 231 |
+
+ {msg.data.length - 10} autres contacts
|
| 232 |
+
</div>
|
| 233 |
+
)}
|
| 234 |
+
</div>
|
| 235 |
+
</div>
|
| 236 |
+
)}
|
| 237 |
+
|
| 238 |
+
{msg.type === 'choice_buttons' && msg.data && (
|
| 239 |
+
<div className="flex flex-col gap-2 animate-in fade-in slide-in-from-left-2 duration-500">
|
| 240 |
+
{msg.data.map((btn: any, i: number) => (
|
| 241 |
+
<button
|
| 242 |
+
key={i}
|
| 243 |
+
onClick={() => handleSendMessage(btn.action)}
|
| 244 |
+
className="w-full text-left p-4 bg-white border border-slate-200 rounded-2xl font-bold text-slate-700 hover:border-indigo-500 hover:bg-indigo-50/30 transition-all shadow-sm"
|
| 245 |
+
>
|
| 246 |
+
{btn.label}
|
| 247 |
+
</button>
|
| 248 |
+
))}
|
| 249 |
+
</div>
|
| 250 |
+
)}
|
| 251 |
+
|
| 252 |
+
{msg.type === 'campaign_summary' && msg.data && (
|
| 253 |
+
<div className="bg-white border border-slate-100 rounded-[2rem] overflow-hidden shadow-2xl animate-in zoom-in-95 duration-500 max-w-md">
|
| 254 |
+
<div className="p-6 bg-indigo-600 text-white">
|
| 255 |
+
<div className="flex items-center gap-3 mb-4">
|
| 256 |
+
<Sparkles className="w-6 h-6" />
|
| 257 |
+
<h4 className="font-black text-lg">Résumé de Campagne</h4>
|
| 258 |
+
</div>
|
| 259 |
+
<p className="text-indigo-100 text-sm font-medium">
|
| 260 |
+
{msg.data.length} messages personnalisés prêts à être envoyés.
|
| 261 |
+
</p>
|
| 262 |
+
</div>
|
| 263 |
+
<div className="p-6 space-y-4">
|
| 264 |
+
<div className="max-h-48 overflow-y-auto space-y-3 pr-2 scrollbar-hide">
|
| 265 |
+
{msg.data.slice(0, 3).map((m: any, i: number) => (
|
| 266 |
+
<div key={i} className="p-3 bg-slate-50 rounded-xl border border-slate-100">
|
| 267 |
+
<p className="text-[10px] font-black text-slate-400 mb-1">DESTINATAIRE: {m.to}</p>
|
| 268 |
+
<p className="text-xs text-slate-600 italic line-clamp-2">"{m.text}"</p>
|
| 269 |
+
</div>
|
| 270 |
+
))}
|
| 271 |
+
{msg.data.length > 3 && (
|
| 272 |
+
<p className="text-center text-[10px] font-bold text-slate-400 uppercase">+ {msg.data.length - 3} autres messages</p>
|
| 273 |
+
)}
|
| 274 |
+
</div>
|
| 275 |
+
<button
|
| 276 |
+
onClick={() => handleBulkSend(msg.data)}
|
| 277 |
+
className="w-full py-4 bg-indigo-600 text-white rounded-2xl font-black text-sm shadow-xl shadow-indigo-100 hover:bg-indigo-700 hover:scale-[1.02] active:scale-95 transition-all flex items-center justify-center gap-2"
|
| 278 |
+
>
|
| 279 |
+
🚀 Tout envoyer via WhatsApp API
|
| 280 |
+
</button>
|
| 281 |
+
</div>
|
| 282 |
+
</div>
|
| 283 |
+
)}
|
| 284 |
+
{msg.type === 'stats' && (
|
| 285 |
+
<div className="w-full max-w-2xl">
|
| 286 |
+
<CRMAnalytics />
|
| 287 |
+
</div>
|
| 288 |
+
)}
|
| 289 |
+
</div>
|
| 290 |
+
</div>
|
| 291 |
+
</div>
|
| 292 |
+
))}
|
| 293 |
+
{loading && (
|
| 294 |
+
<div className="flex justify-start">
|
| 295 |
+
<div className="bg-white border border-slate-100 p-5 rounded-[1.5rem] rounded-tl-none flex items-center gap-3 text-slate-400 font-medium animate-pulse">
|
| 296 |
+
<Loader2 className="w-5 h-5 animate-spin" /> L'IA prépare une réponse...
|
| 297 |
+
</div>
|
| 298 |
+
</div>
|
| 299 |
+
)}
|
| 300 |
+
<div ref={messagesEndRef} />
|
| 301 |
+
</div>
|
| 302 |
+
|
| 303 |
+
{/* Input Area */}
|
| 304 |
+
<div className="p-6">
|
| 305 |
+
{/* Quick Actions Suggestions */}
|
| 306 |
+
<div className="flex flex-wrap gap-2 mb-6 justify-center">
|
| 307 |
+
{[
|
| 308 |
+
{ label: "📊 Historique", icon: History, action: "Montre moi l'historique des contacts" },
|
| 309 |
+
{ label: "📤 Importer", icon: Upload, action: "Je veux importer de nouveaux contacts" },
|
| 310 |
+
{ label: "✨ Campagne", icon: Sparkles, action: "Génère une nouvelle campagne marketing" }
|
| 311 |
+
].map((btn, i) => (
|
| 312 |
+
<button
|
| 313 |
+
key={i}
|
| 314 |
+
onClick={() => handleSendMessage(btn.action)}
|
| 315 |
+
className="flex items-center gap-2 px-6 py-3 bg-white border border-slate-200 rounded-full text-xs font-black uppercase tracking-widest text-slate-500 hover:border-indigo-500 hover:text-indigo-600 hover:shadow-xl hover:scale-105 transition-all active:scale-95"
|
| 316 |
+
>
|
| 317 |
+
<btn.icon className="w-4 h-4" /> {btn.label}
|
| 318 |
+
</button>
|
| 319 |
+
))}
|
| 320 |
+
</div>
|
| 321 |
+
|
| 322 |
+
<div className="relative group max-w-3xl mx-auto">
|
| 323 |
+
<div className="absolute inset-0 bg-indigo-500 blur-3xl opacity-5 group-focus-within:opacity-10 transition-opacity" />
|
| 324 |
+
<div className="relative bg-white border border-slate-200 rounded-[2.5rem] p-2 flex items-center shadow-2xl focus-within:border-indigo-500 focus-within:ring-4 focus-within:ring-indigo-50 transition-all">
|
| 325 |
+
<button
|
| 326 |
+
className={`w-12 h-12 rounded-full flex items-center justify-center ml-2 transition-all ${isRecording ? 'bg-red-500 text-white animate-pulse' : 'bg-slate-50 text-slate-400 hover:bg-indigo-50 hover:text-indigo-600'}`}
|
| 327 |
+
onClick={isRecording ? stopRecording : startRecording}
|
| 328 |
+
>
|
| 329 |
+
<Mic className={`w-6 h-6 ${isRecording ? 'animate-bounce' : ''}`} />
|
| 330 |
+
</button>
|
| 331 |
+
<input
|
| 332 |
+
className="flex-1 bg-transparent border-none focus:ring-0 px-6 py-4 font-medium text-slate-900 placeholder:text-slate-400"
|
| 333 |
+
placeholder="Écrivez votre commande ici..."
|
| 334 |
+
value={input}
|
| 335 |
+
onChange={(e) => setInput(e.target.value)}
|
| 336 |
+
onKeyPress={(e) => e.key === 'Enter' && handleSendMessage(input)}
|
| 337 |
+
/>
|
| 338 |
+
<button
|
| 339 |
+
onClick={() => handleSendMessage(input)}
|
| 340 |
+
className="w-14 h-14 bg-indigo-600 text-white rounded-[2rem] flex items-center justify-center hover:bg-indigo-700 hover:rotate-12 transition-all shadow-xl shadow-indigo-100 mr-2"
|
| 341 |
+
>
|
| 342 |
+
<Send className="w-6 h-6" />
|
| 343 |
+
</button>
|
| 344 |
+
</div>
|
| 345 |
+
</div>
|
| 346 |
+
<p className="text-center text-[10px] font-bold text-slate-400 uppercase tracking-widest mt-6 opacity-50">
|
| 347 |
+
Propulsé par Xamlé AI • CRM PaaS Intégré
|
| 348 |
+
</p>
|
| 349 |
+
</div>
|
| 350 |
+
</div>
|
| 351 |
+
);
|
| 352 |
+
}
|
apps/api/package.json
CHANGED
|
@@ -42,6 +42,7 @@
|
|
| 42 |
"pptxgenjs": "^3.12.0",
|
| 43 |
"puppeteer": "^22.0.0",
|
| 44 |
"stripe": "^20.3.1",
|
|
|
|
| 45 |
"xlsx": "^0.18.5",
|
| 46 |
"zod": "^3.25.76"
|
| 47 |
},
|
|
@@ -52,6 +53,7 @@
|
|
| 52 |
"@types/fast-levenshtein": "^0.0.4",
|
| 53 |
"@types/node": "^20.0.0",
|
| 54 |
"@types/node-cron": "^3.0.11",
|
|
|
|
| 55 |
"@vitest/ui": "^4.0.18",
|
| 56 |
"tsx": "^3.0.0",
|
| 57 |
"typescript": "^5.0.0",
|
|
|
|
| 42 |
"pptxgenjs": "^3.12.0",
|
| 43 |
"puppeteer": "^22.0.0",
|
| 44 |
"stripe": "^20.3.1",
|
| 45 |
+
"web-push": "^3.6.7",
|
| 46 |
"xlsx": "^0.18.5",
|
| 47 |
"zod": "^3.25.76"
|
| 48 |
},
|
|
|
|
| 53 |
"@types/fast-levenshtein": "^0.0.4",
|
| 54 |
"@types/node": "^20.0.0",
|
| 55 |
"@types/node-cron": "^3.0.11",
|
| 56 |
+
"@types/web-push": "^3.6.4",
|
| 57 |
"@vitest/ui": "^4.0.18",
|
| 58 |
"tsx": "^3.0.0",
|
| 59 |
"typescript": "^5.0.0",
|
apps/api/src/index.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { organizationRoutes } from './routes/organizations';
|
|
| 11 |
import { aiRoutes } from './routes/ai';
|
| 12 |
import { paymentRoutes, stripeWebhookRoute } from './routes/payments';
|
| 13 |
import { analyticsRoutes } from './routes/analytics';
|
|
|
|
| 14 |
import { internalRoutes } from './routes/internal';
|
| 15 |
import { authRoutes } from './routes/auth';
|
| 16 |
import { setupRateLimit } from './middleware/rateLimit';
|
|
@@ -107,6 +108,7 @@ const registerRoutes = async () => {
|
|
| 107 |
scope.register(aiRoutes, { prefix: '/v1/ai' });
|
| 108 |
scope.register(paymentRoutes, { prefix: '/v1/payments' });
|
| 109 |
scope.register(analyticsRoutes, { prefix: '/v1/analytics' });
|
|
|
|
| 110 |
scope.register(internalRoutes);
|
| 111 |
});
|
| 112 |
|
|
|
|
| 11 |
import { aiRoutes } from './routes/ai';
|
| 12 |
import { paymentRoutes, stripeWebhookRoute } from './routes/payments';
|
| 13 |
import { analyticsRoutes } from './routes/analytics';
|
| 14 |
+
import { notificationRoutes } from './routes/notifications';
|
| 15 |
import { internalRoutes } from './routes/internal';
|
| 16 |
import { authRoutes } from './routes/auth';
|
| 17 |
import { setupRateLimit } from './middleware/rateLimit';
|
|
|
|
| 108 |
scope.register(aiRoutes, { prefix: '/v1/ai' });
|
| 109 |
scope.register(paymentRoutes, { prefix: '/v1/payments' });
|
| 110 |
scope.register(analyticsRoutes, { prefix: '/v1/analytics' });
|
| 111 |
+
scope.register(notificationRoutes, { prefix: '/v1/notifications' });
|
| 112 |
scope.register(internalRoutes);
|
| 113 |
});
|
| 114 |
|
apps/api/src/routes/ai.ts
CHANGED
|
@@ -312,4 +312,209 @@ export async function aiRoutes(fastify: FastifyInstance) {
|
|
| 312 |
const { text, aiSource } = await aiService.generateText(systemPrompt, userPrompt, temperature);
|
| 313 |
return { success: true, text, aiSource };
|
| 314 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 315 |
}
|
|
|
|
| 312 |
const { text, aiSource } = await aiService.generateText(systemPrompt, userPrompt, temperature);
|
| 313 |
return { success: true, text, aiSource };
|
| 314 |
});
|
| 315 |
+
|
| 316 |
+
// 10. CRM: Generate Personalized Campaign Message
|
| 317 |
+
fastify.post('/crm/generate-campaign', async (request) => {
|
| 318 |
+
const bodySchema = z.object({
|
| 319 |
+
contact: z.any(),
|
| 320 |
+
objective: z.string(),
|
| 321 |
+
language: z.string().optional().default('FR')
|
| 322 |
+
});
|
| 323 |
+
const { contact, objective, language } = bodySchema.parse(request.body);
|
| 324 |
+
|
| 325 |
+
const organizationId = request.headers['x-organization-id'] as string;
|
| 326 |
+
// In Fastify, prisma is usually attached to the instance if decorated
|
| 327 |
+
const prisma = (fastify as any).prisma;
|
| 328 |
+
|
| 329 |
+
const org = await prisma.organization.findUnique({
|
| 330 |
+
where: { id: organizationId },
|
| 331 |
+
select: { name: true, personalityConfig: true }
|
| 332 |
+
});
|
| 333 |
+
|
| 334 |
+
const personality = (org?.personalityConfig as any) || {};
|
| 335 |
+
|
| 336 |
+
const result = await aiService.generateCrmCampaign(
|
| 337 |
+
contact,
|
| 338 |
+
objective,
|
| 339 |
+
{
|
| 340 |
+
name: org?.name || 'Notre Entreprise',
|
| 341 |
+
mission: personality.coreMission || 'Offrir un service d excellence',
|
| 342 |
+
tone: personality.toneDescription || 'Professionnel et chaleureux'
|
| 343 |
+
},
|
| 344 |
+
language
|
| 345 |
+
);
|
| 346 |
+
|
| 347 |
+
return { success: true, ...result };
|
| 348 |
+
});
|
| 349 |
+
|
| 350 |
+
// 11. CRM: Conversational Command Processor
|
| 351 |
+
fastify.post('/crm/command', async (request) => {
|
| 352 |
+
const bodySchema = z.object({
|
| 353 |
+
query: z.string()
|
| 354 |
+
});
|
| 355 |
+
const { query } = bodySchema.parse(request.body);
|
| 356 |
+
const organizationId = request.headers['x-organization-id'] as string;
|
| 357 |
+
const prisma = (fastify as any).prisma;
|
| 358 |
+
|
| 359 |
+
// Step 1: Detect intent using a lightweight prompt
|
| 360 |
+
const systemPrompt = `You are a CRM Command Interpreter. Your job is to classify the user's intent into one of these:
|
| 361 |
+
- LIST_CONTACTS: User wants to see their contacts or history.
|
| 362 |
+
- SHOW_IMPORT: User wants to import data or Excel files.
|
| 363 |
+
- START_CAMPAIGN: User wants to start or generate a campaign.
|
| 364 |
+
- GENERAL_CHAT: Any other conversation.
|
| 365 |
+
|
| 366 |
+
Return ONLY a JSON object: { "intent": "INTENT_NAME", "reply": "A brief, friendly acknowledgment in French" }`;
|
| 367 |
+
|
| 368 |
+
const { text } = await aiService.generateText(systemPrompt, query, 0.1);
|
| 369 |
+
let analysis;
|
| 370 |
+
try {
|
| 371 |
+
analysis = JSON.parse(text);
|
| 372 |
+
} catch (e) {
|
| 373 |
+
analysis = { intent: 'GENERAL_CHAT', reply: "Je suis à votre écoute. Comment puis-je vous aider ?" };
|
| 374 |
+
}
|
| 375 |
+
|
| 376 |
+
let payload = null;
|
| 377 |
+
let type = 'text';
|
| 378 |
+
|
| 379 |
+
// Step 2: Execute action based on intent
|
| 380 |
+
if (analysis.intent === 'LIST_CONTACTS') {
|
| 381 |
+
payload = await prisma.contact.findMany({
|
| 382 |
+
where: { organizationId },
|
| 383 |
+
orderBy: { createdAt: 'desc' },
|
| 384 |
+
take: 10
|
| 385 |
+
});
|
| 386 |
+
type = 'contact_list';
|
| 387 |
+
analysis.reply = "Voici les derniers contacts de votre base de données :";
|
| 388 |
+
} else if (analysis.intent === 'SHOW_IMPORT') {
|
| 389 |
+
type = 'import_dropzone';
|
| 390 |
+
analysis.reply = "C'est noté. Vous pouvez glisser-déposer votre fichier Excel ici pour l'importation :";
|
| 391 |
+
} else if (analysis.intent === 'START_CAMPAIGN' || query.toLowerCase().includes('génère une campagne')) {
|
| 392 |
+
// Fetch contacts to generate messages for
|
| 393 |
+
const contacts = await prisma.contact.findMany({
|
| 394 |
+
where: { organizationId },
|
| 395 |
+
take: 5 // Sample for demo/speed
|
| 396 |
+
});
|
| 397 |
+
|
| 398 |
+
if (contacts.length === 0) {
|
| 399 |
+
analysis.reply = "Votre base de données est vide. Souhaitez-vous importer des contacts ?";
|
| 400 |
+
type = 'choice_buttons';
|
| 401 |
+
payload = [{ label: "📤 Importer Excel", action: "Importer des clients" }];
|
| 402 |
+
} else {
|
| 403 |
+
// Generate messages for each contact
|
| 404 |
+
const org = await prisma.organization.findUnique({ where: { id: organizationId } });
|
| 405 |
+
const results = [];
|
| 406 |
+
for (const contact of contacts) {
|
| 407 |
+
const gen = await aiService.generateCrmCampaign(contact, "Présentation de nos nouveaux services", {
|
| 408 |
+
name: org?.name || 'Xamlé',
|
| 409 |
+
mission: (org?.personalityConfig as any)?.coreMission || 'Accompagnement IA',
|
| 410 |
+
tone: (org?.personalityConfig as any)?.toneDescription || 'Professionnel'
|
| 411 |
+
});
|
| 412 |
+
results.push({
|
| 413 |
+
contactId: contact.id,
|
| 414 |
+
to: contact.phoneNumber,
|
| 415 |
+
text: gen.personalizedMessage
|
| 416 |
+
});
|
| 417 |
+
}
|
| 418 |
+
|
| 419 |
+
type = 'campaign_summary';
|
| 420 |
+
payload = results;
|
| 421 |
+
analysis.reply = `J'ai analysé ${contacts.length} contacts et rédigé des approches personnalisées. Voici le résumé :`;
|
| 422 |
+
}
|
| 423 |
+
} else if (query.toLowerCase().includes('stat') || query.toLowerCase().includes('analyt') || query.toLowerCase().includes('perfor')) {
|
| 424 |
+
type = 'stats';
|
| 425 |
+
analysis.reply = "Voici l'analyse détaillée de vos dernières campagnes :";
|
| 426 |
+
} else {
|
| 427 |
+
// General chat or unknown intent - provide guide buttons
|
| 428 |
+
type = 'choice_buttons';
|
| 429 |
+
payload = [
|
| 430 |
+
{ label: "📊 Voir mes contacts", action: "Montre moi l'historique" },
|
| 431 |
+
{ label: "📤 Importer Excel", action: "Importer des clients" },
|
| 432 |
+
{ label: "✨ Créer une Campagne", action: "Lancer une campagne" }
|
| 433 |
+
];
|
| 434 |
+
}
|
| 435 |
+
|
| 436 |
+
return {
|
| 437 |
+
success: true,
|
| 438 |
+
message: analysis.reply,
|
| 439 |
+
type,
|
| 440 |
+
payload
|
| 441 |
+
};
|
| 442 |
+
});
|
| 443 |
+
|
| 444 |
+
// 12. CRM: Voice Command Processor
|
| 445 |
+
fastify.post('/crm/voice-command', async (request, reply) => {
|
| 446 |
+
const file = await (request as any).file();
|
| 447 |
+
if (!file) return reply.code(400).send({ error: 'No audio file' });
|
| 448 |
+
|
| 449 |
+
const buffer = await file.toBuffer();
|
| 450 |
+
|
| 451 |
+
// Step 1: Transcribe
|
| 452 |
+
const { text: transcription } = await aiService.transcribeAudio(buffer, 'command.webm');
|
| 453 |
+
|
| 454 |
+
// Step 2: Route to the command processor logic
|
| 455 |
+
return { success: true, transcription };
|
| 456 |
+
});
|
| 457 |
+
|
| 458 |
+
// 13. CRM: Bulk Send Campaign via WhatsApp Cloud API
|
| 459 |
+
fastify.post('/crm/send-bulk', async (request, reply) => {
|
| 460 |
+
const bodySchema = z.object({
|
| 461 |
+
messages: z.array(z.object({
|
| 462 |
+
contactId: z.string().optional(),
|
| 463 |
+
to: z.string(),
|
| 464 |
+
text: z.string()
|
| 465 |
+
}))
|
| 466 |
+
});
|
| 467 |
+
|
| 468 |
+
const { messages } = bodySchema.parse(request.body);
|
| 469 |
+
const organizationId = request.headers['x-organization-id'] as string;
|
| 470 |
+
const prisma = (fastify as any).prisma;
|
| 471 |
+
|
| 472 |
+
// 1. Fetch Org Credentials
|
| 473 |
+
const org = await prisma.organization.findUnique({
|
| 474 |
+
where: { id: organizationId },
|
| 475 |
+
include: { phoneNumbers: true }
|
| 476 |
+
});
|
| 477 |
+
|
| 478 |
+
if (!org || !org.systemUserToken) {
|
| 479 |
+
return reply.code(400).send({ error: 'WhatsApp not configured for this organization' });
|
| 480 |
+
}
|
| 481 |
+
|
| 482 |
+
const phoneNumberId = org.phoneNumbers?.[0]?.id;
|
| 483 |
+
if (!phoneNumberId) {
|
| 484 |
+
return reply.code(400).send({ error: 'No phone number connected' });
|
| 485 |
+
}
|
| 486 |
+
|
| 487 |
+
const { decryptSecrets } = await import('../services/organization');
|
| 488 |
+
const { whatsappService } = await import('../services/whatsapp');
|
| 489 |
+
const decryptedOrg = decryptSecrets(org);
|
| 490 |
+
|
| 491 |
+
// 2. Send and Log sequentially (to get contactIds right)
|
| 492 |
+
const results = { sent: 0, failed: 0 };
|
| 493 |
+
for (const msg of messages) {
|
| 494 |
+
try {
|
| 495 |
+
const res = await whatsappService.sendMessage({
|
| 496 |
+
accessToken: decryptedOrg.systemUserToken,
|
| 497 |
+
phoneNumberId
|
| 498 |
+
}, { to: msg.to, text: msg.text });
|
| 499 |
+
|
| 500 |
+
// Archive in DB
|
| 501 |
+
if (msg.contactId) {
|
| 502 |
+
await prisma.campaignHistory.create({
|
| 503 |
+
data: {
|
| 504 |
+
organizationId,
|
| 505 |
+
contactId: msg.contactId,
|
| 506 |
+
whatsappMessageId: res.id,
|
| 507 |
+
content: msg.text,
|
| 508 |
+
status: 'SENT'
|
| 509 |
+
}
|
| 510 |
+
});
|
| 511 |
+
}
|
| 512 |
+
results.sent++;
|
| 513 |
+
} catch (err) {
|
| 514 |
+
results.failed++;
|
| 515 |
+
}
|
| 516 |
+
}
|
| 517 |
+
|
| 518 |
+
return { success: true, results };
|
| 519 |
+
});
|
| 520 |
}
|
apps/api/src/routes/analytics.ts
CHANGED
|
@@ -108,4 +108,60 @@ export async function analyticsRoutes(fastify: FastifyInstance) {
|
|
| 108 |
return reply.code(500).send({ error: 'Failed to fetch pedagogical analytics' });
|
| 109 |
}
|
| 110 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
}
|
|
|
|
| 108 |
return reply.code(500).send({ error: 'Failed to fetch pedagogical analytics' });
|
| 109 |
}
|
| 110 |
});
|
| 111 |
+
|
| 112 |
+
/**
|
| 113 |
+
* GET /v1/analytics/campaigns
|
| 114 |
+
* Returns CRM campaign funnel: sent, delivered, read, failed.
|
| 115 |
+
*/
|
| 116 |
+
fastify.get('/campaigns', async (req, reply) => {
|
| 117 |
+
const organizationId = (req as any).organizationId;
|
| 118 |
+
|
| 119 |
+
if (!organizationId) {
|
| 120 |
+
return reply.code(400).send({ error: 'Organization ID is required' });
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
try {
|
| 124 |
+
const stats = await prisma.campaignHistory.groupBy({
|
| 125 |
+
by: ['status'],
|
| 126 |
+
where: { organizationId },
|
| 127 |
+
_count: { _all: true }
|
| 128 |
+
});
|
| 129 |
+
|
| 130 |
+
const counts: Record<string, number> = {
|
| 131 |
+
SENT: 0,
|
| 132 |
+
DELIVERED: 0,
|
| 133 |
+
READ: 0,
|
| 134 |
+
FAILED: 0
|
| 135 |
+
};
|
| 136 |
+
|
| 137 |
+
stats.forEach((s: any) => {
|
| 138 |
+
counts[s.status] = s._count._all;
|
| 139 |
+
});
|
| 140 |
+
|
| 141 |
+
const total = counts.SENT + counts.DELIVERED + counts.READ + counts.FAILED;
|
| 142 |
+
|
| 143 |
+
// Funnel logic: DELIVERED usually implies it was SENT, etc.
|
| 144 |
+
// But here we count specific statuses.
|
| 145 |
+
|
| 146 |
+
return {
|
| 147 |
+
summary: {
|
| 148 |
+
total,
|
| 149 |
+
sent: counts.SENT,
|
| 150 |
+
delivered: counts.DELIVERED,
|
| 151 |
+
read: counts.READ,
|
| 152 |
+
failed: counts.FAILED,
|
| 153 |
+
deliveryRate: total > 0 ? ((counts.DELIVERED + counts.READ) / total) * 100 : 0,
|
| 154 |
+
readRate: (counts.DELIVERED + counts.READ) > 0 ? (counts.READ / (counts.DELIVERED + counts.READ)) * 100 : 0
|
| 155 |
+
},
|
| 156 |
+
funnel: [
|
| 157 |
+
{ name: 'Envoyés', value: total, fill: '#6366f1' },
|
| 158 |
+
{ name: 'Livrés', value: counts.DELIVERED + counts.READ, fill: '#8b5cf6' },
|
| 159 |
+
{ name: 'Lus', value: counts.READ, fill: '#ec4899' }
|
| 160 |
+
]
|
| 161 |
+
};
|
| 162 |
+
} catch (err) {
|
| 163 |
+
logger.error({ err }, '[ANALYTICS] Campaigns fetch failed:');
|
| 164 |
+
return reply.code(500).send({ error: 'Failed to fetch campaign analytics' });
|
| 165 |
+
}
|
| 166 |
+
});
|
| 167 |
}
|
apps/api/src/routes/internal.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
import { FastifyInstance } from 'fastify';
|
| 2 |
-
import {
|
| 3 |
import { z } from 'zod';
|
| 4 |
import { getOrganizationByPhoneNumberId } from '../services/organization';
|
| 5 |
import { logger } from '../logger';
|
|
@@ -65,12 +65,12 @@ export async function internalRoutes(fastify: FastifyInstance) {
|
|
| 65 |
text = message.interactive?.button_reply?.id || message.interactive?.list_reply?.id;
|
| 66 |
} else if (message.type === 'audio' || message.type === 'image') {
|
| 67 |
// Delegate media to worker via service
|
| 68 |
-
await
|
| 69 |
continue;
|
| 70 |
}
|
| 71 |
|
| 72 |
if (phone && text) {
|
| 73 |
-
await
|
| 74 |
}
|
| 75 |
}
|
| 76 |
}
|
|
@@ -90,7 +90,7 @@ export async function internalRoutes(fastify: FastifyInstance) {
|
|
| 90 |
}, async (request, reply) => {
|
| 91 |
const { phone, text, audioUrl, imageUrl, organizationId } = request.body;
|
| 92 |
try {
|
| 93 |
-
await
|
| 94 |
return reply.send({ ok: true });
|
| 95 |
} catch (err: any) {
|
| 96 |
return reply.code(500).send({ error: err.message });
|
|
|
|
| 1 |
import { FastifyInstance } from 'fastify';
|
| 2 |
+
import { whatsappService } from '../services/whatsapp';
|
| 3 |
import { z } from 'zod';
|
| 4 |
import { getOrganizationByPhoneNumberId } from '../services/organization';
|
| 5 |
import { logger } from '../logger';
|
|
|
|
| 65 |
text = message.interactive?.button_reply?.id || message.interactive?.list_reply?.id;
|
| 66 |
} else if (message.type === 'audio' || message.type === 'image') {
|
| 67 |
// Delegate media to worker via service
|
| 68 |
+
await whatsappService.handleIncomingMessage(phone, '', message.audio?.id || undefined, message.image?.id || undefined, undefined, organizationId);
|
| 69 |
continue;
|
| 70 |
}
|
| 71 |
|
| 72 |
if (phone && text) {
|
| 73 |
+
await whatsappService.handleIncomingMessage(phone, text, undefined, undefined, undefined, organizationId);
|
| 74 |
}
|
| 75 |
}
|
| 76 |
}
|
|
|
|
| 90 |
}, async (request, reply) => {
|
| 91 |
const { phone, text, audioUrl, imageUrl, organizationId } = request.body;
|
| 92 |
try {
|
| 93 |
+
await whatsappService.handleIncomingMessage(phone, text, audioUrl, imageUrl, undefined, organizationId);
|
| 94 |
return reply.send({ ok: true });
|
| 95 |
} catch (err: any) {
|
| 96 |
return reply.code(500).send({ error: err.message });
|
apps/api/src/routes/notifications.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { FastifyInstance } from 'fastify';
|
| 2 |
+
import { pushService } from '../services/push';
|
| 3 |
+
import { z } from 'zod';
|
| 4 |
+
|
| 5 |
+
export async function notificationRoutes(fastify: FastifyInstance) {
|
| 6 |
+
/**
|
| 7 |
+
* POST /v1/notifications/subscribe
|
| 8 |
+
* Subscribe the current user to push notifications
|
| 9 |
+
*/
|
| 10 |
+
fastify.post('/subscribe', async (request, reply) => {
|
| 11 |
+
const bodySchema = z.object({
|
| 12 |
+
subscription: z.object({
|
| 13 |
+
endpoint: z.string(),
|
| 14 |
+
keys: z.object({
|
| 15 |
+
p256dh: z.string(),
|
| 16 |
+
auth: z.string()
|
| 17 |
+
})
|
| 18 |
+
})
|
| 19 |
+
});
|
| 20 |
+
|
| 21 |
+
const { subscription } = bodySchema.parse(request.body);
|
| 22 |
+
const user = (request as any).user;
|
| 23 |
+
const organizationId = (request as any).organizationId;
|
| 24 |
+
|
| 25 |
+
if (!user || !organizationId) {
|
| 26 |
+
return reply.code(401).send({ error: 'Unauthorized' });
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
await pushService.subscribe(user.id, organizationId, subscription);
|
| 30 |
+
|
| 31 |
+
return { success: true };
|
| 32 |
+
});
|
| 33 |
+
|
| 34 |
+
/**
|
| 35 |
+
* GET /v1/notifications/vapid-key
|
| 36 |
+
* Returns the public VAPID key for the frontend
|
| 37 |
+
*/
|
| 38 |
+
fastify.get('/vapid-key', async () => {
|
| 39 |
+
return { publicKey: process.env.VAPID_PUBLIC_KEY };
|
| 40 |
+
});
|
| 41 |
+
}
|
apps/api/src/routes/organizations.ts
CHANGED
|
@@ -289,4 +289,41 @@ export async function organizationRoutes(fastify: FastifyInstance) {
|
|
| 289 |
});
|
| 290 |
return contacts;
|
| 291 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 292 |
}
|
|
|
|
| 289 |
});
|
| 290 |
return contacts;
|
| 291 |
});
|
| 292 |
+
|
| 293 |
+
// 10. CRM: Delete Single Contact
|
| 294 |
+
fastify.delete('/:id/contacts/:contactId', async (req, reply) => {
|
| 295 |
+
const { id: organizationId, contactId } = req.params as { id: string; contactId: string };
|
| 296 |
+
|
| 297 |
+
try {
|
| 298 |
+
await (prisma as any).contact.delete({
|
| 299 |
+
where: { id: contactId, organizationId } // Security check
|
| 300 |
+
});
|
| 301 |
+
return { ok: true };
|
| 302 |
+
} catch (err) {
|
| 303 |
+
return reply.code(404).send({ error: 'Contact not found or not owned by organization' });
|
| 304 |
+
}
|
| 305 |
+
});
|
| 306 |
+
|
| 307 |
+
// 11. CRM: Bulk Delete Contacts
|
| 308 |
+
fastify.post('/:id/contacts/bulk-delete', async (req, reply) => {
|
| 309 |
+
const { id: organizationId } = req.params as { id: string };
|
| 310 |
+
const { contactIds } = req.body as { contactIds: string[] };
|
| 311 |
+
|
| 312 |
+
if (!contactIds || !Array.isArray(contactIds)) {
|
| 313 |
+
return reply.code(400).send({ error: 'Invalid contactIds' });
|
| 314 |
+
}
|
| 315 |
+
|
| 316 |
+
try {
|
| 317 |
+
const result = await (prisma as any).contact.deleteMany({
|
| 318 |
+
where: {
|
| 319 |
+
id: { in: contactIds },
|
| 320 |
+
organizationId
|
| 321 |
+
}
|
| 322 |
+
});
|
| 323 |
+
return { ok: true, deletedCount: result.count };
|
| 324 |
+
} catch (err) {
|
| 325 |
+
logger.error({ err }, '[CRM-BULK-DELETE] Failed');
|
| 326 |
+
return reply.code(500).send({ error: 'Failed to delete contacts' });
|
| 327 |
+
}
|
| 328 |
+
});
|
| 329 |
}
|
apps/api/src/routes/whatsapp.ts
CHANGED
|
@@ -1,153 +1,121 @@
|
|
| 1 |
-
import { logger } from '../logger';
|
| 2 |
import { FastifyInstance } from 'fastify';
|
| 3 |
-
import
|
| 4 |
-
import crypto from 'crypto';
|
| 5 |
-
import { z } from 'zod';
|
| 6 |
-
import { getOrganizationByPhoneNumberId } from '../services/organization';
|
| 7 |
-
import { whatsappQueue } from '../services/queue';
|
| 8 |
-
|
| 9 |
-
// ─── Zod Schema for WhatsApp Webhook Payload ─────────────────────────────────
|
| 10 |
-
const WhatsAppMessageSchema = z.object({
|
| 11 |
-
from: z.string(),
|
| 12 |
-
id: z.string(),
|
| 13 |
-
timestamp: z.string(),
|
| 14 |
-
type: z.enum(['text', 'audio', 'image', 'video', 'document', 'sticker', 'reaction', 'interactive']),
|
| 15 |
-
text: z.object({ body: z.string() }).optional(),
|
| 16 |
-
audio: z.object({ id: z.string(), mime_type: z.string().optional() }).optional(),
|
| 17 |
-
image: z.object({ id: z.string(), caption: z.string().optional() }).optional(),
|
| 18 |
-
interactive: z.object({
|
| 19 |
-
type: z.enum(['button_reply', 'list_reply']),
|
| 20 |
-
button_reply: z.object({
|
| 21 |
-
id: z.string(),
|
| 22 |
-
title: z.string(),
|
| 23 |
-
}).optional(),
|
| 24 |
-
list_reply: z.object({
|
| 25 |
-
id: z.string(),
|
| 26 |
-
title: z.string(),
|
| 27 |
-
description: z.string().optional()
|
| 28 |
-
}).optional(),
|
| 29 |
-
}).optional()
|
| 30 |
-
});
|
| 31 |
-
|
| 32 |
-
const WebhookPayloadSchema = z.object({
|
| 33 |
-
object: z.literal('whatsapp_business_account'),
|
| 34 |
-
entry: z.array(z.object({
|
| 35 |
-
id: z.string(),
|
| 36 |
-
changes: z.array(z.object({
|
| 37 |
-
value: z.object({
|
| 38 |
-
messaging_product: z.string().optional(),
|
| 39 |
-
metadata: z.object({ phone_number_id: z.string() }).optional(),
|
| 40 |
-
contacts: z.array(z.object({
|
| 41 |
-
profile: z.object({ name: z.string() }).optional(),
|
| 42 |
-
wa_id: z.string()
|
| 43 |
-
})).optional(),
|
| 44 |
-
messages: z.array(WhatsAppMessageSchema).optional(),
|
| 45 |
-
statuses: z.array(z.object({
|
| 46 |
-
id: z.string(),
|
| 47 |
-
status: z.string(),
|
| 48 |
-
timestamp: z.string().optional(),
|
| 49 |
-
recipient_id: z.string().optional()
|
| 50 |
-
})).optional(),
|
| 51 |
-
}),
|
| 52 |
-
field: z.string(),
|
| 53 |
-
})),
|
| 54 |
-
})),
|
| 55 |
-
});
|
| 56 |
-
|
| 57 |
-
// ─── HMAC Signature Verification ─────────────────────────────────────────────
|
| 58 |
-
function verifyWebhookSignature(rawBody: Buffer, signature: string | undefined, secret: string): boolean {
|
| 59 |
-
if (!signature) return false;
|
| 60 |
-
const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
|
| 61 |
-
try {
|
| 62 |
-
return crypto.timingSafeEqual(Buffer.from(expected, 'utf8'), Buffer.from(signature, 'utf8'));
|
| 63 |
-
} catch {
|
| 64 |
-
return false;
|
| 65 |
-
}
|
| 66 |
-
}
|
| 67 |
|
| 68 |
-
// ─── Route Plugin ─────────────────────────────────────────────────────────────
|
| 69 |
export async function whatsappRoutes(fastify: FastifyInstance) {
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
fastify.get('/webhook', async (request, reply) => {
|
| 76 |
-
const query = request.query as
|
| 77 |
const mode = query['hub.mode'];
|
| 78 |
const token = query['hub.verify_token'];
|
| 79 |
const challenge = query['hub.challenge'];
|
| 80 |
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
if (mode === 'subscribe' && token === process.env.WHATSAPP_VERIFY_TOKEN) {
|
| 84 |
-
return reply.code(200).type('text/plain').send(challenge);
|
| 85 |
-
}
|
| 86 |
-
return reply.code(403).send('Forbidden');
|
| 87 |
-
});
|
| 88 |
-
|
| 89 |
-
fastify.post('/webhook', async (request, reply) => handleIncoming(request, reply));
|
| 90 |
-
fastify.post('/webhook/:organizationId', async (request, reply) => handleIncoming(request, reply));
|
| 91 |
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
const signature = request.headers['x-hub-signature-256'] as string;
|
| 99 |
-
if (!request.rawBody || !verifyWebhookSignature(request.rawBody, signature, appSecret)) {
|
| 100 |
-
request.log.warn(`[WEBHOOK] Invalid HMAC for Org ${urlOrgId || 'global'}`);
|
| 101 |
-
return reply.code(403).send({ error: 'Invalid signature' });
|
| 102 |
}
|
| 103 |
}
|
|
|
|
| 104 |
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
}
|
| 143 |
-
}
|
| 144 |
-
attempts: 3,
|
| 145 |
-
backoff: { type: 'exponential', delay: 1000 }
|
| 146 |
-
});
|
| 147 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
}
|
|
|
|
|
|
|
| 149 |
}
|
| 150 |
-
|
| 151 |
-
return reply.code(200).send({ status: 'received' });
|
| 152 |
-
}
|
| 153 |
}
|
|
|
|
|
|
|
| 1 |
import { FastifyInstance } from 'fastify';
|
| 2 |
+
import { logger } from '../logger';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
|
|
|
|
| 4 |
export async function whatsappRoutes(fastify: FastifyInstance) {
|
| 5 |
+
|
| 6 |
+
/**
|
| 7 |
+
* GET /v1/whatsapp/webhook
|
| 8 |
+
* Meta verification challenge
|
| 9 |
+
*/
|
| 10 |
fastify.get('/webhook', async (request, reply) => {
|
| 11 |
+
const query = request.query as any;
|
| 12 |
const mode = query['hub.mode'];
|
| 13 |
const token = query['hub.verify_token'];
|
| 14 |
const challenge = query['hub.challenge'];
|
| 15 |
|
| 16 |
+
// Use a verify token that you will set in the Meta Dashboard
|
| 17 |
+
const VERIFY_TOKEN = process.env.WHATSAPP_VERIFY_TOKEN || 'xamle_studio_secret_token';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
+
if (mode && token) {
|
| 20 |
+
if (mode === 'subscribe' && token === VERIFY_TOKEN) {
|
| 21 |
+
logger.info('[WHATSAPP-WEBHOOK] Webhook verified successfully');
|
| 22 |
+
return reply.code(200).send(challenge);
|
| 23 |
+
} else {
|
| 24 |
+
return reply.code(403).send();
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
}
|
| 26 |
}
|
| 27 |
+
});
|
| 28 |
|
| 29 |
+
/**
|
| 30 |
+
* POST /v1/whatsapp/webhook
|
| 31 |
+
* Main entry point for incoming messages and events
|
| 32 |
+
*/
|
| 33 |
+
fastify.post('/webhook', async (request, reply) => {
|
| 34 |
+
const body = request.body as any;
|
| 35 |
+
|
| 36 |
+
if (body.object === 'whatsapp_business_account') {
|
| 37 |
+
try {
|
| 38 |
+
const entry = body.entry?.[0];
|
| 39 |
+
const wabaId = entry?.id;
|
| 40 |
+
const changes = entry?.changes?.[0];
|
| 41 |
+
const value = changes?.value;
|
| 42 |
+
const prisma = (fastify as any).prisma;
|
| 43 |
+
|
| 44 |
+
// 1. Handle Status Updates (delivered, read, etc.)
|
| 45 |
+
const statusUpdate = value?.statuses?.[0];
|
| 46 |
+
if (statusUpdate) {
|
| 47 |
+
const messageId = statusUpdate.id;
|
| 48 |
+
const status = statusUpdate.status.toUpperCase(); // DELIVERED, READ, etc.
|
| 49 |
+
|
| 50 |
+
await prisma.campaignHistory.update({
|
| 51 |
+
where: { whatsappMessageId: messageId },
|
| 52 |
+
data: { status }
|
| 53 |
+
});
|
| 54 |
|
| 55 |
+
// Log analytics for "READ"
|
| 56 |
+
if (status === 'READ') {
|
| 57 |
+
const history = await prisma.campaignHistory.findUnique({ where: { whatsappMessageId: messageId } });
|
| 58 |
+
if (history) {
|
| 59 |
+
await prisma.analyticsLog.create({
|
| 60 |
+
data: {
|
| 61 |
+
campaignHistoryId: history.id,
|
| 62 |
+
eventType: 'READ'
|
| 63 |
+
}
|
| 64 |
+
});
|
| 65 |
+
}
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
return reply.code(200).send('EVENT_RECEIVED');
|
| 69 |
+
}
|
| 70 |
|
| 71 |
+
// 2. Handle Incoming Messages (already implemented)
|
| 72 |
+
const message = value?.messages?.[0];
|
| 73 |
+
if (message) {
|
| 74 |
+
const from = message.from;
|
| 75 |
+
const text = message.text?.body;
|
| 76 |
+
|
| 77 |
+
const org = await prisma.organization.findUnique({
|
| 78 |
+
where: { wabaId },
|
| 79 |
+
include: { phoneNumbers: true }
|
| 80 |
+
});
|
| 81 |
|
| 82 |
+
if (org && text) {
|
| 83 |
+
logger.info({ from, wabaId, orgName: org.name }, '[WHATSAPP-WEBHOOK] Processing automated response');
|
| 84 |
+
|
| 85 |
+
const { aiService } = await import('../services/ai');
|
| 86 |
+
const { response } = await aiService.handleCrmConversation(from, org.id, text);
|
| 87 |
+
|
| 88 |
+
// Trigger Push Notification to the team
|
| 89 |
+
const { pushService } = await import('../services/push');
|
| 90 |
+
await pushService.notifyOrganization(
|
| 91 |
+
org.id,
|
| 92 |
+
"Nouveau message WhatsApp",
|
| 93 |
+
`Le client ${from} a répondu : "${text.substring(0, 50)}..."`
|
| 94 |
+
).catch(e => logger.warn({ e }, "[PUSH] Failed to notify"));
|
| 95 |
+
|
| 96 |
+
const phoneNumberId = org.phoneNumbers?.[0]?.id;
|
| 97 |
+
if (phoneNumberId && org.systemUserToken) {
|
| 98 |
+
const { decryptSecrets } = await import('../services/organization');
|
| 99 |
+
const { whatsappService } = await import('../services/whatsapp');
|
| 100 |
+
const decryptedOrg = decryptSecrets(org);
|
| 101 |
+
|
| 102 |
+
await whatsappService.sendMessage({
|
| 103 |
+
accessToken: decryptedOrg.systemUserToken,
|
| 104 |
+
phoneNumberId
|
| 105 |
+
}, { to: from, text: response });
|
| 106 |
+
|
| 107 |
+
logger.info({ to: from }, '[WHATSAPP-WEBHOOK] Response sent successfully');
|
| 108 |
}
|
| 109 |
+
}
|
|
|
|
|
|
|
|
|
|
| 110 |
}
|
| 111 |
+
|
| 112 |
+
return reply.code(200).send('EVENT_RECEIVED');
|
| 113 |
+
} catch (err) {
|
| 114 |
+
logger.error({ err }, '[WHATSAPP-WEBHOOK] Error in automated loop');
|
| 115 |
+
return reply.code(200).send('EVENT_RECEIVED');
|
| 116 |
}
|
| 117 |
+
} else {
|
| 118 |
+
return reply.code(404).send();
|
| 119 |
}
|
| 120 |
+
});
|
|
|
|
|
|
|
| 121 |
}
|
apps/api/src/services/ai/index.ts
CHANGED
|
@@ -257,6 +257,33 @@ export class AIService {
|
|
| 257 |
return provider.instance.transcribeAudio(audioBuffer, filename, language);
|
| 258 |
}
|
| 259 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 260 |
async extractBusinessProfile(userInput: string, dayNumber: number, userLanguage: string = 'FR'): Promise<any> {
|
| 261 |
const personality = await this.getTenantPersonality();
|
| 262 |
const prompt = PromptLoader.compile('business-profile-extraction', {
|
|
@@ -303,6 +330,52 @@ export class AIService {
|
|
| 303 |
}
|
| 304 |
throw new Error(`[AI_ERROR] All providers for ${capability} failed.`);
|
| 305 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 306 |
}
|
| 307 |
|
| 308 |
export const aiService = new AIService();
|
|
|
|
| 257 |
return provider.instance.transcribeAudio(audioBuffer, filename, language);
|
| 258 |
}
|
| 259 |
|
| 260 |
+
async generateCrmCampaign(
|
| 261 |
+
contact: any,
|
| 262 |
+
objective: string,
|
| 263 |
+
orgData: { name: string; mission: string; tone: string },
|
| 264 |
+
language: string = 'FR'
|
| 265 |
+
): Promise<{ personalizedMessage: string, reasoning: string, aiSource: string }> {
|
| 266 |
+
const personality = await this.getTenantPersonality();
|
| 267 |
+
const prompt = PromptLoader.compile('crm-campaign', {
|
| 268 |
+
orgName: orgData.name,
|
| 269 |
+
coreMission: orgData.mission,
|
| 270 |
+
toneDescription: orgData.tone,
|
| 271 |
+
campaignObjective: objective,
|
| 272 |
+
contactName: contact.name || 'Inconnu',
|
| 273 |
+
contactPhone: contact.phoneNumber,
|
| 274 |
+
contactAttributes: JSON.stringify(contact.attributes || {}),
|
| 275 |
+
languageLabel: language === 'WOLOF' ? 'WOLOF (ñ, ë, é)' : 'Français'
|
| 276 |
+
}, personality);
|
| 277 |
+
|
| 278 |
+
const schema = z.object({
|
| 279 |
+
personalizedMessage: z.string(),
|
| 280 |
+
reasoning: z.string()
|
| 281 |
+
});
|
| 282 |
+
|
| 283 |
+
const { data, source } = await this.callWithFailover(prompt, schema);
|
| 284 |
+
return { ...data, aiSource: source };
|
| 285 |
+
}
|
| 286 |
+
|
| 287 |
async extractBusinessProfile(userInput: string, dayNumber: number, userLanguage: string = 'FR'): Promise<any> {
|
| 288 |
const personality = await this.getTenantPersonality();
|
| 289 |
const prompt = PromptLoader.compile('business-profile-extraction', {
|
|
|
|
| 330 |
}
|
| 331 |
throw new Error(`[AI_ERROR] All providers for ${capability} failed.`);
|
| 332 |
}
|
| 333 |
+
|
| 334 |
+
/**
|
| 335 |
+
* CRM: Handles an incoming message, manages memory, and generates a response.
|
| 336 |
+
*/
|
| 337 |
+
async handleCrmConversation(
|
| 338 |
+
phoneNumber: string,
|
| 339 |
+
organizationId: string,
|
| 340 |
+
userMessage: string
|
| 341 |
+
): Promise<{ response: string, aiSource: string }> {
|
| 342 |
+
const memoryKey = `crm:chat:${organizationId}:${phoneNumber}`;
|
| 343 |
+
|
| 344 |
+
// 1. Retrieve History from Redis
|
| 345 |
+
let history = [];
|
| 346 |
+
try {
|
| 347 |
+
const cached = await redis.get(memoryKey);
|
| 348 |
+
if (cached) history = JSON.parse(cached);
|
| 349 |
+
} catch (err) {
|
| 350 |
+
logger.warn({ err }, "[AI_SERVICE] Memory retrieval failed");
|
| 351 |
+
}
|
| 352 |
+
|
| 353 |
+
// 2. Fetch Organization context
|
| 354 |
+
const org = await prisma.organization.findUnique({ where: { id: organizationId } });
|
| 355 |
+
const personality = (org?.personalityConfig as any) || {};
|
| 356 |
+
|
| 357 |
+
// 3. Compile Closing Prompt
|
| 358 |
+
const prompt = PromptLoader.compile('crm-closing', {
|
| 359 |
+
orgName: org?.name || 'Xamlé',
|
| 360 |
+
coreMission: personality.coreMission || 'Service client',
|
| 361 |
+
toneDescription: personality.toneDescription || 'Professionnel',
|
| 362 |
+
chatHistory: history.map((h: any) => `${h.role === 'user' ? 'Client' : 'Assistant'}: ${h.content}`).join('\n'),
|
| 363 |
+
userInput: userMessage
|
| 364 |
+
}, personality);
|
| 365 |
+
|
| 366 |
+
// 4. Generate Response
|
| 367 |
+
const { text, aiSource } = await this.generateText(
|
| 368 |
+
"Tu es un assistant commercial expert. Ta mission est de répondre aux questions du client et de le guider vers un rendez-vous ou une vente, tout en respectant l'historique de la conversation.",
|
| 369 |
+
prompt,
|
| 370 |
+
0.7
|
| 371 |
+
);
|
| 372 |
+
|
| 373 |
+
// 5. Update Memory (Keep last 10 messages)
|
| 374 |
+
const updatedHistory = [...history, { role: 'user', content: userMessage }, { role: 'assistant', content: text }].slice(-10);
|
| 375 |
+
await redis.set(memoryKey, JSON.stringify(updatedHistory), 'EX', 86400); // 24h expiration
|
| 376 |
+
|
| 377 |
+
return { response: text, aiSource };
|
| 378 |
+
}
|
| 379 |
}
|
| 380 |
|
| 381 |
export const aiService = new AIService();
|
apps/api/src/services/push.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import webpush from 'web-push';
|
| 2 |
+
import { prisma } from './prisma';
|
| 3 |
+
import { logger } from '../logger';
|
| 4 |
+
|
| 5 |
+
// VAPID keys should be in .env. If not, we log a warning.
|
| 6 |
+
const publicVapidKey = process.env.VAPID_PUBLIC_KEY;
|
| 7 |
+
const privateVapidKey = process.env.VAPID_PRIVATE_KEY;
|
| 8 |
+
const contactEmail = process.env.VAPID_EMAIL || 'mailto:support@xamle.studio';
|
| 9 |
+
|
| 10 |
+
if (publicVapidKey && privateVapidKey) {
|
| 11 |
+
webpush.setVapidDetails(contactEmail, publicVapidKey, privateVapidKey);
|
| 12 |
+
} else {
|
| 13 |
+
logger.warn('[PUSH-SERVICE] VAPID keys are missing. Push notifications will not work.');
|
| 14 |
+
// To generate keys, you can use: webpush.generateVAPIDKeys()
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
export const pushService = {
|
| 18 |
+
/**
|
| 19 |
+
* Store a new subscription for a user
|
| 20 |
+
*/
|
| 21 |
+
async subscribe(userId: string, organizationId: string, subscription: any) {
|
| 22 |
+
return (prisma as any).pushSubscription.upsert({
|
| 23 |
+
where: { endpoint: subscription.endpoint },
|
| 24 |
+
update: {
|
| 25 |
+
userId,
|
| 26 |
+
organizationId,
|
| 27 |
+
p256dh: subscription.keys.p256dh,
|
| 28 |
+
auth: subscription.keys.auth
|
| 29 |
+
},
|
| 30 |
+
create: {
|
| 31 |
+
userId,
|
| 32 |
+
organizationId,
|
| 33 |
+
endpoint: subscription.endpoint,
|
| 34 |
+
p256dh: subscription.keys.p256dh,
|
| 35 |
+
auth: subscription.keys.auth
|
| 36 |
+
}
|
| 37 |
+
});
|
| 38 |
+
},
|
| 39 |
+
|
| 40 |
+
/**
|
| 41 |
+
* Send a notification to all active subscriptions of an organization
|
| 42 |
+
*/
|
| 43 |
+
async notifyOrganization(organizationId: string, title: string, body: string, icon?: string) {
|
| 44 |
+
const subscriptions = await (prisma as any).pushSubscription.findMany({
|
| 45 |
+
where: { organizationId }
|
| 46 |
+
});
|
| 47 |
+
|
| 48 |
+
const payload = JSON.stringify({
|
| 49 |
+
title,
|
| 50 |
+
body,
|
| 51 |
+
icon: icon || 'https://xamle.studio/logo.png',
|
| 52 |
+
timestamp: Date.now()
|
| 53 |
+
});
|
| 54 |
+
|
| 55 |
+
const results = await Promise.allSettled(
|
| 56 |
+
subscriptions.map((sub: any) => {
|
| 57 |
+
const pushConfig = {
|
| 58 |
+
endpoint: sub.endpoint,
|
| 59 |
+
keys: {
|
| 60 |
+
p256dh: sub.p256dh,
|
| 61 |
+
auth: sub.auth
|
| 62 |
+
}
|
| 63 |
+
};
|
| 64 |
+
|
| 65 |
+
return webpush.sendNotification(pushConfig, payload);
|
| 66 |
+
})
|
| 67 |
+
);
|
| 68 |
+
|
| 69 |
+
// Clean up failed subscriptions (e.g. expired or unsubscribed)
|
| 70 |
+
for (let i = 0; i < results.length; i++) {
|
| 71 |
+
if (results[i].status === 'rejected') {
|
| 72 |
+
const error = (results[i] as PromiseRejectedResult).reason;
|
| 73 |
+
if (error.statusCode === 410 || error.statusCode === 404) {
|
| 74 |
+
logger.info(`[PUSH-SERVICE] Removing expired subscription: ${subscriptions[i].endpoint}`);
|
| 75 |
+
await (prisma as any).pushSubscription.delete({ where: { id: subscriptions[i].id } }).catch(() => {});
|
| 76 |
+
}
|
| 77 |
+
}
|
| 78 |
+
}
|
| 79 |
+
}
|
| 80 |
+
};
|
apps/api/src/services/whatsapp.ts
CHANGED
|
@@ -1,34 +1,109 @@
|
|
|
|
|
| 1 |
import { logger } from '../logger';
|
| 2 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
|
| 4 |
export class WhatsAppService {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
/**
|
| 6 |
-
*
|
| 7 |
-
* Delegates all business logic to the background worker via BullMQ.
|
| 8 |
*/
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
}
|
| 34 |
}
|
|
|
|
|
|
|
|
|
| 1 |
+
import axios from 'axios';
|
| 2 |
import { logger } from '../logger';
|
| 3 |
+
|
| 4 |
+
export interface WhatsAppMessage {
|
| 5 |
+
to: string;
|
| 6 |
+
text: string;
|
| 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
|
| 14 |
+
* Note: Requires a 24h window or a pre-approved template for proactive outreach
|
| 15 |
+
*/
|
| 16 |
+
async sendMessage(config: { accessToken: string; phoneNumberId: string }, message: WhatsAppMessage) {
|
| 17 |
+
try {
|
| 18 |
+
const url = `${this.baseUrl}/${config.phoneNumberId}/messages`;
|
| 19 |
+
const response = await axios.post(url, {
|
| 20 |
+
messaging_product: 'whatsapp',
|
| 21 |
+
recipient_type: 'individual',
|
| 22 |
+
to: message.to,
|
| 23 |
+
type: 'text',
|
| 24 |
+
text: { body: message.text }
|
| 25 |
+
}, {
|
| 26 |
+
headers: {
|
| 27 |
+
'Authorization': `Bearer ${config.accessToken}`,
|
| 28 |
+
'Content-Type': 'application/json'
|
| 29 |
+
}
|
| 30 |
+
});
|
| 31 |
+
|
| 32 |
+
return {
|
| 33 |
+
id: response.data.messages?.[0]?.id,
|
| 34 |
+
status: 'SENT'
|
| 35 |
+
};
|
| 36 |
+
} catch (err: any) {
|
| 37 |
+
logger.error({
|
| 38 |
+
error: err.response?.data || err.message,
|
| 39 |
+
to: message.to
|
| 40 |
+
}, '[WHATSAPP_SERVICE] Failed to send message');
|
| 41 |
+
throw err;
|
| 42 |
+
}
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
/**
|
| 46 |
+
* Bulk send utility
|
| 47 |
+
*/
|
| 48 |
+
async sendBulk(config: { accessToken: string; phoneNumberId: string }, messages: WhatsAppMessage[]) {
|
| 49 |
+
const results = { sent: 0, failed: 0 };
|
| 50 |
+
|
| 51 |
+
// We use sequential or limited concurrency to avoid Meta rate limits
|
| 52 |
+
for (const msg of messages) {
|
| 53 |
+
try {
|
| 54 |
+
await this.sendMessage(config, msg);
|
| 55 |
+
results.sent++;
|
| 56 |
+
} catch (err) {
|
| 57 |
+
results.failed++;
|
| 58 |
+
}
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
return results;
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
/**
|
| 65 |
+
* Orchestrates the AI response loop and notifications for an incoming message
|
|
|
|
| 66 |
*/
|
| 67 |
+
async handleIncomingMessage(phone: string, text: string, _audioId?: string, _imageId?: string, _videoId?: string, organizationId?: string) {
|
| 68 |
+
if (!organizationId) {
|
| 69 |
+
logger.warn({ phone }, '[WHATSAPP_SERVICE] Cannot handle message without organizationId');
|
| 70 |
+
return;
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
try {
|
| 74 |
+
const { prisma } = await import('./prisma');
|
| 75 |
+
const { aiService } = await import('./ai');
|
| 76 |
+
const { pushService } = await import('./push');
|
| 77 |
+
const { decryptSecrets } = await import('./organization');
|
| 78 |
+
|
| 79 |
+
// 1. Process via AI Closing Engine
|
| 80 |
+
const { response } = await aiService.handleCrmConversation(phone, organizationId, text || '[Médias Reçus]');
|
| 81 |
+
|
| 82 |
+
// 2. Trigger Push Notification to the team
|
| 83 |
+
await pushService.notifyOrganization(
|
| 84 |
+
organizationId,
|
| 85 |
+
"Nouveau message WhatsApp",
|
| 86 |
+
`Le client ${phone} a répondu : "${(text || '').substring(0, 50)}..."`
|
| 87 |
+
).catch(e => logger.warn({ e }, "[PUSH] Failed to notify"));
|
| 88 |
+
|
| 89 |
+
// 3. Send automated response if possible
|
| 90 |
+
const org = await prisma.organization.findUnique({
|
| 91 |
+
where: { id: organizationId },
|
| 92 |
+
include: { phoneNumbers: true }
|
| 93 |
+
});
|
| 94 |
+
|
| 95 |
+
const phoneNumberId = org?.phoneNumbers?.[0]?.id;
|
| 96 |
+
if (org && phoneNumberId && org.systemUserToken) {
|
| 97 |
+
const decryptedOrg = decryptSecrets(org);
|
| 98 |
+
await this.sendMessage({
|
| 99 |
+
accessToken: decryptedOrg.systemUserToken,
|
| 100 |
+
phoneNumberId
|
| 101 |
+
}, { to: phone, text: response });
|
| 102 |
+
}
|
| 103 |
+
} catch (err) {
|
| 104 |
+
logger.error({ err, phone }, '[WHATSAPP_SERVICE] handleIncomingMessage failed');
|
| 105 |
+
}
|
| 106 |
}
|
| 107 |
}
|
| 108 |
+
|
| 109 |
+
export const whatsappService = new WhatsAppService();
|
packages/database/prisma/schema.prisma
CHANGED
|
@@ -43,6 +43,7 @@ model Organization {
|
|
| 43 |
progress UserProgress[]
|
| 44 |
phoneNumbers WhatsAppPhoneNumber[]
|
| 45 |
contacts Contact[]
|
|
|
|
| 46 |
}
|
| 47 |
|
| 48 |
model Contact {
|
|
@@ -54,11 +55,46 @@ model Contact {
|
|
| 54 |
createdAt DateTime @default(now())
|
| 55 |
updatedAt DateTime @updatedAt
|
| 56 |
organization Organization @relation(fields: [organizationId], references: [id])
|
|
|
|
| 57 |
|
| 58 |
@@unique([phoneNumber, organizationId])
|
| 59 |
@@index([organizationId])
|
| 60 |
}
|
| 61 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
model KnowledgeBaseEntry {
|
| 63 |
id String @id @default(uuid())
|
| 64 |
organizationId String
|
|
@@ -103,6 +139,7 @@ model User {
|
|
| 103 |
responses Response[]
|
| 104 |
organization Organization @relation(fields: [organizationId], references: [id])
|
| 105 |
progress UserProgress[]
|
|
|
|
| 106 |
|
| 107 |
@@unique([phone, organizationId])
|
| 108 |
@@unique([email, organizationId])
|
|
@@ -111,6 +148,20 @@ model User {
|
|
| 111 |
@@index([email])
|
| 112 |
}
|
| 113 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
model BusinessProfile {
|
| 115 |
id String @id @default(uuid())
|
| 116 |
userId String @unique
|
|
|
|
| 43 |
progress UserProgress[]
|
| 44 |
phoneNumbers WhatsAppPhoneNumber[]
|
| 45 |
contacts Contact[]
|
| 46 |
+
campaigns CampaignHistory[]
|
| 47 |
}
|
| 48 |
|
| 49 |
model Contact {
|
|
|
|
| 55 |
createdAt DateTime @default(now())
|
| 56 |
updatedAt DateTime @updatedAt
|
| 57 |
organization Organization @relation(fields: [organizationId], references: [id])
|
| 58 |
+
campaigns CampaignHistory[]
|
| 59 |
|
| 60 |
@@unique([phoneNumber, organizationId])
|
| 61 |
@@index([organizationId])
|
| 62 |
}
|
| 63 |
|
| 64 |
+
// ... existing models ...
|
| 65 |
+
|
| 66 |
+
model CampaignHistory {
|
| 67 |
+
id String @id @default(uuid())
|
| 68 |
+
organizationId String
|
| 69 |
+
contactId String
|
| 70 |
+
whatsappMessageId String? @unique // ID returned by Meta
|
| 71 |
+
content String
|
| 72 |
+
status String @default("SENT") // SENT, DELIVERED, READ, FAILED
|
| 73 |
+
error String?
|
| 74 |
+
sentAt DateTime @default(now())
|
| 75 |
+
updatedAt DateTime @updatedAt
|
| 76 |
+
organization Organization @relation(fields: [organizationId], references: [id])
|
| 77 |
+
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
|
| 78 |
+
analytics AnalyticsLog[]
|
| 79 |
+
|
| 80 |
+
@@index([organizationId])
|
| 81 |
+
@@index([contactId])
|
| 82 |
+
@@index([whatsappMessageId])
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
model AnalyticsLog {
|
| 86 |
+
id String @id @default(uuid())
|
| 87 |
+
campaignHistoryId String
|
| 88 |
+
eventType String // CLICK, READ, RESPONSE
|
| 89 |
+
metadata Json?
|
| 90 |
+
occurredAt DateTime @default(now())
|
| 91 |
+
campaign CampaignHistory @relation(fields: [campaignHistoryId], references: [id], onDelete: Cascade)
|
| 92 |
+
|
| 93 |
+
@@index([campaignHistoryId])
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
// ... existing enums ...
|
| 97 |
+
|
| 98 |
model KnowledgeBaseEntry {
|
| 99 |
id String @id @default(uuid())
|
| 100 |
organizationId String
|
|
|
|
| 139 |
responses Response[]
|
| 140 |
organization Organization @relation(fields: [organizationId], references: [id])
|
| 141 |
progress UserProgress[]
|
| 142 |
+
pushSubscriptions PushSubscription[]
|
| 143 |
|
| 144 |
@@unique([phone, organizationId])
|
| 145 |
@@unique([email, organizationId])
|
|
|
|
| 148 |
@@index([email])
|
| 149 |
}
|
| 150 |
|
| 151 |
+
model PushSubscription {
|
| 152 |
+
id String @id @default(uuid())
|
| 153 |
+
userId String
|
| 154 |
+
organizationId String
|
| 155 |
+
endpoint String @unique
|
| 156 |
+
p256dh String
|
| 157 |
+
auth String
|
| 158 |
+
createdAt DateTime @default(now())
|
| 159 |
+
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
| 160 |
+
|
| 161 |
+
@@index([organizationId])
|
| 162 |
+
@@index([userId])
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
model BusinessProfile {
|
| 166 |
id String @id @default(uuid())
|
| 167 |
userId String @unique
|
packages/prompts/src/templates/crm-campaign.md
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# AI CRM Campaign Generator
|
| 2 |
+
|
| 3 |
+
Tu es un expert en marketing conversationnel WhatsApp et en psychologie de la vente.
|
| 4 |
+
Ton objectif est de générer un message de prospection ou de relance ultra-personnalisé pour un contact spécifique.
|
| 5 |
+
|
| 6 |
+
## CONTEXTE DE L'ORGANISATION
|
| 7 |
+
Nom: {{orgName}}
|
| 8 |
+
Mission: {{coreMission}}
|
| 9 |
+
Ton: {{toneDescription}}
|
| 10 |
+
|
| 11 |
+
## OBJECTIF DE LA CAMPAGNE
|
| 12 |
+
{{campaignObjective}}
|
| 13 |
+
|
| 14 |
+
## DONNÉES DU CONTACT
|
| 15 |
+
Nom: {{contactName}}
|
| 16 |
+
Téléphone: {{contactPhone}}
|
| 17 |
+
Attributs additionnels (JSON):
|
| 18 |
+
{{contactAttributes}}
|
| 19 |
+
|
| 20 |
+
## DIRECTIVES DE RÉDACTION
|
| 21 |
+
1. **Accroche Contextuelle** : Utilise les attributs du contact (ex: son secteur d'activité, son dernier achat ou sa ville) pour montrer que le message n'est pas un spam.
|
| 22 |
+
2. **Style WhatsApp** : Court, direct, chaleureux. Utilise des emojis avec parcimonie.
|
| 23 |
+
3. **Appel à l'action (CTA)** : Toujours finir par une question ouverte ou une proposition d'action simple.
|
| 24 |
+
4. **Langue** : {{languageLabel}} (si WOLOF, utilise un wolof standardisé et respectueux).
|
| 25 |
+
|
| 26 |
+
## FORMAT DE RÉPONSE ATTENDU (JSON)
|
| 27 |
+
```json
|
| 28 |
+
{
|
| 29 |
+
"personalizedMessage": "Le contenu du message ici...",
|
| 30 |
+
"reasoning": "Pourquoi as-tu choisi cette approche ?"
|
| 31 |
+
}
|
| 32 |
+
```
|