CognxSafeTrack commited on
Commit
0f2f80a
·
1 Parent(s): 7b0c22b

feat: implement whatsapp templates management with security hardening, audit logs, and crm integration

Browse files
Files changed (38) hide show
  1. apps/admin/src/App.tsx +2 -0
  2. apps/admin/src/components/LanguageSwitcher.tsx +62 -0
  3. apps/admin/src/components/crm/CrmAIAssistant.tsx +56 -17
  4. apps/admin/src/components/layouts/MainLayout.tsx +20 -10
  5. apps/admin/src/lib/i18n.ts +11 -16
  6. apps/admin/src/locales/en.json +103 -0
  7. apps/admin/src/locales/es.json +29 -0
  8. apps/admin/src/locales/fr.json +29 -0
  9. apps/admin/src/locales/pt.json +29 -0
  10. apps/admin/src/pages/CrmConversationalDashboard.tsx +34 -1
  11. apps/admin/src/pages/TemplatesPage.tsx +347 -0
  12. apps/api/src/logger.ts +22 -16
  13. apps/api/src/middleware/tenant.ts +0 -25
  14. apps/api/src/routes/admin.ts +76 -29
  15. apps/api/src/routes/ai.ts +22 -7
  16. apps/api/src/routes/internal.ts +5 -0
  17. apps/api/src/routes/whatsapp.ts +99 -0
  18. apps/api/src/services/organization.ts +3 -5
  19. apps/api/src/services/whatsapp.ts +42 -0
  20. apps/api/src/tests/tenant-isolation.test.ts +2 -2
  21. apps/whatsapp-worker/src/fix-types.ts +0 -19
  22. apps/whatsapp-worker/src/handlers/AIAgentHandler.ts +2 -0
  23. apps/whatsapp-worker/src/handlers/CommandHandler.ts +15 -33
  24. apps/whatsapp-worker/src/handlers/EnrollHandler.ts +1 -1
  25. apps/whatsapp-worker/src/handlers/InboundHandler.ts +4 -1
  26. apps/whatsapp-worker/src/handlers/MessageHandler.ts +5 -1
  27. apps/whatsapp-worker/src/index.ts +10 -6
  28. apps/whatsapp-worker/src/logger.ts +13 -14
  29. apps/whatsapp-worker/src/pedagogy.ts +16 -13
  30. apps/whatsapp-worker/src/services/i18n.ts +141 -0
  31. apps/whatsapp-worker/src/services/organization.ts +3 -5
  32. apps/whatsapp-worker/src/services/whatsapp-logic.ts +11 -7
  33. docs/audit_dette_technique_03052026.md +46 -3
  34. docs/audit_meta_app_review_2026-05-04.md +79 -0
  35. docs/global_audit_report_2026-05-04.md +25 -0
  36. docs/implementation_plan_whatsapp_templates.md +58 -0
  37. packages/database/prisma/schema.prisma +4 -0
  38. packages/prompts/src/index.ts +3 -3
apps/admin/src/App.tsx CHANGED
@@ -23,6 +23,7 @@ import ConversationalDashboard from '@/pages/ConversationalDashboard';
23
  import CrmConversationalDashboard from '@/pages/CrmConversationalDashboard';
24
  import KnowledgeBasePage from '@/pages/KnowledgeBasePage';
25
  import CampaignHistoryPage from '@/pages/CampaignHistoryPage';
 
26
  import { useTenant } from '@/lib/tenant';
27
  import { api } from '@/lib/api';
28
 
@@ -92,6 +93,7 @@ function AppShell() {
92
  <Route path="/reset-password" element={<ResetPasswordPage />} />
93
  <Route path="/kb" element={<KnowledgeBasePage />} />
94
  <Route path="/campaign-history" element={<CampaignHistoryPage />} />
 
95
  </Routes>
96
  </MainLayout>
97
  );
 
23
  import CrmConversationalDashboard from '@/pages/CrmConversationalDashboard';
24
  import KnowledgeBasePage from '@/pages/KnowledgeBasePage';
25
  import CampaignHistoryPage from '@/pages/CampaignHistoryPage';
26
+ import TemplatesPage from '@/pages/TemplatesPage';
27
  import { useTenant } from '@/lib/tenant';
28
  import { api } from '@/lib/api';
29
 
 
93
  <Route path="/reset-password" element={<ResetPasswordPage />} />
94
  <Route path="/kb" element={<KnowledgeBasePage />} />
95
  <Route path="/campaign-history" element={<CampaignHistoryPage />} />
96
+ <Route path="/whatsapp-templates" element={<TemplatesPage />} />
97
  </Routes>
98
  </MainLayout>
99
  );
