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 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: '/contacts', label: 'Contacts', icon: <Users className="w-4 h-4 text-blue-400" />, show: isCrmMode },
 
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 filteredContacts = contacts.filter(c =>
76
- c.name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
77
- c.phoneNumber.includes(searchQuery)
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={4} className="px-8 py-20 text-center">
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={4} className="px-8 py-20 text-center">
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="hover:bg-slate-50/50 transition">
 
 
 
 
 
 
 
 
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
- <button className="p-2 text-slate-400 hover:text-red-500 transition">
216
- <Trash2 className="w-5 h-5" />
217
- </button>
 
 
 
 
 
 
 
 
 
 
 
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 { WhatsAppService } from '../services/whatsapp';
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 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,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 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 });
 
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 type { FastifyRequest } from 'fastify';
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
- fastify.addContentTypeParser('application/json', { parseAs: 'buffer' }, async (req: FastifyRequest, body: Buffer) => {
71
- req.rawBody = body;
72
- return JSON.parse(body.toString('utf8'));
73
- });
74
-
75
  fastify.get('/webhook', async (request, reply) => {
76
- const query = request.query as Record<string, string>;
77
  const mode = query['hub.mode'];
78
  const token = query['hub.verify_token'];
79
  const challenge = query['hub.challenge'];
80
 
81
- if (!mode && !token && !challenge) return reply.code(200).type('text/plain').send('ok');
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
- async function handleIncoming(request: any, reply: any) {
93
- const { organizationId: urlOrgId } = request.params as { organizationId?: string };
94
-
95
- // 1. HMAC Verification
96
- const appSecret = process.env.WHATSAPP_APP_SECRET;
97
- if (appSecret) {
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
- // 2. Gateway Forwarding
106
- const railwayInternalUrl = process.env.RAILWAY_INTERNAL_URL;
107
- const isGateway = process.env.IS_GATEWAY === 'true' || !!process.env.HF_SPACE_ID;
108
-
109
- if (railwayInternalUrl && isGateway) {
110
- const targetUrl = `${railwayInternalUrl.replace(/\/$/, '')}/v1/internal/whatsapp/inbound`;
111
- fetch(targetUrl, {
112
- method: 'POST',
113
- headers: {
114
- 'Content-Type': 'application/json',
115
- 'x-api-key': process.env.ADMIN_API_KEY || '',
116
- 'x-organization-id': urlOrgId || ''
117
- },
118
- body: JSON.stringify(request.body)
119
- }).catch(err => logger.error({ err }, '[WEBHOOK] Forwarding failed:'));
120
-
121
- return reply.code(200).send({ status: 'forwarded' });
122
- }
 
 
 
 
 
 
 
123
 
124
- // 3. Queue for Processing
125
- const parsed = WebhookPayloadSchema.safeParse(request.body);
126
- if (!parsed.success) return reply.code(200).send({ status: 'ignored' });
 
 
 
 
 
 
 
 
 
 
 
 
127
 
128
- for (const entry of parsed.data.entry) {
129
- for (const change of entry.changes) {
130
- const phoneNumberId = change.value.metadata?.phone_number_id || 'unknown';
131
-
132
- // Use URL OrgId if present, otherwise resolve from phone number
133
- const organizationId = urlOrgId || await getOrganizationByPhoneNumberId(phoneNumberId);
 
 
 
 
134
 
135
- for (const message of change.value.messages || []) {
136
- await whatsappQueue.add('process-message', {
137
- message,
138
- organizationId,
139
- metadata: {
140
- phoneNumberId,
141
- displayPhoneNumber: (change.value.metadata as any)?.display_phone_number
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- import { whatsappQueue } from './queue';
 
 
 
 
3
 
4
  export class WhatsAppService {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  /**
6
- * Unified entry point for incoming messages.
7
- * Delegates all business logic to the background worker via BullMQ.
8
  */
9
- static async handleIncomingMessage(
10
- phone: string,
11
- text: string,
12
- audioUrl?: string,
13
- imageUrl?: string,
14
- timeTravelDayOverride?: number,
15
- organizationId: string = 'default-org-id'
16
- ) {
17
- const traceId = audioUrl ? `[STT-FLOW-${phone.slice(-4)}]` : imageUrl ? `[IMG-FLOW-${phone.slice(-4)}]` : `[TXT-FLOW-${phone.slice(-4)}]`;
18
- logger.info(`${traceId} Enqueueing message for worker: "${text.substring(0, 50)}..." (Org: ${organizationId})`);
19
-
20
- await whatsappQueue.add('handle-inbound', {
21
- phone,
22
- text,
23
- audioUrl,
24
- imageUrl,
25
- isTimeTravelMode: timeTravelDayOverride !== undefined,
26
- realCurrentDay: timeTravelDayOverride,
27
- organizationId
28
- }, {
29
- priority: 1,
30
- attempts: 3,
31
- backoff: { type: 'exponential', delay: 2000 }
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
+ ```