CognxSafeTrack commited on
Commit ·
0f2f80a
1
Parent(s): 7b0c22b
feat: implement whatsapp templates management with security hardening, audit logs, and crm integration
Browse files- apps/admin/src/App.tsx +2 -0
- apps/admin/src/components/LanguageSwitcher.tsx +62 -0
- apps/admin/src/components/crm/CrmAIAssistant.tsx +56 -17
- apps/admin/src/components/layouts/MainLayout.tsx +20 -10
- apps/admin/src/lib/i18n.ts +11 -16
- apps/admin/src/locales/en.json +103 -0
- apps/admin/src/locales/es.json +29 -0
- apps/admin/src/locales/fr.json +29 -0
- apps/admin/src/locales/pt.json +29 -0
- apps/admin/src/pages/CrmConversationalDashboard.tsx +34 -1
- apps/admin/src/pages/TemplatesPage.tsx +347 -0
- apps/api/src/logger.ts +22 -16
- apps/api/src/middleware/tenant.ts +0 -25
- apps/api/src/routes/admin.ts +76 -29
- apps/api/src/routes/ai.ts +22 -7
- apps/api/src/routes/internal.ts +5 -0
- apps/api/src/routes/whatsapp.ts +99 -0
- apps/api/src/services/organization.ts +3 -5
- apps/api/src/services/whatsapp.ts +42 -0
- apps/api/src/tests/tenant-isolation.test.ts +2 -2
- apps/whatsapp-worker/src/fix-types.ts +0 -19
- apps/whatsapp-worker/src/handlers/AIAgentHandler.ts +2 -0
- apps/whatsapp-worker/src/handlers/CommandHandler.ts +15 -33
- apps/whatsapp-worker/src/handlers/EnrollHandler.ts +1 -1
- apps/whatsapp-worker/src/handlers/InboundHandler.ts +4 -1
- apps/whatsapp-worker/src/handlers/MessageHandler.ts +5 -1
- apps/whatsapp-worker/src/index.ts +10 -6
- apps/whatsapp-worker/src/logger.ts +13 -14
- apps/whatsapp-worker/src/pedagogy.ts +16 -13
- apps/whatsapp-worker/src/services/i18n.ts +141 -0
- apps/whatsapp-worker/src/services/organization.ts +3 -5
- apps/whatsapp-worker/src/services/whatsapp-logic.ts +11 -7
- docs/audit_dette_technique_03052026.md +46 -3
- docs/audit_meta_app_review_2026-05-04.md +79 -0
- docs/global_audit_report_2026-05-04.md +25 -0
- docs/implementation_plan_whatsapp_templates.md +58 -0
- packages/database/prisma/schema.prisma +4 -0
- 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 |
-
<
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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: '
|
| 25 |
-
{ to: '/analytics', label: '
|
| 26 |
-
{ to: '/contacts', label: '
|
| 27 |
-
{ to: '/campaign-history', label: '
|
| 28 |
-
{ to: '/
|
| 29 |
-
{ to: '/
|
|
|
|
| 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: '
|
| 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 |
-
|
|
|
|
| 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 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 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: '
|
|
|
|
| 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 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
}
|
| 43 |
-
return baseLogger[level](arg1, arg2, ...args);
|
| 44 |
-
};
|
| 45 |
|
| 46 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 53 |
-
}
|
| 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.
|
| 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
|
| 246 |
-
|
|
|
|
|
|
|
|
|
|
| 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({
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 279 |
-
await prisma.track.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 268 |
} else {
|
| 269 |
// SUCCÈS (Branching 2)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 270 |
formattedFeedback += `🌟 ${feedback.validation}\n\n` +
|
| 271 |
`🚀 ${feedback.enrichedVersion}\n\n` +
|
| 272 |
`💡 ${feedback.actionableAdvice}\n\n` +
|
| 273 |
-
|
| 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 |
-
|
| 41 |
-
|
| 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
|
| 53 |
const detectedUnknown = await getOrganizationByPhoneNumberId("unknown-id");
|
| 54 |
-
console.log(`ℹ️ Unknown ID
|
| 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
|
| 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 |
-
|
| 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:
|
| 46 |
}
|
| 47 |
return true;
|
| 48 |
}
|
|
@@ -62,28 +58,26 @@ export class CommandHandler implements MessageHandler {
|
|
| 62 |
});
|
| 63 |
|
| 64 |
if (pastDays.length === 0) {
|
| 65 |
-
|
| 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:
|
| 73 |
-
description:
|
| 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 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
[{
|
| 86 |
-
title:
|
| 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,
|
| 108 |
organizationId: ctx.organizationId
|
| 109 |
});
|
| 110 |
return true;
|
| 111 |
} else if (action === 'EXERCISE') {
|
| 112 |
-
|
| 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 |
-
|
| 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
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 17 |
const organizationId = getOrganizationId();
|
| 18 |
-
|
| 19 |
-
return { ...obj, organizationId };
|
| 20 |
-
}
|
| 21 |
-
return obj;
|
| 22 |
}
|
| 23 |
|
| 24 |
export const logger = {
|
| 25 |
-
info: (first:
|
| 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:
|
| 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:
|
| 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:
|
| 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(
|
|
|
|
| 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
|
| 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
|
| 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(
|
| 98 |
|
| 99 |
// Visuals Dispatch
|
| 100 |
if (trackDay.videoUrl) {
|
| 101 |
try {
|
| 102 |
-
await sendVideoMessage(user.phone, trackDay.videoUrl, trackDay.videoCaption || (
|
| 103 |
} catch (err) {
|
| 104 |
-
const fallbackMsg =
|
| 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,
|
| 139 |
} else {
|
| 140 |
-
const rows = isHistorical ? [{ id: `DAY${dayNumber}_REPLAY`, title:
|
| 141 |
-
{ id: `DAY${dayNumber}_EXERCISE`, title:
|
| 142 |
-
{ id: `MENU_HISTORIQUE`, title:
|
| 143 |
];
|
| 144 |
-
await sendInteractiveListMessage(user.phone,
|
| 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 |
-
|
| 126 |
-
|
| 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
|
| 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
|
| 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:
|
| 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
|
| 150 |
-
|
| 151 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
}
|