apps/admin/src/components/LanguageSwitcher.tsx ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
+ import { ChevronDown } from 'lucide-react';
4
+ import { motion, AnimatePresence } from 'framer-motion';
5
+
6
+ const languages = [
7
+ { code: 'en', label: 'English', flag: '🇺🇸' },
8
+ { code: 'fr', label: 'Français', flag: '🇫🇷' },
9
+ { code: 'es', label: 'Español', flag: '🇪🇸' },
10
+ { code: 'pt', label: 'Português', flag: '🇵🇹' }
11
+ ];
12
+
13
+ export default function LanguageSwitcher() {
14
+ const { i18n } = useTranslation();
15
+ const [isOpen, setIsOpen] = React.useState(false);
16
+
17
+ const currentLanguage = languages.find(l => l.code === i18n.language) || languages[1];
18
+
19
+ const changeLanguage = (code: string) => {
20
+ i18n.changeLanguage(code);
21
+ setIsOpen(false);
22
+ };
23
+
24
+ return (
25
+ <div className="relative">
26
+ <button
27
+ onClick={() => setIsOpen(!isOpen)}
28
+ className="flex items-center gap-2 px-3 py-2 text-slate-300 hover:text-white transition bg-slate-800 rounded-xl text-xs w-full justify-between"
29
+ >
30
+ <div className="flex items-center gap-2">
31
+ <span>{currentLanguage.flag}</span>
32
+ <span className="font-medium">{currentLanguage.label}</span>
33
+ </div>
34
+ <ChevronDown className={`w-3 h-3 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
35
+ </button>
36
+
37
+ <AnimatePresence>
38
+ {isOpen && (
39
+ <motion.div
40
+ initial={{ opacity: 0, y: 10 }}
41
+ animate={{ opacity: 1, y: 0 }}
42
+ exit={{ opacity: 0, y: 10 }}
43
+ className="absolute bottom-full left-0 right-0 mb-2 bg-slate-800 border border-slate-700 rounded-xl overflow-hidden shadow-xl z-50"
44
+ >
45
+ {languages.map((lang) => (
46
+ <button
47
+ key={lang.code}
48
+ onClick={() => changeLanguage(lang.code)}
49
+ className={`w-full flex items-center gap-3 px-4 py-2.5 text-xs text-left transition hover:bg-slate-700 ${
50
+ i18n.language === lang.code ? 'text-white bg-slate-700/50' : 'text-slate-400'
51
+ }`}
52
+ >
53
+ <span>{lang.flag}</span>
54
+ <span>{lang.label}</span>
55
+ </button>
56
+ ))}
57
+ </motion.div>
58
+ )}
59
+ </AnimatePresence>
60
+ </div>
61
+ );
62
+ }
apps/admin/src/components/crm/CrmAIAssistant.tsx CHANGED
@@ -1,4 +1,5 @@
1
  import { useRef, useEffect } from 'react';
 
2
  import { Upload, Send, Bot, User, Loader2, FileText, CheckCircle2, Mic } from 'lucide-react';
3
  import { motion } from 'framer-motion';
4
 
@@ -27,6 +28,10 @@ interface CrmAIAssistantProps {
27
  onStartRecording: () => void;
28
  onStopRecording: () => void;
29
  isSendingBroadcast?: boolean;
 
 
 
 
30
  suggestions?: Array<{
31
  id: string;
32
  label: string;
@@ -53,8 +58,13 @@ export default function CrmAIAssistant({
53
  onStartRecording,
54
  onStopRecording,
55
  isSendingBroadcast,
 
 
 
 
56
  suggestions = []
57
  }: CrmAIAssistantProps) {
 
58
  const messagesEndRef = useRef<HTMLDivElement>(null);
59
 
60
  const scrollToBottom = () => {
@@ -153,23 +163,52 @@ export default function CrmAIAssistant({
153
  {msg.content}
154
  </div>
155
  {msg.role === 'assistant' && msg.id !== '1' && !isGenerating && messages[messages.length - 1].id === msg.id && msg.type === 'campaign' && (
156
- <motion.button
157
- initial={{ opacity: 0, scale: 0.9 }}
158
- animate={{ opacity: 1, scale: 1 }}
159
- onClick={() => onValidateAndSend(msg.content)}
160
- disabled={isSendingBroadcast}
161
- 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 disabled:opacity-50 disabled:scale-100"
162
- >
163
- {isSendingBroadcast ? (
164
- <>
165
- <Loader2 className="w-4 h-4 animate-spin" /> Envoi en cours...
166
- </>
167
- ) : (
168
- <>
169
- <CheckCircle2 className="w-4 h-4" /> Valider et Envoyer à la liste
170
- </>
171
- )}
172
- </motion.button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
173
  )}
174
  </div>
175
  </div>
 
1
  import { useRef, useEffect } from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
  import { Upload, Send, Bot, User, Loader2, FileText, CheckCircle2, Mic } from 'lucide-react';
4
  import { motion } from 'framer-motion';
5
 
 
28
  onStartRecording: () => void;
29
  onStopRecording: () => void;
30
  isSendingBroadcast?: boolean;
31
+ approvedTemplates?: any[];
32
+ selectedTemplateName?: string;
33
+ onSelectTemplate?: (name: string) => void;
34
+ isLoadingTemplates?: boolean;
35
  suggestions?: Array<{
36
  id: string;
37
  label: string;
 
58
  onStartRecording,
59
  onStopRecording,
60
  isSendingBroadcast,
61
+ approvedTemplates = [],
62
+ selectedTemplateName = '',
63
+ onSelectTemplate = () => {},
64
+ isLoadingTemplates = false,
65
  suggestions = []
66
  }: CrmAIAssistantProps) {
67
+ const { t } = useTranslation();
68
  const messagesEndRef = useRef<HTMLDivElement>(null);
69
 
70
  const scrollToBottom = () => {
 
163
  {msg.content}
164
  </div>
165
  {msg.role === 'assistant' && msg.id !== '1' && !isGenerating && messages[messages.length - 1].id === msg.id && msg.type === 'campaign' && (
166
+ <div className="mt-4 w-full max-w-sm">
167
+ <div className="bg-slate-50 border border-slate-200 rounded-2xl p-4 mb-3">
168
+ <label className="block text-[10px] font-bold text-slate-400 uppercase tracking-widest mb-2">
169
+ {t('crm.campaigns.select_template')}
170
+ </label>
171
+ <select
172
+ value={selectedTemplateName}
173
+ onChange={(e) => onSelectTemplate(e.target.value)}
174
+ disabled={isLoadingTemplates || isSendingBroadcast}
175
+ className="w-full bg-white border border-slate-200 rounded-xl px-3 py-2 text-xs font-bold outline-none focus:ring-2 focus:ring-indigo-500/20 transition-all disabled:opacity-50"
176
+ >
177
+ <option value="">{t('crm.campaigns.use_ai_text')}</option>
178
+ {approvedTemplates.map((tpl: any) => (
179
+ <option key={tpl.name} value={tpl.name}>
180
+ {tpl.name} ({tpl.language})
181
+ </option>
182
+ ))}
183
+ </select>
184
+ {approvedTemplates.length === 0 && !isLoadingTemplates && (
185
+ <p className="text-[10px] text-amber-600 mt-2 font-medium italic">
186
+ {t('crm.campaigns.no_approved_templates')}
187
+ </p>
188
+ )}
189
+ </div>
190
+
191
+ <motion.button
192
+ initial={{ opacity: 0, scale: 0.9 }}
193
+ animate={{ opacity: 1, scale: 1 }}
194
+ onClick={() => onValidateAndSend(msg.content)}
195
+ disabled={isSendingBroadcast}
196
+ className="w-full bg-emerald-500 text-white px-6 py-4 rounded-2xl font-black text-xs shadow-lg shadow-emerald-100 hover:bg-emerald-600 hover:scale-[1.02] active:scale-[0.98] transition-all flex items-center justify-center gap-2 disabled:opacity-50 disabled:scale-100"
197
+ >
198
+ {isSendingBroadcast ? (
199
+ <>
200
+ <Loader2 className="w-4 h-4 animate-spin" /> Envoi en cours...
201
+ </>
202
+ ) : (
203
+ <>
204
+ <CheckCircle2 className="w-5 h-5" />
205
+ {selectedTemplateName
206
+ ? `Envoyer Template: ${selectedTemplateName}`
207
+ : 'Valider et Envoyer à la liste'}
208
+ </>
209
+ )}
210
+ </motion.button>
211
+ </div>
212
  )}
213
  </div>
214
  </div>
apps/admin/src/components/layouts/MainLayout.tsx CHANGED
@@ -1,10 +1,12 @@
1
  import React from 'react';
2
  import { Link } from 'react-router-dom';
 
3
  import { useAuth } from '@/lib/auth';
4
  import { useTenant } from '@/lib/tenant';
5
- import { BarChart2, TrendingUp, Users, BookOpen, Mic, Building2, Activity, Lightbulb, Database, Megaphone } from 'lucide-react';
6
 
7
  import RoleGuard from '@/components/RoleGuard';
 
8
 
9
  interface MainLayoutProps {
10
  children: React.ReactNode;
@@ -13,6 +15,7 @@ interface MainLayoutProps {
13
  }
14
 
15
  export default function MainLayout({ children, isSuperAdmin, orgs }: MainLayoutProps) {
 
16
  const { logout, user } = useAuth();
17
  const { selectedOrgId, setSelectedOrgId, currentOrg } = useTenant();
18
 
@@ -21,17 +24,18 @@ export default function MainLayout({ children, isSuperAdmin, orgs }: MainLayoutP
21
  const isSuperAdminLocal = !!isSuperAdmin;
22
 
23
  const allNavItems = [
24
- { to: '/', label: 'Dashboard', icon: <BarChart2 className="w-4 h-4" /> },
25
- { to: '/analytics', label: 'Statistiques', icon: <TrendingUp className="w-4 h-4 text-amber-500" /> },
26
- { to: '/contacts', label: 'Clients', icon: <Users className="w-4 h-4 text-blue-400" />, show: isCrmActive },
27
- { to: '/campaign-history', label: 'Campagnes', icon: <Megaphone className="w-4 h-4 text-amber-500" />, show: isCrmActive },
28
- { to: '/kb', label: 'Base de connaissance', icon: <Database className="w-4 h-4 text-violet-400" /> },
29
- { to: '/content', label: 'Parcours', icon: <BookOpen className="w-4 h-4" />, show: isEdTechActive },
 
30
  { to: '/live-feed', label: 'Modération', icon: <Mic className="w-4 h-4 text-emerald-500" />, show: isEdTechActive },
31
  { to: '/clients', label: 'Clients B2B', icon: <Building2 className="w-4 h-4 text-indigo-400" />, superOnly: true, show: isSuperAdminLocal },
32
  { to: '/training', label: 'Training Lab', icon: <Activity className="w-4 h-4 text-purple-400" />, superOnly: true, show: isSuperAdminLocal },
33
  { to: '/users', label: 'Utilisateurs', icon: <Users className="w-4 h-4" />, show: isEdTechActive },
34
- { to: '/settings', label: 'Paramètres', icon: <Lightbulb className="w-4 h-4" /> },
35
  ];
36
 
37
  const navItems = allNavItems.filter(item => {
@@ -81,6 +85,11 @@ export default function MainLayout({ children, isSuperAdmin, orgs }: MainLayoutP
81
  </nav>
82
 
83
  <div className="pt-6 mt-6 border-t border-slate-800">
 
 
 
 
 
84
  <div className="flex items-center gap-3 px-4 mb-4">
85
  <div className="w-8 h-8 rounded-full bg-indigo-500 flex items-center justify-center font-bold text-xs">
86
  {user?.name?.[0] || 'U'}
@@ -90,8 +99,9 @@ export default function MainLayout({ children, isSuperAdmin, orgs }: MainLayoutP
90
  <p className="text-[10px] text-slate-500 truncate capitalize">{user?.role?.toLowerCase()}</p>
91
  </div>
92
  </div>
93
- <button onClick={logout} className="w-full flex items-center gap-3 px-4 py-2 text-xs text-slate-500 hover:text-white transition">
94
- 🔓 Se déconnecter
 
95
  </button>
96
  </div>
97
  </aside>
 
1
  import React from 'react';
2
  import { Link } from 'react-router-dom';
3
+ import { useTranslation } from 'react-i18next';
4
  import { useAuth } from '@/lib/auth';
5
  import { useTenant } from '@/lib/tenant';
6
+ import { BarChart2, TrendingUp, Users, BookOpen, Mic, Building2, Activity, Lightbulb, Database, Megaphone, LogOut, LayoutTemplate } from 'lucide-react';
7
 
8
  import RoleGuard from '@/components/RoleGuard';
9
+ import LanguageSwitcher from '@/components/LanguageSwitcher';
10
 
11
  interface MainLayoutProps {
12
  children: React.ReactNode;
 
15
  }
16
 
17
  export default function MainLayout({ children, isSuperAdmin, orgs }: MainLayoutProps) {
18
+ const { t } = useTranslation();
19
  const { logout, user } = useAuth();
20
  const { selectedOrgId, setSelectedOrgId, currentOrg } = useTenant();
21
 
 
24
  const isSuperAdminLocal = !!isSuperAdmin;
25
 
26
  const allNavItems = [
27
+ { to: '/', label: t('nav.home'), icon: <BarChart2 className="w-4 h-4" /> },
28
+ { to: '/analytics', label: t('common.analytics'), icon: <TrendingUp className="w-4 h-4 text-amber-500" /> },
29
+ { to: '/contacts', label: t('common.clients'), icon: <Users className="w-4 h-4 text-blue-400" />, show: isCrmActive },
30
+ { to: '/campaign-history', label: t('nav.campaigns'), icon: <Megaphone className="w-4 h-4 text-amber-500" />, show: isCrmActive },
31
+ { to: '/whatsapp-templates', label: t('nav.templates'), icon: <LayoutTemplate className="w-4 h-4 text-indigo-400" />, show: isCrmActive },
32
+ { to: '/kb', label: t('nav.inbox'), icon: <Database className="w-4 h-4 text-violet-400" /> },
33
+ { to: '/content', label: t('nav.organizations'), icon: <BookOpen className="w-4 h-4" />, show: isEdTechActive },
34
  { to: '/live-feed', label: 'Modération', icon: <Mic className="w-4 h-4 text-emerald-500" />, show: isEdTechActive },
35
  { to: '/clients', label: 'Clients B2B', icon: <Building2 className="w-4 h-4 text-indigo-400" />, superOnly: true, show: isSuperAdminLocal },
36
  { to: '/training', label: 'Training Lab', icon: <Activity className="w-4 h-4 text-purple-400" />, superOnly: true, show: isSuperAdminLocal },
37
  { to: '/users', label: 'Utilisateurs', icon: <Users className="w-4 h-4" />, show: isEdTechActive },
38
+ { to: '/settings', label: t('common.settings'), icon: <Lightbulb className="w-4 h-4" /> },
39
  ];
40
 
41
  const navItems = allNavItems.filter(item => {
 
85
  </nav>
86
 
87
  <div className="pt-6 mt-6 border-t border-slate-800">
88
+ <div className="mb-6">
89
+ <label className="block text-[10px] uppercase font-bold text-slate-500 tracking-wider mb-2">Interface Language</label>
90
+ <LanguageSwitcher />
91
+ </div>
92
+
93
  <div className="flex items-center gap-3 px-4 mb-4">
94
  <div className="w-8 h-8 rounded-full bg-indigo-500 flex items-center justify-center font-bold text-xs">
95
  {user?.name?.[0] || 'U'}
 
99
  <p className="text-[10px] text-slate-500 truncate capitalize">{user?.role?.toLowerCase()}</p>
100
  </div>
101
  </div>
102
+ <button onClick={logout} className="w-full flex items-center gap-3 px-4 py-2 text-xs text-slate-500 hover:text-white transition group">
103
+ <LogOut className="w-3.5 h-3.5 group-hover:text-red-400 transition" />
104
+ {t('common.logout')}
105
  </button>
106
  </div>
107
  </aside>
apps/admin/src/lib/i18n.ts CHANGED
@@ -1,29 +1,24 @@
1
-
2
  import i18n from 'i18next';
3
  import { initReactI18next } from 'react-i18next';
4
  import LanguageDetector from 'i18next-browser-languagedetector';
5
 
 
 
 
 
 
6
  i18n
7
  .use(LanguageDetector)
8
  .use(initReactI18next)
9
  .init({
10
  resources: {
11
- fr: {
12
- translation: {
13
- "welcome": "Bienvenue",
14
- "dashboard": "Tableau de Bord",
15
- "clients": "Gestion des Clients",
16
- "analytics": "Analyses",
17
- "settings": "Paramètres",
18
- "logout": "Déconnexion",
19
- "onboarding": {
20
- "title": "Bienvenue sur Xamlé.Studio",
21
- "subtitle": "Configurons votre école en quelques secondes."
22
- }
23
- }
24
- }
25
  },
26
- fallbackLng: 'fr',
 
27
  interpolation: {
28
  escapeValue: false,
29
  }
 
 
1
  import i18n from 'i18next';
2
  import { initReactI18next } from 'react-i18next';
3
  import LanguageDetector from 'i18next-browser-languagedetector';
4
 
5
+ import en from '../locales/en.json';
6
+ import fr from '../locales/fr.json';
7
+ import es from '../locales/es.json';
8
+ import pt from '../locales/pt.json';
9
+
10
  i18n
11
  .use(LanguageDetector)
12
  .use(initReactI18next)
13
  .init({
14
  resources: {
15
+ en: { translation: en },
16
+ fr: { translation: fr },
17
+ es: { translation: es },
18
+ pt: { translation: pt }
 
 
 
 
 
 
 
 
 
 
19
  },
20
+ fallbackLng: 'en',
21
+ debug: false,
22
  interpolation: {
23
  escapeValue: false,
24
  }
apps/admin/src/locales/en.json ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "common": {
3
+ "welcome": "Welcome",
4
+ "dashboard": "Dashboard",
5
+ "clients": "Client Management",
6
+ "analytics": "Analytics",
7
+ "settings": "Settings",
8
+ "logout": "Logout",
9
+ "save": "Save Changes",
10
+ "cancel": "Cancel",
11
+ "loading": "Loading...",
12
+ "error": "An error occurred",
13
+ "success": "Action successful",
14
+ "create": "Create",
15
+ "delete": "Delete",
16
+ "edit": "Edit",
17
+ "search": "Search...",
18
+ "actions": "Actions"
19
+ },
20
+ "nav": {
21
+ "home": "Home",
22
+ "inbox": "Conversations",
23
+ "campaigns": "Campaign History",
24
+ "templates": "WhatsApp Templates",
25
+ "organizations": "Organizations",
26
+ "users": "Users",
27
+ "training": "Training Lab",
28
+ "moderation": "Moderation",
29
+ "b2b": "B2B Clients"
30
+ },
31
+ "onboarding": {
32
+ "title": "Welcome to Xamlé.Studio",
33
+ "subtitle": "Set up your WhatsApp school in a few minutes.",
34
+ "step_welcome": "Welcome",
35
+ "step_legal": "Contract",
36
+ "step_whatsapp": "WhatsApp",
37
+ "step_ai": "AI Brain",
38
+ "connect_fb": "Connect with Facebook",
39
+ "fb_connected": "Facebook Account Connected!",
40
+ "setup_waba": "Setting up your WhatsApp Business Account...",
41
+ "cta_launch": "Launch my platform",
42
+ "legal_text": "By accepting, you agree to our Platform Solution Provider terms and Meta's Business Policies."
43
+ },
44
+ "crm": {
45
+ "stats": {
46
+ "total_contacts": "Total Contacts",
47
+ "messages_sent": "Messages Sent",
48
+ "open_rate": "Open Rate",
49
+ "conversion": "Conversion"
50
+ },
51
+ "inbox": {
52
+ "title": "Conversations",
53
+ "no_messages": "No conversations found.",
54
+ "reply_placeholder": "Type your message here...",
55
+ "send": "Send Reply"
56
+ },
57
+ "campaigns": {
58
+ "title": "Campaign History",
59
+ "subtitle": "Track all your outgoing broadcasts and their performance.",
60
+ "new_campaign": "New Campaign",
61
+ "select_template": "WhatsApp Template (Optional)",
62
+ "choose_approved": "Select an approved template...",
63
+ "no_approved_templates": "No approved templates found. Sync them first.",
64
+ "use_ai_text": "Use AI Generated Text",
65
+ "status_sent": "Sent",
66
+ "status_delivered": "Delivered",
67
+ "status_read": "Read",
68
+ "status_failed": "Failed"
69
+ }
70
+ },
71
+ "settings": {
72
+ "profile": "Organization Profile",
73
+ "branding": "Branding & Colors",
74
+ "ai_config": "AI Configuration",
75
+ "whatsapp_config": "WhatsApp Technical State",
76
+ "billing": "Billing & Subscription"
77
+ },
78
+ "whatsapp": {
79
+ "templates": {
80
+ "title": "WhatsApp Message Templates",
81
+ "subtitle": "Manage and synchronize your pre-approved Meta templates.",
82
+ "sync_button": "Sync with Meta",
83
+ "create_button": "Create Template",
84
+ "no_templates": "No templates found. Synchronize or create your first one.",
85
+ "table": {
86
+ "name": "Template Name",
87
+ "category": "Category",
88
+ "language": "Language",
89
+ "status": "Status"
90
+ },
91
+ "create_modal": {
92
+ "title": "Create New Template",
93
+ "name_label": "Template Name (lowercase, no spaces)",
94
+ "category_label": "Category",
95
+ "language_label": "Language",
96
+ "body_label": "Body Text",
97
+ "submit": "Submit for Approval",
98
+ "success": "Template submitted successfully!",
99
+ "error": "Failed to submit template."
100
+ }
101
+ }
102
+ }
103
+ }
apps/admin/src/locales/es.json ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "common": {
3
+ "welcome": "Bienvenido",
4
+ "dashboard": "Tablero",
5
+ "clients": "Clientes",
6
+ "analytics": "Análisis",
7
+ "settings": "Configuración",
8
+ "logout": "Cerrar sesión",
9
+ "save": "Guardar",
10
+ "cancel": "Cancelar",
11
+ "loading": "Cargando...",
12
+ "error": "Ocurrió un error",
13
+ "success": "Éxito"
14
+ },
15
+ "onboarding": {
16
+ "title": "Bienvenido a Xamlé.Studio",
17
+ "subtitle": "Configuremos tu escuela en unos segundos.",
18
+ "connect_fb": "Conectar con Facebook",
19
+ "fb_connected": "¡Cuenta de Facebook conectada!",
20
+ "setup_waba": "Configurando tu cuenta de WhatsApp Business..."
21
+ },
22
+ "nav": {
23
+ "home": "Inicio",
24
+ "inbox": "Bandeja de entrada",
25
+ "campaigns": "Campañas",
26
+ "templates": "Plantillas",
27
+ "organizations": "Organizaciones"
28
+ }
29
+ }
apps/admin/src/locales/fr.json ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "common": {
3
+ "welcome": "Bienvenue",
4
+ "dashboard": "Tableau de Bord",
5
+ "clients": "Gestion des Clients",
6
+ "analytics": "Analyses",
7
+ "settings": "Paramètres",
8
+ "logout": "Déconnexion",
9
+ "save": "Enregistrer",
10
+ "cancel": "Annuler",
11
+ "loading": "Chargement...",
12
+ "error": "Une erreur est survenue",
13
+ "success": "Succès"
14
+ },
15
+ "onboarding": {
16
+ "title": "Bienvenue sur Xamlé.Studio",
17
+ "subtitle": "Configurons votre école en quelques secondes.",
18
+ "connect_fb": "Se connecter avec Facebook",
19
+ "fb_connected": "Compte Facebook connecté !",
20
+ "setup_waba": "Configuration de votre compte WhatsApp Business..."
21
+ },
22
+ "nav": {
23
+ "home": "Accueil",
24
+ "inbox": "Messagerie",
25
+ "campaigns": "Campagnes",
26
+ "templates": "Modèles",
27
+ "organizations": "Organisations"
28
+ }
29
+ }
apps/admin/src/locales/pt.json ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "common": {
3
+ "welcome": "Bem-vindo",
4
+ "dashboard": "Painel",
5
+ "clients": "Clientes",
6
+ "analytics": "Análises",
7
+ "settings": "Configurações",
8
+ "logout": "Sair",
9
+ "save": "Salvar",
10
+ "cancel": "Cancelar",
11
+ "loading": "Carregando...",
12
+ "error": "Ocorreu um erro",
13
+ "success": "Sucesso"
14
+ },
15
+ "onboarding": {
16
+ "title": "Bem-vindo ao Xamlé.Studio",
17
+ "subtitle": "Vamos configurar sua escola em alguns segundos.",
18
+ "connect_fb": "Conectar com Facebook",
19
+ "fb_connected": "Conta do Facebook conectada!",
20
+ "setup_waba": "Configurando sua conta do WhatsApp Business..."
21
+ },
22
+ "nav": {
23
+ "home": "Início",
24
+ "inbox": "Caixa de entrada",
25
+ "campaigns": "Campanhas",
26
+ "templates": "Modelos",
27
+ "organizations": "Organizações"
28
+ }
29
+ }
apps/admin/src/pages/CrmConversationalDashboard.tsx CHANGED
@@ -41,6 +41,34 @@ export default function CrmConversationalDashboard() {
41
  const [isDragging, setIsDragging] = useState(false);
42
  const [isSendingBroadcast, setIsSendingBroadcast] = useState(false);
43
  const [uploadedFile, setUploadedFile] = useState<{ name: string, listId: string, listName: string } | null>(null);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
 
45
  // Recording & Transcription logic moved to useAudioRecorder hook
46
  // Real-time streaming transcription logic
@@ -142,7 +170,8 @@ export default function CrmConversationalDashboard() {
142
  },
143
  body: JSON.stringify({
144
  listId: uploadedFile.listId,
145
- message: message
 
146
  })
147
  });
148
 
@@ -240,6 +269,10 @@ export default function CrmConversationalDashboard() {
240
  onStopRecording={stopRecording}
241
  isSendingBroadcast={isSendingBroadcast}
242
  suggestions={SUGGESTED_ACTIONS}
 
 
 
 
243
  />
244
 
245
  <FileImporter
 
41
  const [isDragging, setIsDragging] = useState(false);
42
  const [isSendingBroadcast, setIsSendingBroadcast] = useState(false);
43
  const [uploadedFile, setUploadedFile] = useState<{ name: string, listId: string, listName: string } | null>(null);
44
+ const [approvedTemplates, setApprovedTemplates] = useState<any[]>([]);
45
+ const [selectedTemplateName, setSelectedTemplateName] = useState<string>('');
46
+ const [isLoadingTemplates, setIsLoadingTemplates] = useState(false);
47
+
48
+ const fetchTemplates = async () => {
49
+ if (!token || !selectedOrgId) return;
50
+ setIsLoadingTemplates(true);
51
+ try {
52
+ const data = await fetch(`${import.meta.env.VITE_API_URL}/v1/whatsapp/templates`, {
53
+ headers: {
54
+ 'Authorization': `Bearer ${token}`,
55
+ 'x-organization-id': selectedOrgId
56
+ }
57
+ }).then(r => r.json());
58
+
59
+ if (Array.isArray(data)) {
60
+ setApprovedTemplates(data.filter((t: any) => t.status === 'APPROVED'));
61
+ }
62
+ } catch (err) {
63
+ console.error("Templates fetch failed:", err);
64
+ } finally {
65
+ setIsLoadingTemplates(false);
66
+ }
67
+ };
68
+
69
+ useEffect(() => {
70
+ if (view === 'assistant') fetchTemplates();
71
+ }, [view, selectedOrgId]);
72
 
73
  // Recording & Transcription logic moved to useAudioRecorder hook
74
  // Real-time streaming transcription logic
 
170
  },
171
  body: JSON.stringify({
172
  listId: uploadedFile.listId,
173
+ message: message,
174
+ templateName: selectedTemplateName || undefined
175
  })
176
  });
177
 
 
269
  onStopRecording={stopRecording}
270
  isSendingBroadcast={isSendingBroadcast}
271
  suggestions={SUGGESTED_ACTIONS}
272
+ approvedTemplates={approvedTemplates}
273
+ selectedTemplateName={selectedTemplateName}
274
+ onSelectTemplate={setSelectedTemplateName}
275
+ isLoadingTemplates={isLoadingTemplates}
276
  />
277
 
278
  <FileImporter
apps/admin/src/pages/TemplatesPage.tsx ADDED
@@ -0,0 +1,347 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect } from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
+ import {
4
+ LayoutTemplate,
5
+ RefreshCw,
6
+ Plus,
7
+ CheckCircle2,
8
+ Clock,
9
+ XCircle,
10
+ ChevronRight,
11
+ Search,
12
+ MessageSquare,
13
+ Globe,
14
+ Tag,
15
+ Loader2
16
+ } from 'lucide-react';
17
+ import { motion, AnimatePresence } from 'framer-motion';
18
+ import { api } from '../lib/api';
19
+ import { useAuth } from '../lib/auth';
20
+ import { useTenant } from '../lib/tenant';
21
+
22
+ interface TemplateComponent {
23
+ type: string;
24
+ text?: string;
25
+ format?: string;
26
+ }
27
+
28
+ interface WhatsAppTemplate {
29
+ name: string;
30
+ status: 'APPROVED' | 'PENDING' | 'REJECTED' | 'PAUSED' | 'DISABLED';
31
+ category: 'MARKETING' | 'UTILITY' | 'AUTHENTICATION';
32
+ language: string;
33
+ components: TemplateComponent[];
34
+ id: string;
35
+ }
36
+
37
+ const STATUS_CONFIG = {
38
+ APPROVED: { icon: CheckCircle2, color: 'text-green-500', bg: 'bg-green-50', label: 'whatsapp.templates.status_approved' },
39
+ PENDING: { icon: Clock, color: 'text-amber-500', bg: 'bg-amber-50', label: 'whatsapp.templates.status_pending' },
40
+ REJECTED: { icon: XCircle, color: 'text-red-500', bg: 'bg-red-50', label: 'whatsapp.templates.status_rejected' },
41
+ PAUSED: { icon: Clock, color: 'text-slate-400', bg: 'bg-slate-50', label: 'whatsapp.templates.status_paused' },
42
+ DISABLED: { icon: XCircle, color: 'text-slate-400', bg: 'bg-slate-50', label: 'whatsapp.templates.status_disabled' },
43
+ };
44
+
45
+ export default function TemplatesPage() {
46
+ const { t } = useTranslation();
47
+ const { token } = useAuth();
48
+ const { selectedOrgId } = useTenant();
49
+
50
+ const [templates, setTemplates] = useState<WhatsAppTemplate[]>([]);
51
+ const [loading, setLoading] = useState(true);
52
+ const [syncing, setSyncing] = useState(false);
53
+ const [search, setSearch] = useState('');
54
+ const [isModalOpen, setIsModalOpen] = useState(false);
55
+
56
+ // Form State
57
+ const [newTemplate, setNewTemplate] = useState({
58
+ name: '',
59
+ category: 'MARKETING',
60
+ language: 'fr',
61
+ body: ''
62
+ });
63
+
64
+ const fetchTemplates = async () => {
65
+ if (!token || !selectedOrgId) return;
66
+ setLoading(true);
67
+ try {
68
+ const data = await api.get(`/v1/whatsapp/templates`, token, selectedOrgId);
69
+ setTemplates(data);
70
+ } catch (err) {
71
+ console.error('[TEMPLATES] Fetch failed:', err);
72
+ } finally {
73
+ setLoading(false);
74
+ }
75
+ };
76
+
77
+ useEffect(() => {
78
+ fetchTemplates();
79
+ }, [token, selectedOrgId]);
80
+
81
+ const handleSync = async () => {
82
+ setSyncing(true);
83
+ await fetchTemplates();
84
+ setSyncing(false);
85
+ };
86
+
87
+ const handleCreate = async (e: React.FormEvent) => {
88
+ e.preventDefault();
89
+ if (!token || !selectedOrgId) return;
90
+
91
+ try {
92
+ const payload = {
93
+ name: newTemplate.name.toLowerCase().replace(/\s+/g, '_'),
94
+ language: newTemplate.language,
95
+ category: newTemplate.category,
96
+ components: [
97
+ {
98
+ type: 'BODY',
99
+ text: newTemplate.body
100
+ }
101
+ ]
102
+ };
103
+ await api.post(`/v1/whatsapp/templates`, payload, token, selectedOrgId);
104
+ setIsModalOpen(false);
105
+ fetchTemplates();
106
+ setNewTemplate({ name: '', category: 'MARKETING', language: 'fr', body: '' });
107
+ } catch (err) {
108
+ alert(t('whatsapp.templates.create_modal.error'));
109
+ }
110
+ };
111
+
112
+ const filteredTemplates = templates.filter(tpl =>
113
+ tpl.name.toLowerCase().includes(search.toLowerCase())
114
+ );
115
+
116
+ return (
117
+ <div className="p-8 max-w-6xl mx-auto min-h-screen">
118
+ {/* Header */}
119
+ <div className="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-10">
120
+ <motion.div
121
+ initial={{ opacity: 0, x: -20 }}
122
+ animate={{ opacity: 1, x: 0 }}
123
+ >
124
+ <div className="flex items-center gap-3 mb-2">
125
+ <div className="w-12 h-12 bg-indigo-600 rounded-2xl flex items-center justify-center shadow-lg shadow-indigo-200">
126
+ <LayoutTemplate className="w-6 h-6 text-white" />
127
+ </div>
128
+ <h1 className="text-3xl font-bold text-slate-900 tracking-tight">
129
+ {t('whatsapp.templates.title')}
130
+ </h1>
131
+ </div>
132
+ <p className="text-slate-500 font-medium ml-1">
133
+ {t('whatsapp.templates.subtitle')}
134
+ </p>
135
+ </motion.div>
136
+
137
+ <div className="flex items-center gap-3">
138
+ <button
139
+ onClick={handleSync}
140
+ disabled={syncing}
141
+ className="flex items-center gap-2 px-5 py-2.5 bg-white border border-slate-200 text-slate-700 rounded-xl font-semibold hover:bg-slate-50 transition-all active:scale-95 disabled:opacity-50"
142
+ >
143
+ <RefreshCw className={`w-4 h-4 ${syncing ? 'animate-spin' : ''}`} />
144
+ {t('whatsapp.templates.sync_button')}
145
+ </button>
146
+ <button
147
+ onClick={() => setIsModalOpen(true)}
148
+ className="flex items-center gap-2 px-5 py-2.5 bg-indigo-600 text-white rounded-xl font-semibold hover:bg-indigo-700 transition-all shadow-lg shadow-indigo-100 active:scale-95"
149
+ >
150
+ <Plus className="w-4 h-4" />
151
+ {t('whatsapp.templates.create_button')}
152
+ </button>
153
+ </div>
154
+ </div>
155
+
156
+ {/* Filters & Search */}
157
+ <div className="relative mb-8">
158
+ <Search className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" />
159
+ <input
160
+ type="text"
161
+ placeholder={t('common.search')}
162
+ value={search}
163
+ onChange={(e) => setSearch(e.target.value)}
164
+ className="w-full pl-12 pr-6 py-4 bg-white border border-slate-100 rounded-2xl shadow-sm outline-none focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-500 transition-all text-slate-700"
165
+ />
166
+ </div>
167
+
168
+ {/* Content Area */}
169
+ {loading ? (
170
+ <div className="flex flex-col items-center justify-center py-32 text-slate-400">
171
+ <Loader2 className="w-12 h-12 animate-spin mb-4 text-indigo-600" />
172
+ <p className="font-medium animate-pulse">{t('common.loading')}</p>
173
+ </div>
174
+ ) : filteredTemplates.length === 0 ? (
175
+ <motion.div
176
+ initial={{ opacity: 0, y: 20 }}
177
+ animate={{ opacity: 1, y: 0 }}
178
+ className="bg-white border border-slate-100 rounded-3xl p-20 text-center shadow-sm"
179
+ >
180
+ <div className="w-20 h-20 bg-slate-50 rounded-full flex items-center justify-center mx-auto mb-6">
181
+ <LayoutTemplate className="w-10 h-10 text-slate-200" />
182
+ </div>
183
+ <h3 className="text-xl font-bold text-slate-800 mb-2">
184
+ {t('whatsapp.templates.no_templates')}
185
+ </h3>
186
+ </motion.div>
187
+ ) : (
188
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
189
+ <AnimatePresence mode="popLayout">
190
+ {filteredTemplates.map((tpl, idx) => {
191
+ const status = STATUS_CONFIG[tpl.status] || STATUS_CONFIG.PAUSED;
192
+ const bodyText = tpl.components.find(c => c.type === 'BODY')?.text || '';
193
+
194
+ return (
195
+ <motion.div
196
+ key={tpl.name}
197
+ layout
198
+ initial={{ opacity: 0, y: 20 }}
199
+ animate={{ opacity: 1, y: 0 }}
200
+ transition={{ delay: idx * 0.05 }}
201
+ className="group bg-white border border-slate-100 rounded-3xl p-6 shadow-sm hover:shadow-xl hover:shadow-indigo-500/5 transition-all duration-300 relative overflow-hidden"
202
+ >
203
+ <div className="flex justify-between items-start mb-6">
204
+ <div className={`px-3 py-1.5 rounded-full ${status.bg} ${status.color} flex items-center gap-2 text-[10px] font-bold uppercase tracking-wider`}>
205
+ <status.icon className="w-3.5 h-3.5" />
206
+ {t(status.label)}
207
+ </div>
208
+ <div className="px-3 py-1.5 bg-slate-50 text-slate-500 rounded-full text-[10px] font-bold flex items-center gap-1.5">
209
+ <Globe className="w-3.5 h-3.5" />
210
+ {tpl.language}
211
+ </div>
212
+ </div>
213
+
214
+ <div className="mb-4">
215
+ <h4 className="text-lg font-bold text-slate-800 break-words line-clamp-1 mb-1 group-hover:text-indigo-600 transition-colors">
216
+ {tpl.name}
217
+ </h4>
218
+ <div className="flex items-center gap-1.5 text-slate-400 text-xs font-medium">
219
+ <Tag className="w-3.5 h-3.5" />
220
+ {tpl.category}
221
+ </div>
222
+ </div>
223
+
224
+ <div className="bg-slate-50/50 rounded-2xl p-4 min-h-[100px] mb-4 relative">
225
+ <MessageSquare className="absolute right-4 top-4 w-4 h-4 text-slate-200" />
226
+ <p className="text-slate-600 text-sm leading-relaxed line-clamp-4 italic">
227
+ "{bodyText}"
228
+ </p>
229
+ </div>
230
+
231
+ <button className="w-full py-3 bg-slate-50 text-slate-600 rounded-2xl text-sm font-bold flex items-center justify-center gap-2 hover:bg-indigo-50 hover:text-indigo-600 transition-all opacity-0 group-hover:opacity-100 translate-y-2 group-hover:translate-y-0">
232
+ View Full Preview
233
+ <ChevronRight className="w-4 h-4" />
234
+ </button>
235
+ </motion.div>
236
+ );
237
+ })}
238
+ </AnimatePresence>
239
+ </div>
240
+ )}
241
+
242
+ {/* Create Modal */}
243
+ <AnimatePresence>
244
+ {isModalOpen && (
245
+ <div className="fixed inset-0 z-50 flex items-center justify-center p-4">
246
+ <motion.div
247
+ initial={{ opacity: 0 }}
248
+ animate={{ opacity: 1 }}
249
+ exit={{ opacity: 0 }}
250
+ onClick={() => setIsModalOpen(false)}
251
+ className="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
252
+ />
253
+ <motion.div
254
+ initial={{ opacity: 0, scale: 0.9, y: 20 }}
255
+ animate={{ opacity: 1, scale: 1, y: 0 }}
256
+ exit={{ opacity: 0, scale: 0.9, y: 20 }}
257
+ className="bg-white rounded-[32px] w-full max-w-xl shadow-2xl relative overflow-hidden z-10"
258
+ >
259
+ <div className="p-8">
260
+ <h2 className="text-2xl font-bold text-slate-900 mb-6">
261
+ {t('whatsapp.templates.create_modal.title')}
262
+ </h2>
263
+
264
+ <form onSubmit={handleCreate} className="space-y-6">
265
+ <div>
266
+ <label className="block text-sm font-bold text-slate-700 mb-2">
267
+ {t('whatsapp.templates.create_modal.name_label')}
268
+ </label>
269
+ <input
270
+ required
271
+ type="text"
272
+ value={newTemplate.name}
273
+ onChange={e => setNewTemplate({...newTemplate, name: e.target.value})}
274
+ className="w-full px-5 py-3.5 bg-slate-50 border border-slate-200 rounded-2xl outline-none focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-500 transition-all font-medium"
275
+ placeholder="welcome_message"
276
+ />
277
+ </div>
278
+
279
+ <div className="grid grid-cols-2 gap-4">
280
+ <div>
281
+ <label className="block text-sm font-bold text-slate-700 mb-2">
282
+ {t('whatsapp.templates.create_modal.category_label')}
283
+ </label>
284
+ <select
285
+ value={newTemplate.category}
286
+ onChange={e => setNewTemplate({...newTemplate, category: e.target.value})}
287
+ className="w-full px-5 py-3.5 bg-slate-50 border border-slate-200 rounded-2xl outline-none focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-500 transition-all font-medium"
288
+ >
289
+ <option value="MARKETING">Marketing</option>
290
+ <option value="UTILITY">Utility</option>
291
+ </select>
292
+ </div>
293
+ <div>
294
+ <label className="block text-sm font-bold text-slate-700 mb-2">
295
+ {t('whatsapp.templates.create_modal.language_label')}
296
+ </label>
297
+ <select
298
+ value={newTemplate.language}
299
+ onChange={e => setNewTemplate({...newTemplate, language: e.target.value})}
300
+ className="w-full px-5 py-3.5 bg-slate-50 border border-slate-200 rounded-2xl outline-none focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-500 transition-all font-medium"
301
+ >
302
+ <option value="fr">French</option>
303
+ <option value="en">English</option>
304
+ <option value="es">Spanish</option>
305
+ <option value="pt_BR">Portuguese (BR)</option>
306
+ </select>
307
+ </div>
308
+ </div>
309
+
310
+ <div>
311
+ <label className="block text-sm font-bold text-slate-700 mb-2">
312
+ {t('whatsapp.templates.create_modal.body_label')}
313
+ </label>
314
+ <textarea
315
+ required
316
+ rows={4}
317
+ value={newTemplate.body}
318
+ onChange={e => setNewTemplate({...newTemplate, body: e.target.value})}
319
+ className="w-full px-5 py-3.5 bg-slate-50 border border-slate-200 rounded-2xl outline-none focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-500 transition-all font-medium resize-none"
320
+ placeholder="Hello {{1}}, welcome to our school!"
321
+ />
322
+ </div>
323
+
324
+ <div className="flex gap-4 pt-4">
325
+ <button
326
+ type="button"
327
+ onClick={() => setIsModalOpen(false)}
328
+ className="flex-1 py-4 bg-slate-50 text-slate-600 rounded-2xl font-bold hover:bg-slate-100 transition-all active:scale-95"
329
+ >
330
+ {t('common.cancel')}
331
+ </button>
332
+ <button
333
+ type="submit"
334
+ className="flex-1 py-4 bg-indigo-600 text-white rounded-2xl font-bold hover:bg-indigo-700 transition-all shadow-lg shadow-indigo-100 active:scale-95"
335
+ >
336
+ {t('whatsapp.templates.create_modal.submit')}
337
+ </button>
338
+ </div>
339
+ </form>
340
+ </div>
341
+ </motion.div>
342
+ </div>
343
+ )}
344
+ </AnimatePresence>
345
+ </div>
346
+ );
347
+ }
apps/api/src/logger.ts CHANGED
@@ -29,27 +29,33 @@ const baseLogger = pino({
29
  }
30
  });
31
 
32
- /**
33
- * Compatible Logger Wrapper
34
- *
35
- * Provides a more permissive interface to match existing codebase usage:
36
- * logger.error("Message", errorObject) -> logger.error({ err: errorObject }, "Message")
37
- */
38
- const wrap = (level: 'info' | 'error' | 'warn' | 'debug') => (arg1: any, arg2?: any, ...args: any[]) => {
39
- if (typeof arg1 === 'string' && arg2 !== undefined) {
40
- // Handle (msg, data) pattern by flipping it for Pino
41
- return baseLogger[level]({ err: arg2, data: args.length > 0 ? args : undefined }, arg1);
42
- }
43
- return baseLogger[level](arg1, arg2, ...args);
44
- };
45
 
46
- export const logger = {
 
 
 
 
 
 
 
 
 
47
  info: wrap('info'),
48
  error: wrap('error'),
49
  warn: wrap('warn'),
50
  debug: wrap('debug'),
51
  child: (bindings: pino.Bindings) => baseLogger.child(bindings),
52
- pino: baseLogger // Access to raw instance if needed
53
- } as any;
54
 
55
  export const log = logger;
 
29
  }
30
  });
31
 
32
+ type LogFn = (arg1: Record<string, unknown> | string, arg2?: unknown, ...args: unknown[]) => void;
33
+
34
+ export interface AppLogger {
35
+ info: LogFn;
36
+ error: LogFn;
37
+ warn: LogFn;
38
+ debug: LogFn;
39
+ child: (bindings: pino.Bindings) => pino.Logger;
40
+ pino: pino.Logger;
41
+ }
 
 
 
42
 
43
+ // Bridges (msg, data) call pattern to pino's ({data}, msg) convention.
44
+ const wrap = (level: 'info' | 'error' | 'warn' | 'debug'): LogFn =>
45
+ (arg1: Record<string, unknown> | string, arg2?: unknown, ...args: unknown[]) => {
46
+ if (typeof arg1 === 'string' && arg2 !== undefined) {
47
+ return baseLogger[level]({ err: arg2, data: args.length > 0 ? args : undefined }, arg1);
48
+ }
49
+ return baseLogger[level](arg1 as Record<string, unknown>, arg2 as string);
50
+ };
51
+
52
+ export const logger: AppLogger = {
53
  info: wrap('info'),
54
  error: wrap('error'),
55
  warn: wrap('warn'),
56
  debug: wrap('debug'),
57
  child: (bindings: pino.Bindings) => baseLogger.child(bindings),
58
+ pino: baseLogger
59
+ };
60
 
61
  export const log = logger;
apps/api/src/middleware/tenant.ts DELETED
@@ -1,25 +0,0 @@
1
- import { FastifyReply } from 'fastify';
2
- import { runWithTenant } from '@repo/database';
3
-
4
- /**
5
- * Middleware to wrap request execution in a tenant context.
6
- * It looks for organizationId in:
7
- * 1. request.organizationId (set by previous auth middleware)
8
- * 2. x-organization-id header (for internal/admin calls)
9
- */
10
- export const tenantMiddleware = async (request: any, _reply: FastifyReply) => {
11
- const organizationId = request.organizationId || request.headers['x-organization-id'] || 'default-org-id';
12
-
13
- // Wrap the entire request execution in the tenant context
14
- return runWithTenant(organizationId, async () => {
15
- // We don't call 'done()' here because we want to wrap the remaining lifecycle
16
- });
17
- };
18
-
19
- // Since Fastify hooks don't easily allow wrapping the whole lifecycle with AsyncLocalStorage.run
20
- // without some tricks, we might need a different approach for Fastify.
21
- // The best way in Fastify is to use a 'preHandler' hook that starts the context,
22
- // but it's tricky to keep it alive across the route handler.
23
-
24
- // Better approach for Fastify + AsyncLocalStorage:
25
- // Use a 'addHook('onRequest', ...)' but we need to ensure the context is preserved.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
apps/api/src/routes/admin.ts CHANGED
@@ -48,19 +48,25 @@ const PaginationSchema = z.object({
48
  export async function adminRoutes(fastify: FastifyInstance) {
49
 
50
  // ── Dashboard Stats ────────────────────────────────────────────────────────
51
- fastify.get('/stats', async () => {
 
 
 
52
  const [totalUsers, activeEnrollments, completedEnrollments, totalTracks, totalRevenue] = await Promise.all([
53
- prisma.user.count(),
54
- prisma.enrollment.count({ where: { status: 'ACTIVE' } }),
55
- prisma.enrollment.count({ where: { status: 'COMPLETED' } }),
56
- prisma.track.count(),
57
- prisma.payment.aggregate({ where: { status: 'COMPLETED' }, _sum: { amount: true } }),
58
  ]);
59
  return { totalUsers, activeEnrollments, completedEnrollments, totalTracks, totalRevenue: totalRevenue._sum.amount || 0 };
60
  });
61
 
62
  // ── Users ──────────────────────────────────────────────────────────────────
63
  fastify.get('/users', async (req, reply) => {
 
 
 
64
  const parsed = PaginationSchema.safeParse(req.query);
65
  if (!parsed.success) return reply.code(400).send({ error: 'Invalid query parameters', details: parsed.error.flatten() });
66
 
@@ -68,6 +74,7 @@ export async function adminRoutes(fastify: FastifyInstance) {
68
 
69
  const [users, total] = await Promise.all([
70
  prisma.user.findMany({
 
71
  orderBy: { createdAt: 'desc' },
72
  skip: (page - 1) * limit,
73
  take: limit,
@@ -76,31 +83,38 @@ export async function adminRoutes(fastify: FastifyInstance) {
76
  _count: { select: { enrollments: true, responses: true } }
77
  }
78
  }),
79
- prisma.user.count()
80
  ]);
81
  return { users, total, page, limit };
82
  });
83
 
84
  fastify.get('/users/:userId/messages', async (req, reply) => {
 
 
 
85
  const { userId } = req.params as { userId: string };
86
  const messages = await prisma.message.findMany({
87
- where: { userId },
88
  orderBy: { createdAt: 'asc' },
89
  });
90
 
91
- const user = await prisma.user.findUnique({
92
- where: { id: userId },
93
  select: { id: true, name: true, phone: true }
94
  });
95
 
96
- if (!user) return reply.status(404).send({ error: 'User not found' });
97
 
98
  return { user, messages };
99
  });
100
 
101
  // ── Enrollments ────────────────────────────────────────────────────────────
102
- fastify.get('/enrollments', async () => {
 
 
 
103
  const enrollments = await prisma.enrollment.findMany({
 
104
  include: { user: true, track: true },
105
  orderBy: { startedAt: 'desc' },
106
  take: 100,
@@ -112,9 +126,13 @@ export async function adminRoutes(fastify: FastifyInstance) {
112
 
113
  // LIVE FEED : Students blocked waiting for manual review
114
  // LIVE FEED : Students blocked waiting for manual review
115
- fastify.get('/live-feed', async () => {
 
 
 
116
  const pendingReviews = await prisma.userProgress.findMany({
117
  where: {
 
118
  exerciseStatus: 'PENDING_REVIEW',
119
  user: { language: 'WOLOF' } // Currently only focusing on Wolof interceptions
120
  },
@@ -128,7 +146,7 @@ export async function adminRoutes(fastify: FastifyInstance) {
128
  // Map the raw payload to find the actual response audio for each pending review
129
  const liveFeed = await Promise.all(pendingReviews.map(async (progress) => {
130
  const enrollment = await prisma.enrollment.findFirst({
131
- where: { userId: progress.userId, trackId: progress.trackId, status: 'ACTIVE' }
132
  });
133
 
134
  // If no active enrollment found, fallback gracefully
@@ -136,7 +154,7 @@ export async function adminRoutes(fastify: FastifyInstance) {
136
 
137
  // Find the most recent inbound message with an audioUrl for this user
138
  const lastMessage = await prisma.message.findFirst({
139
- where: { userId: progress.userId, direction: 'INBOUND', mediaUrl: { not: null } },
140
  orderBy: { createdAt: 'desc' }
141
  });
142
 
@@ -233,8 +251,12 @@ export async function adminRoutes(fastify: FastifyInstance) {
233
  // ══════════════════════════════════════════════════════════════════════════
234
 
235
  // List tracks
236
- fastify.get('/tracks', async () => {
 
 
 
237
  return prisma.track.findMany({
 
238
  include: { _count: { select: { days: true, enrollments: true } } },
239
  orderBy: { createdAt: 'desc' }
240
  });
@@ -242,11 +264,14 @@ export async function adminRoutes(fastify: FastifyInstance) {
242
 
243
  // Get single track with all days
244
  fastify.get<{ Params: { id: string } }>('/tracks/:id', async (req, reply) => {
245
- const track = await prisma.track.findUnique({
246
- where: { id: req.params.id },
 
 
 
247
  include: { days: { orderBy: { dayNumber: 'asc' } } }
248
  });
249
- if (!track) return reply.code(404).send({ error: 'Track not found' });
250
  return track;
251
  });
252
 
@@ -262,21 +287,34 @@ export async function adminRoutes(fastify: FastifyInstance) {
262
 
263
  // Update track
264
  fastify.put<{ Params: { id: string } }>('/tracks/:id', async (req, reply) => {
 
 
 
265
  const body = TrackSchema.partial().safeParse(req.body);
266
  if (!body.success) return reply.code(400).send({ error: body.error.flatten() });
267
  try {
268
- const track = await prisma.track.update({ where: { id: req.params.id }, data: body.data });
 
 
 
269
  return track;
270
  } catch {
271
- return reply.code(404).send({ error: 'Track not found' });
272
  }
273
  });
274
 
275
  // Delete track
276
  fastify.delete<{ Params: { id: string } }>('/tracks/:id', async (req, reply) => {
 
 
 
277
  try {
278
- await prisma.trackDay.deleteMany({ where: { trackId: req.params.id } });
279
- await prisma.track.delete({ where: { id: req.params.id } });
 
 
 
 
280
  return { ok: true };
281
  } catch {
282
  return reply.code(404).send({ error: 'Track not found' });
@@ -304,9 +342,12 @@ export async function adminRoutes(fastify: FastifyInstance) {
304
  // ══════════════════════════════════════════════════════════════════════════
305
 
306
  // List days for a track
307
- fastify.get<{ Params: { trackId: string } }>('/tracks/:trackId/days', async (req) => {
 
 
 
308
  return prisma.trackDay.findMany({
309
- where: { trackId: req.params.trackId },
310
  orderBy: { dayNumber: 'asc' }
311
  });
312
  });
@@ -331,26 +372,32 @@ export async function adminRoutes(fastify: FastifyInstance) {
331
 
332
  // Update day
333
  fastify.put<{ Params: { trackId: string; dayId: string } }>('/tracks/:trackId/days/:dayId', async (req, reply) => {
 
 
 
334
  const body = TrackDaySchema.partial().safeParse(req.body);
335
  if (!body.success) return reply.code(400).send({ error: body.error.flatten() });
336
  try {
337
  const day = await prisma.trackDay.update({
338
- where: { id: req.params.dayId },
339
  data: { ...body.data, audioUrl: body.data.audioUrl === '' ? null : body.data.audioUrl }
340
  });
341
  return day;
342
  } catch {
343
- return reply.code(404).send({ error: 'Day not found' });
344
  }
345
  });
346
 
347
  // Delete day
348
  fastify.delete<{ Params: { trackId: string; dayId: string } }>('/tracks/:trackId/days/:dayId', async (req, reply) => {
 
 
 
349
  try {
350
- await prisma.trackDay.delete({ where: { id: req.params.dayId } });
351
  return { ok: true };
352
  } catch {
353
- return reply.code(404).send({ error: 'Day not found' });
354
  }
355
  });
356
 
 
48
  export async function adminRoutes(fastify: FastifyInstance) {
49
 
50
  // ── Dashboard Stats ────────────────────────────────────────────────────────
51
+ fastify.get('/stats', async (req, reply) => {
52
+ const organizationId = req.organizationId;
53
+ if (!organizationId) return reply.code(400).send({ error: 'Organization ID required' });
54
+
55
  const [totalUsers, activeEnrollments, completedEnrollments, totalTracks, totalRevenue] = await Promise.all([
56
+ prisma.user.count({ where: { organizationId } }),
57
+ prisma.enrollment.count({ where: { status: 'ACTIVE', organizationId } }),
58
+ prisma.enrollment.count({ where: { status: 'COMPLETED', organizationId } }),
59
+ prisma.track.count({ where: { organizationId } }),
60
+ prisma.payment.aggregate({ where: { status: 'COMPLETED', organizationId }, _sum: { amount: true } }),
61
  ]);
62
  return { totalUsers, activeEnrollments, completedEnrollments, totalTracks, totalRevenue: totalRevenue._sum.amount || 0 };
63
  });
64
 
65
  // ── Users ──────────────────────────────────────────────────────────────────
66
  fastify.get('/users', async (req, reply) => {
67
+ const organizationId = req.organizationId;
68
+ if (!organizationId) return reply.code(400).send({ error: 'Organization ID required' });
69
+
70
  const parsed = PaginationSchema.safeParse(req.query);
71
  if (!parsed.success) return reply.code(400).send({ error: 'Invalid query parameters', details: parsed.error.flatten() });
72
 
 
74
 
75
  const [users, total] = await Promise.all([
76
  prisma.user.findMany({
77
+ where: { organizationId },
78
  orderBy: { createdAt: 'desc' },
79
  skip: (page - 1) * limit,
80
  take: limit,
 
83
  _count: { select: { enrollments: true, responses: true } }
84
  }
85
  }),
86
+ prisma.user.count({ where: { organizationId } })
87
  ]);
88
  return { users, total, page, limit };
89
  });
90
 
91
  fastify.get('/users/:userId/messages', async (req, reply) => {
92
+ const organizationId = req.organizationId;
93
+ if (!organizationId) return reply.code(400).send({ error: 'Organization ID required' });
94
+
95
  const { userId } = req.params as { userId: string };
96
  const messages = await prisma.message.findMany({
97
+ where: { userId, organizationId },
98
  orderBy: { createdAt: 'asc' },
99
  });
100
 
101
+ const user = await prisma.user.findFirst({
102
+ where: { id: userId, organizationId },
103
  select: { id: true, name: true, phone: true }
104
  });
105
 
106
+ if (!user) return reply.status(404).send({ error: 'User not found or access denied' });
107
 
108
  return { user, messages };
109
  });
110
 
111
  // ── Enrollments ────────────────────────────────────────────────────────────
112
+ fastify.get('/enrollments', async (req, reply) => {
113
+ const organizationId = req.organizationId;
114
+ if (!organizationId) return reply.code(400).send({ error: 'Organization ID required' });
115
+
116
  const enrollments = await prisma.enrollment.findMany({
117
+ where: { organizationId },
118
  include: { user: true, track: true },
119
  orderBy: { startedAt: 'desc' },
120
  take: 100,
 
126
 
127
  // LIVE FEED : Students blocked waiting for manual review
128
  // LIVE FEED : Students blocked waiting for manual review
129
+ fastify.get('/live-feed', async (req, reply) => {
130
+ const organizationId = req.organizationId;
131
+ if (!organizationId) return reply.code(400).send({ error: 'Organization ID required' });
132
+
133
  const pendingReviews = await prisma.userProgress.findMany({
134
  where: {
135
+ organizationId,
136
  exerciseStatus: 'PENDING_REVIEW',
137
  user: { language: 'WOLOF' } // Currently only focusing on Wolof interceptions
138
  },
 
146
  // Map the raw payload to find the actual response audio for each pending review
147
  const liveFeed = await Promise.all(pendingReviews.map(async (progress) => {
148
  const enrollment = await prisma.enrollment.findFirst({
149
+ where: { userId: progress.userId, trackId: progress.trackId, status: 'ACTIVE', organizationId }
150
  });
151
 
152
  // If no active enrollment found, fallback gracefully
 
154
 
155
  // Find the most recent inbound message with an audioUrl for this user
156
  const lastMessage = await prisma.message.findFirst({
157
+ where: { userId: progress.userId, direction: 'INBOUND', mediaUrl: { not: null }, organizationId },
158
  orderBy: { createdAt: 'desc' }
159
  });
160
 
 
251
  // ══════════════════════════════════════════════════════════════════════════
252
 
253
  // List tracks
254
+ fastify.get('/tracks', async (req, reply) => {
255
+ const organizationId = req.organizationId;
256
+ if (!organizationId) return reply.code(400).send({ error: 'Organization ID required' });
257
+
258
  return prisma.track.findMany({
259
+ where: { organizationId },
260
  include: { _count: { select: { days: true, enrollments: true } } },
261
  orderBy: { createdAt: 'desc' }
262
  });
 
264
 
265
  // Get single track with all days
266
  fastify.get<{ Params: { id: string } }>('/tracks/:id', async (req, reply) => {
267
+ const organizationId = req.organizationId;
268
+ if (!organizationId) return reply.code(400).send({ error: 'Organization ID required' });
269
+
270
+ const track = await prisma.track.findFirst({
271
+ where: { id: req.params.id, organizationId },
272
  include: { days: { orderBy: { dayNumber: 'asc' } } }
273
  });
274
+ if (!track) return reply.code(404).send({ error: 'Track not found or access denied' });
275
  return track;
276
  });
277
 
 
287
 
288
  // Update track
289
  fastify.put<{ Params: { id: string } }>('/tracks/:id', async (req, reply) => {
290
+ const organizationId = req.organizationId;
291
+ if (!organizationId) return reply.code(400).send({ error: 'Organization ID required' });
292
+
293
  const body = TrackSchema.partial().safeParse(req.body);
294
  if (!body.success) return reply.code(400).send({ error: body.error.flatten() });
295
  try {
296
+ const track = await prisma.track.update({
297
+ where: { id: req.params.id, organizationId },
298
+ data: body.data
299
+ });
300
  return track;
301
  } catch {
302
+ return reply.code(404).send({ error: 'Track not found or access denied' });
303
  }
304
  });
305
 
306
  // Delete track
307
  fastify.delete<{ Params: { id: string } }>('/tracks/:id', async (req, reply) => {
308
+ const organizationId = req.organizationId;
309
+ if (!organizationId) return reply.code(400).send({ error: 'Organization ID required' });
310
+
311
  try {
312
+ // Ensure organization owns the track before deleting children and track
313
+ const track = await prisma.track.findFirst({ where: { id: req.params.id, organizationId } });
314
+ if (!track) return reply.code(404).send({ error: 'Track not found or access denied' });
315
+
316
+ await prisma.trackDay.deleteMany({ where: { trackId: req.params.id, organizationId } });
317
+ await prisma.track.delete({ where: { id: req.params.id, organizationId } });
318
  return { ok: true };
319
  } catch {
320
  return reply.code(404).send({ error: 'Track not found' });
 
342
  // ══════════════════════════════════════════════════════════════════════════
343
 
344
  // List days for a track
345
+ fastify.get<{ Params: { trackId: string } }>('/tracks/:trackId/days', async (req, reply) => {
346
+ const organizationId = req.organizationId;
347
+ if (!organizationId) return reply.code(400).send({ error: 'Organization ID required' });
348
+
349
  return prisma.trackDay.findMany({
350
+ where: { trackId: req.params.trackId, organizationId },
351
  orderBy: { dayNumber: 'asc' }
352
  });
353
  });
 
372
 
373
  // Update day
374
  fastify.put<{ Params: { trackId: string; dayId: string } }>('/tracks/:trackId/days/:dayId', async (req, reply) => {
375
+ const organizationId = req.organizationId;
376
+ if (!organizationId) return reply.code(400).send({ error: 'Organization ID required' });
377
+
378
  const body = TrackDaySchema.partial().safeParse(req.body);
379
  if (!body.success) return reply.code(400).send({ error: body.error.flatten() });
380
  try {
381
  const day = await prisma.trackDay.update({
382
+ where: { id: req.params.dayId, organizationId },
383
  data: { ...body.data, audioUrl: body.data.audioUrl === '' ? null : body.data.audioUrl }
384
  });
385
  return day;
386
  } catch {
387
+ return reply.code(404).send({ error: 'Day not found or access denied' });
388
  }
389
  });
390
 
391
  // Delete day
392
  fastify.delete<{ Params: { trackId: string; dayId: string } }>('/tracks/:trackId/days/:dayId', async (req, reply) => {
393
+ const organizationId = req.organizationId;
394
+ if (!organizationId) return reply.code(400).send({ error: 'Organization ID required' });
395
+
396
  try {
397
+ await prisma.trackDay.delete({ where: { id: req.params.dayId, organizationId } });
398
  return { ok: true };
399
  } catch {
400
+ return reply.code(404).send({ error: 'Day not found or access denied' });
401
  }
402
  });
403
 
apps/api/src/routes/ai.ts CHANGED
@@ -261,18 +261,33 @@ export async function aiRoutes(fastify: FastifyInstance) {
261
 
262
  if (feedback.isQualified === false) {
263
  // ÉCHEC (Branching 1)
264
- formattedFeedback += `${feedback.validation}\n\n` +
265
- (userLanguage === 'WOLOF'
266
- ? `🚨 Am na yenn mbir yu laaj gëna dëgër. Xoolal li ma la digal te fexe tontu waat ci exercice bi !`
267
- : `🚨 Certains points sont encore à renforcer. Regarde mes conseils et réessaie de répondre à l'exercice !`);
 
 
 
 
 
 
 
268
  } else {
269
  // SUCCÈS (Branching 2)
 
 
 
 
 
 
 
 
 
 
270
  formattedFeedback += `🌟 ${feedback.validation}\n\n` +
271
  `🚀 ${feedback.enrichedVersion}\n\n` +
272
  `💡 ${feedback.actionableAdvice}\n\n` +
273
- (userLanguage === 'WOLOF'
274
- ? `Soo bëggé gëna xóotal pënd bi ak li ngay dund ci yaw ci terrain bi, bindal 1️⃣ *APPROFONDIR*, soo ko bëggul bindal 2️⃣ *SUITE*.`
275
- : `Si tu veux affiner ce point avec une donnée de ton propre terrain, tape 1️⃣ *APPROFONDIR*, sinon tape 2️⃣ *SUITE*.`);
276
  }
277
 
278
 
 
261
 
262
  if (feedback.isQualified === false) {
263
  // ÉCHEC (Branching 1)
264
+ const failMsg = userLanguage === 'WOLOF'
265
+ ? `🚨 Am na yenn mbir yu laaj gëna dëgër. Xoolal li ma la digal te fexe tontu waat ci exercice bi !`
266
+ : userLanguage === 'EN'
267
+ ? `🚨 Some points still need strengthening. Check my advice and try the exercise again!`
268
+ : userLanguage === 'ES'
269
+ ? `🚨 Algunos puntos aún necesitan reforzarse. ¡Mira mis consejos e intenta el ejercicio de nuevo!`
270
+ : userLanguage === 'PT'
271
+ ? `🚨 Alguns pontos ainda precisam ser reforçados. Veja meus conselhos e tente o exercício novamente!`
272
+ : `🚨 Certains points sont encore à renforcer. Regarde mes conseils et réessaie de répondre à l'exercice !`;
273
+
274
+ formattedFeedback += `${feedback.validation}\n\n${failMsg}`;
275
  } else {
276
  // SUCCÈS (Branching 2)
277
+ const successMsg = userLanguage === 'WOLOF'
278
+ ? `Soo bëggé gëna xóotal pënd bi ak li ngay dund ci yaw ci terrain bi, bindal 1️⃣ *APPROFONDIR*, soo ko bëggul bindal 2️⃣ *SUITE*.`
279
+ : userLanguage === 'EN'
280
+ ? `If you want to refine this point with data from your own experience, type 1️⃣ *DEEP DIVE*, otherwise type 2️⃣ *CONTINUE*.`
281
+ : userLanguage === 'ES'
282
+ ? `Si quieres profundizar en este punto con datos de tu propia experiencia, escribe 1️⃣ *PROFUNDIZAR*, de lo contrario escribe 2️⃣ *CONTINUAR*.`
283
+ : userLanguage === 'PT'
284
+ ? `Se você quiser refinar este ponto com dados de sua própria experiência, digite 1️⃣ *APROFUNDAR*, caso contrário, digite 2️⃣ *CONTINUAR*.`
285
+ : `Si tu veux affiner ce point avec une donnée de ton propre terrain, tape 1️⃣ *APPROFONDIR*, sinon tape 2️⃣ *SUITE*.`;
286
+
287
  formattedFeedback += `🌟 ${feedback.validation}\n\n` +
288
  `🚀 ${feedback.enrichedVersion}\n\n` +
289
  `💡 ${feedback.actionableAdvice}\n\n` +
290
+ successMsg;
 
 
291
  }
292
 
293
 
apps/api/src/routes/internal.ts CHANGED
@@ -60,6 +60,11 @@ export async function internalRoutes(fastify: FastifyInstance) {
60
  const phoneNumberId = change.value.metadata?.phone_number_id || 'unknown';
61
  const organizationId = await getOrganizationByPhoneNumberId(phoneNumberId);
62
 
 
 
 
 
 
63
  for (const message of change.value.messages || []) {
64
  const phone = message.from;
65
  let text = '';
 
60
  const phoneNumberId = change.value.metadata?.phone_number_id || 'unknown';
61
  const organizationId = await getOrganizationByPhoneNumberId(phoneNumberId);
62
 
63
+ if (!organizationId) {
64
+ logger.warn(`[INTERNAL-WEBHOOK] Unknown phone_number_id ${phoneNumberId} — skipping`);
65
+ continue;
66
+ }
67
+
68
  for (const message of change.value.messages || []) {
69
  const phone = message.from;
70
  let text = '';
apps/api/src/routes/whatsapp.ts CHANGED
@@ -157,4 +157,103 @@ export async function whatsappRoutes(fastify: FastifyInstance) {
157
  return reply.code(200).send('EVENT_RECEIVED');
158
  }
159
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
160
  }
 
157
  return reply.code(200).send('EVENT_RECEIVED');
158
  }
159
  });
160
+
161
+ /**
162
+ * PROTECTED ROUTES (Requires Auth)
163
+ */
164
+ fastify.register(async (sub) => {
165
+ sub.addHook('onRequest', async (req, reply) => {
166
+ try {
167
+ await req.jwtVerify();
168
+ } catch (err) {
169
+ return reply.code(401).send({ error: 'Unauthorized' });
170
+ }
171
+ });
172
+
173
+ sub.get('/templates', async (req, reply) => {
174
+ const organizationId = req.headers['x-organization-id'] as string;
175
+ if (!organizationId) return reply.code(400).send({ error: 'Organization ID required' });
176
+
177
+ const { whatsappService } = await import('../services/whatsapp');
178
+ const { decryptSecrets } = await import('../services/organization');
179
+ const prisma = fastify.prisma;
180
+
181
+ const org = await prisma.organization.findUnique({ where: { id: organizationId } });
182
+ if (!org || !org.wabaId || !org.systemUserToken) {
183
+ return reply.code(400).send({ error: 'WhatsApp Business Account not configured for this organization' });
184
+ }
185
+
186
+ const decryptedOrg = decryptSecrets(org);
187
+ try {
188
+ const templates = await whatsappService.fetchMetaTemplates({
189
+ accessToken: decryptedOrg.systemUserToken!,
190
+ wabaId: org.wabaId
191
+ });
192
+
193
+ // Audit Log
194
+ await prisma.auditLog.create({
195
+ data: {
196
+ action: 'SYNC_WHATSAPP_TEMPLATES',
197
+ actorId: (req.user as any)?.id,
198
+ resourceId: organizationId,
199
+ details: { count: templates.length, message: 'Templates successfully synchronized with Meta' }
200
+ }
201
+ }).catch(e => logger.warn({ e }, '[AUDIT] Failed to log template sync'));
202
+
203
+ return templates;
204
+ } catch (err: any) {
205
+ return reply.code(500).send({ error: 'Failed to fetch templates from Meta', detail: err.message });
206
+ }
207
+ });
208
+
209
+ sub.post('/templates', async (req, reply) => {
210
+ const organizationId = req.headers['x-organization-id'] as string;
211
+ if (!organizationId) return reply.code(400).send({ error: 'Organization ID required' });
212
+
213
+ const schema = z.object({
214
+ name: z.string().regex(/^[a-z0-9_]+$/, 'Only lowercase, numbers and underscores allowed'),
215
+ language: z.string().default('fr'),
216
+ category: z.enum(['MARKETING', 'UTILITY', 'AUTHENTICATION']).default('MARKETING'),
217
+ components: z.array(z.any())
218
+ });
219
+
220
+ const body = schema.safeParse(req.body);
221
+ if (!body.success) return reply.code(400).send({ error: body.error.flatten() });
222
+
223
+ const { whatsappService } = await import('../services/whatsapp');
224
+ const { decryptSecrets } = await import('../services/organization');
225
+ const prisma = fastify.prisma;
226
+
227
+ const org = await prisma.organization.findUnique({ where: { id: organizationId } });
228
+ if (!org || !org.wabaId || !org.systemUserToken) {
229
+ return reply.code(400).send({ error: 'WhatsApp Business Account not configured' });
230
+ }
231
+
232
+ const decryptedOrg = decryptSecrets(org);
233
+ try {
234
+ const result = await whatsappService.createMetaTemplate({
235
+ accessToken: decryptedOrg.systemUserToken!,
236
+ wabaId: org.wabaId
237
+ }, body.data);
238
+
239
+ // Audit Log
240
+ await prisma.auditLog.create({
241
+ data: {
242
+ action: 'CREATE_WHATSAPP_TEMPLATE',
243
+ actorId: (req.user as any)?.id,
244
+ resourceId: organizationId,
245
+ details: {
246
+ templateName: body.data.name,
247
+ category: body.data.category,
248
+ language: body.data.language
249
+ }
250
+ }
251
+ }).catch(e => logger.warn({ e }, '[AUDIT] Failed to log template creation'));
252
+
253
+ return result;
254
+ } catch (err: any) {
255
+ return reply.code(500).send({ error: 'Failed to create template on Meta', detail: err.message });
256
+ }
257
+ });
258
+ });
259
  }
