CognxSafeTrack commited on
Commit
4339e77
·
1 Parent(s): d983a7d

chore: CRM stabilization sprint completion

Browse files

- Refactor: Decomposed CrmConversationalDashboard into modular components (Inbox, AIAssistant, StatsHeader)
- Fix: Centralized entity resolution with EntityResolver service (API & Worker)
- Fix: Strict Prisma typing, removed 'as any' casts in organization and whatsapp routes
- Fix: Implemented broadcast message persistence and accurate usage quota tracking
- Schema: Added MessageStatus and status field to Message model

apps/admin/src/components/crm/CrmAIAssistant.tsx ADDED
@@ -0,0 +1,198 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useRef, useEffect } from 'react';
2
+ import { Upload, Send, Bot, User, Loader2, FileText, CheckCircle2 } from 'lucide-react';
3
+ import { motion } from 'framer-motion';
4
+
5
+ interface Message {
6
+ id: string;
7
+ role: 'user' | 'assistant';
8
+ content: string;
9
+ timestamp: Date;
10
+ }
11
+
12
+ interface CrmAIAssistantProps {
13
+ messages: Message[];
14
+ input: string;
15
+ setInput: (val: string) => void;
16
+ isUploading: boolean;
17
+ isGenerating: boolean;
18
+ isDragging: boolean;
19
+ onFileUpload: (file: File) => void;
20
+ onDragOver: (e: React.DragEvent) => void;
21
+ onDragLeave: () => void;
22
+ onDrop: (e: React.DragEvent) => void;
23
+ onSendMessage: (e?: React.FormEvent) => void;
24
+ onValidateAndSend: (message: string) => void;
25
+ }
26
+
27
+ export default function CrmAIAssistant({
28
+ messages,
29
+ input,
30
+ setInput,
31
+ isUploading,
32
+ isGenerating,
33
+ isDragging,
34
+ onFileUpload,
35
+ onDragOver,
36
+ onDragLeave,
37
+ onDrop,
38
+ onSendMessage,
39
+ onValidateAndSend
40
+ }: CrmAIAssistantProps) {
41
+ const messagesEndRef = useRef<HTMLDivElement>(null);
42
+
43
+ const scrollToBottom = () => {
44
+ messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
45
+ };
46
+
47
+ useEffect(() => {
48
+ scrollToBottom();
49
+ }, [messages]);
50
+
51
+ return (
52
+ <div className="flex-1 flex overflow-hidden h-full">
53
+ {/* Left Side: Context / Stats */}
54
+ <div className="w-1/3 p-8 border-r border-slate-200 bg-white/50 backdrop-blur-sm hidden lg:block overflow-y-auto">
55
+ <div className="h-full flex flex-col">
56
+ <div className="mb-8">
57
+ <h2 className="text-xl font-black text-slate-900 mb-2">Source de Données</h2>
58
+ <p className="text-sm text-slate-500 font-medium leading-relaxed">
59
+ Déposez votre fichier Excel pour créer une nouvelle liste de diffusion instantanément.
60
+ </p>
61
+ </div>
62
+
63
+ <div
64
+ onDragOver={onDragOver}
65
+ onDragLeave={onDragLeave}
66
+ onDrop={onDrop}
67
+ className={`flex-1 min-h-[300px] border-4 border-dashed rounded-[2.5rem] flex flex-col items-center justify-center p-10 transition-all duration-300 ${
68
+ isDragging
69
+ ? 'border-indigo-500 bg-indigo-50 scale-[0.98]'
70
+ : 'border-slate-200 bg-slate-50 hover:border-slate-300 hover:bg-slate-100/50'
71
+ }`}
72
+ >
73
+ {isUploading ? (
74
+ <div className="flex flex-col items-center text-center">
75
+ <div className="relative">
76
+ <Loader2 className="w-16 h-16 text-indigo-600 animate-spin mb-6" />
77
+ <div className="absolute inset-0 flex items-center justify-center">
78
+ <FileText className="w-6 h-6 text-indigo-400" />
79
+ </div>
80
+ </div>
81
+ <h3 className="text-lg font-black text-slate-900">Analyse de l'Excel...</h3>
82
+ <p className="text-sm text-slate-500 mt-2">Nous normalisons les numéros et identifions les colonnes.</p>
83
+ </div>
84
+ ) : (
85
+ <div className="flex flex-col items-center text-center">
86
+ <div className="w-20 h-20 bg-white rounded-3xl shadow-xl flex items-center justify-center mb-6 group-hover:scale-110 transition-transform">
87
+ <Upload className={`w-10 h-10 ${isDragging ? 'text-indigo-600' : 'text-slate-400'}`} />
88
+ </div>
89
+ <h3 className="text-lg font-black text-slate-900">Glissez-déposez ici</h3>
90
+ <p className="text-sm text-slate-400 mt-2 mb-8 font-medium">Fichiers .xlsx, .xls ou .csv</p>
91
+
92
+ <label className="bg-slate-900 text-white px-8 py-4 rounded-2xl font-bold text-sm cursor-pointer hover:bg-slate-800 transition shadow-xl shadow-slate-200 active:scale-95">
93
+ Parcourir mes fichiers
94
+ <input
95
+ type="file"
96
+ className="hidden"
97
+ accept=".xlsx,.xls,.csv"
98
+ onChange={(e) => e.target.files?.[0] && onFileUpload(e.target.files[0])}
99
+ />
100
+ </label>
101
+ </div>
102
+ )}
103
+ </div>
104
+
105
+ <div className="mt-8 p-6 bg-indigo-50 rounded-3xl border border-indigo-100">
106
+ <h4 className="text-[10px] font-black text-indigo-400 uppercase tracking-widest mb-3">Conseil IA</h4>
107
+ <p className="text-xs text-indigo-900 font-medium leading-relaxed">
108
+ Assurez-vous que votre fichier contient une colonne "Téléphone" ou "Mobile". L'IA s'occupe de mapper le reste !
109
+ </p>
110
+ </div>
111
+ </div>
112
+ </div>
113
+
114
+ {/* Main View Area */}
115
+ <div className="flex-1 flex flex-col relative bg-slate-50 overflow-hidden">
116
+ <div className="flex-1 overflow-y-auto p-8 space-y-6 scrollbar-hide">
117
+ {messages.map((msg) => (
118
+ <motion.div
119
+ key={msg.id}
120
+ initial={{ opacity: 0, y: 10 }}
121
+ animate={{ opacity: 1, y: 0 }}
122
+ className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
123
+ >
124
+ <div className={`flex gap-4 max-w-[80%] ${msg.role === 'user' ? 'flex-row-reverse' : ''}`}>
125
+ <div className={`w-10 h-10 rounded-2xl flex items-center justify-center shadow-sm flex-shrink-0 ${
126
+ msg.role === 'user' ? 'bg-slate-900 text-white' : 'bg-indigo-600 text-white'
127
+ }`}>
128
+ {msg.role === 'user' ? <User className="w-5 h-5" /> : <Bot className="w-6 h-6" />}
129
+ </div>
130
+ <div className="flex flex-col items-start gap-1">
131
+ <div className={`p-5 rounded-[1.8rem] text-sm font-medium leading-relaxed shadow-sm ${
132
+ msg.role === 'user'
133
+ ? 'bg-indigo-600 text-white rounded-tr-none'
134
+ : 'bg-white border border-slate-200 text-slate-800 rounded-tl-none'
135
+ }`}>
136
+ {msg.content}
137
+ </div>
138
+ {msg.role === 'assistant' && msg.id !== '1' && !isGenerating && messages[messages.length - 1].id === msg.id && (
139
+ <motion.button
140
+ initial={{ opacity: 0, scale: 0.9 }}
141
+ animate={{ opacity: 1, scale: 1 }}
142
+ onClick={() => onValidateAndSend(msg.content)}
143
+ className="mt-3 bg-emerald-500 text-white px-6 py-3 rounded-2xl font-black text-xs shadow-lg shadow-emerald-100 hover:bg-emerald-600 hover:scale-105 active:scale-95 transition-all flex items-center gap-2"
144
+ >
145
+ <CheckCircle2 className="w-4 h-4" /> Valider et Envoyer à la liste
146
+ </motion.button>
147
+ )}
148
+ </div>
149
+ </div>
150
+ </motion.div>
151
+ ))}
152
+ {isGenerating && (
153
+ <motion.div
154
+ initial={{ opacity: 0, y: 10 }}
155
+ animate={{ opacity: 1, y: 0 }}
156
+ className="flex justify-start"
157
+ >
158
+ <div className="flex gap-4 max-w-[80%]">
159
+ <div className="w-10 h-10 rounded-2xl flex items-center justify-center bg-indigo-600 text-white shadow-sm flex-shrink-0 animate-pulse">
160
+ <Bot className="w-6 h-6" />
161
+ </div>
162
+ <div className="bg-white border border-slate-200 p-5 rounded-[1.8rem] rounded-tl-none flex items-center gap-3 text-slate-400 font-bold text-xs animate-pulse">
163
+ <Loader2 className="w-4 h-4 animate-spin" /> L'IA rédige votre campagne...
164
+ </div>
165
+ </div>
166
+ </motion.div>
167
+ )}
168
+ <div ref={messagesEndRef} />
169
+ </div>
170
+
171
+ {/* Chat Input */}
172
+ <div className="p-8 bg-gradient-to-t from-slate-50 via-slate-50 to-transparent">
173
+ <form
174
+ onSubmit={onSendMessage}
175
+ className="max-w-3xl mx-auto relative group"
176
+ >
177
+ <div className="absolute inset-0 bg-indigo-500 blur-3xl opacity-0 group-focus-within:opacity-10 transition-opacity duration-500" />
178
+ <div className="relative bg-white border border-slate-200 rounded-[2.2rem] p-2 flex items-center shadow-2xl focus-within:border-indigo-500 transition-all duration-300">
179
+ <input
180
+ className="flex-1 bg-transparent border-none focus:ring-0 px-6 py-4 font-medium text-slate-900 placeholder:text-slate-400"
181
+ placeholder="Posez une question ou donnez un ordre à l'IA..."
182
+ value={input}
183
+ onChange={(e) => setInput(e.target.value)}
184
+ />
185
+ <button
186
+ type="submit"
187
+ className="w-14 h-14 bg-indigo-600 text-white rounded-[1.8rem] flex items-center justify-center hover:bg-indigo-700 hover:rotate-6 transition-all shadow-xl shadow-indigo-100 disabled:opacity-50 disabled:rotate-0"
188
+ disabled={!input.trim()}
189
+ >
190
+ <Send className="w-6 h-6" />
191
+ </button>
192
+ </div>
193
+ </form>
194
+ </div>
195
+ </div>
196
+ </div>
197
+ );
198
+ }
apps/admin/src/components/crm/CrmInbox.tsx ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Bot, Loader2, Send } from 'lucide-react';
2
+ import { motion } from 'framer-motion';
3
+
4
+ interface CrmInboxProps {
5
+ inboxMessages: any[];
6
+ isLoadingInbox: boolean;
7
+ selectedContactId: string | null;
8
+ setSelectedContactId: (id: string | null) => void;
9
+ replyInput: string;
10
+ setReplyInput: (val: string) => void;
11
+ isSendingReply: boolean;
12
+ onFetchInbox: () => void;
13
+ onReply: (e: React.FormEvent) => void;
14
+ }
15
+
16
+ export default function CrmInbox({
17
+ inboxMessages,
18
+ isLoadingInbox,
19
+ selectedContactId,
20
+ setSelectedContactId,
21
+ replyInput,
22
+ setReplyInput,
23
+ isSendingReply,
24
+ onFetchInbox,
25
+ onReply
26
+ }: CrmInboxProps) {
27
+ return (
28
+ <div className="flex-1 overflow-y-auto p-8 bg-slate-50 h-full">
29
+ <div className="max-w-5xl mx-auto">
30
+ <div className="flex items-center justify-between mb-8">
31
+ <h2 className="text-2xl font-black text-slate-900">Conversations Récentes</h2>
32
+ <button
33
+ onClick={onFetchInbox}
34
+ className="p-2 hover:bg-slate-200 rounded-xl transition-all"
35
+ >
36
+ <Loader2 className={`w-5 h-5 text-slate-400 ${isLoadingInbox ? 'animate-spin' : ''}`} />
37
+ </button>
38
+ </div>
39
+
40
+ {inboxMessages.length === 0 && !isLoadingInbox ? (
41
+ <div className="bg-white rounded-[2rem] border border-slate-200 p-20 flex flex-col items-center text-center shadow-sm">
42
+ <div className="w-20 h-20 bg-slate-50 rounded-3xl flex items-center justify-center mb-6">
43
+ <Bot className="w-10 h-10 text-slate-200" />
44
+ </div>
45
+ <h3 className="text-lg font-black text-slate-900 mb-2">Aucun message pour le moment</h3>
46
+ <p className="text-sm text-slate-400 max-w-xs mx-auto font-medium">
47
+ Les réponses de vos clients apparaîtront ici dès qu'ils interagiront avec vos campagnes.
48
+ </p>
49
+ </div>
50
+ ) : (
51
+ <div className="flex flex-col h-full relative">
52
+ <div className="flex-1 space-y-4 pb-32">
53
+ {inboxMessages.map((msg: any) => (
54
+ <motion.div
55
+ key={msg.id}
56
+ initial={{ opacity: 0, x: -10 }}
57
+ animate={{ opacity: 1, x: 0 }}
58
+ onClick={() => setSelectedContactId(msg.contactId)}
59
+ className={`p-6 rounded-3xl border transition-all cursor-pointer ${
60
+ selectedContactId === msg.contactId
61
+ ? 'bg-indigo-50 border-indigo-200 shadow-md ring-2 ring-indigo-500/10'
62
+ : 'bg-white border-slate-200 shadow-sm hover:shadow-md'
63
+ } flex items-center justify-between group`}
64
+ >
65
+ <div className="flex items-center gap-5">
66
+ <div className={`w-12 h-12 rounded-2xl flex items-center justify-center font-black text-white ${
67
+ msg.direction === 'INBOUND' ? 'bg-indigo-600' : 'bg-slate-400'
68
+ }`}>
69
+ {msg.contact?.name?.[0] || msg.contact?.phoneNumber?.slice(-1)}
70
+ </div>
71
+ <div>
72
+ <div className="flex items-center gap-2 mb-1">
73
+ <span className="font-black text-slate-900">{msg.contact?.name || msg.contact?.phoneNumber}</span>
74
+ <span className={`text-[10px] px-2 py-0.5 rounded-full font-black ${
75
+ msg.direction === 'INBOUND' ? 'bg-indigo-50 text-indigo-600' : 'bg-slate-50 text-slate-400'
76
+ }`}>
77
+ {msg.direction === 'INBOUND' ? 'REÇU' : 'ENVOYÉ'}
78
+ </span>
79
+ </div>
80
+ <p className="text-sm text-slate-500 font-medium line-clamp-1">{msg.content}</p>
81
+ </div>
82
+ </div>
83
+ <div className="text-right">
84
+ <p className="text-[10px] font-black text-slate-400 uppercase">
85
+ {new Date(msg.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
86
+ </p>
87
+ <p className="text-[10px] font-bold text-slate-300">
88
+ {new Date(msg.createdAt).toLocaleDateString()}
89
+ </p>
90
+ </div>
91
+ </motion.div>
92
+ ))}
93
+ </div>
94
+
95
+ {/* Reply Floating Bar */}
96
+ {selectedContactId && (
97
+ <div className="fixed bottom-8 right-8 left-[35%] lg:left-[40%] z-10 p-4">
98
+ <form
99
+ onSubmit={onReply}
100
+ className="max-w-3xl mx-auto relative group"
101
+ >
102
+ <div className="absolute inset-0 bg-indigo-500 blur-3xl opacity-5 group-focus-within:opacity-20 transition-opacity duration-500" />
103
+ <div className="relative bg-white border-2 border-indigo-500 rounded-[2.2rem] p-2 flex items-center shadow-2xl transition-all duration-300">
104
+ <input
105
+ className="flex-1 bg-transparent border-none focus:ring-0 px-6 py-4 font-bold text-slate-900 placeholder:text-slate-400"
106
+ placeholder={`Répondre à ${inboxMessages.find(m => m.contactId === selectedContactId)?.contact?.name || 'ce contact'}...`}
107
+ value={replyInput}
108
+ onChange={(e) => setReplyInput(e.target.value)}
109
+ />
110
+ <button
111
+ type="submit"
112
+ disabled={!replyInput.trim() || isSendingReply}
113
+ className="w-14 h-14 bg-indigo-600 text-white rounded-[1.8rem] flex items-center justify-center hover:bg-indigo-700 hover:rotate-6 transition-all shadow-xl shadow-indigo-100 disabled:opacity-50"
114
+ >
115
+ {isSendingReply ? <Loader2 className="w-6 h-6 animate-spin" /> : <Send className="w-6 h-6" />}
116
+ </button>
117
+ </div>
118
+ </form>
119
+ </div>
120
+ )}
121
+ </div>
122
+ )}
123
+ </div>
124
+ </div>
125
+ );
126
+ }
apps/admin/src/components/crm/CrmStatsHeader.tsx ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Sparkles, CheckCircle2, X } from 'lucide-react';
2
+ import { motion, AnimatePresence } from 'framer-motion';
3
+
4
+ interface CrmStatsHeaderProps {
5
+ view: 'assistant' | 'inbox';
6
+ setView: (view: 'assistant' | 'inbox') => void;
7
+ uploadedFile: { name: string, listName: string } | null;
8
+ onClearFile: () => void;
9
+ }
10
+
11
+ export default function CrmStatsHeader({ view, setView, uploadedFile, onClearFile }: CrmStatsHeaderProps) {
12
+ return (
13
+ <div className="bg-white border-b border-slate-200 px-8 py-4 flex items-center justify-between shadow-sm z-10">
14
+ <div className="flex items-center gap-3">
15
+ <div className="w-10 h-10 bg-indigo-600 rounded-xl flex items-center justify-center text-white shadow-lg shadow-indigo-100">
16
+ <Sparkles className="w-6 h-6" />
17
+ </div>
18
+ <div>
19
+ <h1 className="text-lg font-black text-slate-900 tracking-tight">Assistant CRM Intelligent</h1>
20
+ <p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">Workspace WhatsApp Business</p>
21
+ </div>
22
+ </div>
23
+
24
+ <div className="flex items-center gap-4">
25
+ <div className="flex bg-slate-100 p-1 rounded-2xl">
26
+ <button
27
+ onClick={() => setView('assistant')}
28
+ className={`px-4 py-2 rounded-xl text-xs font-black transition-all ${view === 'assistant' ? 'bg-white shadow-sm text-indigo-600' : 'text-slate-400'}`}
29
+ >
30
+ Assistant IA
31
+ </button>
32
+ <button
33
+ onClick={() => setView('inbox')}
34
+ className={`px-4 py-2 rounded-xl text-xs font-black transition-all ${view === 'inbox' ? 'bg-white shadow-sm text-indigo-600' : 'text-slate-400'}`}
35
+ >
36
+ Boîte de réception
37
+ </button>
38
+ </div>
39
+
40
+ <AnimatePresence>
41
+ {uploadedFile && (
42
+ <motion.div
43
+ initial={{ opacity: 0, x: 20 }}
44
+ animate={{ opacity: 1, x: 0 }}
45
+ exit={{ opacity: 0, x: 20 }}
46
+ className="flex items-center gap-3 bg-emerald-50 border border-emerald-100 px-4 py-2 rounded-xl"
47
+ >
48
+ <CheckCircle2 className="w-4 h-4 text-emerald-500" />
49
+ <span className="text-xs font-bold text-emerald-700">Liste active : {uploadedFile.listName}</span>
50
+ <button onClick={onClearFile} className="p-1 hover:bg-emerald-100 rounded-full transition">
51
+ <X className="w-3 h-3 text-emerald-400" />
52
+ </button>
53
+ </motion.div>
54
+ )}
55
+ </AnimatePresence>
56
+ </div>
57
+ </div>
58
+ );
59
+ }
apps/admin/src/pages/CrmConversationalDashboard.tsx CHANGED
@@ -1,8 +1,9 @@
1
- import { useState, useRef, useEffect } from 'react';
2
- import { Upload, Send, Bot, User, Loader2, FileText, CheckCircle2, X, Sparkles } from 'lucide-react';
3
- import { motion, AnimatePresence } from 'framer-motion';
4
  import { useAuth } from '../lib/auth';
5
  import { useTenant } from '../lib/tenant';
 
 
 
6
 
7
  interface Message {
8
  id: string;
@@ -33,15 +34,6 @@ export default function CrmConversationalDashboard() {
33
  const [isGenerating, setIsGenerating] = useState(false);
34
  const [isDragging, setIsDragging] = useState(false);
35
  const [uploadedFile, setUploadedFile] = useState<{ name: string, listId: string, listName: string } | null>(null);
36
- const messagesEndRef = useRef<HTMLDivElement>(null);
37
-
38
- const scrollToBottom = () => {
39
- messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
40
- };
41
-
42
- useEffect(() => {
43
- scrollToBottom();
44
- }, [messages]);
45
 
46
  const fetchInbox = async () => {
47
  if (!token || !selectedOrgId) return;
@@ -103,26 +95,6 @@ export default function CrmConversationalDashboard() {
103
  }
104
  };
105
 
106
- const handleDragOver = (e: React.DragEvent) => {
107
- e.preventDefault();
108
- setIsDragging(true);
109
- };
110
-
111
- const handleDragLeave = () => {
112
- setIsDragging(false);
113
- };
114
-
115
- const handleDrop = (e: React.DragEvent) => {
116
- e.preventDefault();
117
- setIsDragging(false);
118
- const file = e.dataTransfer.files[0];
119
- if (file && (file.name.endsWith('.xlsx') || file.name.endsWith('.csv'))) {
120
- handleFileUpload(file);
121
- } else {
122
- alert("Veuillez déposer un fichier Excel (.xlsx) ou CSV.");
123
- }
124
- };
125
-
126
  const handleSendMessage = async (e?: React.FormEvent) => {
127
  e?.preventDefault();
128
  if (!input.trim() || !token || !selectedOrgId) return;
@@ -197,7 +169,6 @@ export default function CrmConversationalDashboard() {
197
 
198
  if (res.ok) {
199
  alert("🚀 Votre campagne est en cours de distribution !");
200
- // Reset state for a new campaign
201
  setUploadedFile(null);
202
  setMessages([
203
  {
@@ -248,300 +219,51 @@ export default function CrmConversationalDashboard() {
248
 
249
  return (
250
  <div className="flex flex-col h-[calc(100vh-64px)] bg-slate-50">
251
- {/* Header / Stats Bar */}
252
- <div className="bg-white border-b border-slate-200 px-8 py-4 flex items-center justify-between shadow-sm z-10">
253
- <div className="flex items-center gap-3">
254
- <div className="w-10 h-10 bg-indigo-600 rounded-xl flex items-center justify-center text-white shadow-lg shadow-indigo-100">
255
- <Sparkles className="w-6 h-6" />
256
- </div>
257
- <div>
258
- <h1 className="text-lg font-black text-slate-900 tracking-tight">Assistant CRM Intelligent</h1>
259
- <p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">Workspace WhatsApp Business</p>
260
- </div>
261
- </div>
262
-
263
- <div className="flex items-center gap-4">
264
- <div className="flex bg-slate-100 p-1 rounded-2xl">
265
- <button
266
- onClick={() => setView('assistant')}
267
- className={`px-4 py-2 rounded-xl text-xs font-black transition-all ${view === 'assistant' ? 'bg-white shadow-sm text-indigo-600' : 'text-slate-400'}`}
268
- >
269
- Assistant IA
270
- </button>
271
- <button
272
- onClick={() => setView('inbox')}
273
- className={`px-4 py-2 rounded-xl text-xs font-black transition-all ${view === 'inbox' ? 'bg-white shadow-sm text-indigo-600' : 'text-slate-400'}`}
274
- >
275
- Boîte de réception
276
- </button>
277
- </div>
278
-
279
- <AnimatePresence>
280
- {uploadedFile && (
281
- <motion.div
282
- initial={{ opacity: 0, x: 20 }}
283
- animate={{ opacity: 1, x: 0 }}
284
- className="flex items-center gap-3 bg-emerald-50 border border-emerald-100 px-4 py-2 rounded-xl"
285
- >
286
- <CheckCircle2 className="w-4 h-4 text-emerald-500" />
287
- <span className="text-xs font-bold text-emerald-700">Liste active : {uploadedFile.listName}</span>
288
- <button onClick={() => setUploadedFile(null)} className="p-1 hover:bg-emerald-100 rounded-full transition">
289
- <X className="w-3 h-3 text-emerald-400" />
290
- </button>
291
- </motion.div>
292
- )}
293
- </AnimatePresence>
294
- </div>
295
- </div>
296
 
297
  <div className="flex-1 flex overflow-hidden">
298
- {/* Left Side: Context / Stats (Only show in Assistant mode) */}
299
- {view === 'assistant' && (
300
- <div className="w-1/3 p-8 border-r border-slate-200 bg-white/50 backdrop-blur-sm hidden lg:block">
301
- <div className="h-full flex flex-col">
302
- <div className="mb-8">
303
- <h2 className="text-xl font-black text-slate-900 mb-2">Source de Données</h2>
304
- <p className="text-sm text-slate-500 font-medium leading-relaxed">
305
- Déposez votre fichier Excel pour créer une nouvelle liste de diffusion instantanément.
306
- </p>
307
- </div>
308
-
309
- <div
310
- onDragOver={handleDragOver}
311
- onDragLeave={handleDragLeave}
312
- onDrop={handleDrop}
313
- className={`flex-1 border-4 border-dashed rounded-[2.5rem] flex flex-col items-center justify-center p-10 transition-all duration-300 ${
314
- isDragging
315
- ? 'border-indigo-500 bg-indigo-50 scale-[0.98]'
316
- : 'border-slate-200 bg-slate-50 hover:border-slate-300 hover:bg-slate-100/50'
317
- }`}
318
- >
319
- {isUploading ? (
320
- <div className="flex flex-col items-center text-center">
321
- <div className="relative">
322
- <Loader2 className="w-16 h-16 text-indigo-600 animate-spin mb-6" />
323
- <div className="absolute inset-0 flex items-center justify-center">
324
- <FileText className="w-6 h-6 text-indigo-400" />
325
- </div>
326
- </div>
327
- <h3 className="text-lg font-black text-slate-900">Analyse de l'Excel...</h3>
328
- <p className="text-sm text-slate-500 mt-2">Nous normalisons les numéros et identifions les colonnes.</p>
329
- </div>
330
- ) : (
331
- <div className="flex flex-col items-center text-center">
332
- <div className="w-20 h-20 bg-white rounded-3xl shadow-xl flex items-center justify-center mb-6 group-hover:scale-110 transition-transform">
333
- <Upload className={`w-10 h-10 ${isDragging ? 'text-indigo-600' : 'text-slate-400'}`} />
334
- </div>
335
- <h3 className="text-lg font-black text-slate-900">Glissez-déposez ici</h3>
336
- <p className="text-sm text-slate-400 mt-2 mb-8 font-medium">Fichiers .xlsx, .xls ou .csv</p>
337
-
338
- <label className="bg-slate-900 text-white px-8 py-4 rounded-2xl font-bold text-sm cursor-pointer hover:bg-slate-800 transition shadow-xl shadow-slate-200 active:scale-95">
339
- Parcourir mes fichiers
340
- <input
341
- type="file"
342
- className="hidden"
343
- accept=".xlsx,.xls,.csv"
344
- onChange={(e) => e.target.files?.[0] && handleFileUpload(e.target.files[0])}
345
- />
346
- </label>
347
- </div>
348
- )}
349
- </div>
350
-
351
- <div className="mt-8 p-6 bg-indigo-50 rounded-3xl border border-indigo-100">
352
- <h4 className="text-[10px] font-black text-indigo-400 uppercase tracking-widest mb-3">Conseil IA</h4>
353
- <p className="text-xs text-indigo-900 font-medium leading-relaxed">
354
- Assurez-vous que votre fichier contient une colonne "Téléphone" ou "Mobile". L'IA s'occupe de mapper le reste !
355
- </p>
356
- </div>
357
- </div>
358
- </div>
359
  )}
360
-
361
- {/* Main View Area */}
362
- <div className="flex-1 flex flex-col relative bg-slate-50">
363
- {view === 'assistant' ? (
364
- <>
365
- <div className="flex-1 overflow-y-auto p-8 space-y-6 scrollbar-hide">
366
- {messages.map((msg) => (
367
- <motion.div
368
- key={msg.id}
369
- initial={{ opacity: 0, y: 10 }}
370
- animate={{ opacity: 1, y: 0 }}
371
- className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
372
- >
373
- <div className={`flex gap-4 max-w-[80%] ${msg.role === 'user' ? 'flex-row-reverse' : ''}`}>
374
- <div className={`w-10 h-10 rounded-2xl flex items-center justify-center shadow-sm flex-shrink-0 ${
375
- msg.role === 'user' ? 'bg-slate-900 text-white' : 'bg-indigo-600 text-white'
376
- }`}>
377
- {msg.role === 'user' ? <User className="w-5 h-5" /> : <Bot className="w-6 h-6" />}
378
- </div>
379
- <div className="flex flex-col items-start gap-1">
380
- <div className={`p-5 rounded-[1.8rem] text-sm font-medium leading-relaxed shadow-sm ${
381
- msg.role === 'user'
382
- ? 'bg-indigo-600 text-white rounded-tr-none'
383
- : 'bg-white border border-slate-200 text-slate-800 rounded-tl-none'
384
- }`}>
385
- {msg.content}
386
- </div>
387
- {msg.role === 'assistant' && msg.id !== '1' && !isGenerating && messages[messages.length - 1].id === msg.id && (
388
- <motion.button
389
- initial={{ opacity: 0, scale: 0.9 }}
390
- animate={{ opacity: 1, scale: 1 }}
391
- onClick={() => handleValidateAndSend(msg.content)}
392
- className="mt-3 bg-emerald-500 text-white px-6 py-3 rounded-2xl font-black text-xs shadow-lg shadow-emerald-100 hover:bg-emerald-600 hover:scale-105 active:scale-95 transition-all flex items-center gap-2"
393
- >
394
- <CheckCircle2 className="w-4 h-4" /> Valider et Envoyer à la liste
395
- </motion.button>
396
- )}
397
- </div>
398
- </div>
399
- </motion.div>
400
- ))}
401
- {isGenerating && (
402
- <motion.div
403
- initial={{ opacity: 0, y: 10 }}
404
- animate={{ opacity: 1, y: 0 }}
405
- className="flex justify-start"
406
- >
407
- <div className="flex gap-4 max-w-[80%]">
408
- <div className="w-10 h-10 rounded-2xl flex items-center justify-center bg-indigo-600 text-white shadow-sm flex-shrink-0 animate-pulse">
409
- <Bot className="w-6 h-6" />
410
- </div>
411
- <div className="bg-white border border-slate-200 p-5 rounded-[1.8rem] rounded-tl-none flex items-center gap-3 text-slate-400 font-bold text-xs animate-pulse">
412
- <Loader2 className="w-4 h-4 animate-spin" /> L'IA rédige votre campagne...
413
- </div>
414
- </div>
415
- </motion.div>
416
- )}
417
- <div ref={messagesEndRef} />
418
- </div>
419
-
420
- {/* Chat Input */}
421
- <div className="p-8 bg-gradient-to-t from-slate-50 via-slate-50 to-transparent">
422
- <form
423
- onSubmit={handleSendMessage}
424
- className="max-w-3xl mx-auto relative group"
425
- >
426
- <div className="absolute inset-0 bg-indigo-500 blur-3xl opacity-0 group-focus-within:opacity-10 transition-opacity duration-500" />
427
- <div className="relative bg-white border border-slate-200 rounded-[2.2rem] p-2 flex items-center shadow-2xl focus-within:border-indigo-500 transition-all duration-300">
428
- <input
429
- className="flex-1 bg-transparent border-none focus:ring-0 px-6 py-4 font-medium text-slate-900 placeholder:text-slate-400"
430
- placeholder="Posez une question ou donnez un ordre à l'IA..."
431
- value={input}
432
- onChange={(e) => setInput(e.target.value)}
433
- />
434
- <button
435
- type="submit"
436
- className="w-14 h-14 bg-indigo-600 text-white rounded-[1.8rem] flex items-center justify-center hover:bg-indigo-700 hover:rotate-6 transition-all shadow-xl shadow-indigo-100 disabled:opacity-50 disabled:rotate-0"
437
- disabled={!input.trim()}
438
- >
439
- <Send className="w-6 h-6" />
440
- </button>
441
- </div>
442
- </form>
443
- </div>
444
- </>
445
- ) : (
446
- <div className="flex-1 overflow-y-auto p-8">
447
- <div className="max-w-5xl mx-auto">
448
- <div className="flex items-center justify-between mb-8">
449
- <h2 className="text-2xl font-black text-slate-900">Conversations Récentes</h2>
450
- <button
451
- onClick={fetchInbox}
452
- className="p-2 hover:bg-slate-200 rounded-xl transition-all"
453
- >
454
- <Loader2 className={`w-5 h-5 text-slate-400 ${isLoadingInbox ? 'animate-spin' : ''}`} />
455
- </button>
456
- </div>
457
-
458
- {inboxMessages.length === 0 && !isLoadingInbox ? (
459
- <div className="bg-white rounded-[2rem] border border-slate-200 p-20 flex flex-col items-center text-center shadow-sm">
460
- <div className="w-20 h-20 bg-slate-50 rounded-3xl flex items-center justify-center mb-6">
461
- <Bot className="w-10 h-10 text-slate-200" />
462
- </div>
463
- <h3 className="text-lg font-black text-slate-900 mb-2">Aucun message pour le moment</h3>
464
- <p className="text-sm text-slate-400 max-w-xs mx-auto font-medium">
465
- Les réponses de vos clients apparaîtront ici dès qu'ils interagiront avec vos campagnes.
466
- </p>
467
- </div>
468
- ) : (
469
- <div className="flex flex-col h-full">
470
- <div className="flex-1 space-y-4 pb-20">
471
- {inboxMessages.map((msg: any) => (
472
- <motion.div
473
- key={msg.id}
474
- initial={{ opacity: 0, x: -10 }}
475
- animate={{ opacity: 1, x: 0 }}
476
- onClick={() => setSelectedContactId(msg.contactId)}
477
- className={`p-6 rounded-3xl border transition-all cursor-pointer ${
478
- selectedContactId === msg.contactId
479
- ? 'bg-indigo-50 border-indigo-200 shadow-md ring-2 ring-indigo-500/10'
480
- : 'bg-white border-slate-200 shadow-sm hover:shadow-md'
481
- } flex items-center justify-between group`}
482
- >
483
- <div className="flex items-center gap-5">
484
- <div className={`w-12 h-12 rounded-2xl flex items-center justify-center font-black text-white ${
485
- msg.direction === 'INBOUND' ? 'bg-indigo-600' : 'bg-slate-400'
486
- }`}>
487
- {msg.contact?.name?.[0] || msg.contact?.phoneNumber?.slice(-1)}
488
- </div>
489
- <div>
490
- <div className="flex items-center gap-2 mb-1">
491
- <span className="font-black text-slate-900">{msg.contact?.name || msg.contact?.phoneNumber}</span>
492
- <span className={`text-[10px] px-2 py-0.5 rounded-full font-black ${
493
- msg.direction === 'INBOUND' ? 'bg-indigo-50 text-indigo-600' : 'bg-slate-50 text-slate-400'
494
- }`}>
495
- {msg.direction === 'INBOUND' ? 'REÇU' : 'ENVOYÉ'}
496
- </span>
497
- </div>
498
- <p className="text-sm text-slate-500 font-medium line-clamp-1">{msg.content}</p>
499
- </div>
500
- </div>
501
- <div className="text-right">
502
- <p className="text-[10px] font-black text-slate-400 uppercase">
503
- {new Date(msg.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
504
- </p>
505
- <p className="text-[10px] font-bold text-slate-300">
506
- {new Date(msg.createdAt).toLocaleDateString()}
507
- </p>
508
- </div>
509
- </motion.div>
510
- ))}
511
- </div>
512
-
513
- {/* Reply Floating Bar */}
514
- {selectedContactId && (
515
- <div className="fixed bottom-8 right-8 left-[35%] lg:left-[40%] z-10 p-4">
516
- <form
517
- onSubmit={handleReply}
518
- className="max-w-3xl mx-auto relative group"
519
- >
520
- <div className="absolute inset-0 bg-indigo-500 blur-3xl opacity-5 group-focus-within:opacity-20 transition-opacity duration-500" />
521
- <div className="relative bg-white border-2 border-indigo-500 rounded-[2.2rem] p-2 flex items-center shadow-2xl transition-all duration-300">
522
- <input
523
- className="flex-1 bg-transparent border-none focus:ring-0 px-6 py-4 font-bold text-slate-900 placeholder:text-slate-400"
524
- placeholder={`Répondre à ${inboxMessages.find(m => m.contactId === selectedContactId)?.contact?.name || 'ce contact'}...`}
525
- value={replyInput}
526
- onChange={(e) => setReplyInput(e.target.value)}
527
- />
528
- <button
529
- type="submit"
530
- disabled={!replyInput.trim() || isSendingReply}
531
- className="w-14 h-14 bg-indigo-600 text-white rounded-[1.8rem] flex items-center justify-center hover:bg-indigo-700 hover:rotate-6 transition-all shadow-xl shadow-indigo-100 disabled:opacity-50"
532
- >
533
- {isSendingReply ? <Loader2 className="w-6 h-6 animate-spin" /> : <Send className="w-6 h-6" />}
534
- </button>
535
- </div>
536
- </form>
537
- </div>
538
- )}
539
- </div>
540
- )}
541
- </div>
542
- </div>
543
- )}
544
- </div>
545
  </div>
546
  </div>
547
  );
 
1
+ import { useState, useEffect } from 'react';
 
 
2
  import { useAuth } from '../lib/auth';
3
  import { useTenant } from '../lib/tenant';
4
+ import CrmStatsHeader from '../components/crm/CrmStatsHeader';
5
+ import CrmAIAssistant from '../components/crm/CrmAIAssistant';
6
+ import CrmInbox from '../components/crm/CrmInbox';
7
 
8
  interface Message {
9
  id: string;
 
34
  const [isGenerating, setIsGenerating] = useState(false);
35
  const [isDragging, setIsDragging] = useState(false);
36
  const [uploadedFile, setUploadedFile] = useState<{ name: string, listId: string, listName: string } | null>(null);
 
 
 
 
 
 
 
 
 
37
 
38
  const fetchInbox = async () => {
39
  if (!token || !selectedOrgId) return;
 
95
  }
96
  };
97
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98
  const handleSendMessage = async (e?: React.FormEvent) => {
99
  e?.preventDefault();
100
  if (!input.trim() || !token || !selectedOrgId) return;
 
169
 
170
  if (res.ok) {
171
  alert("🚀 Votre campagne est en cours de distribution !");
 
172
  setUploadedFile(null);
173
  setMessages([
174
  {
 
219
 
220
  return (
221
  <div className="flex flex-col h-[calc(100vh-64px)] bg-slate-50">
222
+ <CrmStatsHeader
223
+ view={view}
224
+ setView={setView}
225
+ uploadedFile={uploadedFile}
226
+ onClearFile={() => setUploadedFile(null)}
227
+ />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
228
 
229
  <div className="flex-1 flex overflow-hidden">
230
+ {view === 'assistant' ? (
231
+ <CrmAIAssistant
232
+ messages={messages}
233
+ input={input}
234
+ setInput={setInput}
235
+ isUploading={isUploading}
236
+ isGenerating={isGenerating}
237
+ isDragging={isDragging}
238
+ onFileUpload={handleFileUpload}
239
+ onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }}
240
+ onDragLeave={() => setIsDragging(false)}
241
+ onDrop={(e) => {
242
+ e.preventDefault();
243
+ setIsDragging(false);
244
+ const file = e.dataTransfer.files[0];
245
+ if (file && (file.name.endsWith('.xlsx') || file.name.endsWith('.csv'))) {
246
+ handleFileUpload(file);
247
+ } else {
248
+ alert("Veuillez déposer un fichier Excel (.xlsx) ou CSV.");
249
+ }
250
+ }}
251
+ onSendMessage={handleSendMessage}
252
+ onValidateAndSend={handleValidateAndSend}
253
+ />
254
+ ) : (
255
+ <CrmInbox
256
+ inboxMessages={inboxMessages}
257
+ isLoadingInbox={isLoadingInbox}
258
+ selectedContactId={selectedContactId}
259
+ setSelectedContactId={setSelectedContactId}
260
+ replyInput={replyInput}
261
+ setReplyInput={setReplyInput}
262
+ isSendingReply={isSendingReply}
263
+ onFetchInbox={fetchInbox}
264
+ onReply={handleReply}
265
+ />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
266
  )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
267
  </div>
268
  </div>
269
  );
apps/api/src/routes/organizations.ts CHANGED
@@ -275,7 +275,7 @@ export async function organizationRoutes(fastify: FastifyInstance) {
275
  logger.info(`[CRM-IMPORT] Creating list "${listName}" with ${rows.length} potential contacts for Org: ${organizationId}`);
276
 
277
  // 1. Create the Broadcast List
278
- const broadcastList = await (prisma as any).broadcastList.create({
279
  data: {
280
  name: listName,
281
  organizationId
@@ -313,7 +313,7 @@ export async function organizationRoutes(fastify: FastifyInstance) {
313
  if (nameKey) delete attributes[nameKey];
314
 
315
  // Upsert contact and connect to list
316
- await (prisma as any).contact.upsert({
317
  where: {
318
  phoneNumber_organizationId: { phoneNumber, organizationId }
319
  },
@@ -368,7 +368,7 @@ export async function organizationRoutes(fastify: FastifyInstance) {
368
  // 11. CRM: List Messages (Inbox)
369
  fastify.get('/:id/messages', async (req) => {
370
  const { id: organizationId } = req.params as { id: string };
371
- const messages = await (prisma.message as any).findMany({
372
  where: { organizationId },
373
  include: { contact: true },
374
  orderBy: { createdAt: 'desc' },
@@ -411,7 +411,7 @@ export async function organizationRoutes(fastify: FastifyInstance) {
411
 
412
  try {
413
  // 1. Create message record
414
- const newMessage = await (prisma.message as any).create({
415
  data: {
416
  organizationId,
417
  contactId,
 
275
  logger.info(`[CRM-IMPORT] Creating list "${listName}" with ${rows.length} potential contacts for Org: ${organizationId}`);
276
 
277
  // 1. Create the Broadcast List
278
+ const broadcastList = await prisma.broadcastList.create({
279
  data: {
280
  name: listName,
281
  organizationId
 
313
  if (nameKey) delete attributes[nameKey];
314
 
315
  // Upsert contact and connect to list
316
+ await prisma.contact.upsert({
317
  where: {
318
  phoneNumber_organizationId: { phoneNumber, organizationId }
319
  },
 
368
  // 11. CRM: List Messages (Inbox)
369
  fastify.get('/:id/messages', async (req) => {
370
  const { id: organizationId } = req.params as { id: string };
371
+ const messages = await prisma.message.findMany({
372
  where: { organizationId },
373
  include: { contact: true },
374
  orderBy: { createdAt: 'desc' },
 
411
 
412
  try {
413
  // 1. Create message record
414
+ const newMessage = await prisma.message.create({
415
  data: {
416
  organizationId,
417
  contactId,
apps/api/src/routes/whatsapp.ts CHANGED
@@ -131,33 +131,19 @@ export async function whatsappRoutes(fastify: FastifyInstance) {
131
  return reply.code(200).send('EVENT_RECEIVED');
132
  }
133
 
134
- if (text) {
135
- logger.info({ from, wabaId, orgName: org.name }, '[WHATSAPP-WEBHOOK] Processing automated response');
136
-
137
- const { aiService } = await import('../services/ai');
138
- const { response } = await aiService.handleCrmConversation(from, org.id, text);
139
 
140
- const { pushService } = await import('../services/push');
141
- await pushService.notifyOrganization(
142
- org.id,
143
- "Nouveau message WhatsApp",
144
- `Le client ${from} a répondu : "${text.substring(0, 50)}..."`
145
- ).catch(e => logger.warn({ e }, "[PUSH] Failed to notify"));
146
-
147
- const phoneNumberId = org.phoneNumbers?.[0]?.id;
148
- if (phoneNumberId && org.systemUserToken) {
149
- const { decryptSecrets } = await import('../services/organization');
150
- const { whatsappService } = await import('../services/whatsapp');
151
- const decryptedOrg = decryptSecrets(org);
152
-
153
- await whatsappService.sendMessage({
154
- accessToken: decryptedOrg.systemUserToken,
155
- phoneNumberId
156
- }, { to: from, text: response });
157
-
158
- logger.info({ to: from }, '[WHATSAPP-WEBHOOK] Response sent successfully');
159
- }
160
- }
161
  }
162
  }
163
 
 
131
  return reply.code(200).send('EVENT_RECEIVED');
132
  }
133
 
134
+ // 🤖 AUTOMATED RESPONSE LOOP (AI CLOSING)
135
+ const audioUrl = message.audio?.id; // Note: In a real flow, we'd need to fetch the media URL from Meta first
136
+ const imageUrl = message.image?.id;
 
 
137
 
138
+ const { whatsappService } = await import('../services/whatsapp');
139
+ await whatsappService.handleIncomingMessage(
140
+ from,
141
+ text || '',
142
+ audioUrl,
143
+ imageUrl,
144
+ undefined,
145
+ org.id
146
+ );
 
 
 
 
 
 
 
 
 
 
 
 
147
  }
148
  }
149
 
apps/api/src/services/EntityResolver.ts ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { prisma } from './prisma';
2
+ import { logger } from '../logger';
3
+
4
+ export interface ResolvedEntity {
5
+ user: any | null;
6
+ contact: any | null;
7
+ organization: any | null;
8
+ activeEnrollment: any | null;
9
+ }
10
+
11
+ export class EntityResolver {
12
+ /**
13
+ * Resolves User, Contact, Organization and Active Enrollment for a given phone number and organization.
14
+ */
15
+ static async resolve(phone: string, organizationId: string): Promise<ResolvedEntity> {
16
+ try {
17
+ const [user, contact, organization] = await Promise.all([
18
+ prisma.user.findFirst({
19
+ where: { phone, organizationId },
20
+ include: { organization: true }
21
+ }),
22
+ prisma.contact.findFirst({
23
+ where: { phoneNumber: phone, organizationId }
24
+ }),
25
+ prisma.organization.findUnique({
26
+ where: { id: organizationId }
27
+ })
28
+ ]);
29
+
30
+ let activeEnrollment = null;
31
+ if (user) {
32
+ activeEnrollment = await prisma.enrollment.findFirst({
33
+ where: { userId: user.id, status: 'ACTIVE' },
34
+ include: { track: true }
35
+ });
36
+ }
37
+
38
+ return {
39
+ user,
40
+ contact,
41
+ organization: organization || user?.organization || null,
42
+ activeEnrollment
43
+ };
44
+ } catch (err) {
45
+ logger.error({ err, phone, organizationId }, '[EntityResolver] Failed to resolve entity');
46
+ return { user: null, contact: null, organization: null, activeEnrollment: null };
47
+ }
48
+ }
49
+ }
apps/api/src/services/whatsapp.ts CHANGED
@@ -64,37 +64,50 @@ export class WhatsAppService {
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
 
64
  /**
65
  * Orchestrates the AI response loop and notifications for an incoming message
66
  */
67
+ async handleIncomingMessage(phone: string, text: string, audioUrl?: string, imageUrl?: 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 { EntityResolver } = await import('./EntityResolver');
75
  const { aiService } = await import('./ai');
76
  const { pushService } = await import('./push');
77
  const { decryptSecrets } = await import('./organization');
78
+ const { prisma } = await import('./prisma');
79
 
80
+ // 1. Resolve Entities (Standardized logic)
81
+ const { user, contact, organization } = await EntityResolver.resolve(phone, organizationId);
82
+
83
+ // 2. Log Message (Non-blocking)
84
+ if (user || contact) {
85
+ prisma.message.create({
86
+ data: {
87
+ content: text || '[Media]',
88
+ mediaUrl: audioUrl || imageUrl,
89
+ direction: 'INBOUND',
90
+ userId: user?.id,
91
+ contactId: contact?.id,
92
+ organizationId
93
+ }
94
+ }).catch(err => logger.warn({ err }, '[WHATSAPP_SERVICE] Failed to log message'));
95
+ }
96
+
97
+ // 3. Process via AI Closing Engine
98
  const { response } = await aiService.handleCrmConversation(phone, organizationId, text || '[Médias Reçus]');
99
 
100
+ // 4. Trigger Push Notification to the team
101
  await pushService.notifyOrganization(
102
  organizationId,
103
  "Nouveau message WhatsApp",
104
  `Le client ${phone} a répondu : "${(text || '').substring(0, 50)}..."`
105
  ).catch(e => logger.warn({ e }, "[PUSH] Failed to notify"));
106
 
107
+ // 5. Send automated response if possible
108
+ const phoneNumberId = organization?.phoneNumbers?.[0]?.id;
109
+ if (organization && phoneNumberId && organization.systemUserToken) {
110
+ const decryptedOrg = decryptSecrets(organization);
 
 
 
 
 
111
  await this.sendMessage({
112
  accessToken: decryptedOrg.systemUserToken,
113
  phoneNumberId
apps/whatsapp-worker/src/handlers/BroadcastHandler.ts CHANGED
@@ -86,7 +86,7 @@ export class BroadcastHandler implements JobHandler {
86
  successCount++;
87
  // P0 Fix: Persist broadcast message for history
88
  try {
89
- await (prisma as any).message.create({
90
  data: {
91
  organizationId,
92
  contactId: contact.id,
 
86
  successCount++;
87
  // P0 Fix: Persist broadcast message for history
88
  try {
89
+ await prisma.message.create({
90
  data: {
91
  organizationId,
92
  contactId: contact.id,
apps/whatsapp-worker/src/handlers/DirectMessageHandler.ts CHANGED
@@ -75,7 +75,7 @@ export class DirectMessageHandler implements JobHandler {
75
  logger.info({ contactId }, '[DirectMessageHandler] 1-to-1 reply sent');
76
  // Update status if model exists in types, else use any
77
  try {
78
- await (prisma as any).message.update({
79
  where: { id: messageId },
80
  data: { status: 'SENT' }
81
  });
 
75
  logger.info({ contactId }, '[DirectMessageHandler] 1-to-1 reply sent');
76
  // Update status if model exists in types, else use any
77
  try {
78
+ await prisma.message.update({
79
  where: { id: messageId },
80
  data: { status: 'SENT' }
81
  });
apps/whatsapp-worker/src/services/EntityResolver.ts ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { prisma } from './prisma';
2
+ import { logger } from '../logger';
3
+
4
+ export interface ResolvedEntity {
5
+ user: any | null;
6
+ contact: any | null;
7
+ organization: any | null;
8
+ activeEnrollment: any | null;
9
+ }
10
+
11
+ export class EntityResolver {
12
+ /**
13
+ * Resolves User, Contact, Organization and Active Enrollment for a given phone number and organization.
14
+ */
15
+ static async resolve(phone: string, organizationId: string): Promise<ResolvedEntity> {
16
+ try {
17
+ const [user, contact, organization] = await Promise.all([
18
+ prisma.user.findFirst({
19
+ where: { phone, organizationId },
20
+ include: { organization: true }
21
+ }),
22
+ prisma.contact.findFirst({
23
+ where: { phoneNumber: phone, organizationId }
24
+ }),
25
+ prisma.organization.findUnique({
26
+ where: { id: organizationId }
27
+ })
28
+ ]);
29
+
30
+ let activeEnrollment = null;
31
+ if (user) {
32
+ activeEnrollment = await prisma.enrollment.findFirst({
33
+ where: { userId: user.id, status: 'ACTIVE' },
34
+ include: { track: true }
35
+ });
36
+ }
37
+
38
+ return {
39
+ user,
40
+ contact,
41
+ organization: organization || user?.organization || null,
42
+ activeEnrollment
43
+ };
44
+ } catch (err) {
45
+ logger.error({ err, phone, organizationId }, '[EntityResolver] Failed to resolve entity');
46
+ return { user: null, contact: null, organization: null, activeEnrollment: null };
47
+ }
48
+ }
49
+ }
apps/whatsapp-worker/src/services/whatsapp-logic.ts CHANGED
@@ -1,4 +1,5 @@
1
  import { logger } from '../logger';
 
2
  import { prisma } from './prisma';
3
  import { Queue } from 'bullmq';
4
  import Redis from 'ioredis';
@@ -44,21 +45,12 @@ export class WhatsAppLogic {
44
  const normalizedText = this.normalizeCommand(text);
45
  logger.info(`${traceId} Orchestrating Inbound: ${normalizedText}`);
46
 
47
- // 1. Find User, Contact, Enrollment & Organization
48
- const [user, contact, organization] = await Promise.all([
49
- prisma.user.findFirst({ where: { phone, organizationId } }),
50
- prisma.contact.findFirst({ where: { phoneNumber: phone, organizationId } }),
51
- prisma.organization.findUnique({ where: { id: organizationId } })
52
- ]);
53
-
54
- const activeEnrollment = user ? await prisma.enrollment.findFirst({
55
- where: { userId: user.id, status: 'ACTIVE' },
56
- include: { track: true }
57
- }) : null;
58
 
59
  // 2. Log Message (Non-blocking)
60
  if (user || contact) {
61
- (prisma as any).message.create({
62
  data: {
63
  content: text,
64
  mediaUrl: audioUrl || imageUrl,
@@ -80,7 +72,7 @@ export class WhatsAppLogic {
80
  imageUrl,
81
  traceId,
82
  user: user || undefined,
83
- activeEnrollment: (activeEnrollment as any) || undefined, // Casting only for internal context mapping if schema mismatch
84
  redis: connection,
85
  whatsappQueue,
86
  organizationId,
 
1
  import { logger } from '../logger';
2
+ import { EntityResolver } from './EntityResolver';
3
  import { prisma } from './prisma';
4
  import { Queue } from 'bullmq';
5
  import Redis from 'ioredis';
 
45
  const normalizedText = this.normalizeCommand(text);
46
  logger.info(`${traceId} Orchestrating Inbound: ${normalizedText}`);
47
 
48
+ // 1. Resolve Entities (User, Contact, Org, Enrollment)
49
+ const { user, contact, organization, activeEnrollment } = await EntityResolver.resolve(phone, organizationId);
 
 
 
 
 
 
 
 
 
50
 
51
  // 2. Log Message (Non-blocking)
52
  if (user || contact) {
53
+ prisma.message.create({
54
  data: {
55
  content: text,
56
  mediaUrl: audioUrl || imageUrl,
 
72
  imageUrl,
73
  traceId,
74
  user: user || undefined,
75
+ activeEnrollment: activeEnrollment || undefined,
76
  redis: connection,
77
  whatsappQueue,
78
  organizationId,
packages/database/prisma/schema.prisma CHANGED
@@ -338,6 +338,7 @@ model Message {
338
  mediaUrl String?
339
  payload Json?
340
  createdAt DateTime @default(now())
 
341
  organizationId String @default("default-org-id")
342
  organization Organization @relation(fields: [organizationId], references: [id])
343
  user User? @relation(fields: [userId], references: [id])
@@ -441,6 +442,13 @@ enum ContentType {
441
  VIDEO
442
  }
443
 
 
 
 
 
 
 
 
444
  enum Direction {
445
  INBOUND
446
  OUTBOUND
 
338
  mediaUrl String?
339
  payload Json?
340
  createdAt DateTime @default(now())
341
+ status MessageStatus @default(SENT)
342
  organizationId String @default("default-org-id")
343
  organization Organization @relation(fields: [organizationId], references: [id])
344
  user User? @relation(fields: [userId], references: [id])
 
442
  VIDEO
443
  }
444
 
445
+ enum MessageStatus {
446
+ SENT
447
+ DELIVERED
448
+ READ
449
+ RECEIVED
450
+ }
451
+
452
  enum Direction {
453
  INBOUND
454
  OUTBOUND