apps/api/src/services/organization.ts CHANGED
@@ -11,7 +11,7 @@ redis.on('error', (err) => logger.error({ err }, '[REDIS] Organization service e
11
 
12
  const CACHE_TTL = 86400; // 24 hours (routing is stable)
13
 
14
- export async function getOrganizationByPhoneNumberId(phoneNumberId: string): Promise<string> {
15
  const cacheKey = `org:phone:${phoneNumberId}`;
16
 
17
  // 1. Check Redis Cache
@@ -37,10 +37,8 @@ export async function getOrganizationByPhoneNumberId(phoneNumberId: string): Pro
37
  return phoneRecord.organizationId;
38
  }
39
 
40
- // 3. Fallback to default organization
41
- const defaultOrgId = 'default-org-id';
42
- logger.warn(`[ORG-SERVICE] No organization found for phone_number_id: ${phoneNumberId}. Falling back to ${defaultOrgId}`);
43
- return defaultOrgId;
44
  }
45
 
46
  /**
 
11
 
12
  const CACHE_TTL = 86400; // 24 hours (routing is stable)
13
 
14
+ export async function getOrganizationByPhoneNumberId(phoneNumberId: string): Promise<string | null> {
15
  const cacheKey = `org:phone:${phoneNumberId}`;
16
 
17
  // 1. Check Redis Cache
 
37
  return phoneRecord.organizationId;
38
  }
39
 
40
+ logger.warn(`[ORG-SERVICE] No organization found for phone_number_id: ${phoneNumberId} — rejecting`);
41
+ return null;
 
 
42
  }
43
 
44
  /**
apps/api/src/services/whatsapp.ts CHANGED
@@ -61,6 +61,48 @@ export class WhatsAppService {
61
  return results;
62
  }
63
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
  /**
65
  * Orchestrates the AI response loop and notifications for an incoming message
66
  */
 
61
  return results;
62
  }
63
 
64
+ /**
65
+ * Fetches message templates from Meta Graph API
66
+ */
67
+ async fetchMetaTemplates(config: { accessToken: string; wabaId: string }) {
68
+ try {
69
+ const url = `${this.baseUrl}/${config.wabaId}/message_templates`;
70
+ const response = await axios.get(url, {
71
+ headers: { 'Authorization': `Bearer ${config.accessToken}` },
72
+ params: { limit: 100 }
73
+ });
74
+ return response.data.data;
75
+ } catch (err: any) {
76
+ logger.error({
77
+ error: err.response?.data || err.message,
78
+ wabaId: config.wabaId
79
+ }, '[WHATSAPP_SERVICE] Failed to fetch templates');
80
+ throw err;
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Submits a new message template to Meta for approval
86
+ */
87
+ async createMetaTemplate(config: { accessToken: string; wabaId: string }, template: any) {
88
+ try {
89
+ const url = `${this.baseUrl}/${config.wabaId}/message_templates`;
90
+ const response = await axios.post(url, template, {
91
+ headers: {
92
+ 'Authorization': `Bearer ${config.accessToken}`,
93
+ 'Content-Type': 'application/json'
94
+ }
95
+ });
96
+ return response.data;
97
+ } catch (err: any) {
98
+ logger.error({
99
+ error: err.response?.data || err.message,
100
+ wabaId: config.wabaId
101
+ }, '[WHATSAPP_SERVICE] Failed to create template');
102
+ throw err;
103
+ }
104
+ }
105
+
106
  /**
107
  * Orchestrates the AI response loop and notifications for an incoming message
108
  */
apps/api/src/tests/tenant-isolation.test.ts CHANGED
@@ -49,9 +49,9 @@ async function testTenantIsolation() {
49
  console.log(`❌ Org B routing failed. Expected ${orgB.id}, got ${detectedB}`);
50
  }
51
 
52
- // 4. Test Unknown Routing (should fallback to default)
53
  const detectedUnknown = await getOrganizationByPhoneNumberId("unknown-id");
54
- console.log(`ℹ️ Unknown ID routed to: ${detectedUnknown} (Default fallback)`);
55
 
56
  } finally {
57
  // Cleanup
 
49
  console.log(`❌ Org B routing failed. Expected ${orgB.id}, got ${detectedB}`);
50
  }
51
 
52
+ // 4. Test Unknown Routing (should return null for unregistered phone IDs)
53
  const detectedUnknown = await getOrganizationByPhoneNumberId("unknown-id");
54
+ console.log(`ℹ️ Unknown ID result: ${detectedUnknown} (null = correctly rejected)`);
55
 
56
  } finally {
57
  // Cleanup
apps/whatsapp-worker/src/fix-types.ts DELETED
@@ -1,19 +0,0 @@
1
- import { logger } from './logger';
2
- import * as fs from 'fs';
3
- import * as path from 'path';
4
-
5
- function replaceInFile(filePath: string, replacements: [RegExp, string][]) {
6
- const fullPath = path.resolve(__dirname, '..', filePath);
7
- if (!fs.existsSync(fullPath)) return;
8
- let content = fs.readFileSync(fullPath, 'utf8');
9
- for (const [regex, replacement] of replacements) {
10
- content = content.replace(regex, replacement);
11
- }
12
- fs.writeFileSync(fullPath, content);
13
- logger.info(`Fixed: ${filePath}`);
14
- }
15
-
16
- replaceInFile('src/whatsapp-cloud.ts', [
17
- [/err\.response\?\.data\?\.error\?\.(?:message|error_user_msg)/g, '(err as any)?.response?.data?.error?.message']
18
- ]);
19
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
apps/whatsapp-worker/src/handlers/AIAgentHandler.ts CHANGED
@@ -17,7 +17,9 @@ export class AIAgentHandler implements MessageHandler {
17
 
18
  try {
19
  // 1. Prepare the system prompt
 
20
  let systemPrompt = organization.customPrompt || "Tu es un assistant virtuel utile et poli.";
 
21
 
22
  // 2. RAG / Knowledge Base logic
23
  if (organization.knowledgeBaseUrl) {
 
17
 
18
  try {
19
  // 1. Prepare the system prompt
20
+ const userLang = ctx.user?.language || 'FR';
21
  let systemPrompt = organization.customPrompt || "Tu es un assistant virtuel utile et poli.";
22
+ systemPrompt += `\n\nIMPORTANT: Réponds TOUJOURS en langue: ${userLang}.`;
23
 
24
  // 2. RAG / Knowledge Base logic
25
  if (organization.knowledgeBaseUrl) {
apps/whatsapp-worker/src/handlers/CommandHandler.ts CHANGED
@@ -2,8 +2,7 @@ import { MessageContext, MessageHandler } from './types';
2
  import { isFuzzyMatch } from '../services/utils';
3
  import { prisma } from '../services/prisma';
4
  import { logger } from '../logger';
5
-
6
-
7
 
8
  export class CommandHandler implements MessageHandler {
9
  async canHandle(ctx: MessageContext): Promise<boolean> {
@@ -22,11 +21,10 @@ export class CommandHandler implements MessageHandler {
22
  const { user, normalizedText, whatsappQueue, traceId, activeEnrollment } = ctx;
23
  if (!user) return false;
24
 
25
- const isWolof = user.language === 'WOLOF';
26
 
27
  // --- 1. SEED ---
28
  if (isFuzzyMatch(normalizedText, 'SEED')) {
29
- // ... (existing seed logic)
30
  logger.info({ traceId, userId: user.id }, "Database Seeding requested");
31
  try {
32
  type SeedModule = { seedDatabase: (prisma: any) => Promise<{ seeded: boolean }> };
@@ -35,14 +33,12 @@ export class CommandHandler implements MessageHandler {
35
  await prisma.businessProfile.deleteMany({ where: { userId: user.id } });
36
  await prisma.user.update({ where: { id: user.id }, data: { activity: null } });
37
 
38
- const msg = result.seeded
39
- ? "✅ Seeding terminé ! Le Cache Cognitif a été réinitialisé.\nEnvoie INSCRIPTION pour commencer."
40
- : "ℹ️ Les données existent déjà. Cache Cognitif purgé. Envoie INSCRIPTION.";
41
- await whatsappQueue.add('send-message', { userId: user.id, text: msg });
42
  logger.info({ traceId, userId: user.id, success: true }, "Database SEED completed");
43
  } catch (err) {
44
  logger.error({ traceId, userId: user.id, err }, "Database SEED failed");
45
- await whatsappQueue.add('send-message', { userId: user.id, text: `❌ Erreur seed` });
46
  }
47
  return true;
48
  }
@@ -62,28 +58,26 @@ export class CommandHandler implements MessageHandler {
62
  });
63
 
64
  if (pastDays.length === 0) {
65
- const msg = isWolof ? "Amul bés yu passé ba pare !" : "Tu n'as pas encore d'anciennes leçons !";
66
- await whatsappQueue.add('send-message', { userId: user.id, text: msg });
67
  return true;
68
  }
69
 
70
  const rows = pastDays.map(day => ({
71
  id: `DAY${day.dayNumber}_REPLAY`,
72
- title: isWolof ? `Bés ${day.dayNumber}` : `Leçon ${day.dayNumber}`,
73
- description: isWolof ? "Revoir cette leçon" : "Revoir cette leçon"
74
  }));
75
 
76
- // WhatsApp list limit is 10 per section. Let's take the last 10 if too many.
77
  const limitedRows = rows.slice(-10);
78
 
79
  const { sendInteractiveListMessage } = await import('../whatsapp-cloud');
80
  await sendInteractiveListMessage(
81
  user.phone || '',
82
- isWolof ? "Sa njàng" : "Ton parcours",
83
- isWolof ? "Tànnal bés bi nga bëgg tontu waat :" : "Choisis la leçon que tu souhaites revoir :",
84
- isWolof ? "Seeti" : "Parcourir",
85
  [{
86
- title: isWolof ? "Bés yu passé" : "Leçons passées",
87
  rows: limitedRows
88
  }]
89
  );
@@ -99,21 +93,18 @@ export class CommandHandler implements MessageHandler {
99
  logger.info({ traceId, userId: user.id, action, day: dayActionMatch[1] }, "Day action triggered");
100
 
101
  if (action === 'REPLAY' && enrollment) {
102
- // Important: Trigger a full content send for the historical day
103
  await whatsappQueue.add('send-content', {
104
  userId: user.id,
105
  trackId: enrollment.trackId,
106
  dayNumber: parseFloat(dayActionMatch[1]),
107
- isHistorical: true, // Signal to skip progress update
108
  organizationId: ctx.organizationId
109
  });
110
  return true;
111
  } else if (action === 'EXERCISE') {
112
- const msg = isWolof ? "🎙️ J'écoute ta réponse (vocal wala mbind) :" : "🎙️ J'écoute ta réponse (vocal ou texte) :";
113
- await whatsappQueue.add('send-message', { userId: user.id, text: msg });
114
  return true;
115
  } else if (action === 'CONTINUE' && enrollment) {
116
- // On ajoute l'envoi de la suite du contenu dans la file d'attente
117
  await whatsappQueue.add('send-content', {
118
  userId: user.id,
119
  trackId: enrollment.trackId,
@@ -122,20 +113,11 @@ export class CommandHandler implements MessageHandler {
122
  });
123
  return true;
124
  } else if (action === 'PROMPT') {
125
- // On demande à l'utilisateur de répondre, avec gestion du Wolof si besoin
126
- const msg = isWolof
127
- ? "🖊️ Dafa neex ma xam sa xibaar..."
128
- : "🖊️ Envoie ta réponse texte ou vocale :";
129
-
130
- await whatsappQueue.add('send-message', {
131
- userId: user.id,
132
- text: msg
133
- });
134
  return true;
135
  }
136
  }
137
 
138
-
139
  return false;
140
  }
141
  }
 
2
  import { isFuzzyMatch } from '../services/utils';
3
  import { prisma } from '../services/prisma';
4
  import { logger } from '../logger';
5
+ import { getT } from '../services/i18n';
 
6
 
7
  export class CommandHandler implements MessageHandler {
8
  async canHandle(ctx: MessageContext): Promise<boolean> {
 
21
  const { user, normalizedText, whatsappQueue, traceId, activeEnrollment } = ctx;
22
  if (!user) return false;
23
 
24
+ const t = getT(user.language);
25
 
26
  // --- 1. SEED ---
27
  if (isFuzzyMatch(normalizedText, 'SEED')) {
 
28
  logger.info({ traceId, userId: user.id }, "Database Seeding requested");
29
  try {
30
  type SeedModule = { seedDatabase: (prisma: any) => Promise<{ seeded: boolean }> };
 
33
  await prisma.businessProfile.deleteMany({ where: { userId: user.id } });
34
  await prisma.user.update({ where: { id: user.id }, data: { activity: null } });
35
 
36
+ const msg = result.seeded ? t('seed_success') : t('seed_exists');
37
+ await whatsappQueue.add('send-message', { userId: user.id, text: msg, organizationId: ctx.organizationId });
 
 
38
  logger.info({ traceId, userId: user.id, success: true }, "Database SEED completed");
39
  } catch (err) {
40
  logger.error({ traceId, userId: user.id, err }, "Database SEED failed");
41
+ await whatsappQueue.add('send-message', { userId: user.id, text: t('seed_error'), organizationId: ctx.organizationId });
42
  }
43
  return true;
44
  }
 
58
  });
59
 
60
  if (pastDays.length === 0) {
61
+ await whatsappQueue.add('send-message', { userId: user.id, text: t('no_past_lessons'), organizationId: ctx.organizationId });
 
62
  return true;
63
  }
64
 
65
  const rows = pastDays.map(day => ({
66
  id: `DAY${day.dayNumber}_REPLAY`,
67
+ title: `${t('lesson_title')} ${day.dayNumber}`,
68
+ description: t('replay_description')
69
  }));
70
 
 
71
  const limitedRows = rows.slice(-10);
72
 
73
  const { sendInteractiveListMessage } = await import('../whatsapp-cloud');
74
  await sendInteractiveListMessage(
75
  user.phone || '',
76
+ t('history_menu_title'),
77
+ t('history_menu_body'),
78
+ t('history_menu_button'),
79
  [{
80
+ title: t('history_section_title'),
81
  rows: limitedRows
82
  }]
83
  );
 
93
  logger.info({ traceId, userId: user.id, action, day: dayActionMatch[1] }, "Day action triggered");
94
 
95
  if (action === 'REPLAY' && enrollment) {
 
96
  await whatsappQueue.add('send-content', {
97
  userId: user.id,
98
  trackId: enrollment.trackId,
99
  dayNumber: parseFloat(dayActionMatch[1]),
100
+ isHistorical: true,
101
  organizationId: ctx.organizationId
102
  });
103
  return true;
104
  } else if (action === 'EXERCISE') {
105
+ await whatsappQueue.add('send-message', { userId: user.id, text: t('exercise_prompt'), organizationId: ctx.organizationId });
 
106
  return true;
107
  } else if (action === 'CONTINUE' && enrollment) {
 
108
  await whatsappQueue.add('send-content', {
109
  userId: user.id,
110
  trackId: enrollment.trackId,
 
113
  });
114
  return true;
115
  } else if (action === 'PROMPT') {
116
+ await whatsappQueue.add('send-message', { userId: user.id, text: t('text_prompt'), organizationId: ctx.organizationId });
 
 
 
 
 
 
 
 
117
  return true;
118
  }
119
  }
120
 
 
121
  return false;
122
  }
123
  }
apps/whatsapp-worker/src/handlers/EnrollHandler.ts CHANGED
@@ -75,7 +75,7 @@ export class EnrollHandler implements JobHandler {
75
  trackId: trackId || '',
76
  status: 'ACTIVE',
77
  currentDay: 1,
78
- organizationId: organizationId || 'default-org-id'
79
  }
80
  });
81
  const user = await prisma.user.findUnique({ where: { id: userId } });
 
75
  trackId: trackId || '',
76
  status: 'ACTIVE',
77
  currentDay: 1,
78
+ organizationId: organizationId as string
79
  }
80
  });
81
  const user = await prisma.user.findUnique({ where: { id: userId } });
apps/whatsapp-worker/src/handlers/InboundHandler.ts CHANGED
@@ -15,7 +15,10 @@ export class InboundHandler implements JobHandler {
15
  return;
16
  }
17
 
18
- organizationId = organizationId || 'default-org-id';
 
 
 
19
 
20
  // 🎙️ Handle Inbound Audio Transcription
21
  if (audioUrl && (!text || text.trim() === '')) {
 
15
  return;
16
  }
17
 
18
+ if (!organizationId) {
19
+ logger.error({ phone }, `[INBOUND_HANDLER] Missing organizationId — skipping to prevent cross-tenant contamination`);
20
+ return;
21
+ }
22
 
23
  // 🎙️ Handle Inbound Audio Transcription
24
  if (audioUrl && (!text || text.trim() === '')) {
apps/whatsapp-worker/src/handlers/MessageHandler.ts CHANGED
@@ -37,7 +37,11 @@ export class MessageHandler implements JobHandler {
37
 
38
  async handle(job: Job<JobData>, connection: Redis): Promise<void> {
39
  const { organizationId } = job.data;
40
- const tenantConfig = await this.getTenantConfig(organizationId || 'default-org-id', connection);
 
 
 
 
41
 
42
  switch (job.name) {
43
  case 'send-message': {
 
37
 
38
  async handle(job: Job<JobData>, connection: Redis): Promise<void> {
39
  const { organizationId } = job.data;
40
+ if (!organizationId) {
41
+ logger.error({ jobId: job.id, jobName: job.name }, '[MESSAGE_HANDLER] Missing organizationId — skipping');
42
+ return;
43
+ }
44
+ const tenantConfig = await this.getTenantConfig(organizationId, connection);
45
 
46
  switch (job.name) {
47
  case 'send-message': {
apps/whatsapp-worker/src/index.ts CHANGED
@@ -83,18 +83,18 @@ server.post('/v1/internal/whatsapp/inbound', async (req: FastifyRequest, reply:
83
  return reply.code(401).send({ error: 'Unauthorized' });
84
  }
85
 
86
- let organizationId = req.headers['x-organization-id'] as string;
87
  const payload = req.body as { entry?: Array<{ changes?: Array<{ value?: { metadata?: { phone_number_id?: string } } }> }> };
88
 
89
- // 🏢 Multi-Tenant Routing Hardening:
90
- // If we're coming from the Gateway (HF), the header might be missing or generic.
91
  // We MUST resolve it from the phone_number_id in the metadata to ensure isolation.
92
- if (!organizationId || organizationId === 'default-org-id') {
93
  const phoneNumberId = payload.entry?.[0]?.changes?.[0]?.value?.metadata?.phone_number_id;
94
  if (phoneNumberId) {
95
  const { getOrganizationByPhoneNumberId } = await import('./services/organization');
96
  organizationId = await getOrganizationByPhoneNumberId(phoneNumberId);
97
- logger.info(`[BRIDGE] Resolved Org: ${organizationId} from Phone: ${phoneNumberId}`);
98
  }
99
  }
100
 
@@ -182,7 +182,11 @@ server.get('/health', async () => ({ status: 'ok' }));
182
 
183
  // ─── WORKER ──────────────────────────────────────────────────────────────────
184
  const worker = new Worker('whatsapp-queue', async (job: Job<JobData>) => {
185
- const organizationId = job.data.organizationId || 'default-org-id';
 
 
 
 
186
 
187
  // ─── USAGE LIMIT ENFORCEMENT ─────────────────────────────────────────────
188
  // Only limit outbound message jobs
 
83
  return reply.code(401).send({ error: 'Unauthorized' });
84
  }
85
 
86
+ let organizationId: string | null = req.headers['x-organization-id'] as string || null;
87
  const payload = req.body as { entry?: Array<{ changes?: Array<{ value?: { metadata?: { phone_number_id?: string } } }> }> };
88
 
89
+ // 🏢 Multi-Tenant Routing Hardening:
90
+ // If we're coming from the Gateway (HF), the header might be missing.
91
  // We MUST resolve it from the phone_number_id in the metadata to ensure isolation.
92
+ if (!organizationId) {
93
  const phoneNumberId = payload.entry?.[0]?.changes?.[0]?.value?.metadata?.phone_number_id;
94
  if (phoneNumberId) {
95
  const { getOrganizationByPhoneNumberId } = await import('./services/organization');
96
  organizationId = await getOrganizationByPhoneNumberId(phoneNumberId);
97
+ if (organizationId) logger.info(`[BRIDGE] Resolved Org: ${organizationId} from Phone: ${phoneNumberId}`);
98
  }
99
  }
100
 
 
182
 
183
  // ─── WORKER ──────────────────────────────────────────────────────────────────
184
  const worker = new Worker('whatsapp-queue', async (job: Job<JobData>) => {
185
+ const organizationId = job.data.organizationId;
186
+ if (!organizationId) {
187
+ logger.error({ jobId: job.id, jobName: job.name }, '[WORKER] Job missing organizationId — skipping to prevent cross-tenant contamination');
188
+ return { skipped: true, reason: 'missing_org_id' };
189
+ }
190
 
191
  // ─── USAGE LIMIT ENFORCEMENT ─────────────────────────────────────────────
192
  // Only limit outbound message jobs
apps/whatsapp-worker/src/logger.ts CHANGED
@@ -13,47 +13,46 @@ const pinoLogger = pino({
13
  } : undefined
14
  });
15
 
16
- function getEnrichedObject(obj: any = {}) {
 
 
17
  const organizationId = getOrganizationId();
18
- if (organizationId) {
19
- return { ...obj, organizationId };
20
- }
21
- return obj;
22
  }
23
 
24
  export const logger = {
25
- info: (first: any, ...rest: any[]) => {
26
  const orgId = getOrganizationId();
27
  if (typeof first === 'string') {
28
  pinoLogger.info(orgId ? { organizationId: orgId } : {}, first, ...rest);
29
  } else {
30
- pinoLogger.info(getEnrichedObject(first), rest[0] || '', ...rest.slice(1));
31
  }
32
  },
33
- error: (first: any, ...rest: any[]) => {
34
  const orgId = getOrganizationId();
35
  if (first instanceof Error) {
36
- pinoLogger.error(getEnrichedObject({ err: first }), rest[0] || first.message, ...rest.slice(1));
37
  } else if (typeof first === 'string') {
38
  pinoLogger.error(orgId ? { organizationId: orgId } : {}, first, ...rest);
39
  } else {
40
- pinoLogger.error(getEnrichedObject(first), rest[0] || '', ...rest.slice(1));
41
  }
42
  },
43
- warn: (first: any, ...rest: any[]) => {
44
  const orgId = getOrganizationId();
45
  if (typeof first === 'string') {
46
  pinoLogger.warn(orgId ? { organizationId: orgId } : {}, first, ...rest);
47
  } else {
48
- pinoLogger.warn(getEnrichedObject(first), rest[0] || '', ...rest.slice(1));
49
  }
50
  },
51
- debug: (first: any, ...rest: any[]) => {
52
  const orgId = getOrganizationId();
53
  if (typeof first === 'string') {
54
  pinoLogger.debug(orgId ? { organizationId: orgId } : {}, first, ...rest);
55
  } else {
56
- pinoLogger.debug(getEnrichedObject(first), rest[0] || '', ...rest.slice(1));
57
  }
58
  },
59
  };
 
13
  } : undefined
14
  });
15
 
16
+ type LogArg = string | Error | Record<string, unknown>;
17
+
18
+ function getEnrichedObject(obj: Record<string, unknown> = {}): Record<string, unknown> {
19
  const organizationId = getOrganizationId();
20
+ return organizationId ? { ...obj, organizationId } : obj;
 
 
 
21
  }
22
 
23
  export const logger = {
24
+ info: (first: LogArg, ...rest: unknown[]) => {
25
  const orgId = getOrganizationId();
26
  if (typeof first === 'string') {
27
  pinoLogger.info(orgId ? { organizationId: orgId } : {}, first, ...rest);
28
  } else {
29
+ pinoLogger.info(getEnrichedObject(first as Record<string, unknown>), (rest[0] as string) || '', ...rest.slice(1));
30
  }
31
  },
32
+ error: (first: LogArg, ...rest: unknown[]) => {
33
  const orgId = getOrganizationId();
34
  if (first instanceof Error) {
35
+ pinoLogger.error(getEnrichedObject({ err: first }), (rest[0] as string) || first.message, ...rest.slice(1));
36
  } else if (typeof first === 'string') {
37
  pinoLogger.error(orgId ? { organizationId: orgId } : {}, first, ...rest);
38
  } else {
39
+ pinoLogger.error(getEnrichedObject(first as Record<string, unknown>), (rest[0] as string) || '', ...rest.slice(1));
40
  }
41
  },
42
+ warn: (first: LogArg, ...rest: unknown[]) => {
43
  const orgId = getOrganizationId();
44
  if (typeof first === 'string') {
45
  pinoLogger.warn(orgId ? { organizationId: orgId } : {}, first, ...rest);
46
  } else {
47
+ pinoLogger.warn(getEnrichedObject(first as Record<string, unknown>), (rest[0] as string) || '', ...rest.slice(1));
48
  }
49
  },
50
+ debug: (first: LogArg, ...rest: unknown[]) => {
51
  const orgId = getOrganizationId();
52
  if (typeof first === 'string') {
53
  pinoLogger.debug(orgId ? { organizationId: orgId } : {}, first, ...rest);
54
  } else {
55
+ pinoLogger.debug(getEnrichedObject(first as Record<string, unknown>), (rest[0] as string) || '', ...rest.slice(1));
56
  }
57
  },
58
  };
apps/whatsapp-worker/src/pedagogy.ts CHANGED
@@ -7,6 +7,10 @@ import { ButtonsJson } from './handlers/types';
7
  import { castJson } from '@repo/shared-types';
8
  import { AIPedagogyService } from './services/ai-pedagogy';
9
 
 
 
 
 
10
  const BADGE_EMOJIS: Record<string, string> = {
11
  "CLARTÉ": "🏅", "CONFIANCE": "🌟", "CLIENT": "👥", "OFFRE": "📦", "PITCH": "🎙️"
12
  };
@@ -18,14 +22,13 @@ function generateProgressBar(current: number, total: number): string {
18
  return `[${bar}] ${Math.round((current / total) * 100)}%`;
19
  }
20
 
21
- function generateLessonHeader(isWolof: boolean, trackTitle: string, dayNumber: number, totalDays: number, badgeName?: string): string {
 
22
  const progressBar = isFeatureEnabled('FEATURE_PROGRESS_BAR') ? `\n${generateProgressBar(dayNumber, totalDays)}` : '';
23
  const badgeText = badgeName ? `\nBadge : ${badgeName} ${BADGE_EMOJIS[badgeName] || '🏅'}` : '';
24
  const dayDisplay = dayNumber === 1.5 ? '1bis' : Math.floor(dayNumber).toString();
25
 
26
- return isWolof
27
- ? `*${trackTitle}*\n*Bés ${dayDisplay}* 🗓️${progressBar}${badgeText}\n\n`
28
- : `*${trackTitle}*\n*Jour ${dayDisplay}* 🗓️${progressBar}${badgeText}\n\n`;
29
  }
30
 
31
  export async function sendLessonDay(
@@ -45,7 +48,7 @@ export async function sendLessonDay(
45
  });
46
 
47
  if (!user || !user.phone) return;
48
- const isWolof = user.language === 'WOLOF';
49
  const activeEnrollment = user.enrollments[0];
50
  const tenantConfig = {
51
  accessToken: user.organization?.systemUserToken || '',
@@ -94,14 +97,14 @@ export async function sendLessonDay(
94
  const badges = (userProgress?.badges as string[]) || [];
95
  const lastBadge = badges.length > 0 ? (badges[badges.length - 1] === 'REPRISE' && dayNumber % 1 === 0 ? badges[badges.length - 2] : badges[badges.length - 1]) : undefined;
96
 
97
- lessonText = generateLessonHeader(isWolof, activeEnrollment?.track?.title || 'XAMLÉ', dayNumber, totalDays, lastBadge) + lessonText;
98
 
99
  // Visuals Dispatch
100
  if (trackDay.videoUrl) {
101
  try {
102
- await sendVideoMessage(user.phone, trackDay.videoUrl, trackDay.videoCaption || (isWolof ? "Xoolal vidéo bi !" : "Regarde cette vidéo !"), tenantConfig);
103
  } catch (err) {
104
- const fallbackMsg = isWolof ? `⚠️ Vidéo bi mënul neex léegi. Klikal fii :\n${trackDay.videoUrl}` : `⚠️ La vidéo ne peut être affichée directement. Clique ici :\n${trackDay.videoUrl}`;
105
  await sendTextMessage(user.phone, fallbackMsg, tenantConfig);
106
  if (trackDay.imageUrl) await sendImageMessage(user.phone, trackDay.imageUrl, undefined, tenantConfig);
107
  }
@@ -135,13 +138,13 @@ export async function sendLessonDay(
135
  // Action Menu
136
  const isHistorical = options?.skipProgressUpdate === true || dayNumber < (activeEnrollment?.currentDay || 1);
137
  if (dayNumber === 1 && !isHistorical) {
138
- await sendTextMessage(user.phone, isWolof ? "🎙️ Tontul kàddu gi ci vocal walla mbind." : "🎙️ Réponds par message vocal ou texte.", tenantConfig);
139
  } else {
140
- const rows = isHistorical ? [{ id: `DAY${dayNumber}_REPLAY`, title: isWolof ? `🎧 Refaire Bés ${dayNumber}` : `🎧 Refaire Leçon ${dayNumber}` }] : [
141
- { id: `DAY${dayNumber}_EXERCISE`, title: isWolof ? "📝 Tontul" : "📝 Répondre" },
142
- { id: `MENU_HISTORIQUE`, title: isWolof ? "📚 Li nekk ci ginnaaw" : "📚 Revoir leçons" }
143
  ];
144
- await sendInteractiveListMessage(user.phone, isWolof ? "Sa Mbir" : "Actions", isWolof ? "Tànnal :" : "Choisis :", isWolof ? "Tànn" : "Menu", [{ title: "Menu", rows }], undefined, tenantConfig);
145
  }
146
  }
147
 
 
7
  import { castJson } from '@repo/shared-types';
8
  import { AIPedagogyService } from './services/ai-pedagogy';
9
 
10
+ import { getT } from './services/i18n';
11
+
12
+ import { Language } from '@repo/database';
13
+
14
  const BADGE_EMOJIS: Record<string, string> = {
15
  "CLARTÉ": "🏅", "CONFIANCE": "🌟", "CLIENT": "👥", "OFFRE": "📦", "PITCH": "🎙️"
16
  };
 
22
  return `[${bar}] ${Math.round((current / total) * 100)}%`;
23
  }
24
 
25
+ function generateLessonHeader(lang: Language, trackTitle: string, dayNumber: number, totalDays: number, badgeName?: string): string {
26
+ const t = getT(lang);
27
  const progressBar = isFeatureEnabled('FEATURE_PROGRESS_BAR') ? `\n${generateProgressBar(dayNumber, totalDays)}` : '';
28
  const badgeText = badgeName ? `\nBadge : ${badgeName} ${BADGE_EMOJIS[badgeName] || '🏅'}` : '';
29
  const dayDisplay = dayNumber === 1.5 ? '1bis' : Math.floor(dayNumber).toString();
30
 
31
+ return `*${trackTitle}*\n*${t('day_label')} ${dayDisplay}* 🗓️${progressBar}${badgeText}\n\n`;
 
 
32
  }
33
 
34
  export async function sendLessonDay(
 
48
  });
49
 
50
  if (!user || !user.phone) return;
51
+ const t = getT(user.language);
52
  const activeEnrollment = user.enrollments[0];
53
  const tenantConfig = {
54
  accessToken: user.organization?.systemUserToken || '',
 
97
  const badges = (userProgress?.badges as string[]) || [];
98
  const lastBadge = badges.length > 0 ? (badges[badges.length - 1] === 'REPRISE' && dayNumber % 1 === 0 ? badges[badges.length - 2] : badges[badges.length - 1]) : undefined;
99
 
100
+ lessonText = generateLessonHeader(user.language, activeEnrollment?.track?.title || 'XAMLÉ', dayNumber, totalDays, lastBadge) + lessonText;
101
 
102
  // Visuals Dispatch
103
  if (trackDay.videoUrl) {
104
  try {
105
+ await sendVideoMessage(user.phone, trackDay.videoUrl, trackDay.videoCaption || t('video_caption'), tenantConfig);
106
  } catch (err) {
107
+ const fallbackMsg = `${t('video_fallback')}\n${trackDay.videoUrl}`;
108
  await sendTextMessage(user.phone, fallbackMsg, tenantConfig);
109
  if (trackDay.imageUrl) await sendImageMessage(user.phone, trackDay.imageUrl, undefined, tenantConfig);
110
  }
 
138
  // Action Menu
139
  const isHistorical = options?.skipProgressUpdate === true || dayNumber < (activeEnrollment?.currentDay || 1);
140
  if (dayNumber === 1 && !isHistorical) {
141
+ await sendTextMessage(user.phone, t('exercise_prompt_hint'), tenantConfig);
142
  } else {
143
+ const rows = isHistorical ? [{ id: `DAY${dayNumber}_REPLAY`, title: `🎧 ${t('replay_lesson_title')} ${dayNumber}` }] : [
144
+ { id: `DAY${dayNumber}_EXERCISE`, title: `📝 ${t('reply_button')}` },
145
+ { id: `MENU_HISTORIQUE`, title: `📚 ${t('review_lessons_button')}` }
146
  ];
147
+ await sendInteractiveListMessage(user.phone, t('action_menu_header'), t('action_menu_body'), t('action_menu_label'), [{ title: "Menu", rows }], undefined, tenantConfig);
148
  }
149
  }
150
 
apps/whatsapp-worker/src/services/i18n.ts ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Language } from '@repo/database';
2
+
3
+ export const translations: Record<Language, any> = {
4
+ FR: {
5
+ seed_success: "✅ Seeding terminé ! Le Cache Cognitif a été réinitialisé.\nEnvoie INSCRIPTION pour commencer.",
6
+ seed_exists: "ℹ️ Les données existent déjà. Cache Cognitif purgé. Envoie INSCRIPTION.",
7
+ seed_error: "❌ Erreur lors de la réinitialisation.",
8
+ no_past_lessons: "Tu n'as pas encore d'anciennes leçons !",
9
+ lesson_title: "Leçon",
10
+ replay_description: "Revoir cette leçon",
11
+ history_menu_title: "Ton parcours",
12
+ history_menu_body: "Choisis la leçon que tu souhaites revoir :",
13
+ history_menu_button: "Parcourir",
14
+ history_section_title: "Leçons passées",
15
+ exercise_prompt: "🎙️ J'écoute ta réponse (vocal ou texte) :",
16
+ text_prompt: "🖊️ Envoie ta réponse texte ou vocale :",
17
+ short_message_error: "Je n'ai pas bien compris. Peux-tu me réexpliquer en quelques mots ?",
18
+ welcome_fallback: "🎓 Bienvenue chez XAMLÉ !\nPour commencer ta formation gratuite, envoie le mot : *INSCRIPTION*",
19
+ day_label: "Jour",
20
+ video_caption: "Regarde cette vidéo !",
21
+ video_fallback: "⚠️ La vidéo ne peut être affichée directement. Clique ici :",
22
+ replay_lesson_title: "Refaire Leçon",
23
+ reply_button: "Répondre",
24
+ review_lessons_button: "Revoir leçons",
25
+ action_menu_header: "Actions",
26
+ action_menu_body: "Choisis :",
27
+ action_menu_label: "Menu",
28
+ exercise_prompt_hint: "🎙️ Réponds par message vocal ou texte."
29
+ },
30
+ EN: {
31
+ seed_success: "✅ Seeding completed! Cognitive Cache has been reset.\nSend ENROLL to start.",
32
+ seed_exists: "ℹ️ Data already exists. Cognitive Cache purged. Send ENROLL.",
33
+ seed_error: "❌ Seed error.",
34
+ no_past_lessons: "You don't have any past lessons yet!",
35
+ lesson_title: "Lesson",
36
+ replay_description: "Review this lesson",
37
+ history_menu_title: "Your Journey",
38
+ history_menu_body: "Choose the lesson you want to review:",
39
+ history_menu_button: "Browse",
40
+ history_section_title: "Past Lessons",
41
+ exercise_prompt: "🎙️ I'm listening to your answer (voice or text):",
42
+ text_prompt: "🖊️ Send your text or voice reply:",
43
+ short_message_error: "I didn't quite understand. Could you explain it to me in a few more words?",
44
+ welcome_fallback: "🎓 Welcome to XAMLÉ!\nTo start your free training, send the word: *ENROLL*",
45
+ day_label: "Day",
46
+ video_caption: "Watch this video!",
47
+ video_fallback: "⚠️ The video cannot be displayed directly. Click here:",
48
+ replay_lesson_title: "Replay Lesson",
49
+ reply_button: "Reply",
50
+ review_lessons_button: "Review lessons",
51
+ action_menu_header: "Actions",
52
+ action_menu_body: "Choose:",
53
+ action_menu_label: "Menu",
54
+ exercise_prompt_hint: "🎙️ Reply with a voice or text message."
55
+ },
56
+ ES: {
57
+ seed_success: "✅ ¡Seeding completado! El Caché Cognitivo ha sido reiniciado.\nEnvía INSCRIPCIÓN para comenzar.",
58
+ seed_exists: "ℹ️ Los datos ya existen. Caché Cognitivo purgado. Envía INSCRIPCIÓN.",
59
+ seed_error: "❌ Error de semilla.",
60
+ no_past_lessons: "¡Aún no tienes lecciones pasadas!",
61
+ lesson_title: "Lección",
62
+ replay_description: "Repasar esta lección",
63
+ history_menu_title: "Tu Recorrido",
64
+ history_menu_body: "Elige la lección que deseas repasar:",
65
+ history_menu_button: "Explorar",
66
+ history_section_title: "Lecciones pasadas",
67
+ exercise_prompt: "🎙️ Estoy escuchando tu respuesta (voz o texto):",
68
+ text_prompt: "🖊️ Envía tu respuesta por texto o voz:",
69
+ short_message_error: "No he entendido bien. ¿Podrías explicármelo con unas palabras más?",
70
+ welcome_fallback: "🎓 ¡Bienvenido a XAMLÉ!\nPara comenzar tu formación gratuita, envía la palabra: *INSCRIPCIÓN*",
71
+ day_label: "Día",
72
+ video_caption: "¡Mira este video!",
73
+ video_fallback: "⚠️ El video no se puede mostrar directamente. Haz clic aquí:",
74
+ replay_lesson_title: "Repetir Lección",
75
+ reply_button: "Responder",
76
+ review_lessons_button: "Revisar lecciones",
77
+ action_menu_header: "Acciones",
78
+ action_menu_body: "Elegir:",
79
+ action_menu_label: "Menú",
80
+ exercise_prompt_hint: "🎙️ Responde con un mensaje de voz o texto."
81
+ },
82
+ PT: {
83
+ seed_success: "✅ Seeding concluído! O Cache Cognitivo foi reiniciado.\nEnvie INSCRIÇÃO para começar.",
84
+ seed_exists: "ℹ️ Os dados já existem. Cache Cognitivo purgado. Envie INSCRIÇÃO.",
85
+ seed_error: "❌ Erro de semente.",
86
+ no_past_lessons: "Você ainda não tem lições passadas!",
87
+ lesson_title: "Lição",
88
+ replay_description: "Rever esta lição",
89
+ history_menu_title: "Sua Jornada",
90
+ history_menu_body: "Escolha a lição que deseja rever:",
91
+ history_menu_button: "Navegar",
92
+ history_section_title: "Lições passadas",
93
+ exercise_prompt: "🎙️ Estou ouvindo sua resposta (voz ou texto):",
94
+ text_prompt: "🖊️ Envie sua resposta por texto ou voz:",
95
+ short_message_error: "Não entendi muito bem. Poderia explicar em mais algumas palavras?",
96
+ welcome_fallback: "🎓 Bem-vindo à XAMLÉ!\nPara iniciar sua formação gratuita, envie a palavra: *INSCRIÇÃO*",
97
+ day_label: "Dia",
98
+ video_caption: "Assista a este vídeo!",
99
+ video_fallback: "⚠️ O vídeo não pode ser exibido diretamente. Clique aqui:",
100
+ replay_lesson_title: "Repetir Lição",
101
+ reply_button: "Responder",
102
+ review_lessons_button: "Revisar lições",
103
+ action_menu_header: "Ações",
104
+ action_menu_body: "Escolha:",
105
+ action_menu_label: "Menu",
106
+ exercise_prompt_hint: "🎙️ Responda com uma mensagem de voz ou texto."
107
+ },
108
+ WOLOF: {
109
+ seed_success: "✅ Seeding terminé ! Le Cache Cognitif a été réinitialisé.\nEnvoie INSCRIPTION pour commencer.",
110
+ seed_exists: "ℹ️ Les données existent déjà. Cache Cognitif purgé. Envoie INSCRIPTION.",
111
+ seed_error: "❌ Erreur seed",
112
+ no_past_lessons: "Amul bés yu passé ba pare !",
113
+ lesson_title: "Bés",
114
+ replay_description: "Revoir cette leçon",
115
+ history_menu_title: "Sa njàng",
116
+ history_menu_body: "Tànnal bés bi nga bëgg tontu waat :",
117
+ history_menu_button: "Seeti",
118
+ history_section_title: "Bés yu passé",
119
+ exercise_prompt: "🎙️ J'écoute ta réponse (vocal wala mbind) :",
120
+ text_prompt: "🖊️ Dafa neex ma xam sa xibaar...",
121
+ short_message_error: "Dama lay xaar nga wax ma lu gën a yaatu ci sa mbir. Waxtaanal ak man !",
122
+ welcome_fallback: "🎓 Bienvenue chez XAMLÉ !\nPour commencer ta formation gratuite, envoie le mot : *INSCRIPTION*",
123
+ day_label: "Bés",
124
+ video_caption: "Xoolal vidéo bi !",
125
+ video_fallback: "⚠️ Vidéo bi mënul neex léegi. Klikal fii :",
126
+ replay_lesson_title: "Refaire Bés",
127
+ reply_button: "Tontul",
128
+ review_lessons_button: "Li nekk ci ginnaaw",
129
+ action_menu_header: "Sa Mbir",
130
+ action_menu_body: "Tànnal :",
131
+ action_menu_label: "Tànn",
132
+ exercise_prompt_hint: "🎙️ Tontul kàddu gi ci vocal walla mbind."
133
+ }
134
+ };
135
+
136
+ export function getT(lang: Language = 'FR') {
137
+ return (key: string) => {
138
+ const langStrings = translations[lang] || translations['FR'];
139
+ return langStrings[key] || key;
140
+ };
141
+ }
apps/whatsapp-worker/src/services/organization.ts CHANGED
@@ -87,7 +87,7 @@ export async function invalidateOrgCache(id: string) {
87
  }
88
  }
89
 
90
- export async function getOrganizationByPhoneNumberId(phoneNumberId: string): Promise<string> {
91
  const cacheKey = `org:phone:${phoneNumberId}`;
92
 
93
  // 1. Check L1 Cache
@@ -122,8 +122,6 @@ export async function getOrganizationByPhoneNumberId(phoneNumberId: string): Pro
122
  return orgId;
123
  }
124
 
125
- // 4. Fallback to default organization
126
- const defaultOrgId = 'default-org-id';
127
- logger.warn(`[ORG-SERVICE] No organization found for phone_number_id: ${phoneNumberId}. Falling back to ${defaultOrgId}`);
128
- return defaultOrgId;
129
  }
 
87
  }
88
  }
89
 
90
+ export async function getOrganizationByPhoneNumberId(phoneNumberId: string): Promise<string | null> {
91
  const cacheKey = `org:phone:${phoneNumberId}`;
92
 
93
  // 1. Check L1 Cache
 
122
  return orgId;
123
  }
124
 
125
+ logger.warn(`[ORG-SERVICE] No organization found for phone_number_id: ${phoneNumberId} — rejecting`);
126
+ return null;
 
 
127
  }
apps/whatsapp-worker/src/services/whatsapp-logic.ts CHANGED
@@ -11,6 +11,7 @@ import { CommandHandler } from '../handlers/CommandHandler';
11
  import { NavigationHandler } from '../handlers/NavigationHandler';
12
  import { ExerciseHandler } from '../handlers/ExerciseHandler';
13
  import { AIAgentHandler } from '../handlers/AIAgentHandler';
 
14
 
15
  const connection = process.env.REDIS_URL
16
  ? new Redis(process.env.REDIS_URL, { maxRetriesPerRequest: null })
@@ -46,10 +47,14 @@ export class WhatsAppLogic {
46
  text: string,
47
  audioUrl?: string,
48
  imageUrl?: string,
49
- organizationId: string = 'default-org-id',
50
  mediaType?: string,
51
  mediaId?: string
52
  ) {
 
 
 
 
53
  const traceId = audioUrl ? `[STT-FLOW-${phone.slice(-4)}]` : imageUrl ? `[IMG-FLOW-${phone.slice(-4)}]` : `[TXT-FLOW-${phone.slice(-4)}]`;
54
  const normalizedText = this.normalizeCommand(text);
55
  logger.info(`${traceId} Orchestrating Inbound: ${normalizedText}`);
@@ -97,14 +102,13 @@ export class WhatsAppLogic {
97
  };
98
 
99
  // 3. Short Message Guard (Safety)
100
- const systemCommands = ['1', '2', 'SUITE', 'APPROFONDIR', 'INSCRIPTION', 'SEED'];
101
  const isSystemCommand = systemCommands.some(cmd => isFuzzyMatch(normalizedText, cmd)) || normalizedText.includes('INSCRI');
102
 
 
 
103
  if (text.length < 2 && !isSystemCommand) {
104
- const lang = user?.language || 'FR';
105
- const msg = lang === 'WOLOF'
106
- ? "Dama lay xaar nga wax ma lu gën a yaatu ci sa mbir. Waxtaanal ak man !"
107
- : "Je n'ai pas bien compris. Peux-tu me réexpliquer en quelques mots ?";
108
 
109
  if (user) {
110
  await whatsappQueue.add('send-message', { userId: user.id, text: msg, organizationId });
@@ -133,7 +137,7 @@ export class WhatsAppLogic {
133
  if (!user) {
134
  await whatsappQueue.add('send-message-direct', {
135
  phone,
136
- text: "🎓 Bienvenue chez XAMLÉ !\nPour commencer ta formation gratuite, envoie le mot : *INSCRIPTION*",
137
  organizationId
138
  });
139
  } else {
 
11
  import { NavigationHandler } from '../handlers/NavigationHandler';
12
  import { ExerciseHandler } from '../handlers/ExerciseHandler';
13
  import { AIAgentHandler } from '../handlers/AIAgentHandler';
14
+ import { getT } from './i18n';
15
 
16
  const connection = process.env.REDIS_URL
17
  ? new Redis(process.env.REDIS_URL, { maxRetriesPerRequest: null })
 
47
  text: string,
48
  audioUrl?: string,
49
  imageUrl?: string,
50
+ organizationId?: string,
51
  mediaType?: string,
52
  mediaId?: string
53
  ) {
54
+ if (!organizationId) {
55
+ logger.error({ phone }, '[WHATSAPP_LOGIC] Missing organizationId — refusing to process to prevent cross-tenant contamination');
56
+ return;
57
+ }
58
  const traceId = audioUrl ? `[STT-FLOW-${phone.slice(-4)}]` : imageUrl ? `[IMG-FLOW-${phone.slice(-4)}]` : `[TXT-FLOW-${phone.slice(-4)}]`;
59
  const normalizedText = this.normalizeCommand(text);
60
  logger.info(`${traceId} Orchestrating Inbound: ${normalizedText}`);
 
102
  };
103
 
104
  // 3. Short Message Guard (Safety)
105
+ const systemCommands = ['1', '2', 'SUITE', 'APPROFONDIR', 'INSCRIPTION', 'SEED', 'ENROLL', 'INSCRIÇÃO'];
106
  const isSystemCommand = systemCommands.some(cmd => isFuzzyMatch(normalizedText, cmd)) || normalizedText.includes('INSCRI');
107
 
108
+ const t = getT(user?.language || 'FR');
109
+
110
  if (text.length < 2 && !isSystemCommand) {
111
+ const msg = t('short_message_error');
 
 
 
112
 
113
  if (user) {
114
  await whatsappQueue.add('send-message', { userId: user.id, text: msg, organizationId });
 
137
  if (!user) {
138
  await whatsappQueue.add('send-message-direct', {
139
  phone,
140
+ text: t('welcome_fallback'),
141
  organizationId
142
  });
143
  } else {
docs/audit_dette_technique_03052026.md CHANGED
@@ -146,6 +146,49 @@ La fonction existe dans `apps/api/src/services/organization.ts` ET `apps/whatsap
146
  | R4 | 🟡 | `process.exit(1)` dans config | ✅ Corrigé |
147
  | R5 | 🟢 | Logique dupliquée WhatsApp/org service | 📋 Backlog |
148
  | R6 | 🟢 | Catch vide silencieux dans whatsapp.ts | ✅ Corrigé |
149
- | R1b | 🟢 | ~75 casts `as any` résiduels (plugins Fastify, etc.) | 📋 Backlog |
150
-
151
- **9/12 problèmes corrigés. 2 en backlog (non-critiques).**
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
146
  | R4 | 🟡 | `process.exit(1)` dans config | ✅ Corrigé |
147
  | R5 | 🟢 | Logique dupliquée WhatsApp/org service | 📋 Backlog |
148
  | R6 | 🟢 | Catch vide silencieux dans whatsapp.ts | ✅ Corrigé |
149
+ | R1b | 🟢 | ~75 casts `as any` résiduels | Corrigé (session 3) |
150
+ | S2b | 🔴 | Fallback `default-org-id` côté worker (InboundHandler, MessageHandler, WhatsAppLogic, EnrollHandler, index.ts) | ✅ Corrigé (session 3) |
151
+ | S2c | 🔴 | Fallback `default-org-id` côté API (services/organization.ts + internal.ts) | Corrigé (session 4) |
152
+
153
+ **13/13 problèmes corrigés. R5 reste en backlog (non-critique, extraction package).**
154
+
155
+ ### Session 4 — Détail des corrections supplémentaires
156
+
157
+ **S2c — Élimination des derniers `default-org-id` côté API :**
158
+ - `apps/api/src/services/organization.ts` : `getOrganizationByPhoneNumberId` → retourne `string | null` au lieu de `'default-org-id'`
159
+ - `apps/api/src/routes/internal.ts` : guard `if (!organizationId) { continue }` — aucun job enqueué pour un téléphone inconnu
160
+ - `apps/api/src/middleware/tenant.ts` : fichier mort supprimé (jamais importé nulle part)
161
+ - Idem côté worker : `apps/whatsapp-worker/src/services/organization.ts` + `index.ts` (type `string | null`, guard simplifié)
162
+
163
+ **Logger typé :**
164
+ - `apps/api/src/logger.ts` : interface `AppLogger` + `LogFn` — suppression du `as any` sur l'export
165
+ - `apps/whatsapp-worker/src/logger.ts` : type `LogArg = string | Error | Record<string, unknown>` sur tous les paramètres
166
+ - `apps/whatsapp-worker/src/fix-types.ts` : script de migration obsolète supprimé
167
+
168
+ ### Session 3 — Détail des corrections supplémentaires
169
+
170
+ **R1b — Nettoyage `as any` complet :**
171
+ - `withTenantIsolation` → retourne `TenantClient extends PrismaClient` (élimine tous les `(prisma as any).modelName`)
172
+ - `ioredis` dédupliqué à `5.9.3` via override pnpm (élimine `connection as any` dans BullMQ)
173
+ - `QuotaExceededError` interface + `isQuotaExceeded()` guard dans `ai.ts` et `openai-provider.ts`
174
+ - `parsePersonalityConfig()` / `PersonalityConfig` utilisés à la place des casts `personalityConfig as any`
175
+ - `getWAErrorMessage(AxiosError<>)` helper dans `whatsapp-cloud.ts`
176
+ - `TranscriptionVerbose` pour le retour de Whisper `verbose_json`
177
+ - `MultipartValue` correctement inféré via discriminant `part.type === 'field'`
178
+ - `cors`, `rateLimit`, `BullMQAdapter` — casts plugin supprimés (tous compilent maintenant sans)
179
+ - Redis `.set()` avec `'EX'` littéral au lieu de `mode as any`
180
+ - `previousResponses.flatMap(r => r.response !== null ? [...] : [])` pour le null safety
181
+
182
+ **S2c — Isolation multi-tenant API :**
183
+ - `apps/api/src/services/organization.ts` : `getOrganizationByPhoneNumberId` retourne `null` au lieu de `'default-org-id'`
184
+ - `apps/api/src/routes/internal.ts` : guard `if (!organizationId) { continue }` — le job n'est jamais enqueué pour un téléphone inconnu
185
+ - `apps/api/src/middleware/tenant.ts` : fichier mort supprimé (jamais importé)
186
+ - `apps/whatsapp-worker/src/services/organization.ts` : même correction, retourne `null`
187
+ - `apps/whatsapp-worker/src/index.ts` : suppression du check `=== 'default-org-id'`, type `string | null`
188
+
189
+ **S2b — Isolation multi-tenant worker :**
190
+ - `index.ts` job processor : fail-fast si `organizationId` manquant (return `{ skipped: true }`)
191
+ - `InboundHandler` : guard explicite, log + return si org manquante
192
+ - `MessageHandler` : guard + early return si org manquante
193
+ - `WhatsAppLogic.handleIncomingMessage` : `organizationId?: string` + guard interne
194
+ - `EnrollHandler` : suppression du fallback `|| 'default-org-id'` sur le create
docs/audit_meta_app_review_2026-05-04.md ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Audit Technique : App Review Meta (WhatsApp Business API)
2
+ **Date :** 04 mai 2026
3
+ **Objectif :** État des lieux pour la validation des permissions Meta (App Review).
4
+
5
+ ---
6
+
7
+ ## 1. Flux de Connexion et Permissions (`business_management`)
8
+ **Statut : ✅ OPÉRATIONNEL (Côté Code)**
9
+
10
+ * **Analyse :** Contrairement à une architecture purement Server-to-Server manuelle, nous avons implémenté le **Meta Embedded Signup**.
11
+ * **Frontend :** Un bouton "Se connecter avec Facebook" existe dans `OnboardingWizard.tsx` (Ligne 170). Il appelle la fonction `launchEmbeddedSignup()`.
12
+ * **API :** L'endpoint `POST /v1/organizations/:id/whatsapp-setup` est prêt à recevoir et chiffrer le `systemUserToken` et le `wabaId` renvoyés par Meta.
13
+ * **Action pour le Screencast :** Il faut filmer le parcours dans l'Onboarding Wizard, du clic sur le bouton jusqu'à l'apparition du badge "Compte Facebook connecté !".
14
+
15
+ ---
16
+
17
+ ## 2. Messagerie de bout en bout (`whatsapp_business_messaging`)
18
+ **Statut : ✅ OPÉRATIONNEL**
19
+
20
+ * **Analyse :** Meta veut voir l'envoi et la réception réelle.
21
+ * **Frontend :** La page `CrmConversationalDashboard.tsx` dispose de deux modes :
22
+ * `assistant` : Pour envoyer des messages (Broadcast/Campagnes).
23
+ * `inbox` : Pour voir les messages entrants et y répondre manuellement (`handleReply`).
24
+ * **API :** Les routes `/messages/reply` et `/campaigns/broadcast` sont fonctionnelles.
25
+ * **Action pour le Screencast :** Filmer un écran scindé ou alterné :
26
+ 1. Un utilisateur envoie un message depuis son téléphone vers le bot.
27
+ 2. Le message apparaît dans l' `inbox` du Dashboard.
28
+ 3. L'admin répond depuis le Dashboard et le message arrive sur le téléphone.
29
+
30
+ ---
31
+
32
+ ## 3. Gestion des Templates (`whatsapp_business_management`)
33
+ **Statut : ❌ NON IMPLÉMENTÉ**
34
+
35
+ * **Analyse :** C'est le point de blocage majeur identifié par Meta. Nous n'avons actuellement **aucune interface** ni **aucune route API** pour gérer les modèles de messages (Message Templates).
36
+ * **Tâches de développement nécessaires :**
37
+ * **API :** Créer des routes pour lister les templates Meta (`GET /v1/whatsapp/templates`), en créer de nouveaux et vérifier leur statut de validation.
38
+ * **Frontend :** Créer une page `TemplatesPage.tsx` permettant de visualiser la liste des modèles approuvés par Meta.
39
+ * **Action :** Ce module doit être développé en priorité pour le prochain screencast.
40
+
41
+ ---
42
+
43
+ ## 4. Vérification d'accès (`manage_app_solution`)
44
+ **Statut : 🟡 EN ATTENTE (Administratif/Config)**
45
+
46
+ * **Analyse :** Ce rejet est souvent lié au fait que l'application est toujours en "Mode Développement" dans le dashboard Meta ou que l'entité Business Manager n'est pas encore totalement vérifiée.
47
+ * **Point Technique :** Il n'y a pas de blocage de code ici, mais Meta attend que l'App soit associée à une "Solution" de fournisseur de technologie validée.
48
+ * **Action :** Vérifier les paramètres de "Solution" dans le portail Meta Developers.
49
+
50
+ ---
51
+
52
+ ## 5. Internationalisation (i18n)
53
+ **Statut : 🔄 EN COURS**
54
+
55
+ * **Analyse :** Meta exige que l'interface de démonstration soit en anglais pour la validation internationale.
56
+ * **Frontend :**
57
+ * `react-i18next` est initialisé avec support EN, FR, ES, PT.
58
+ * Le fichier `en.json` a été enrichi pour couvrir l'intégralité du parcours utilisateur.
59
+ * Un composant `LanguageSwitcher` (Premium UI) est actif dans la Navbar.
60
+ * **Backend (Bot WhatsApp) :**
61
+ * Le schéma Prisma est **déjà compatible** : les modèles `Contact` et `User` possèdent un champ `language` (Enum: EN, FR, ES, PT, WOLOF).
62
+ * La logique du `CommandHandler` est prête à être migrée vers une gestion multilingue dynamique (remplacement du flag binaire `isWolof`).
63
+
64
+ ---
65
+
66
+ ## 🚀 Liste des tâches de développement (Roadmap Screencast)
67
+
68
+ ### Frontend (Dashboard Admin)
69
+ 1. **Internationalisation (i18n) :** (🔄 En cours) - Interface prête pour le tournage en Anglais.
70
+ 2. **Création de la page Templates :** Créer `apps/admin/src/pages/TemplatesPage.tsx` pour lister les modèles WhatsApp.
71
+ 3. **Amélioration de l'Inbox :** S'assurer que le statut des messages (Envoyé/Délivré/Lu) est bien visible.
72
+
73
+ ### API (Backend)
74
+ 1. **Logique Multilingue Worker :** Adapter le `CommandHandler` et le service IA pour répondre selon la préférence `language` de l'utilisateur. (🔄 Prochaine étape)
75
+ 2. **Service Template :** Implémenter les appels au Meta Graph API pour récupérer les templates dans `apps/api/src/services/whatsapp.ts`.
76
+ 3. **Routes Template :** Exposer ces données via de nouvelles routes dans `apps/api/src/routes/whatsapp.ts`.
77
+
78
+ ### Documentation / Vidéo
79
+ 1. Préparer un script de démonstration fluide en **Anglais** incluant l'étape d'onboarding (Embedded Signup).
docs/global_audit_report_2026-05-04.md ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Global Code Audit Report - May 4, 2026
2
+
3
+ ## 1. Security & Multi-Tenancy (CRITICAL)
4
+ - **Finding**: Several administrative routes in `apps/api/src/routes/admin.ts` (e.g., `/stats`, `/users`, `/enrollments`) do not enforce tenant isolation.
5
+ - **Impact**: An `ORG_ADMIN` from Organization A could potentially see data (counts, lists) from Organization B by making direct API calls.
6
+ - **Required Action**: Refactor all `prisma.model.findMany()` and `prisma.model.count()` calls in `admin.ts` to include a `where: { organizationId }` clause using the `request.organizationId` injected by the `onRequest` hook.
7
+
8
+ ## 2. Internationalization (High Priority)
9
+ - **Finding**: While the i18n infrastructure (`react-i18next`) is correctly initialized and `en.json` exists, the majority of the Admin UI (Campaign History, Contacts, Dashboard) still contains hardcoded French strings.
10
+ - **Impact**: Unprofessional experience for non-French speakers and potential rejection during Meta App Review if the screencast shows mixed languages.
11
+ - **Required Action**: Systematically replace hardcoded strings with the `t()` function from `useTranslation`.
12
+
13
+ ## 3. WhatsApp Templates Module (Missing)
14
+ - **Finding**: No logic exists for fetching or creating Meta Message Templates.
15
+ - **Impact**: Blocking for the Meta App Review "whatsapp_business_management" permission.
16
+ - **Required Action**: Proceed with the implementation plan after fixing the critical security issues.
17
+
18
+ ## 4. Technical Debt & Cleanup
19
+ - **Finding**: Minor TypeScript error in `src/components/LanguageSwitcher.tsx` (unused import).
20
+ - **Finding**: Duplicated code was found and fixed in `CommandHandler.ts` during the previous turn.
21
+
22
+ ---
23
+
24
+ ## Conclusion
25
+ The codebase is structurally sound but lacks rigorous tenant isolation in the admin module. Fixing this is a prerequisite for adding the WhatsApp Templates module to ensure the new routes don't introduce further vulnerabilities.
docs/implementation_plan_whatsapp_templates.md ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Implementation Plan: WhatsApp Templates Management & Security Hardening
2
+
3
+ This document outlines the technical implementation for the WhatsApp Message Templates management interface and the prerequisite security hardening of the administrative routes.
4
+
5
+ ## 1. Objectives
6
+ - **Security First**: Fix tenant isolation vulnerabilities in administrative routes.
7
+ - **Meta Compliance**: Provide a UI to Sync and **Create** templates to justify `whatsapp_business_management` permission.
8
+ - **Global Readiness**: Ensure 100% i18n coverage (EN/FR) for the Meta App Review screencast.
9
+
10
+ ## 2. Step 0: Security Hardening (Prerequisite)
11
+ Audit findings revealed that `apps/api/src/routes/admin.ts` lacks tenant isolation in several GET/POST handlers.
12
+ - **Action**: Update all queries to include `where: { organizationId: request.organizationId }`.
13
+ - **Affected Endpoints**: `/stats`, `/users`, `/enrollments`, `/live-feed`.
14
+
15
+ ## 3. Backend Architecture (API)
16
+
17
+ ### 3.1 Service Layer (`apps/api/src/services/whatsapp.ts`)
18
+ - **`fetchMetaTemplates`**: Fetches existing templates from Meta.
19
+ - **`createMetaTemplate`**: New method to submit a template for approval.
20
+ ```typescript
21
+ async createMetaTemplate(config: { accessToken: string; wabaId: string }, template: any) {
22
+ const url = `${this.baseUrl}/${config.wabaId}/message_templates`;
23
+ return axios.post(url, template, {
24
+ headers: { 'Authorization': `Bearer ${config.accessToken}` }
25
+ });
26
+ }
27
+ ```
28
+
29
+ ### 3.2 API Routes (`apps/api/src/routes/whatsapp.ts`)
30
+ - `GET /v1/whatsapp/templates`: Lists and syncs templates.
31
+ - `POST /v1/whatsapp/templates`: Accepts `name`, `language`, `category`, and `components` to create a new template on Meta.
32
+
33
+ ---
34
+
35
+ ## 4. Frontend Architecture (Admin)
36
+
37
+ ### 4.1 Templates Page (`TemplatesPage.tsx`)
38
+ - **i18n Implementation**: Mandatory use of `useTranslation()` for all labels.
39
+ - **Template Creation Form**:
40
+ - Fields: Name (uppercase/underscores), Category (MARKETING/UTILITY), Language, Body Text.
41
+ - Submit: Triggers the Meta approval workflow.
42
+ - **Sync Logic**: Integration with React Query `invalidateQueries` to refresh the table after creation or manual sync.
43
+
44
+ ### 4.2 Translation Keys (`en.json`)
45
+ Add keys for:
46
+ - `whatsapp.templates.title`
47
+ - `whatsapp.templates.sync_button`
48
+ - `whatsapp.templates.create_button`
49
+ - `whatsapp.templates.status_approved`
50
+ - etc.
51
+
52
+ ---
53
+
54
+ ## 5. Implementation Steps
55
+ 1. **Phase 1: Security**: Patch `admin.ts` routes.
56
+ 2. **Phase 2: Backend**: Extend `WhatsAppService` and add Template routes.
57
+ 3. **Phase 3: Frontend**: Build `TemplatesPage.tsx` with creation form and full i18n.
58
+ 4. **Phase 4: Validation**: Verify the flow with a real Meta Business account for the screencast.
packages/database/prisma/schema.prisma CHANGED
@@ -60,6 +60,7 @@ model Contact {
60
  campaigns CampaignHistory[]
61
  broadcastLists BroadcastList[] @relation("ContactBroadcastLists")
62
  messages Message[]
 
63
 
64
  @@unique([phoneNumber, organizationId])
65
  @@index([organizationId])
@@ -437,6 +438,9 @@ enum EnrollmentStatus {
437
 
438
  enum Language {
439
  FR
 
 
 
440
  WOLOF
441
  }
442
 
 
60
  campaigns CampaignHistory[]
61
  broadcastLists BroadcastList[] @relation("ContactBroadcastLists")
62
  messages Message[]
63
+ language Language @default(FR)
64
 
65
  @@unique([phoneNumber, organizationId])
66
  @@index([organizationId])
 
438
 
439
  enum Language {
440
  FR
441
+ EN
442
+ ES
443
+ PT
444
  WOLOF
445
  }
446
 
packages/prompts/src/index.ts CHANGED
@@ -36,7 +36,7 @@ export class PromptLoader {
36
  * Compiles a template with variables and organization personality
37
  */
38
  static compile(
39
- templateName: string,
40
  variables: Record<string, string | number | boolean>,
41
  personality: Partial<PersonalityConfig> = {}
42
  ): string {
@@ -59,12 +59,12 @@ export class PromptLoader {
59
  toneDescription: config.toneDescription,
60
  constraints: config.constraints?.join('. ') || ''
61
  };
62
-
63
  for (const [key, value] of Object.entries(allVariables)) {
64
  const placeholder = new RegExp(`{{${key}}}`, 'g');
65
  template = template.replace(placeholder, String(value));
66
  }
67
-
68
  return template;
69
  }
70
  }
 
36
  * Compiles a template with variables and organization personality
37
  */
38
  static compile(
39
+ templateName: string,
40
  variables: Record<string, string | number | boolean>,
41
  personality: Partial<PersonalityConfig> = {}
42
  ): string {
 
59
  toneDescription: config.toneDescription,
60
  constraints: config.constraints?.join('. ') || ''
61
  };
62
+
63
  for (const [key, value] of Object.entries(allVariables)) {
64
  const placeholder = new RegExp(`{{${key}}}`, 'g');
65
  template = template.replace(placeholder, String(value));
66
  }
67
+
68
  return template;
69
  }
70
  }