CognxSafeTrack commited on
Commit ·
e289c5c
1
Parent(s): 1a00f18
feat: B2B SaaS Multi-tenant architecture & Tech Debt Resolution
Browse files- apps/admin/src/App.tsx +38 -8
- apps/admin/src/lib/tenant.tsx +33 -0
- apps/admin/src/pages/ClientsManagementView.tsx +132 -0
- apps/admin/src/pages/TrackDaysPage.tsx +7 -1
- apps/admin/src/pages/TrackFormPage.tsx +14 -3
- apps/admin/src/pages/TrackListPage.tsx +12 -2
- apps/api/src/config.ts +30 -0
- apps/api/src/index.ts +21 -4
- apps/api/src/logger.ts +22 -12
- apps/api/src/middleware/tenant.ts +25 -0
- apps/api/src/routes/ai.ts +3 -3
- apps/api/src/routes/internal.ts +20 -162
- apps/api/src/routes/organizations.ts +40 -4
- apps/api/src/services/ai/ProviderRegistry.ts +40 -0
- apps/api/src/services/ai/index.ts +124 -243
- apps/api/src/services/organization.ts +28 -6
- apps/api/src/services/prisma.ts +3 -2
- apps/api/src/services/whatsapp-utils.ts +50 -0
- apps/api/src/services/whatsapp.ts +27 -673
- apps/whatsapp-worker/package.json +6 -2
- apps/whatsapp-worker/scratch/check_orgs.js +9 -0
- apps/whatsapp-worker/src/__tests__/OnboardingHandler.test.ts +105 -0
- apps/whatsapp-worker/src/__tests__/normalizeWolof.test.ts +26 -0
- apps/whatsapp-worker/src/config.ts +28 -100
- apps/whatsapp-worker/src/handlers/AdminHandler.ts +76 -0
- apps/whatsapp-worker/src/handlers/ContentHandler.ts +132 -0
- apps/whatsapp-worker/src/handlers/EnrollHandler.ts +89 -0
- apps/whatsapp-worker/src/handlers/InboundHandler.ts +25 -0
- apps/whatsapp-worker/src/handlers/MediaHandler.ts +145 -0
- apps/whatsapp-worker/src/handlers/MessageHandler.ts +80 -0
- apps/whatsapp-worker/src/handlers/NudgeHandler.ts +57 -0
- apps/whatsapp-worker/src/handlers/types.ts +6 -0
- apps/whatsapp-worker/src/index.ts +50 -1128
- apps/whatsapp-worker/src/logger.ts +22 -12
- apps/whatsapp-worker/src/normalizeWolof.ts +9 -0
- apps/whatsapp-worker/src/pedagogy.ts +75 -369
- apps/whatsapp-worker/src/scratch/check_orgs.ts +17 -0
- apps/whatsapp-worker/src/services/ai-pedagogy.ts +74 -0
- apps/whatsapp-worker/src/services/prisma.ts +3 -2
- packages/database/index.ts +2 -0
- packages/database/prisma/schema.prisma +33 -0
- packages/database/src/context.ts +11 -0
- packages/database/src/extension.ts +58 -0
- packages/prompts/src/index.ts +45 -2
- packages/prompts/src/templates/personalized-lesson.md +3 -3
- pnpm-lock.yaml +466 -0
apps/admin/src/App.tsx
CHANGED
|
@@ -3,6 +3,7 @@ import { BrowserRouter as Router, Routes, Route, Link, Navigate } from 'react-ro
|
|
| 3 |
import { Users, BookOpen, Lightbulb, BarChart2, Mic, Activity, Building2 } from 'lucide-react';
|
| 4 |
|
| 5 |
import { AuthProvider, useAuth } from './lib/auth';
|
|
|
|
| 6 |
|
| 7 |
import LoginPage from './pages/LoginPage';
|
| 8 |
import DashboardPage from './pages/DashboardPage';
|
|
@@ -14,6 +15,8 @@ import SettingsPage from './pages/SettingsPage';
|
|
| 14 |
import LiveFeed from './pages/LiveFeed';
|
| 15 |
import TrainingLab from './pages/TrainingLab';
|
| 16 |
import ClientsManagementView from './pages/ClientsManagementView';
|
|
|
|
|
|
|
| 17 |
|
| 18 |
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
| 19 |
const { apiKey } = useAuth();
|
|
@@ -22,7 +25,17 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
|
| 22 |
}
|
| 23 |
|
| 24 |
function AppShell() {
|
| 25 |
-
const { logout } = useAuth();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
const navItems = [
|
| 27 |
{ to: '/', label: 'Dashboard', icon: <BarChart2 className="w-4 h-4" /> },
|
| 28 |
{ to: '/content', label: 'Parcours', icon: <BookOpen className="w-4 h-4" /> },
|
|
@@ -36,7 +49,22 @@ function AppShell() {
|
|
| 36 |
return (
|
| 37 |
<div className="min-h-screen bg-gray-50 flex">
|
| 38 |
<aside className="w-56 bg-slate-900 text-white p-5 flex flex-col shrink-0">
|
| 39 |
-
<div className="text-lg font-bold mb-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
<nav className="space-y-1 flex-1">
|
| 41 |
{navItems.map(n => (
|
| 42 |
<Link key={n.to} to={n.to} className="flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium text-slate-300 hover:text-white hover:bg-slate-800 transition">
|
|
@@ -67,12 +95,14 @@ function AppShell() {
|
|
| 67 |
function App() {
|
| 68 |
return (
|
| 69 |
<AuthProvider>
|
| 70 |
-
<
|
| 71 |
-
<
|
| 72 |
-
<
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
|
|
|
|
|
|
| 76 |
</AuthProvider>
|
| 77 |
);
|
| 78 |
}
|
|
|
|
| 3 |
import { Users, BookOpen, Lightbulb, BarChart2, Mic, Activity, Building2 } from 'lucide-react';
|
| 4 |
|
| 5 |
import { AuthProvider, useAuth } from './lib/auth';
|
| 6 |
+
import { TenantProvider } from './lib/tenant';
|
| 7 |
|
| 8 |
import LoginPage from './pages/LoginPage';
|
| 9 |
import DashboardPage from './pages/DashboardPage';
|
|
|
|
| 15 |
import LiveFeed from './pages/LiveFeed';
|
| 16 |
import TrainingLab from './pages/TrainingLab';
|
| 17 |
import ClientsManagementView from './pages/ClientsManagementView';
|
| 18 |
+
import { useTenant } from './lib/tenant';
|
| 19 |
+
import { API_URL } from './lib/api';
|
| 20 |
|
| 21 |
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
| 22 |
const { apiKey } = useAuth();
|
|
|
|
| 25 |
}
|
| 26 |
|
| 27 |
function AppShell() {
|
| 28 |
+
const { logout, apiKey } = useAuth();
|
| 29 |
+
const { selectedOrgId, setSelectedOrgId } = useTenant();
|
| 30 |
+
const [orgs, setOrgs] = React.useState<any[]>([]);
|
| 31 |
+
|
| 32 |
+
React.useEffect(() => {
|
| 33 |
+
if (!apiKey) return;
|
| 34 |
+
fetch(`${API_URL}/v1/organizations`, {
|
| 35 |
+
headers: { 'Authorization': `Bearer ${apiKey}` }
|
| 36 |
+
}).then(r => r.json()).then(setOrgs).catch(console.error);
|
| 37 |
+
}, [apiKey]);
|
| 38 |
+
|
| 39 |
const navItems = [
|
| 40 |
{ to: '/', label: 'Dashboard', icon: <BarChart2 className="w-4 h-4" /> },
|
| 41 |
{ to: '/content', label: 'Parcours', icon: <BookOpen className="w-4 h-4" /> },
|
|
|
|
| 49 |
return (
|
| 50 |
<div className="min-h-screen bg-gray-50 flex">
|
| 51 |
<aside className="w-56 bg-slate-900 text-white p-5 flex flex-col shrink-0">
|
| 52 |
+
<div className="text-lg font-bold mb-4 flex items-center gap-2"><span className="text-2xl">🎓</span>EdTech Admin</div>
|
| 53 |
+
|
| 54 |
+
<div className="mb-8">
|
| 55 |
+
<label className="block text-[10px] uppercase font-bold text-slate-500 tracking-wider mb-2">Organisation Active</label>
|
| 56 |
+
<select
|
| 57 |
+
value={selectedOrgId || ''}
|
| 58 |
+
onChange={e => setSelectedOrgId(e.target.value)}
|
| 59 |
+
className="w-full bg-slate-800 text-slate-200 text-xs px-3 py-2.5 rounded-xl outline-none focus:ring-1 focus:ring-slate-600 appearance-none cursor-pointer"
|
| 60 |
+
>
|
| 61 |
+
<option value="">Sélectionner...</option>
|
| 62 |
+
{orgs.map(o => (
|
| 63 |
+
<option key={o.id} value={o.id}>{o.name}</option>
|
| 64 |
+
))}
|
| 65 |
+
</select>
|
| 66 |
+
</div>
|
| 67 |
+
|
| 68 |
<nav className="space-y-1 flex-1">
|
| 69 |
{navItems.map(n => (
|
| 70 |
<Link key={n.to} to={n.to} className="flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium text-slate-300 hover:text-white hover:bg-slate-800 transition">
|
|
|
|
| 95 |
function App() {
|
| 96 |
return (
|
| 97 |
<AuthProvider>
|
| 98 |
+
<TenantProvider>
|
| 99 |
+
<Router>
|
| 100 |
+
<Routes>
|
| 101 |
+
<Route path="/login" element={<LoginPage />} />
|
| 102 |
+
<Route path="/*" element={<ProtectedRoute><AppShell /></ProtectedRoute>} />
|
| 103 |
+
</Routes>
|
| 104 |
+
</Router>
|
| 105 |
+
</TenantProvider>
|
| 106 |
</AuthProvider>
|
| 107 |
);
|
| 108 |
}
|
apps/admin/src/lib/tenant.tsx
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, createContext, useContext, useEffect } from 'react';
|
| 2 |
+
|
| 3 |
+
const TENANT_KEY = 'edtech_selected_org';
|
| 4 |
+
|
| 5 |
+
interface TenantContextType {
|
| 6 |
+
selectedOrgId: string | null;
|
| 7 |
+
setSelectedOrgId: (id: string | null) => void;
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
export const TenantContext = createContext<TenantContextType>({
|
| 11 |
+
selectedOrgId: null,
|
| 12 |
+
setSelectedOrgId: () => {}
|
| 13 |
+
});
|
| 14 |
+
|
| 15 |
+
export function TenantProvider({ children }: { children: React.ReactNode }) {
|
| 16 |
+
const [selectedOrgId, setSelectedOrgId] = useState<string | null>(() => sessionStorage.getItem(TENANT_KEY));
|
| 17 |
+
|
| 18 |
+
useEffect(() => {
|
| 19 |
+
if (selectedOrgId) {
|
| 20 |
+
sessionStorage.setItem(TENANT_KEY, selectedOrgId);
|
| 21 |
+
} else {
|
| 22 |
+
sessionStorage.removeItem(TENANT_KEY);
|
| 23 |
+
}
|
| 24 |
+
}, [selectedOrgId]);
|
| 25 |
+
|
| 26 |
+
return (
|
| 27 |
+
<TenantContext.Provider value={{ selectedOrgId, setSelectedOrgId }}>
|
| 28 |
+
{children}
|
| 29 |
+
</TenantContext.Provider>
|
| 30 |
+
);
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
export const useTenant = () => useContext(TenantContext);
|
apps/admin/src/pages/ClientsManagementView.tsx
CHANGED
|
@@ -11,6 +11,17 @@ interface Organization {
|
|
| 11 |
wabaId?: string;
|
| 12 |
phoneNumbers?: { id: string, phoneNumber: string }[];
|
| 13 |
lastActivity?: string;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
}
|
| 15 |
|
| 16 |
export default function ClientsManagementView() {
|
|
@@ -18,6 +29,7 @@ export default function ClientsManagementView() {
|
|
| 18 |
const [clients, setClients] = useState<Organization[]>([]);
|
| 19 |
const [loading, setLoading] = useState(true);
|
| 20 |
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
|
|
| 21 |
const [newOrgName, setNewOrgName] = useState('');
|
| 22 |
const [isCreating, setIsCreating] = useState(false);
|
| 23 |
|
|
@@ -156,6 +168,12 @@ export default function ClientsManagementView() {
|
|
| 156 |
<MessageSquare className="w-4 h-4" /> Connecter WhatsApp (Meta)
|
| 157 |
</button>
|
| 158 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
</div>
|
| 160 |
</div>
|
| 161 |
|
|
@@ -222,6 +240,30 @@ export default function ClientsManagementView() {
|
|
| 222 |
</div>
|
| 223 |
)}
|
| 224 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 225 |
{/* Meta Compliance Footer */}
|
| 226 |
<div className="mt-12 p-6 bg-slate-50 rounded-2xl border border-slate-100">
|
| 227 |
<h4 className="text-xs font-bold text-slate-400 uppercase tracking-widest mb-4">Meta Tech Provider Compliance</h4>
|
|
@@ -233,3 +275,93 @@ export default function ClientsManagementView() {
|
|
| 233 |
</div>
|
| 234 |
);
|
| 235 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
wabaId?: string;
|
| 12 |
phoneNumbers?: { id: string, phoneNumber: string }[];
|
| 13 |
lastActivity?: string;
|
| 14 |
+
personalityConfig?: {
|
| 15 |
+
botName?: string;
|
| 16 |
+
coreMission?: string;
|
| 17 |
+
toneDescription?: string;
|
| 18 |
+
};
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
interface PersonalityModalProps {
|
| 22 |
+
org: Organization;
|
| 23 |
+
onClose: () => void;
|
| 24 |
+
onSave: (config: any) => void;
|
| 25 |
}
|
| 26 |
|
| 27 |
export default function ClientsManagementView() {
|
|
|
|
| 29 |
const [clients, setClients] = useState<Organization[]>([]);
|
| 30 |
const [loading, setLoading] = useState(true);
|
| 31 |
const [isModalOpen, setIsModalOpen] = useState(false);
|
| 32 |
+
const [selectedOrgForPersonality, setSelectedOrgForPersonality] = useState<Organization | null>(null);
|
| 33 |
const [newOrgName, setNewOrgName] = useState('');
|
| 34 |
const [isCreating, setIsCreating] = useState(false);
|
| 35 |
|
|
|
|
| 168 |
<MessageSquare className="w-4 h-4" /> Connecter WhatsApp (Meta)
|
| 169 |
</button>
|
| 170 |
)}
|
| 171 |
+
<button
|
| 172 |
+
onClick={() => setSelectedOrgForPersonality(client)}
|
| 173 |
+
className="flex items-center gap-2 bg-slate-100 text-slate-700 px-4 py-2.5 rounded-xl font-semibold hover:bg-slate-200 transition"
|
| 174 |
+
>
|
| 175 |
+
<Activity className="w-4 h-4" /> Personality Studio
|
| 176 |
+
</button>
|
| 177 |
</div>
|
| 178 |
</div>
|
| 179 |
|
|
|
|
| 240 |
</div>
|
| 241 |
)}
|
| 242 |
|
| 243 |
+
{/* Personality Studio Modal */}
|
| 244 |
+
{selectedOrgForPersonality && (
|
| 245 |
+
<PersonalityStudioModal
|
| 246 |
+
org={selectedOrgForPersonality}
|
| 247 |
+
onClose={() => setSelectedOrgForPersonality(null)}
|
| 248 |
+
onSave={async (config) => {
|
| 249 |
+
try {
|
| 250 |
+
const res = await fetch(`${API_URL}/v1/organizations/${selectedOrgForPersonality.id}/personality`, {
|
| 251 |
+
method: 'PATCH',
|
| 252 |
+
headers: ah(apiKey || ''),
|
| 253 |
+
body: JSON.stringify(config)
|
| 254 |
+
});
|
| 255 |
+
if (res.ok) {
|
| 256 |
+
alert("Personnalité mise à jour !");
|
| 257 |
+
fetchClients();
|
| 258 |
+
setSelectedOrgForPersonality(null);
|
| 259 |
+
}
|
| 260 |
+
} catch (err) {
|
| 261 |
+
console.error(err);
|
| 262 |
+
}
|
| 263 |
+
}}
|
| 264 |
+
/>
|
| 265 |
+
)}
|
| 266 |
+
|
| 267 |
{/* Meta Compliance Footer */}
|
| 268 |
<div className="mt-12 p-6 bg-slate-50 rounded-2xl border border-slate-100">
|
| 269 |
<h4 className="text-xs font-bold text-slate-400 uppercase tracking-widest mb-4">Meta Tech Provider Compliance</h4>
|
|
|
|
| 275 |
</div>
|
| 276 |
);
|
| 277 |
}
|
| 278 |
+
|
| 279 |
+
function PersonalityStudioModal({ org, onClose, onSave }: PersonalityModalProps) {
|
| 280 |
+
const [config, setConfig] = useState(org.personalityConfig || {
|
| 281 |
+
botName: '',
|
| 282 |
+
coreMission: '',
|
| 283 |
+
toneDescription: ''
|
| 284 |
+
});
|
| 285 |
+
const [isSaving, setIsSaving] = useState(false);
|
| 286 |
+
|
| 287 |
+
const handleSubmit = async (e: React.FormEvent) => {
|
| 288 |
+
e.preventDefault();
|
| 289 |
+
setIsSaving(true);
|
| 290 |
+
await onSave(config);
|
| 291 |
+
setIsSaving(false);
|
| 292 |
+
};
|
| 293 |
+
|
| 294 |
+
return (
|
| 295 |
+
<div className="fixed inset-0 bg-slate-900/60 backdrop-blur-md flex items-center justify-center p-4 z-[60]">
|
| 296 |
+
<div className="bg-white rounded-[2.5rem] shadow-2xl w-full max-w-2xl p-10 animate-in fade-in zoom-in-95 duration-300">
|
| 297 |
+
<div className="flex items-center justify-between mb-8">
|
| 298 |
+
<div className="flex items-center gap-4">
|
| 299 |
+
<div className="w-12 h-12 bg-indigo-50 rounded-2xl flex items-center justify-center">
|
| 300 |
+
<Activity className="w-6 h-6 text-indigo-600" />
|
| 301 |
+
</div>
|
| 302 |
+
<div>
|
| 303 |
+
<h2 className="text-2xl font-black text-slate-900">Personality Studio</h2>
|
| 304 |
+
<p className="text-slate-500 font-medium text-sm">Configuring IA Identity for {org.name}</p>
|
| 305 |
+
</div>
|
| 306 |
+
</div>
|
| 307 |
+
<button onClick={onClose} className="p-3 hover:bg-slate-100 rounded-full transition">
|
| 308 |
+
<X className="w-6 h-6 text-slate-400" />
|
| 309 |
+
</button>
|
| 310 |
+
</div>
|
| 311 |
+
|
| 312 |
+
<form onSubmit={handleSubmit} className="space-y-8">
|
| 313 |
+
<div className="grid gap-6">
|
| 314 |
+
<div>
|
| 315 |
+
<label className="block text-sm font-bold text-slate-700 mb-2.5">Bot Name</label>
|
| 316 |
+
<input
|
| 317 |
+
type="text"
|
| 318 |
+
placeholder="e.g. XAMLÉ, Coach Sarah..."
|
| 319 |
+
value={config.botName}
|
| 320 |
+
onChange={e => setConfig({...config, botName: e.target.value})}
|
| 321 |
+
className="w-full border border-slate-200 rounded-2xl px-5 py-4 outline-none focus:ring-4 focus:ring-indigo-50 transition font-medium"
|
| 322 |
+
/>
|
| 323 |
+
</div>
|
| 324 |
+
|
| 325 |
+
<div>
|
| 326 |
+
<label className="block text-sm font-bold text-slate-700 mb-2.5">Core Mission</label>
|
| 327 |
+
<textarea
|
| 328 |
+
placeholder="What is the main goal of this AI? e.g. Help entrepreneurs master financial literacy..."
|
| 329 |
+
value={config.coreMission}
|
| 330 |
+
onChange={e => setConfig({...config, coreMission: e.target.value})}
|
| 331 |
+
className="w-full border border-slate-200 rounded-2xl px-5 py-4 outline-none focus:ring-4 focus:ring-indigo-50 transition min-h-[120px] font-medium leading-relaxed"
|
| 332 |
+
/>
|
| 333 |
+
</div>
|
| 334 |
+
|
| 335 |
+
<div>
|
| 336 |
+
<label className="block text-sm font-bold text-slate-700 mb-2.5">Tone & Personality Description</label>
|
| 337 |
+
<textarea
|
| 338 |
+
placeholder="e.g. Professional yet encouraging, uses local metaphors, strict on concepts but friendly..."
|
| 339 |
+
value={config.toneDescription}
|
| 340 |
+
onChange={e => setConfig({...config, toneDescription: e.target.value})}
|
| 341 |
+
className="w-full border border-slate-200 rounded-2xl px-5 py-4 outline-none focus:ring-4 focus:ring-indigo-50 transition min-h-[120px] font-medium leading-relaxed"
|
| 342 |
+
/>
|
| 343 |
+
</div>
|
| 344 |
+
</div>
|
| 345 |
+
|
| 346 |
+
<div className="flex gap-4 pt-4">
|
| 347 |
+
<button
|
| 348 |
+
type="button"
|
| 349 |
+
onClick={onClose}
|
| 350 |
+
className="flex-1 px-6 py-4 rounded-2xl font-bold text-slate-600 hover:bg-slate-50 transition"
|
| 351 |
+
>
|
| 352 |
+
Cancel
|
| 353 |
+
</button>
|
| 354 |
+
<button
|
| 355 |
+
type="submit"
|
| 356 |
+
disabled={isSaving}
|
| 357 |
+
className="flex-[2] bg-slate-900 text-white py-4 rounded-2xl font-bold hover:bg-slate-800 transition disabled:opacity-50 flex items-center justify-center gap-3 shadow-xl shadow-slate-200"
|
| 358 |
+
>
|
| 359 |
+
{isSaving ? <Loader2 className="w-5 h-5 animate-spin" /> : <ShieldCheck className="w-5 h-5" />}
|
| 360 |
+
Deploy Personality
|
| 361 |
+
</button>
|
| 362 |
+
</div>
|
| 363 |
+
</form>
|
| 364 |
+
</div>
|
| 365 |
+
</div>
|
| 366 |
+
);
|
| 367 |
+
}
|
apps/admin/src/pages/TrackDaysPage.tsx
CHANGED
|
@@ -2,10 +2,12 @@ import React, { useEffect, useState } from 'react';
|
|
| 2 |
import { useParams, useNavigate } from 'react-router-dom';
|
| 3 |
import { Plus, Edit2, Trash2, ArrowLeft, X, Save } from 'lucide-react';
|
| 4 |
import { useAuth } from '../lib/auth';
|
|
|
|
| 5 |
import { API_URL } from '../lib/api';
|
| 6 |
|
| 7 |
export default function TrackDaysPage() {
|
| 8 |
const { apiKey } = useAuth();
|
|
|
|
| 9 |
const { trackId } = useParams<{ trackId: string }>();
|
| 10 |
const navigate = useNavigate();
|
| 11 |
|
|
@@ -14,7 +16,11 @@ export default function TrackDaysPage() {
|
|
| 14 |
const [editing, setEditing] = useState<any>(null);
|
| 15 |
const [saving, setSaving] = useState(false);
|
| 16 |
|
| 17 |
-
const ah = (k: string) => ({
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
const load = async () => {
|
| 20 |
const [tR, dR] = await Promise.all([
|
|
|
|
| 2 |
import { useParams, useNavigate } from 'react-router-dom';
|
| 3 |
import { Plus, Edit2, Trash2, ArrowLeft, X, Save } from 'lucide-react';
|
| 4 |
import { useAuth } from '../lib/auth';
|
| 5 |
+
import { useTenant } from '../lib/tenant';
|
| 6 |
import { API_URL } from '../lib/api';
|
| 7 |
|
| 8 |
export default function TrackDaysPage() {
|
| 9 |
const { apiKey } = useAuth();
|
| 10 |
+
const { selectedOrgId } = useTenant();
|
| 11 |
const { trackId } = useParams<{ trackId: string }>();
|
| 12 |
const navigate = useNavigate();
|
| 13 |
|
|
|
|
| 16 |
const [editing, setEditing] = useState<any>(null);
|
| 17 |
const [saving, setSaving] = useState(false);
|
| 18 |
|
| 19 |
+
const ah = (k: string) => ({
|
| 20 |
+
'Authorization': `Bearer ${k}`,
|
| 21 |
+
'Content-Type': 'application/json',
|
| 22 |
+
...(selectedOrgId ? { 'x-organization-id': selectedOrgId } : {})
|
| 23 |
+
});
|
| 24 |
|
| 25 |
const load = async () => {
|
| 26 |
const [tR, dR] = await Promise.all([
|
apps/admin/src/pages/TrackFormPage.tsx
CHANGED
|
@@ -2,10 +2,12 @@ import React, { useEffect, useState } from 'react';
|
|
| 2 |
import { useParams, useNavigate } from 'react-router-dom';
|
| 3 |
import { ArrowLeft, Save } from 'lucide-react';
|
| 4 |
import { useAuth } from '../lib/auth';
|
|
|
|
| 5 |
import { API_URL } from '../lib/api';
|
| 6 |
|
| 7 |
export default function TrackFormPage() {
|
| 8 |
const { apiKey } = useAuth();
|
|
|
|
| 9 |
const { id } = useParams<{ id: string }>();
|
| 10 |
const navigate = useNavigate();
|
| 11 |
const isNew = id === 'new';
|
|
@@ -15,7 +17,11 @@ export default function TrackFormPage() {
|
|
| 15 |
isPremium: false, priceAmount: 0, stripePriceId: ''
|
| 16 |
});
|
| 17 |
const [saving, setSaving] = useState(false);
|
| 18 |
-
const ah = (k: string) => ({
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
|
| 20 |
useEffect(() => {
|
| 21 |
if (!isNew) {
|
|
@@ -78,8 +84,13 @@ export default function TrackFormPage() {
|
|
| 78 |
</div>}
|
| 79 |
<div className="flex gap-3 pt-2">
|
| 80 |
<button type="button" onClick={() => navigate('/content')} className="flex-1 border border-slate-200 text-slate-600 py-2.5 rounded-xl text-sm hover:bg-slate-50">Annuler</button>
|
| 81 |
-
<button
|
| 82 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
</button>
|
| 84 |
</div>
|
| 85 |
</form>
|
|
|
|
| 2 |
import { useParams, useNavigate } from 'react-router-dom';
|
| 3 |
import { ArrowLeft, Save } from 'lucide-react';
|
| 4 |
import { useAuth } from '../lib/auth';
|
| 5 |
+
import { useTenant } from '../lib/tenant';
|
| 6 |
import { API_URL } from '../lib/api';
|
| 7 |
|
| 8 |
export default function TrackFormPage() {
|
| 9 |
const { apiKey } = useAuth();
|
| 10 |
+
const { selectedOrgId } = useTenant();
|
| 11 |
const { id } = useParams<{ id: string }>();
|
| 12 |
const navigate = useNavigate();
|
| 13 |
const isNew = id === 'new';
|
|
|
|
| 17 |
isPremium: false, priceAmount: 0, stripePriceId: ''
|
| 18 |
});
|
| 19 |
const [saving, setSaving] = useState(false);
|
| 20 |
+
const ah = (k: string) => ({
|
| 21 |
+
'Authorization': `Bearer ${k}`,
|
| 22 |
+
'Content-Type': 'application/json',
|
| 23 |
+
...(selectedOrgId ? { 'x-organization-id': selectedOrgId } : {})
|
| 24 |
+
});
|
| 25 |
|
| 26 |
useEffect(() => {
|
| 27 |
if (!isNew) {
|
|
|
|
| 84 |
</div>}
|
| 85 |
<div className="flex gap-3 pt-2">
|
| 86 |
<button type="button" onClick={() => navigate('/content')} className="flex-1 border border-slate-200 text-slate-600 py-2.5 rounded-xl text-sm hover:bg-slate-50">Annuler</button>
|
| 87 |
+
<button
|
| 88 |
+
type="submit"
|
| 89 |
+
disabled={saving || (isNew && !selectedOrgId)}
|
| 90 |
+
className="flex-[2] bg-slate-900 text-white py-2.5 rounded-xl text-sm font-bold flex items-center justify-center gap-2 hover:bg-slate-700 disabled:opacity-50"
|
| 91 |
+
>
|
| 92 |
+
<Save className="w-4 h-4" />
|
| 93 |
+
{saving ? 'Enregistrement...' : (!selectedOrgId && isNew ? 'Sélect. Organisation' : 'Enregistrer')}
|
| 94 |
</button>
|
| 95 |
</div>
|
| 96 |
</form>
|
apps/admin/src/pages/TrackListPage.tsx
CHANGED
|
@@ -2,15 +2,21 @@ import { useEffect, useState } from 'react';
|
|
| 2 |
import { useNavigate } from 'react-router-dom';
|
| 3 |
import { BookOpen, Plus, Edit2, Trash2, ChevronRight } from 'lucide-react';
|
| 4 |
import { useAuth } from '../lib/auth';
|
|
|
|
| 5 |
import { API_URL } from '../lib/api';
|
| 6 |
|
| 7 |
export default function TrackListPage() {
|
| 8 |
const { apiKey } = useAuth();
|
|
|
|
| 9 |
const navigate = useNavigate();
|
| 10 |
const [tracks, setTracks] = useState<any[]>([]);
|
| 11 |
const [loading, setLoading] = useState(true);
|
| 12 |
|
| 13 |
-
const ah = (k: string) => ({
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
const load = async () => {
|
| 16 |
const r = await fetch(`${API_URL}/v1/admin/tracks`, { headers: ah(apiKey!) });
|
|
@@ -18,7 +24,11 @@ export default function TrackListPage() {
|
|
| 18 |
setLoading(false);
|
| 19 |
};
|
| 20 |
|
| 21 |
-
useEffect(() => {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
const del = async (id: string) => {
|
| 24 |
if (!confirm('Supprimer ce parcours ?')) return;
|
|
|
|
| 2 |
import { useNavigate } from 'react-router-dom';
|
| 3 |
import { BookOpen, Plus, Edit2, Trash2, ChevronRight } from 'lucide-react';
|
| 4 |
import { useAuth } from '../lib/auth';
|
| 5 |
+
import { useTenant } from '../lib/tenant';
|
| 6 |
import { API_URL } from '../lib/api';
|
| 7 |
|
| 8 |
export default function TrackListPage() {
|
| 9 |
const { apiKey } = useAuth();
|
| 10 |
+
const { selectedOrgId } = useTenant();
|
| 11 |
const navigate = useNavigate();
|
| 12 |
const [tracks, setTracks] = useState<any[]>([]);
|
| 13 |
const [loading, setLoading] = useState(true);
|
| 14 |
|
| 15 |
+
const ah = (k: string) => ({
|
| 16 |
+
'Authorization': `Bearer ${k}`,
|
| 17 |
+
'Content-Type': 'application/json',
|
| 18 |
+
...(selectedOrgId ? { 'x-organization-id': selectedOrgId } : {})
|
| 19 |
+
});
|
| 20 |
|
| 21 |
const load = async () => {
|
| 22 |
const r = await fetch(`${API_URL}/v1/admin/tracks`, { headers: ah(apiKey!) });
|
|
|
|
| 24 |
setLoading(false);
|
| 25 |
};
|
| 26 |
|
| 27 |
+
useEffect(() => {
|
| 28 |
+
if (selectedOrgId || !apiKey) {
|
| 29 |
+
load();
|
| 30 |
+
}
|
| 31 |
+
}, [selectedOrgId, apiKey]);
|
| 32 |
|
| 33 |
const del = async (id: string) => {
|
| 34 |
if (!confirm('Supprimer ce parcours ?')) return;
|
apps/api/src/config.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { z } from 'zod';
|
| 2 |
+
import dotenv from 'dotenv';
|
| 3 |
+
import path from 'path';
|
| 4 |
+
|
| 5 |
+
dotenv.config({ path: path.join(__dirname, '../../../../.env') });
|
| 6 |
+
|
| 7 |
+
const envSchema = z.object({
|
| 8 |
+
DATABASE_URL: z.string().url(),
|
| 9 |
+
REDIS_URL: z.string().url(),
|
| 10 |
+
ADMIN_API_KEY: z.string().min(32),
|
| 11 |
+
WHATSAPP_ACCESS_TOKEN: z.string().optional(),
|
| 12 |
+
WHATSAPP_PHONE_NUMBER_ID: z.string().optional(),
|
| 13 |
+
OPENAI_API_KEY: z.string().optional(),
|
| 14 |
+
GOOGLE_AI_API_KEY: z.string().optional(),
|
| 15 |
+
R2_ACCOUNT_ID: z.string().optional(),
|
| 16 |
+
R2_ACCESS_KEY_ID: z.string().optional(),
|
| 17 |
+
R2_SECRET_ACCESS_KEY: z.string().optional(),
|
| 18 |
+
R2_BUCKET: z.string().optional(),
|
| 19 |
+
PORT: z.string().default('3001').transform(Number),
|
| 20 |
+
NODE_ENV: z.enum(['development', 'production', 'test']).default('development')
|
| 21 |
+
});
|
| 22 |
+
|
| 23 |
+
const result = envSchema.safeParse(process.env);
|
| 24 |
+
|
| 25 |
+
if (!result.success) {
|
| 26 |
+
console.error('❌ Invalid environment variables:', JSON.stringify(result.error.format(), null, 2));
|
| 27 |
+
process.exit(1);
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
export const config = result.data;
|
apps/api/src/index.ts
CHANGED
|
@@ -12,11 +12,11 @@ import { internalRoutes } from './routes/internal';
|
|
| 12 |
import { studentRoutes } from './routes/student';
|
| 13 |
import { organizationRoutes } from './routes/organizations';
|
| 14 |
import { startCleanupCron } from './services/cleanup';
|
| 15 |
-
import {
|
| 16 |
|
| 17 |
declare module 'fastify' {
|
| 18 |
interface FastifyInstance {
|
| 19 |
-
prisma:
|
| 20 |
}
|
| 21 |
interface FastifyRequest {
|
| 22 |
rawBody?: Buffer;
|
|
@@ -102,6 +102,23 @@ server.register(async function guardedRoutes(scope) {
|
|
| 102 |
if (token !== apiKey) {
|
| 103 |
return reply.code(401).send({ error: 'Unauthorized', message: 'Invalid API key' });
|
| 104 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
});
|
| 106 |
|
| 107 |
scope.register(adminRoutes, { prefix: '/v1/admin' });
|
|
@@ -112,8 +129,8 @@ server.register(async function guardedRoutes(scope) {
|
|
| 112 |
});
|
| 113 |
|
| 114 |
// ── Health Routes (public) ─────────────────────────────────────────────────────
|
| 115 |
-
server.get('/', async (_req
|
| 116 |
-
return
|
| 117 |
});
|
| 118 |
|
| 119 |
server.get('/debug/net', async (_req, reply) => {
|
|
|
|
| 12 |
import { studentRoutes } from './routes/student';
|
| 13 |
import { organizationRoutes } from './routes/organizations';
|
| 14 |
import { startCleanupCron } from './services/cleanup';
|
| 15 |
+
import { runWithTenant } from '@repo/database';
|
| 16 |
|
| 17 |
declare module 'fastify' {
|
| 18 |
interface FastifyInstance {
|
| 19 |
+
prisma: any; // Using any because of Prisma extensions complex types
|
| 20 |
}
|
| 21 |
interface FastifyRequest {
|
| 22 |
rawBody?: Buffer;
|
|
|
|
| 102 |
if (token !== apiKey) {
|
| 103 |
return reply.code(401).send({ error: 'Unauthorized', message: 'Invalid API key' });
|
| 104 |
}
|
| 105 |
+
|
| 106 |
+
// 🏢 Multi-Tenant Context Injection
|
| 107 |
+
const orgId = request.headers['x-organization-id'] as string;
|
| 108 |
+
if (orgId) {
|
| 109 |
+
request.log.info(`[CONTEXT] Setting Organization Context: ${orgId}`);
|
| 110 |
+
// We use a trick to keep the context alive for the duration of the request
|
| 111 |
+
// In Fastify 4, calling done() inside run() works for subsequent hooks and the handler
|
| 112 |
+
}
|
| 113 |
+
});
|
| 114 |
+
|
| 115 |
+
scope.addHook('preHandler', (request, _reply, done) => {
|
| 116 |
+
const orgId = request.headers['x-organization-id'] as string;
|
| 117 |
+
if (orgId) {
|
| 118 |
+
runWithTenant(orgId, done);
|
| 119 |
+
} else {
|
| 120 |
+
done();
|
| 121 |
+
}
|
| 122 |
});
|
| 123 |
|
| 124 |
scope.register(adminRoutes, { prefix: '/v1/admin' });
|
|
|
|
| 129 |
});
|
| 130 |
|
| 131 |
// ── Health Routes (public) ─────────────────────────────────────────────────────
|
| 132 |
+
server.get('/', async (_req) => {
|
| 133 |
+
return { ok: true };
|
| 134 |
});
|
| 135 |
|
| 136 |
server.get('/debug/net', async (_req, reply) => {
|
apps/api/src/logger.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
| 1 |
import pino from 'pino';
|
|
|
|
| 2 |
|
| 3 |
const pinoLogger = pino({
|
| 4 |
level: process.env.LOG_LEVEL || 'info',
|
|
@@ -12,38 +13,47 @@ const pinoLogger = pino({
|
|
| 12 |
} : undefined
|
| 13 |
});
|
| 14 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
export const logger = {
|
| 16 |
info: (first: any, ...rest: any[]) => {
|
|
|
|
| 17 |
if (typeof first === 'string') {
|
| 18 |
-
pinoLogger.info(first, ...rest);
|
| 19 |
} else {
|
| 20 |
-
pinoLogger.info(first, rest[0] || '', ...rest.slice(1));
|
| 21 |
}
|
| 22 |
},
|
| 23 |
error: (first: any, ...rest: any[]) => {
|
|
|
|
| 24 |
if (first instanceof Error) {
|
| 25 |
-
pinoLogger.error(first, rest[0] || first.message, ...rest.slice(1));
|
| 26 |
} else if (typeof first === 'string') {
|
| 27 |
-
pinoLogger.error(first, ...rest);
|
| 28 |
} else {
|
| 29 |
-
pinoLogger.error(first, rest[0] || '', ...rest.slice(1));
|
| 30 |
}
|
| 31 |
},
|
| 32 |
warn: (first: any, ...rest: any[]) => {
|
|
|
|
| 33 |
if (typeof first === 'string') {
|
| 34 |
-
pinoLogger.warn(first, ...rest);
|
| 35 |
} else {
|
| 36 |
-
pinoLogger.warn(first, rest[0] || '', ...rest.slice(1));
|
| 37 |
}
|
| 38 |
},
|
| 39 |
debug: (first: any, ...rest: any[]) => {
|
|
|
|
| 40 |
if (typeof first === 'string') {
|
| 41 |
-
pinoLogger.debug(first, ...rest);
|
| 42 |
} else {
|
| 43 |
-
pinoLogger.debug(first, rest[0] || '', ...rest.slice(1));
|
| 44 |
}
|
| 45 |
},
|
| 46 |
};
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
|
|
|
| 1 |
import pino from 'pino';
|
| 2 |
+
import { getOrganizationId } from '@repo/database';
|
| 3 |
|
| 4 |
const pinoLogger = pino({
|
| 5 |
level: process.env.LOG_LEVEL || 'info',
|
|
|
|
| 13 |
} : undefined
|
| 14 |
});
|
| 15 |
|
| 16 |
+
function getEnrichedObject(obj: any = {}) {
|
| 17 |
+
const organizationId = getOrganizationId();
|
| 18 |
+
if (organizationId) {
|
| 19 |
+
return { ...obj, organizationId };
|
| 20 |
+
}
|
| 21 |
+
return obj;
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
export const logger = {
|
| 25 |
info: (first: any, ...rest: any[]) => {
|
| 26 |
+
const orgId = getOrganizationId();
|
| 27 |
if (typeof first === 'string') {
|
| 28 |
+
pinoLogger.info(orgId ? { organizationId: orgId } : {}, first, ...rest);
|
| 29 |
} else {
|
| 30 |
+
pinoLogger.info(getEnrichedObject(first), rest[0] || '', ...rest.slice(1));
|
| 31 |
}
|
| 32 |
},
|
| 33 |
error: (first: any, ...rest: any[]) => {
|
| 34 |
+
const orgId = getOrganizationId();
|
| 35 |
if (first instanceof Error) {
|
| 36 |
+
pinoLogger.error(getEnrichedObject({ err: first }), rest[0] || first.message, ...rest.slice(1));
|
| 37 |
} else if (typeof first === 'string') {
|
| 38 |
+
pinoLogger.error(orgId ? { organizationId: orgId } : {}, first, ...rest);
|
| 39 |
} else {
|
| 40 |
+
pinoLogger.error(getEnrichedObject(first), rest[0] || '', ...rest.slice(1));
|
| 41 |
}
|
| 42 |
},
|
| 43 |
warn: (first: any, ...rest: any[]) => {
|
| 44 |
+
const orgId = getOrganizationId();
|
| 45 |
if (typeof first === 'string') {
|
| 46 |
+
pinoLogger.warn(orgId ? { organizationId: orgId } : {}, first, ...rest);
|
| 47 |
} else {
|
| 48 |
+
pinoLogger.warn(getEnrichedObject(first), rest[0] || '', ...rest.slice(1));
|
| 49 |
}
|
| 50 |
},
|
| 51 |
debug: (first: any, ...rest: any[]) => {
|
| 52 |
+
const orgId = getOrganizationId();
|
| 53 |
if (typeof first === 'string') {
|
| 54 |
+
pinoLogger.debug(orgId ? { organizationId: orgId } : {}, first, ...rest);
|
| 55 |
} else {
|
| 56 |
+
pinoLogger.debug(getEnrichedObject(first), rest[0] || '', ...rest.slice(1));
|
| 57 |
}
|
| 58 |
},
|
| 59 |
};
|
|
|
|
|
|
|
|
|
apps/api/src/middleware/tenant.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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/ai.ts
CHANGED
|
@@ -98,11 +98,11 @@ export async function aiRoutes(fastify: FastifyInstance) {
|
|
| 98 |
response: z.string()
|
| 99 |
})).optional()
|
| 100 |
});
|
| 101 |
-
const { lessonText, userActivity, userLanguage,
|
| 102 |
|
| 103 |
logger.info(`[AI] Personalizing lesson for activity: ${userActivity} (Lang: ${userLanguage}) with ${previousResponses?.length || 0} prev responses.`);
|
| 104 |
|
| 105 |
-
const { lessonText: personalizedText, aiSource } = await aiService.generatePersonalizedLesson(lessonText, userActivity, userLanguage,
|
| 106 |
|
| 107 |
return { success: true, text: personalizedText, aiSource };
|
| 108 |
});
|
|
@@ -240,7 +240,7 @@ export async function aiRoutes(fastify: FastifyInstance) {
|
|
| 240 |
const feedback = await aiService.generateFeedback(
|
| 241 |
answers, exercisePrompt, lessonText, userLanguage, businessProfile ?? undefined, exerciseCriteria ?? undefined,
|
| 242 |
userActivity ?? undefined, userRegion ?? undefined, dayNumber ?? undefined, previousResponses ?? undefined,
|
| 243 |
-
isDeepDive, iterationCount, imageUrl ?? undefined
|
| 244 |
);
|
| 245 |
|
| 246 |
// 🌟 Standard Feedback UX: 2-stage branching (Lead UX Requirement) 🌟
|
|
|
|
| 98 |
response: z.string()
|
| 99 |
})).optional()
|
| 100 |
});
|
| 101 |
+
const { lessonText, userActivity, userLanguage, previousResponses } = bodySchema.parse(request.body);
|
| 102 |
|
| 103 |
logger.info(`[AI] Personalizing lesson for activity: ${userActivity} (Lang: ${userLanguage}) with ${previousResponses?.length || 0} prev responses.`);
|
| 104 |
|
| 105 |
+
const { lessonText: personalizedText, aiSource } = await aiService.generatePersonalizedLesson(lessonText, userActivity, userLanguage, previousResponses);
|
| 106 |
|
| 107 |
return { success: true, text: personalizedText, aiSource };
|
| 108 |
});
|
|
|
|
| 240 |
const feedback = await aiService.generateFeedback(
|
| 241 |
answers, exercisePrompt, lessonText, userLanguage, businessProfile ?? undefined, exerciseCriteria ?? undefined,
|
| 242 |
userActivity ?? undefined, userRegion ?? undefined, dayNumber ?? undefined, previousResponses ?? undefined,
|
| 243 |
+
isDeepDive, iterationCount, imageUrl ?? undefined
|
| 244 |
);
|
| 245 |
|
| 246 |
// 🌟 Standard Feedback UX: 2-stage branching (Lead UX Requirement) 🌟
|
apps/api/src/routes/internal.ts
CHANGED
|
@@ -1,30 +1,8 @@
|
|
| 1 |
import { FastifyInstance } from 'fastify';
|
| 2 |
import { WhatsAppService } from '../services/whatsapp';
|
| 3 |
-
import { prisma } from '../services/prisma';
|
| 4 |
import { z } from 'zod';
|
| 5 |
import { getOrganizationByPhoneNumberId } from '../services/organization';
|
| 6 |
-
|
| 7 |
-
const WhatsAppMessageSchema = z.object({
|
| 8 |
-
from: z.string(),
|
| 9 |
-
id: z.string(),
|
| 10 |
-
timestamp: z.string(),
|
| 11 |
-
type: z.enum(['text', 'audio', 'image', 'video', 'document', 'sticker', 'reaction', 'interactive']),
|
| 12 |
-
text: z.object({ body: z.string() }).optional(),
|
| 13 |
-
audio: z.object({ id: z.string(), mime_type: z.string().optional() }).optional(),
|
| 14 |
-
image: z.object({ id: z.string(), caption: z.string().optional() }).optional(),
|
| 15 |
-
interactive: z.object({
|
| 16 |
-
type: z.enum(['button_reply', 'list_reply']),
|
| 17 |
-
button_reply: z.object({
|
| 18 |
-
id: z.string(),
|
| 19 |
-
title: z.string(),
|
| 20 |
-
}).optional(),
|
| 21 |
-
list_reply: z.object({
|
| 22 |
-
id: z.string(),
|
| 23 |
-
title: z.string(),
|
| 24 |
-
description: z.string().optional()
|
| 25 |
-
}).optional(),
|
| 26 |
-
}).optional()
|
| 27 |
-
});
|
| 28 |
|
| 29 |
const WebhookPayloadSchema = z.object({
|
| 30 |
object: z.literal('whatsapp_business_account'),
|
|
@@ -32,137 +10,52 @@ const WebhookPayloadSchema = z.object({
|
|
| 32 |
id: z.string(),
|
| 33 |
changes: z.array(z.object({
|
| 34 |
value: z.object({
|
| 35 |
-
messaging_product: z.string().optional(),
|
| 36 |
metadata: z.object({ phone_number_id: z.string() }).optional(),
|
| 37 |
-
|
| 38 |
-
messages: z.array(WhatsAppMessageSchema).optional(),
|
| 39 |
-
statuses: z.array(z.any()).optional(),
|
| 40 |
}),
|
| 41 |
field: z.string(),
|
| 42 |
})),
|
| 43 |
})),
|
| 44 |
});
|
| 45 |
|
| 46 |
-
/**
|
| 47 |
-
* Internal-only routes — protected by ADMIN_API_KEY, not exposed publicly.
|
| 48 |
-
* Used by the Railway worker to call handleIncomingMessage after audio transcription.
|
| 49 |
-
*/
|
| 50 |
export async function internalRoutes(fastify: FastifyInstance) {
|
| 51 |
// ── Handle Webhook Forwarding from Gateway (HF -> Railway) ───────────────
|
| 52 |
fastify.post('/v1/internal/whatsapp/inbound', {
|
| 53 |
config: { requireAuth: true }
|
| 54 |
}, async (request, reply) => {
|
| 55 |
-
// We received the raw webhook payload that was forwarded.
|
| 56 |
-
// Send a 200 immediately to release HF Gateway
|
| 57 |
reply.code(200).send({ ok: true });
|
| 58 |
|
| 59 |
-
// Process message parsing outside the request loop
|
| 60 |
setImmediate(async () => {
|
| 61 |
try {
|
| 62 |
const parsed = WebhookPayloadSchema.safeParse(request.body);
|
|
|
|
| 63 |
|
| 64 |
-
|
| 65 |
-
fastify.log.warn(`[INTERNAL-WEBHOOK] Invalid payload schema: ${JSON.stringify(parsed.error.flatten())}`);
|
| 66 |
-
return;
|
| 67 |
-
}
|
| 68 |
-
|
| 69 |
-
const payload = parsed.data;
|
| 70 |
-
|
| 71 |
-
for (const entry of payload.entry) {
|
| 72 |
for (const change of entry.changes) {
|
| 73 |
-
const
|
|
|
|
| 74 |
|
| 75 |
-
for (const message of messages) {
|
| 76 |
const phone = message.from;
|
| 77 |
let text = '';
|
| 78 |
-
|
| 79 |
-
const phoneNumberId = change.value.metadata?.phone_number_id || 'unknown';
|
| 80 |
-
const organizationId = await getOrganizationByPhoneNumberId(phoneNumberId);
|
| 81 |
-
|
| 82 |
-
if (message.type === 'text' && message.text) {
|
| 83 |
-
text = message.text.body;
|
| 84 |
-
|
| 85 |
-
} else if (message.type === 'interactive' && message.interactive) {
|
| 86 |
-
if (message.interactive.type === 'button_reply' && message.interactive.button_reply) {
|
| 87 |
-
text = message.interactive.button_reply.id;
|
| 88 |
-
fastify.log.info(`[INTERNAL-WEBHOOK] Button reply: ${text}`);
|
| 89 |
-
} else if (message.interactive.type === 'list_reply' && message.interactive.list_reply) {
|
| 90 |
-
text = message.interactive.list_reply.id;
|
| 91 |
-
fastify.log.info(`[INTERNAL-WEBHOOK] List reply: ${text}`);
|
| 92 |
-
}
|
| 93 |
-
|
| 94 |
-
} else if (message.type === 'audio' && message.audio) {
|
| 95 |
-
// ─── Audio inbound: delegate download to Railway worker ────────────
|
| 96 |
-
const accessToken = process.env.WHATSAPP_ACCESS_TOKEN || '';
|
| 97 |
-
const { Queue } = await import('bullmq');
|
| 98 |
-
const Redis = (await import('ioredis')).default;
|
| 99 |
-
const conn = process.env.REDIS_URL
|
| 100 |
-
? new Redis(process.env.REDIS_URL, { maxRetriesPerRequest: null })
|
| 101 |
-
: new Redis({ host: process.env.REDIS_HOST || 'localhost', port: parseInt(process.env.REDIS_PORT || '6379'), maxRetriesPerRequest: null });
|
| 102 |
-
// @ts-ignore - version mismatch between bullmq and app ioredis
|
| 103 |
-
const q = new Queue('whatsapp-queue', { connection: conn });
|
| 104 |
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
}, { priority: 1, attempts: 3, backoff: { type: 'exponential', delay: 2000 } });
|
| 112 |
-
|
| 113 |
-
await q.add('send-message-direct', {
|
| 114 |
-
phone,
|
| 115 |
-
text: "⏳ J'analyse ton audio..."
|
| 116 |
-
});
|
| 117 |
-
|
| 118 |
-
fastify.log.info(`[INTERNAL-WEBHOOK] Audio ${message.audio.id} enqueued for Railway download`);
|
| 119 |
-
continue;
|
| 120 |
-
} else if (message.type === 'image' && message.image) {
|
| 121 |
-
// ─── Image inbound: delegate download to Railway worker ────────────
|
| 122 |
-
const accessToken = process.env.WHATSAPP_ACCESS_TOKEN || '';
|
| 123 |
-
const { Queue } = await import('bullmq');
|
| 124 |
-
const Redis = (await import('ioredis')).default;
|
| 125 |
-
const conn = process.env.REDIS_URL
|
| 126 |
-
? new Redis(process.env.REDIS_URL, { maxRetriesPerRequest: null })
|
| 127 |
-
: new Redis({ host: process.env.REDIS_HOST || 'localhost', port: parseInt(process.env.REDIS_PORT || '6379'), maxRetriesPerRequest: null });
|
| 128 |
-
// @ts-ignore - version mismatch between bullmq and app ioredis
|
| 129 |
-
const q = new Queue('whatsapp-queue', { connection: conn });
|
| 130 |
-
|
| 131 |
-
fastify.log.info(`[INTERNAL-WEBHOOK] Image ${message.image.id} detected. Enqueuing download.`);
|
| 132 |
-
|
| 133 |
-
await q.add('download-media', {
|
| 134 |
-
mediaId: message.image.id,
|
| 135 |
-
mimeType: 'image/jpeg',
|
| 136 |
-
phone,
|
| 137 |
-
organizationId,
|
| 138 |
-
caption: message.image.caption || undefined,
|
| 139 |
-
...(accessToken ? { accessToken } : {})
|
| 140 |
-
}, { priority: 1, attempts: 3, backoff: { type: 'exponential', delay: 2000 } });
|
| 141 |
-
|
| 142 |
-
await q.add('send-message-direct', {
|
| 143 |
-
phone,
|
| 144 |
-
text: "⏳ J'analyse ton image..."
|
| 145 |
-
});
|
| 146 |
-
|
| 147 |
-
fastify.log.info(`[INTERNAL-WEBHOOK] Image ${message.image.id} enqueued for Railway download`);
|
| 148 |
continue;
|
| 149 |
}
|
| 150 |
|
| 151 |
if (phone && text) {
|
| 152 |
-
|
| 153 |
-
const user = await prisma.user.findUnique({ where: { phone } });
|
| 154 |
-
let ttDay: number | undefined;
|
| 155 |
-
if (user) {
|
| 156 |
-
const { getTimeTravelContext } = await import('../services/queue');
|
| 157 |
-
ttDay = (await getTimeTravelContext(user.id)) ?? undefined;
|
| 158 |
-
}
|
| 159 |
-
await WhatsAppService.handleIncomingMessage(phone, text, undefined, undefined, ttDay, organizationId);
|
| 160 |
}
|
| 161 |
}
|
| 162 |
}
|
| 163 |
}
|
| 164 |
} catch (error) {
|
| 165 |
-
|
| 166 |
}
|
| 167 |
});
|
| 168 |
});
|
|
@@ -171,51 +64,16 @@ export async function internalRoutes(fastify: FastifyInstance) {
|
|
| 171 |
fastify.post<{
|
| 172 |
Body: { phone: string; text: string; audioUrl?: string; imageUrl?: string; organizationId?: string }
|
| 173 |
}>('/v1/internal/handle-message', {
|
| 174 |
-
config: { requireAuth: true }
|
| 175 |
-
schema: {
|
| 176 |
-
body: {
|
| 177 |
-
type: 'object',
|
| 178 |
-
required: ['phone', 'text'],
|
| 179 |
-
properties: {
|
| 180 |
-
phone: { type: 'string' },
|
| 181 |
-
text: { type: 'string' },
|
| 182 |
-
audioUrl: { type: 'string' },
|
| 183 |
-
imageUrl: { type: 'string' },
|
| 184 |
-
organizationId: { type: 'string' }
|
| 185 |
-
}
|
| 186 |
-
}
|
| 187 |
-
}
|
| 188 |
}, async (request, reply) => {
|
| 189 |
const { phone, text, audioUrl, imageUrl, organizationId } = request.body;
|
| 190 |
-
const traceId = `[INTERNAL-TX-${phone.slice(-4)}]`;
|
| 191 |
-
|
| 192 |
-
if (!phone || text === undefined) {
|
| 193 |
-
request.log.warn(`${traceId} Missing phone or text in handle-message request`);
|
| 194 |
-
return reply.code(400).send({ error: 'phone and text are required' });
|
| 195 |
-
}
|
| 196 |
-
|
| 197 |
-
request.log.info(`${traceId} Received message text: "${text.substring(0, 100)}..." (Image: ${!!imageUrl})`);
|
| 198 |
-
|
| 199 |
-
// Fire and await - ensuring the worker knows if it failed
|
| 200 |
try {
|
| 201 |
await WhatsAppService.handleIncomingMessage(phone, text, audioUrl, imageUrl, undefined, organizationId);
|
| 202 |
-
|
| 203 |
-
} catch (err:
|
| 204 |
-
|
| 205 |
-
request.log.error(`${traceId} handleIncomingMessage error: ${errorMsg}`);
|
| 206 |
-
return reply.code(500).send({ error: errorMsg });
|
| 207 |
}
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
return reply.send({ ok: true });
|
| 211 |
});
|
| 212 |
|
| 213 |
-
//
|
| 214 |
-
fastify.get('/v1/internal/ping', {
|
| 215 |
-
config: { requireAuth: true }
|
| 216 |
-
}, async () => {
|
| 217 |
-
return { ok: true, message: 'Pong! Railway Internal API is reachable and authorized.', timestamp: new Date().toISOString() };
|
| 218 |
-
});
|
| 219 |
}
|
| 220 |
-
|
| 221 |
-
|
|
|
|
| 1 |
import { FastifyInstance } from 'fastify';
|
| 2 |
import { WhatsAppService } from '../services/whatsapp';
|
|
|
|
| 3 |
import { z } from 'zod';
|
| 4 |
import { getOrganizationByPhoneNumberId } from '../services/organization';
|
| 5 |
+
import { logger } from '../logger';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
|
| 7 |
const WebhookPayloadSchema = z.object({
|
| 8 |
object: z.literal('whatsapp_business_account'),
|
|
|
|
| 10 |
id: z.string(),
|
| 11 |
changes: z.array(z.object({
|
| 12 |
value: z.object({
|
|
|
|
| 13 |
metadata: z.object({ phone_number_id: z.string() }).optional(),
|
| 14 |
+
messages: z.array(z.any()).optional(),
|
|
|
|
|
|
|
| 15 |
}),
|
| 16 |
field: z.string(),
|
| 17 |
})),
|
| 18 |
})),
|
| 19 |
});
|
| 20 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
export async function internalRoutes(fastify: FastifyInstance) {
|
| 22 |
// ── Handle Webhook Forwarding from Gateway (HF -> Railway) ───────────────
|
| 23 |
fastify.post('/v1/internal/whatsapp/inbound', {
|
| 24 |
config: { requireAuth: true }
|
| 25 |
}, async (request, reply) => {
|
|
|
|
|
|
|
| 26 |
reply.code(200).send({ ok: true });
|
| 27 |
|
|
|
|
| 28 |
setImmediate(async () => {
|
| 29 |
try {
|
| 30 |
const parsed = WebhookPayloadSchema.safeParse(request.body);
|
| 31 |
+
if (!parsed.success) return;
|
| 32 |
|
| 33 |
+
for (const entry of parsed.data.entry) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
for (const change of entry.changes) {
|
| 35 |
+
const phoneNumberId = change.value.metadata?.phone_number_id || 'unknown';
|
| 36 |
+
const organizationId = await getOrganizationByPhoneNumberId(phoneNumberId);
|
| 37 |
|
| 38 |
+
for (const message of change.value.messages || []) {
|
| 39 |
const phone = message.from;
|
| 40 |
let text = '';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
|
| 42 |
+
if (message.type === 'text') text = message.text?.body;
|
| 43 |
+
else if (message.type === 'interactive') {
|
| 44 |
+
text = message.interactive?.button_reply?.id || message.interactive?.list_reply?.id;
|
| 45 |
+
} else if (message.type === 'audio' || message.type === 'image') {
|
| 46 |
+
// Delegate media to worker via service
|
| 47 |
+
await WhatsAppService.handleIncomingMessage(phone, '', message.audio?.id || undefined, message.image?.id || undefined, undefined, organizationId);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
continue;
|
| 49 |
}
|
| 50 |
|
| 51 |
if (phone && text) {
|
| 52 |
+
await WhatsAppService.handleIncomingMessage(phone, text, undefined, undefined, undefined, organizationId);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
}
|
| 54 |
}
|
| 55 |
}
|
| 56 |
}
|
| 57 |
} catch (error) {
|
| 58 |
+
logger.error(`[INTERNAL-WEBHOOK] Async processing error: ${error}`);
|
| 59 |
}
|
| 60 |
});
|
| 61 |
});
|
|
|
|
| 64 |
fastify.post<{
|
| 65 |
Body: { phone: string; text: string; audioUrl?: string; imageUrl?: string; organizationId?: string }
|
| 66 |
}>('/v1/internal/handle-message', {
|
| 67 |
+
config: { requireAuth: true }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
}, async (request, reply) => {
|
| 69 |
const { phone, text, audioUrl, imageUrl, organizationId } = request.body;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
try {
|
| 71 |
await WhatsAppService.handleIncomingMessage(phone, text, audioUrl, imageUrl, undefined, organizationId);
|
| 72 |
+
return reply.send({ ok: true });
|
| 73 |
+
} catch (err: any) {
|
| 74 |
+
return reply.code(500).send({ error: err.message });
|
|
|
|
|
|
|
| 75 |
}
|
|
|
|
|
|
|
|
|
|
| 76 |
});
|
| 77 |
|
| 78 |
+
fastify.get('/v1/internal/ping', { config: { requireAuth: true } }, async () => ({ ok: true }));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
}
|
|
|
|
|
|
apps/api/src/routes/organizations.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
| 1 |
import { FastifyInstance } from 'fastify';
|
| 2 |
import { prisma } from '../services/prisma';
|
| 3 |
-
import { redis } from '../services/queue';
|
| 4 |
import { z } from 'zod';
|
| 5 |
import { logger } from '../logger';
|
|
|
|
| 6 |
|
| 7 |
const OrganizationSchema = z.object({
|
| 8 |
name: z.string().min(1),
|
|
@@ -10,6 +10,13 @@ const OrganizationSchema = z.object({
|
|
| 10 |
customPrompt: z.string().optional(),
|
| 11 |
});
|
| 12 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
export async function organizationRoutes(fastify: FastifyInstance) {
|
| 14 |
// 1. List all organizations
|
| 15 |
fastify.get('/', async () => {
|
|
@@ -63,7 +70,7 @@ export async function organizationRoutes(fastify: FastifyInstance) {
|
|
| 63 |
data: body.data
|
| 64 |
});
|
| 65 |
// 🚨 INVALIDATE CACHE
|
| 66 |
-
await
|
| 67 |
return org;
|
| 68 |
} catch (err) {
|
| 69 |
return reply.code(404).send({ error: 'Organization not found' });
|
|
@@ -83,7 +90,7 @@ export async function organizationRoutes(fastify: FastifyInstance) {
|
|
| 83 |
const body = schema.safeParse(req.body);
|
| 84 |
if (!body.success) return reply.code(400).send({ error: body.error.flatten() });
|
| 85 |
|
| 86 |
-
const {
|
| 87 |
|
| 88 |
// Update Organization with the permanent token
|
| 89 |
await prisma.organization.update({
|
|
@@ -94,7 +101,7 @@ export async function organizationRoutes(fastify: FastifyInstance) {
|
|
| 94 |
});
|
| 95 |
|
| 96 |
// 🚨 INVALIDATE CACHE
|
| 97 |
-
await
|
| 98 |
|
| 99 |
// Upsert the Phone Number associated with this organization
|
| 100 |
if (phoneNumberId) {
|
|
@@ -114,4 +121,33 @@ export async function organizationRoutes(fastify: FastifyInstance) {
|
|
| 114 |
|
| 115 |
return { ok: true, message: 'WhatsApp configuration updated successfully' };
|
| 116 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
}
|
|
|
|
| 1 |
import { FastifyInstance } from 'fastify';
|
| 2 |
import { prisma } from '../services/prisma';
|
|
|
|
| 3 |
import { z } from 'zod';
|
| 4 |
import { logger } from '../logger';
|
| 5 |
+
import { invalidateOrganizationCache } from '../services/organization';
|
| 6 |
|
| 7 |
const OrganizationSchema = z.object({
|
| 8 |
name: z.string().min(1),
|
|
|
|
| 10 |
customPrompt: z.string().optional(),
|
| 11 |
});
|
| 12 |
|
| 13 |
+
const PersonalityConfigSchema = z.object({
|
| 14 |
+
botName: z.string().optional(),
|
| 15 |
+
coreMission: z.string().optional(),
|
| 16 |
+
toneDescription: z.string().optional(),
|
| 17 |
+
languageConstraints: z.string().optional()
|
| 18 |
+
});
|
| 19 |
+
|
| 20 |
export async function organizationRoutes(fastify: FastifyInstance) {
|
| 21 |
// 1. List all organizations
|
| 22 |
fastify.get('/', async () => {
|
|
|
|
| 70 |
data: body.data
|
| 71 |
});
|
| 72 |
// 🚨 INVALIDATE CACHE
|
| 73 |
+
await invalidateOrganizationCache(id);
|
| 74 |
return org;
|
| 75 |
} catch (err) {
|
| 76 |
return reply.code(404).send({ error: 'Organization not found' });
|
|
|
|
| 90 |
const body = schema.safeParse(req.body);
|
| 91 |
if (!body.success) return reply.code(400).send({ error: body.error.flatten() });
|
| 92 |
|
| 93 |
+
const { accessToken, phoneNumber, phoneNumberId } = body.data;
|
| 94 |
|
| 95 |
// Update Organization with the permanent token
|
| 96 |
await prisma.organization.update({
|
|
|
|
| 101 |
});
|
| 102 |
|
| 103 |
// 🚨 INVALIDATE CACHE
|
| 104 |
+
await invalidateOrganizationCache(id, phoneNumberId);
|
| 105 |
|
| 106 |
// Upsert the Phone Number associated with this organization
|
| 107 |
if (phoneNumberId) {
|
|
|
|
| 121 |
|
| 122 |
return { ok: true, message: 'WhatsApp configuration updated successfully' };
|
| 123 |
});
|
| 124 |
+
|
| 125 |
+
// 6. Update AI Personality Configuration
|
| 126 |
+
fastify.patch('/:id/personality', async (req, reply) => {
|
| 127 |
+
const { id } = req.params as { id: string };
|
| 128 |
+
const body = PersonalityConfigSchema.partial().safeParse(req.body);
|
| 129 |
+
|
| 130 |
+
if (!body.success) return reply.code(400).send({ error: body.error.flatten() });
|
| 131 |
+
|
| 132 |
+
try {
|
| 133 |
+
const org = await prisma.organization.findUnique({ where: { id } });
|
| 134 |
+
if (!org) return reply.code(404).send({ error: 'Organization not found' });
|
| 135 |
+
|
| 136 |
+
const currentConfig = (org.personalityConfig as any) || {};
|
| 137 |
+
const newConfig = { ...currentConfig, ...body.data };
|
| 138 |
+
|
| 139 |
+
const updatedOrg = await prisma.organization.update({
|
| 140 |
+
where: { id },
|
| 141 |
+
data: { personalityConfig: newConfig }
|
| 142 |
+
});
|
| 143 |
+
|
| 144 |
+
// 🚨 INVALIDATE CACHE
|
| 145 |
+
await invalidateOrganizationCache(id);
|
| 146 |
+
|
| 147 |
+
return updatedOrg;
|
| 148 |
+
} catch (err) {
|
| 149 |
+
logger.error('[ORG_ROUTE] Personality update failed:', err);
|
| 150 |
+
return reply.code(500).send({ error: 'Failed to update personality' });
|
| 151 |
+
}
|
| 152 |
+
});
|
| 153 |
}
|
apps/api/src/services/ai/ProviderRegistry.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { LLMProvider } from './types';
|
| 2 |
+
import { logger } from '../../logger';
|
| 3 |
+
|
| 4 |
+
export enum ProviderCapability {
|
| 5 |
+
TEXT = 'TEXT',
|
| 6 |
+
VISION = 'VISION',
|
| 7 |
+
AUDIO_TRANSCRIPTION = 'AUDIO_TRANSCRIPTION',
|
| 8 |
+
SPEECH_GENERATION = 'SPEECH_GENERATION',
|
| 9 |
+
IMAGE_GENERATION = 'IMAGE_GENERATION'
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
export interface RegisteredProvider {
|
| 13 |
+
name: string;
|
| 14 |
+
instance: LLMProvider;
|
| 15 |
+
priority: number;
|
| 16 |
+
capabilities: ProviderCapability[];
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
export class ProviderRegistry {
|
| 20 |
+
private providers: RegisteredProvider[] = [];
|
| 21 |
+
|
| 22 |
+
register(name: string, instance: LLMProvider, priority: number, capabilities: ProviderCapability[]) {
|
| 23 |
+
this.providers.push({ name, instance, priority, capabilities });
|
| 24 |
+
// Sort by priority descending (higher priority first)
|
| 25 |
+
this.providers.sort((a, b) => b.priority - a.priority);
|
| 26 |
+
logger.info(`[PROVIDER_REGISTRY] Registered ${name} (Priority: ${priority}, Capabilities: ${capabilities.join(', ')})`);
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
getProvidersFor(capability: ProviderCapability): RegisteredProvider[] {
|
| 30 |
+
return this.providers.filter(p => p.capabilities.includes(capability));
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
getPrimary(capability: ProviderCapability = ProviderCapability.TEXT): RegisteredProvider | undefined {
|
| 34 |
+
return this.getProvidersFor(capability)[0];
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
getAll(): RegisteredProvider[] {
|
| 38 |
+
return [...this.providers];
|
| 39 |
+
}
|
| 40 |
+
}
|
apps/api/src/services/ai/index.ts
CHANGED
|
@@ -1,119 +1,131 @@
|
|
| 1 |
import { logger } from '../../logger';
|
| 2 |
import { z } from 'zod';
|
| 3 |
-
import {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
import { MockLLMProvider } from './mock-provider';
|
| 5 |
import { OpenAIProvider } from './openai-provider';
|
| 6 |
import { searchService } from './search';
|
| 7 |
import { GeminiProvider } from './gemini-provider';
|
| 8 |
-
import { PromptLoader } from '@repo/prompts';
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
class AIService {
|
| 11 |
-
private
|
| 12 |
-
private fallbackProvider: LLMProvider | null = null;
|
| 13 |
-
private avProvider: LLMProvider | null = null; // Specifically for Audio/Video/Image (OpenAI)
|
| 14 |
-
private mockProvider: LLMProvider;
|
| 15 |
|
| 16 |
constructor() {
|
| 17 |
-
this.
|
|
|
|
|
|
|
| 18 |
|
|
|
|
| 19 |
const geminiApiKey = process.env.GOOGLE_AI_API_KEY;
|
| 20 |
const openAiApiKey = process.env.OPENAI_API_KEY;
|
| 21 |
|
| 22 |
if (geminiApiKey) {
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
}
|
| 31 |
-
} else if (openAiApiKey) {
|
| 32 |
-
logger.info('[AI_SERVICE] Gemini Key missing. Initializing OpenAI as Primary & A/V Provider...');
|
| 33 |
const openai = new OpenAIProvider(openAiApiKey);
|
| 34 |
-
this.
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
}
|
| 41 |
}
|
| 42 |
|
| 43 |
-
/**
|
| 44 |
-
* Internal wrapper for structured data calls with failover logic.
|
| 45 |
-
*/
|
| 46 |
private async callWithFailover<T>(
|
| 47 |
prompt: string,
|
| 48 |
schema: z.ZodSchema<T>,
|
| 49 |
temperature?: number,
|
| 50 |
imageUrl?: string
|
| 51 |
): Promise<{ data: T, source: string }> {
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
logger.info('[AI_INFO] OPENAI used as fallback.');
|
| 63 |
-
return { data, source: 'OPENAI' };
|
| 64 |
}
|
| 65 |
-
throw err;
|
| 66 |
}
|
|
|
|
|
|
|
| 67 |
}
|
| 68 |
|
| 69 |
-
/**
|
| 70 |
-
* Extracts a One-Pager JSON structure from raw user data.
|
| 71 |
-
*/
|
| 72 |
async generateOnePagerData(userContext: string, language: string = 'FR', businessProfile?: any): Promise<OnePagerData> {
|
| 73 |
-
const
|
| 74 |
-
? `\n🌐 DONNÉES DE MARCHÉ RÉELLES (Google Search) :\n${JSON.stringify(businessProfile.marketData, null, 2)}\n`
|
| 75 |
-
: '';
|
| 76 |
-
|
| 77 |
const prompt = PromptLoader.compile('one-pager', {
|
| 78 |
activityLabel: businessProfile?.activityLabel || 'non précisé',
|
| 79 |
userContext,
|
| 80 |
-
marketDataInjected,
|
| 81 |
-
languageLabel: language === 'WOLOF' ? 'WOLOF standardisé
|
| 82 |
-
languageInstruction: language === 'WOLOF' ? 'WOLOF (ñ, ë, é) suivi
|
| 83 |
-
});
|
| 84 |
|
| 85 |
const { data, source } = await this.callWithFailover(prompt, OnePagerSchema);
|
| 86 |
return { ...data, aiSource: source };
|
| 87 |
}
|
| 88 |
|
| 89 |
-
/**
|
| 90 |
-
* Extracts a Slide Deck JSON structure from raw user data.
|
| 91 |
-
*/
|
| 92 |
async generatePitchDeckData(userContext: string, language: string = 'FR', businessProfile?: any): Promise<PitchDeckData & { aiSource?: string }> {
|
| 93 |
-
const
|
| 94 |
-
? `\n🌐 DONNÉES DE MARCHÉ (RECHERCHE WEB) :\n${JSON.stringify(businessProfile.marketData, null, 2)}\n`
|
| 95 |
-
: '';
|
| 96 |
-
|
| 97 |
-
const teamDataInjected = businessProfile?.teamMembers && Array.isArray(businessProfile.teamMembers) && businessProfile.teamMembers.length > 0
|
| 98 |
-
? `\n👥 MEMBRES DE L'ÉQUIPE (PHOTOS/BDD) :\n${JSON.stringify(businessProfile.teamMembers, null, 2)}\n`
|
| 99 |
-
: '';
|
| 100 |
-
|
| 101 |
const prompt = PromptLoader.compile('pitch-deck', {
|
| 102 |
activityLabel: businessProfile?.activityLabel || 'Entrepreneuriat',
|
| 103 |
locationCity: businessProfile?.locationCity || 'Sénégal',
|
| 104 |
userContext,
|
| 105 |
-
marketDataInjected,
|
| 106 |
-
teamDataInjected,
|
| 107 |
languageLabel: language === 'WOLOF' ? 'WOLOF' : 'FRENCH'
|
| 108 |
-
});
|
| 109 |
|
| 110 |
const { data, source } = await this.callWithFailover(prompt, PitchDeckSchema);
|
| 111 |
return { ...data, aiSource: source };
|
| 112 |
}
|
| 113 |
|
| 114 |
-
/**
|
| 115 |
-
* Generates a short pedagogical feedback for the student's answer based on v1.0 criteria.
|
| 116 |
-
*/
|
| 117 |
async generateFeedback(
|
| 118 |
userInput: string,
|
| 119 |
expectedExercise: string,
|
|
@@ -121,221 +133,95 @@ class AIService {
|
|
| 121 |
userLanguage: string = 'FR',
|
| 122 |
businessProfile?: any,
|
| 123 |
exerciseCriteria?: any,
|
| 124 |
-
// Expert coaching context
|
| 125 |
userActivity?: string,
|
| 126 |
userRegion?: string,
|
| 127 |
dayNumber?: number,
|
| 128 |
previousResponses?: Array<{ day: number; response: string }>,
|
| 129 |
isDeepDive: boolean = false,
|
| 130 |
iterationCount: number = 0,
|
| 131 |
-
imageUrl?: string
|
| 132 |
-
isButtonChoice: boolean = false
|
| 133 |
): Promise<FeedbackData & { searchResults?: any[] }> {
|
| 134 |
-
|
| 135 |
-
const questionKeywords = ['?', 'avis', 'penses', 'conseil', 'aider', 'comment', 'pourquoi', 'idée', 'prix', 'standard'];
|
| 136 |
-
const lowerInput = userInput.toLowerCase();
|
| 137 |
-
const hasQuestion = questionKeywords.some(kw => lowerInput.includes(kw));
|
| 138 |
-
|
| 139 |
-
logger.info(`[AI_INTERACTION] User asked a question: ${hasQuestion}`);
|
| 140 |
-
|
| 141 |
const activityLabel = userActivity || businessProfile?.activityLabel || 'non précisé';
|
| 142 |
-
const region = userRegion || businessProfile?.region || 'Sénégal';
|
| 143 |
-
const customer = businessProfile?.mainCustomer || '';
|
| 144 |
-
const offer = businessProfile?.offerSimple || '';
|
| 145 |
-
const promise = businessProfile?.promise || '';
|
| 146 |
-
const problem = businessProfile?.mainProblem || '';
|
| 147 |
-
|
| 148 |
-
const businessContext = [
|
| 149 |
-
`🏪 BUSINESS DE L'ÉTUDIANT :`,
|
| 150 |
-
`- Activité : ${activityLabel}`,
|
| 151 |
-
`- Région : ${region}`,
|
| 152 |
-
customer ? `- Client principal : ${customer}` : '',
|
| 153 |
-
offer ? `- Offre : ${offer}` : '',
|
| 154 |
-
problem ? `- Problème résolu : ${problem}` : '',
|
| 155 |
-
promise ? `- Promesse unique : ${promise}` : '',
|
| 156 |
-
].filter(Boolean).join('\n');
|
| 157 |
-
|
| 158 |
-
const prevContext = previousResponses && previousResponses.length > 0
|
| 159 |
-
? `\nCONTEXTE HISTORIQUE DU BUSINESS :\n${previousResponses.map(r => `[Jour ${r.day}]: "${r.response}"`).join('\n')}`
|
| 160 |
-
: '';
|
| 161 |
-
|
| 162 |
-
const previousResponsesContext = previousResponses && previousResponses.length > 0
|
| 163 |
-
? `\n\nHISTORIQUE DES RÉPONSES PRÉCÉDENTES :\n${previousResponses.map(r => `Jour ${r.day}: ${r.response}`).join('\n')}`
|
| 164 |
-
: '';
|
| 165 |
-
|
| 166 |
-
let searchContext = '';
|
| 167 |
-
let searchResults: any[] | undefined = undefined;
|
| 168 |
-
// 🚀 Brique 1: Activation du Browsing (Optimized for Day 7)
|
| 169 |
-
const isDay7Choice = dayNumber === 7 && (userInput.length < 15 || ['whatsapp', 'boutique', 'digital', 'physique'].includes(lowerInput));
|
| 170 |
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
// Remove hallucinatory generic fallback words
|
| 174 |
-
const cleanActivity = activityLabel.replace(/non précisé|e-commerce/i, '').trim() || 'Entrepreneuriat';
|
| 175 |
-
let query = `${cleanActivity} ${region} Sénégal marché chiffres statistiques data`;
|
| 176 |
-
|
| 177 |
-
if (dayNumber === 10) {
|
| 178 |
-
query = `startups concurrents ${cleanActivity} ${region} Sénégal solutions paiement UEMOA`;
|
| 179 |
-
} else if (dayNumber === 11) {
|
| 180 |
-
query = `benchmarks marges rentabilité ${cleanActivity} Afrique de l'Ouest tech business model`;
|
| 181 |
-
} else if (dayNumber === 12) {
|
| 182 |
-
query = `benchmarks revenus levée de fonds analyse concurrents locaux ${cleanActivity} Afrique de l'Ouest`;
|
| 183 |
-
}
|
| 184 |
|
|
|
|
| 185 |
try {
|
| 186 |
-
const results = await searchService.search(
|
| 187 |
-
if (results
|
| 188 |
searchResults = results;
|
| 189 |
-
searchContext = `\n🌐
|
| 190 |
-
logger.info(`[AI_SERVICE] ✅ Search enrichment added (Query: ${query}).`);
|
| 191 |
}
|
| 192 |
-
} catch (err) {
|
| 193 |
-
logger.error('[AI_SERVICE] Search enrichment failed:', err);
|
| 194 |
-
}
|
| 195 |
-
} else {
|
| 196 |
-
logger.info(`[AI_SERVICE] ⚡ Bypassing search for Day 7 choice (Speed-up).`);
|
| 197 |
}
|
| 198 |
|
| 199 |
-
const
|
| 200 |
-
? `CRITÈRES D'ÉVALUATION :\n${JSON.stringify(exerciseCriteria, null, 2)}`
|
| 201 |
-
: 'CRITÈRES : Réponse concrète, personnelle, et spécifique à son business réel.';
|
| 202 |
-
|
| 203 |
let actionPrompt = '';
|
| 204 |
if (isDeepDive) {
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
actionPrompt = PromptLoader.compile('action-feedback-teambuilding', {
|
| 209 |
-
iterationCount,
|
| 210 |
-
imageUrl: imageUrl || '',
|
| 211 |
-
userInput
|
| 212 |
-
});
|
| 213 |
-
} else {
|
| 214 |
-
actionPrompt = PromptLoader.compile('action-feedback-deepdive', {
|
| 215 |
-
iterationCount,
|
| 216 |
-
activityLabel
|
| 217 |
-
});
|
| 218 |
-
}
|
| 219 |
} else {
|
| 220 |
actionPrompt = PromptLoader.compile('action-feedback-standard', {
|
| 221 |
dayNumber: dayNumber || 0,
|
| 222 |
-
expectedExercise
|
| 223 |
-
previousResponsesContext,
|
| 224 |
-
questionDetectionBlock: hasQuestion ? '🚨
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
questionDeepDiveBlock: hasQuestion ? "L'utilisateur ayant posé une question..." : '',
|
| 228 |
-
buttonBypassBlock: isButtonChoice ? '🚨 PROMPT HOOK...' : ''
|
| 229 |
-
});
|
| 230 |
}
|
| 231 |
|
| 232 |
const prompt = PromptLoader.compile('feedback-base', {
|
| 233 |
dayNumber: dayNumber || 0,
|
| 234 |
activityLabel,
|
| 235 |
-
region,
|
| 236 |
-
businessContext,
|
| 237 |
-
prevContext,
|
| 238 |
-
criteriaContext,
|
| 239 |
searchContext,
|
| 240 |
-
previousResponsesContext,
|
| 241 |
lessonContentContent: lessonContent.substring(0, 500),
|
| 242 |
expectedExercise,
|
| 243 |
userInput,
|
| 244 |
actionPrompt,
|
| 245 |
-
userLanguage
|
| 246 |
-
|
| 247 |
-
});
|
| 248 |
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
if (imageUrl && this.avProvider) {
|
| 252 |
-
logger.info(`[AI_SERVICE] 📸 Image detected. Forcing OpenAI/AV-Provider for Day ${dayNumber}.`);
|
| 253 |
-
const data = await this.avProvider.generateStructuredData(prompt, FeedbackSchema, 0.7, imageUrl);
|
| 254 |
-
result = { data, source: 'OPENAI' };
|
| 255 |
-
} else {
|
| 256 |
-
result = await this.callWithFailover(prompt, FeedbackSchema, 0.7, imageUrl);
|
| 257 |
-
}
|
| 258 |
-
const { data, source } = result;
|
| 259 |
-
|
| 260 |
-
// 🚨 Day 11 Guard: Ensure team members are not returned for earlier days
|
| 261 |
-
if (dayNumber !== undefined && dayNumber < 11 && (data as any).teamMembers) {
|
| 262 |
-
logger.info(`[AI_SERVICE] Pruning teamMembers from feedback (Day ${dayNumber} < 11)`);
|
| 263 |
-
delete (data as any).teamMembers;
|
| 264 |
-
}
|
| 265 |
-
|
| 266 |
-
return {
|
| 267 |
-
...data,
|
| 268 |
-
searchResults,
|
| 269 |
-
aiSource: source
|
| 270 |
-
};
|
| 271 |
}
|
| 272 |
|
| 273 |
-
/**
|
| 274 |
-
* Rewrites a daily lesson to use analogies relevant to the user's business sector.
|
| 275 |
-
*/
|
| 276 |
async generatePersonalizedLesson(
|
| 277 |
lessonText: string,
|
| 278 |
userActivity: string,
|
| 279 |
userLanguage: string = 'FR',
|
| 280 |
-
businessProfile?: any,
|
| 281 |
previousResponses?: Array<{ day: number; response: string }>
|
| 282 |
): Promise<{ lessonText: string, aiSource: string }> {
|
| 283 |
-
const
|
| 284 |
-
const customer = businessProfile?.mainCustomer || '';
|
| 285 |
-
const region = businessProfile?.region || 'Sénégal';
|
| 286 |
-
const problem = businessProfile?.mainProblem || '';
|
| 287 |
-
|
| 288 |
-
const businessContext = [
|
| 289 |
-
`🏪 BUSINESS DE L'ÉTUDIANT :`,
|
| 290 |
-
`- Activité : ${activityLabel}`,
|
| 291 |
-
`- Région : ${region}`,
|
| 292 |
-
customer ? `- Client : ${customer}` : '',
|
| 293 |
-
problem ? `- Problème résolu : ${problem}` : '',
|
| 294 |
-
].filter(Boolean).join('\n');
|
| 295 |
-
|
| 296 |
-
// Inject real exercise responses so lesson examples match the user's actual business
|
| 297 |
-
const prevContext = previousResponses && previousResponses.length > 0
|
| 298 |
-
? `\n📝 CE QUE L'ÉTUDIANT A DÉJÀ DIT SUR SON BUSINESS (utilise ces infos exactes pour les exemples) :\n${previousResponses.map(r => ` Jour ${r.day}: "${r.response.substring(0, 200)}"`).join('\n')}\n`
|
| 299 |
-
: '';
|
| 300 |
-
|
| 301 |
const prompt = PromptLoader.compile('personalized-lesson', {
|
| 302 |
-
businessContext,
|
| 303 |
-
prevContext,
|
| 304 |
-
activityLabel,
|
| 305 |
lessonText,
|
| 306 |
-
languageLabel: userLanguage === 'WOLOF' ? 'WOLOF (
|
| 307 |
-
});
|
| 308 |
|
| 309 |
const { data, source } = await this.callWithFailover(prompt, PersonalizedLessonSchema);
|
| 310 |
return { lessonText: data.lessonText, aiSource: source };
|
| 311 |
}
|
| 312 |
|
| 313 |
-
/**
|
| 314 |
-
* Transcribes an audio buffer to text (useful for Wolof/FR voice messages) and returns confidence score.
|
| 315 |
-
*/
|
| 316 |
async transcribeAudio(audioBuffer: Buffer, filename: string, language?: string): Promise<{ text: string, confidence: number }> {
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
return provider.transcribeAudio(audioBuffer, filename, language);
|
| 320 |
}
|
| 321 |
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
*/
|
| 325 |
-
async extractBusinessProfile(userInput: string, dayNumber: number, userLanguage: string = 'FR'): Promise<{
|
| 326 |
-
activityLabel: string | null,
|
| 327 |
-
activityType: string | null,
|
| 328 |
-
mainCustomer: string | null,
|
| 329 |
-
mainProblem: string | null,
|
| 330 |
-
offerSimple: string | null,
|
| 331 |
-
promise: string | null,
|
| 332 |
-
aiSource?: string
|
| 333 |
-
}> {
|
| 334 |
const prompt = PromptLoader.compile('business-profile-extraction', {
|
| 335 |
-
userInput,
|
| 336 |
-
|
| 337 |
-
languageLabel: userLanguage === 'WOLOF' ? 'WOLOF' : 'Français'
|
| 338 |
-
});
|
| 339 |
|
| 340 |
const schema = z.object({
|
| 341 |
activityLabel: z.string().nullable(),
|
|
@@ -350,22 +236,17 @@ class AIService {
|
|
| 350 |
return { ...data, aiSource: source };
|
| 351 |
}
|
| 352 |
|
| 353 |
-
/**
|
| 354 |
-
* Converts text into an audio MP3 buffer (TTS).
|
| 355 |
-
*/
|
| 356 |
async generateSpeech(text: string): Promise<Buffer> {
|
| 357 |
-
const provider = this.
|
| 358 |
-
|
|
|
|
| 359 |
}
|
| 360 |
|
| 361 |
-
/**
|
| 362 |
-
* Generates a realistic image based on a prompt.
|
| 363 |
-
*/
|
| 364 |
async generateImage(prompt: string): Promise<string> {
|
| 365 |
-
const provider = this.
|
| 366 |
-
|
|
|
|
| 367 |
}
|
| 368 |
}
|
| 369 |
|
| 370 |
-
// Export a singleton instance
|
| 371 |
export const aiService = new AIService();
|
|
|
|
| 1 |
import { logger } from '../../logger';
|
| 2 |
import { z } from 'zod';
|
| 3 |
+
import {
|
| 4 |
+
OnePagerData, OnePagerSchema,
|
| 5 |
+
PitchDeckData, PitchDeckSchema,
|
| 6 |
+
PersonalizedLessonSchema,
|
| 7 |
+
FeedbackSchema, FeedbackData
|
| 8 |
+
} from './types';
|
| 9 |
import { MockLLMProvider } from './mock-provider';
|
| 10 |
import { OpenAIProvider } from './openai-provider';
|
| 11 |
import { searchService } from './search';
|
| 12 |
import { GeminiProvider } from './gemini-provider';
|
| 13 |
+
import { PromptLoader, PersonalityConfig } from '@repo/prompts';
|
| 14 |
+
import { getOrganizationId } from '@repo/database';
|
| 15 |
+
import { prisma } from '../prisma';
|
| 16 |
+
import { redis } from '../queue';
|
| 17 |
+
import { ProviderRegistry, ProviderCapability } from './ProviderRegistry';
|
| 18 |
|
| 19 |
class AIService {
|
| 20 |
+
private registry: ProviderRegistry;
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
constructor() {
|
| 23 |
+
this.registry = new ProviderRegistry();
|
| 24 |
+
this.initializeProviders();
|
| 25 |
+
}
|
| 26 |
|
| 27 |
+
private initializeProviders() {
|
| 28 |
const geminiApiKey = process.env.GOOGLE_AI_API_KEY;
|
| 29 |
const openAiApiKey = process.env.OPENAI_API_KEY;
|
| 30 |
|
| 31 |
if (geminiApiKey) {
|
| 32 |
+
this.registry.register('GEMINI', new GeminiProvider(geminiApiKey), 100, [
|
| 33 |
+
ProviderCapability.TEXT,
|
| 34 |
+
ProviderCapability.VISION
|
| 35 |
+
]);
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
if (openAiApiKey) {
|
|
|
|
|
|
|
|
|
|
| 39 |
const openai = new OpenAIProvider(openAiApiKey);
|
| 40 |
+
this.registry.register('OPENAI', openai, 50, [
|
| 41 |
+
ProviderCapability.TEXT,
|
| 42 |
+
ProviderCapability.VISION,
|
| 43 |
+
ProviderCapability.AUDIO_TRANSCRIPTION,
|
| 44 |
+
ProviderCapability.SPEECH_GENERATION,
|
| 45 |
+
ProviderCapability.IMAGE_GENERATION
|
| 46 |
+
]);
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
this.registry.register('MOCK', new MockLLMProvider(), 0, Object.values(ProviderCapability) as ProviderCapability[]);
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
private async getTenantPersonality(): Promise<Partial<PersonalityConfig>> {
|
| 53 |
+
const organizationId = getOrganizationId();
|
| 54 |
+
if (!organizationId) return {};
|
| 55 |
+
|
| 56 |
+
const cacheKey = `org:config:${organizationId}`;
|
| 57 |
+
try {
|
| 58 |
+
const cached = await redis.get(cacheKey);
|
| 59 |
+
if (cached) return JSON.parse(cached);
|
| 60 |
+
} catch (err) {
|
| 61 |
+
logger.error('[AI_SERVICE] Redis error:', err);
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
try {
|
| 65 |
+
const org = await prisma.organization.findUnique({
|
| 66 |
+
where: { id: organizationId },
|
| 67 |
+
select: { personalityConfig: true }
|
| 68 |
+
});
|
| 69 |
+
const personality = (org?.personalityConfig as any) || {};
|
| 70 |
+
await redis.set(cacheKey, JSON.stringify(personality), 'EX', 3600);
|
| 71 |
+
return personality;
|
| 72 |
+
} catch (err) {
|
| 73 |
+
logger.error('[AI_SERVICE] Failed to fetch tenant personality:', err);
|
| 74 |
+
return {};
|
| 75 |
}
|
| 76 |
}
|
| 77 |
|
|
|
|
|
|
|
|
|
|
| 78 |
private async callWithFailover<T>(
|
| 79 |
prompt: string,
|
| 80 |
schema: z.ZodSchema<T>,
|
| 81 |
temperature?: number,
|
| 82 |
imageUrl?: string
|
| 83 |
): Promise<{ data: T, source: string }> {
|
| 84 |
+
const capability = imageUrl ? ProviderCapability.VISION : ProviderCapability.TEXT;
|
| 85 |
+
const providers = this.registry.getProvidersFor(capability);
|
| 86 |
+
|
| 87 |
+
for (const provider of providers) {
|
| 88 |
+
try {
|
| 89 |
+
const data = await provider.instance.generateStructuredData(prompt, schema, temperature, imageUrl);
|
| 90 |
+
logger.info(`[AI_INFO] ${provider.name} used successfully. (Capability: ${capability})`);
|
| 91 |
+
return { data, source: provider.name };
|
| 92 |
+
} catch (err) {
|
| 93 |
+
logger.warn(`[AI_WARNING] ${provider.name} failed: ${(err as Error).message}. Trying next provider...`);
|
|
|
|
|
|
|
| 94 |
}
|
|
|
|
| 95 |
}
|
| 96 |
+
|
| 97 |
+
throw new Error(`[AI_ERROR] All providers for ${capability} failed.`);
|
| 98 |
}
|
| 99 |
|
|
|
|
|
|
|
|
|
|
| 100 |
async generateOnePagerData(userContext: string, language: string = 'FR', businessProfile?: any): Promise<OnePagerData> {
|
| 101 |
+
const personality = await this.getTenantPersonality();
|
|
|
|
|
|
|
|
|
|
| 102 |
const prompt = PromptLoader.compile('one-pager', {
|
| 103 |
activityLabel: businessProfile?.activityLabel || 'non précisé',
|
| 104 |
userContext,
|
| 105 |
+
marketDataInjected: businessProfile?.marketData ? `\n🌐 DONNÉES DE MARCHÉ :\n${JSON.stringify(businessProfile.marketData)}\n` : '',
|
| 106 |
+
languageLabel: language === 'WOLOF' ? 'WOLOF standardisé' : 'Français institutionnel',
|
| 107 |
+
languageInstruction: language === 'WOLOF' ? 'WOLOF (ñ, ë, é) suivi du FR' : 'French'
|
| 108 |
+
}, personality);
|
| 109 |
|
| 110 |
const { data, source } = await this.callWithFailover(prompt, OnePagerSchema);
|
| 111 |
return { ...data, aiSource: source };
|
| 112 |
}
|
| 113 |
|
|
|
|
|
|
|
|
|
|
| 114 |
async generatePitchDeckData(userContext: string, language: string = 'FR', businessProfile?: any): Promise<PitchDeckData & { aiSource?: string }> {
|
| 115 |
+
const personality = await this.getTenantPersonality();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
const prompt = PromptLoader.compile('pitch-deck', {
|
| 117 |
activityLabel: businessProfile?.activityLabel || 'Entrepreneuriat',
|
| 118 |
locationCity: businessProfile?.locationCity || 'Sénégal',
|
| 119 |
userContext,
|
| 120 |
+
marketDataInjected: businessProfile?.marketData ? `\n🌐 MARCHÉ :\n${JSON.stringify(businessProfile.marketData)}\n` : '',
|
| 121 |
+
teamDataInjected: businessProfile?.teamMembers?.length > 0 ? `\n👥 ÉQUIPE :\n${JSON.stringify(businessProfile.teamMembers)}\n` : '',
|
| 122 |
languageLabel: language === 'WOLOF' ? 'WOLOF' : 'FRENCH'
|
| 123 |
+
}, personality);
|
| 124 |
|
| 125 |
const { data, source } = await this.callWithFailover(prompt, PitchDeckSchema);
|
| 126 |
return { ...data, aiSource: source };
|
| 127 |
}
|
| 128 |
|
|
|
|
|
|
|
|
|
|
| 129 |
async generateFeedback(
|
| 130 |
userInput: string,
|
| 131 |
expectedExercise: string,
|
|
|
|
| 133 |
userLanguage: string = 'FR',
|
| 134 |
businessProfile?: any,
|
| 135 |
exerciseCriteria?: any,
|
|
|
|
| 136 |
userActivity?: string,
|
| 137 |
userRegion?: string,
|
| 138 |
dayNumber?: number,
|
| 139 |
previousResponses?: Array<{ day: number; response: string }>,
|
| 140 |
isDeepDive: boolean = false,
|
| 141 |
iterationCount: number = 0,
|
| 142 |
+
imageUrl?: string
|
|
|
|
| 143 |
): Promise<FeedbackData & { searchResults?: any[] }> {
|
| 144 |
+
const hasQuestion = ['?', 'avis', 'penses', 'conseil', 'aider'].some(kw => userInput.toLowerCase().includes(kw));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 145 |
const activityLabel = userActivity || businessProfile?.activityLabel || 'non précisé';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
|
| 147 |
+
let searchContext = '';
|
| 148 |
+
let searchResults: any[] | undefined;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
|
| 150 |
+
if (dayNumber !== 7) {
|
| 151 |
try {
|
| 152 |
+
const results = await searchService.search(`${activityLabel} Sénégal marché`);
|
| 153 |
+
if (results?.length > 0) {
|
| 154 |
searchResults = results;
|
| 155 |
+
searchContext = `\n🌐 MARCHÉ :\n${results.map(r => `- ${r.title}: ${r.snippet}`).join('\n')}\n`;
|
|
|
|
| 156 |
}
|
| 157 |
+
} catch (err) {}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 158 |
}
|
| 159 |
|
| 160 |
+
const personality = await this.getTenantPersonality();
|
|
|
|
|
|
|
|
|
|
| 161 |
let actionPrompt = '';
|
| 162 |
if (isDeepDive) {
|
| 163 |
+
actionPrompt = iterationCount >= 3
|
| 164 |
+
? PromptLoader.compile('action-feedback-deepdive-limit', {}, personality)
|
| 165 |
+
: PromptLoader.compile('action-feedback-deepdive', { iterationCount, activityLabel }, personality);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
} else {
|
| 167 |
actionPrompt = PromptLoader.compile('action-feedback-standard', {
|
| 168 |
dayNumber: dayNumber || 0,
|
| 169 |
+
expectedExercise,
|
| 170 |
+
previousResponsesContext: previousResponses?.map(r => `J${r.day}: ${r.response}`).join('\n') || '',
|
| 171 |
+
questionDetectionBlock: hasQuestion ? '🚨 QUESTION DÉTECTÉE...' : '',
|
| 172 |
+
visionMultimodalBlock: imageUrl ? '📸 ANALYSE VISUELLE...' : ''
|
| 173 |
+
}, personality);
|
|
|
|
|
|
|
|
|
|
| 174 |
}
|
| 175 |
|
| 176 |
const prompt = PromptLoader.compile('feedback-base', {
|
| 177 |
dayNumber: dayNumber || 0,
|
| 178 |
activityLabel,
|
| 179 |
+
region: userRegion || businessProfile?.region || 'Sénégal',
|
| 180 |
+
businessContext: `🏪 Activité: ${activityLabel}`,
|
| 181 |
+
prevContext: previousResponses?.map(r => `[J${r.day}]: "${r.response}"`).join('\n') || '',
|
| 182 |
+
criteriaContext: JSON.stringify(exerciseCriteria || {}),
|
| 183 |
searchContext,
|
|
|
|
| 184 |
lessonContentContent: lessonContent.substring(0, 500),
|
| 185 |
expectedExercise,
|
| 186 |
userInput,
|
| 187 |
actionPrompt,
|
| 188 |
+
userLanguage
|
| 189 |
+
}, personality);
|
|
|
|
| 190 |
|
| 191 |
+
const { data, source } = await this.callWithFailover(prompt, FeedbackSchema, 0.7, imageUrl);
|
| 192 |
+
return { ...data, searchResults, aiSource: source };
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 193 |
}
|
| 194 |
|
|
|
|
|
|
|
|
|
|
| 195 |
async generatePersonalizedLesson(
|
| 196 |
lessonText: string,
|
| 197 |
userActivity: string,
|
| 198 |
userLanguage: string = 'FR',
|
|
|
|
| 199 |
previousResponses?: Array<{ day: number; response: string }>
|
| 200 |
): Promise<{ lessonText: string, aiSource: string }> {
|
| 201 |
+
const personality = await this.getTenantPersonality();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 202 |
const prompt = PromptLoader.compile('personalized-lesson', {
|
| 203 |
+
businessContext: `🏪 Activité: ${userActivity}`,
|
| 204 |
+
prevContext: previousResponses?.map(r => `J${r.day}: "${r.response.substring(0, 100)}"`).join('\n') || '',
|
| 205 |
+
activityLabel: userActivity,
|
| 206 |
lessonText,
|
| 207 |
+
languageLabel: userLanguage === 'WOLOF' ? 'WOLOF (ñ, ë, é)' : 'Français'
|
| 208 |
+
}, personality);
|
| 209 |
|
| 210 |
const { data, source } = await this.callWithFailover(prompt, PersonalizedLessonSchema);
|
| 211 |
return { lessonText: data.lessonText, aiSource: source };
|
| 212 |
}
|
| 213 |
|
|
|
|
|
|
|
|
|
|
| 214 |
async transcribeAudio(audioBuffer: Buffer, filename: string, language?: string): Promise<{ text: string, confidence: number }> {
|
| 215 |
+
const provider = this.registry.getPrimary(ProviderCapability.AUDIO_TRANSCRIPTION);
|
| 216 |
+
if (!provider) throw new Error('[AI_ERROR] No provider for audio transcription.');
|
| 217 |
+
return provider.instance.transcribeAudio(audioBuffer, filename, language);
|
| 218 |
}
|
| 219 |
|
| 220 |
+
async extractBusinessProfile(userInput: string, dayNumber: number, userLanguage: string = 'FR'): Promise<any> {
|
| 221 |
+
const personality = await this.getTenantPersonality();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 222 |
const prompt = PromptLoader.compile('business-profile-extraction', {
|
| 223 |
+
userInput, dayNumber, languageLabel: userLanguage === 'WOLOF' ? 'WOLOF' : 'Français'
|
| 224 |
+
}, personality);
|
|
|
|
|
|
|
| 225 |
|
| 226 |
const schema = z.object({
|
| 227 |
activityLabel: z.string().nullable(),
|
|
|
|
| 236 |
return { ...data, aiSource: source };
|
| 237 |
}
|
| 238 |
|
|
|
|
|
|
|
|
|
|
| 239 |
async generateSpeech(text: string): Promise<Buffer> {
|
| 240 |
+
const provider = this.registry.getPrimary(ProviderCapability.SPEECH_GENERATION);
|
| 241 |
+
if (!provider) throw new Error('[AI_ERROR] No provider for speech generation.');
|
| 242 |
+
return provider.instance.generateSpeech(text);
|
| 243 |
}
|
| 244 |
|
|
|
|
|
|
|
|
|
|
| 245 |
async generateImage(prompt: string): Promise<string> {
|
| 246 |
+
const provider = this.registry.getPrimary(ProviderCapability.IMAGE_GENERATION);
|
| 247 |
+
if (!provider) throw new Error('[AI_ERROR] No provider for image generation.');
|
| 248 |
+
return provider.instance.generateImage(prompt);
|
| 249 |
}
|
| 250 |
}
|
| 251 |
|
|
|
|
| 252 |
export const aiService = new AIService();
|
apps/api/src/services/organization.ts
CHANGED
|
@@ -7,14 +7,18 @@ const redis = process.env.REDIS_URL
|
|
| 7 |
? new Redis(process.env.REDIS_URL)
|
| 8 |
: new Redis({ host: process.env.REDIS_HOST || 'localhost', port: parseInt(process.env.REDIS_PORT || '6379') });
|
| 9 |
|
| 10 |
-
const CACHE_TTL =
|
| 11 |
|
| 12 |
export async function getOrganizationByPhoneNumberId(phoneNumberId: string): Promise<string> {
|
| 13 |
const cacheKey = `org:phone:${phoneNumberId}`;
|
| 14 |
|
| 15 |
// 1. Check Redis Cache
|
| 16 |
-
|
| 17 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
// 2. Lookup in DB
|
| 20 |
const phoneRecord = await (prisma as any).whatsAppPhoneNumber.findUnique({
|
|
@@ -23,14 +27,32 @@ export async function getOrganizationByPhoneNumberId(phoneNumberId: string): Pro
|
|
| 23 |
});
|
| 24 |
|
| 25 |
if (phoneRecord) {
|
| 26 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
return phoneRecord.organizationId;
|
| 28 |
}
|
| 29 |
|
| 30 |
// 3. Fallback to default organization
|
| 31 |
-
// In a strict multi-tenant environment, we might want to throw an error here.
|
| 32 |
-
// For now, we use the default for backward compatibility.
|
| 33 |
const defaultOrgId = 'default-org-id';
|
| 34 |
logger.warn(`[ORG-SERVICE] No organization found for phone_number_id: ${phoneNumberId}. Falling back to ${defaultOrgId}`);
|
| 35 |
return defaultOrgId;
|
| 36 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
? new Redis(process.env.REDIS_URL)
|
| 8 |
: new Redis({ host: process.env.REDIS_HOST || 'localhost', port: parseInt(process.env.REDIS_PORT || '6379') });
|
| 9 |
|
| 10 |
+
const CACHE_TTL = 86400; // 24 hours (routing is stable)
|
| 11 |
|
| 12 |
export async function getOrganizationByPhoneNumberId(phoneNumberId: string): Promise<string> {
|
| 13 |
const cacheKey = `org:phone:${phoneNumberId}`;
|
| 14 |
|
| 15 |
// 1. Check Redis Cache
|
| 16 |
+
try {
|
| 17 |
+
const cached = await redis.get(cacheKey);
|
| 18 |
+
if (cached) return cached;
|
| 19 |
+
} catch (err) {
|
| 20 |
+
logger.error('[ORG-SERVICE] Redis error:', err);
|
| 21 |
+
}
|
| 22 |
|
| 23 |
// 2. Lookup in DB
|
| 24 |
const phoneRecord = await (prisma as any).whatsAppPhoneNumber.findUnique({
|
|
|
|
| 27 |
});
|
| 28 |
|
| 29 |
if (phoneRecord) {
|
| 30 |
+
try {
|
| 31 |
+
await redis.set(cacheKey, phoneRecord.organizationId, 'EX', CACHE_TTL);
|
| 32 |
+
} catch (err) {
|
| 33 |
+
logger.error('[ORG-SERVICE] Redis set error:', err);
|
| 34 |
+
}
|
| 35 |
return phoneRecord.organizationId;
|
| 36 |
}
|
| 37 |
|
| 38 |
// 3. Fallback to default organization
|
|
|
|
|
|
|
| 39 |
const defaultOrgId = 'default-org-id';
|
| 40 |
logger.warn(`[ORG-SERVICE] No organization found for phone_number_id: ${phoneNumberId}. Falling back to ${defaultOrgId}`);
|
| 41 |
return defaultOrgId;
|
| 42 |
}
|
| 43 |
+
|
| 44 |
+
/**
|
| 45 |
+
* Invalidates all cache entries related to an organization
|
| 46 |
+
*/
|
| 47 |
+
export async function invalidateOrganizationCache(organizationId: string, phoneNumberId?: string) {
|
| 48 |
+
try {
|
| 49 |
+
const keys = [`org:config:${organizationId}`];
|
| 50 |
+
if (phoneNumberId) {
|
| 51 |
+
keys.push(`org:phone:${phoneNumberId}`);
|
| 52 |
+
}
|
| 53 |
+
await redis.del(keys);
|
| 54 |
+
logger.info(`[ORG-SERVICE] Cache invalidated for Organization: ${organizationId}`);
|
| 55 |
+
} catch (err) {
|
| 56 |
+
logger.error('[ORG-SERVICE] Cache invalidation failed:', err);
|
| 57 |
+
}
|
| 58 |
+
}
|
apps/api/src/services/prisma.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
| 1 |
-
import { PrismaClient } from '@repo/database';
|
| 2 |
|
| 3 |
-
|
|
|
|
|
|
| 1 |
+
import { PrismaClient, withTenantIsolation } from '@repo/database';
|
| 2 |
|
| 3 |
+
const basePrisma = new PrismaClient();
|
| 4 |
+
export const prisma = withTenantIsolation(basePrisma);
|
apps/api/src/services/whatsapp-utils.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export function normalizeCommand(text: string): string {
|
| 2 |
+
return text
|
| 3 |
+
.trim()
|
| 4 |
+
.toLowerCase()
|
| 5 |
+
.replace(/[.,!?;:]+$/, "") // Remove trailing punctuation
|
| 6 |
+
.toUpperCase();
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
export function detectIntent(text: string): 'YES' | 'NO' | 'UNKNOWN' {
|
| 10 |
+
const normalized = text.trim().toLowerCase().replace(/[.,!?;:]+$/, "");
|
| 11 |
+
const yesWords = ['oui', 'ouais', 'wi', 'waaw', 'yes', 'yep', 'ok', 'd’accord', 'daccord', 'da’accord'];
|
| 12 |
+
const noWords = ['non', 'déet', 'deet', 'no', 'nah', 'nein'];
|
| 13 |
+
|
| 14 |
+
if (yesWords.some(w => normalized.includes(w))) return 'YES';
|
| 15 |
+
if (noWords.some(w => normalized.includes(w))) return 'NO';
|
| 16 |
+
return 'UNKNOWN';
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
export function levenshteinDistance(a: string, b: string): number {
|
| 20 |
+
const matrix: number[][] = [];
|
| 21 |
+
for (let i = 0; i <= b.length; i++) matrix[i] = [i];
|
| 22 |
+
for (let j = 0; j <= a.length; j++) matrix[0][j] = j;
|
| 23 |
+
|
| 24 |
+
for (let i = 1; i <= b.length; i++) {
|
| 25 |
+
for (let j = 1; j <= a.length; j++) {
|
| 26 |
+
if (b.charAt(i - 1) === a.charAt(j - 1)) {
|
| 27 |
+
matrix[i][j] = matrix[i - 1][j - 1];
|
| 28 |
+
} else {
|
| 29 |
+
matrix[i][j] = Math.min(
|
| 30 |
+
matrix[i - 1][j - 1] + 1, // substitution
|
| 31 |
+
matrix[i][j - 1] + 1, // insertion
|
| 32 |
+
matrix[i - 1][j] + 1 // deletion
|
| 33 |
+
);
|
| 34 |
+
}
|
| 35 |
+
}
|
| 36 |
+
}
|
| 37 |
+
return matrix[b.length][a.length];
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
export function isFuzzyMatch(text: string, target: string, threshold = 0.8): boolean {
|
| 41 |
+
const normalized = text.trim().toUpperCase();
|
| 42 |
+
const tar = target.toUpperCase();
|
| 43 |
+
if (normalized === tar) return true;
|
| 44 |
+
if (normalized.includes(tar) || tar.includes(normalized)) return true;
|
| 45 |
+
|
| 46 |
+
const distance = levenshteinDistance(normalized, tar);
|
| 47 |
+
const maxLength = Math.max(normalized.length, tar.length);
|
| 48 |
+
const similarity = 1 - distance / maxLength;
|
| 49 |
+
return similarity >= threshold;
|
| 50 |
+
}
|
apps/api/src/services/whatsapp.ts
CHANGED
|
@@ -1,680 +1,34 @@
|
|
| 1 |
import { logger } from '../logger';
|
| 2 |
-
import {
|
| 3 |
-
import { scheduleMessage, enrollUser, whatsappQueue, scheduleInteractiveList, setTimeTravelContext, getTimeTravelContext, clearTimeTravelContext } from './queue';
|
| 4 |
|
| 5 |
export class WhatsAppService {
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
if (yesWords.some(w => normalized.includes(w))) return 'YES';
|
| 20 |
-
if (noWords.some(w => normalized.includes(w))) return 'NO';
|
| 21 |
-
return 'UNKNOWN';
|
| 22 |
-
}
|
| 23 |
-
|
| 24 |
-
private static levenshteinDistance(a: string, b: string): number {
|
| 25 |
-
const matrix: number[][] = [];
|
| 26 |
-
for (let i = 0; i <= b.length; i++) matrix[i] = [i];
|
| 27 |
-
for (let j = 0; j <= a.length; j++) matrix[0][j] = j;
|
| 28 |
-
|
| 29 |
-
for (let i = 1; i <= b.length; i++) {
|
| 30 |
-
for (let j = 1; j <= a.length; j++) {
|
| 31 |
-
if (b.charAt(i - 1) === a.charAt(j - 1)) {
|
| 32 |
-
matrix[i][j] = matrix[i - 1][j - 1];
|
| 33 |
-
} else {
|
| 34 |
-
matrix[i][j] = Math.min(
|
| 35 |
-
matrix[i - 1][j - 1] + 1, // substitution
|
| 36 |
-
matrix[i][j - 1] + 1, // insertion
|
| 37 |
-
matrix[i - 1][j] + 1 // deletion
|
| 38 |
-
);
|
| 39 |
-
}
|
| 40 |
-
}
|
| 41 |
-
}
|
| 42 |
-
return matrix[b.length][a.length];
|
| 43 |
-
}
|
| 44 |
-
|
| 45 |
-
private static isFuzzyMatch(text: string, target: string, threshold = 0.8): boolean {
|
| 46 |
-
const normalized = text.trim().toUpperCase();
|
| 47 |
-
const tar = target.toUpperCase();
|
| 48 |
-
if (normalized === tar) return true;
|
| 49 |
-
if (normalized.includes(tar) || tar.includes(normalized)) return true;
|
| 50 |
-
|
| 51 |
-
const distance = this.levenshteinDistance(normalized, tar);
|
| 52 |
-
const maxLength = Math.max(normalized.length, tar.length);
|
| 53 |
-
const similarity = 1 - distance / maxLength;
|
| 54 |
-
return similarity >= threshold;
|
| 55 |
-
}
|
| 56 |
-
|
| 57 |
-
static async handleIncomingMessage(phone: string, text: string, audioUrl?: string, imageUrl?: string, timeTravelDayOverride?: number, organizationId: string = 'default-org-id') {
|
| 58 |
const traceId = audioUrl ? `[STT-FLOW-${phone.slice(-4)}]` : imageUrl ? `[IMG-FLOW-${phone.slice(-4)}]` : `[TXT-FLOW-${phone.slice(-4)}]`;
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
user.id,
|
| 74 |
-
"Bienvenue sur Xamlé ! Quelle langue préférez-vous pour votre formation ?",
|
| 75 |
-
[
|
| 76 |
-
{ id: 'LANG_FR', title: 'Français 🇫🇷' },
|
| 77 |
-
{ id: 'LANG_WO', title: 'Wolof 🇸🇳' }
|
| 78 |
-
],
|
| 79 |
-
organizationId
|
| 80 |
-
);
|
| 81 |
-
return;
|
| 82 |
-
} else {
|
| 83 |
-
logger.info(`${traceId} Unregistered user ${phone} sent: "${normalizedText}". Sending instructions.`);
|
| 84 |
-
// Anti-silence: Nudge them to register
|
| 85 |
-
const { whatsappQueue } = await import('./queue');
|
| 86 |
-
await whatsappQueue.add('send-message-direct', {
|
| 87 |
-
phone,
|
| 88 |
-
text: "🎓 Bienvenue chez XAMLÉ !\nPour commencer ta formation gratuite, envoie le mot : *INSCRIPTION*\n\n(WO) Dalal jàmm ! Ngir tàmbali sa njàng mburu, bindal : *INSCRIPTION*"
|
| 89 |
-
});
|
| 90 |
-
return;
|
| 91 |
-
}
|
| 92 |
-
}
|
| 93 |
-
|
| 94 |
-
// 1.2 Log the incoming message in the DB
|
| 95 |
-
try {
|
| 96 |
-
await prisma.message.create({
|
| 97 |
-
data: {
|
| 98 |
-
content: text,
|
| 99 |
-
mediaUrl: audioUrl || imageUrl,
|
| 100 |
-
direction: 'INBOUND',
|
| 101 |
-
userId: user.id,
|
| 102 |
-
organizationId
|
| 103 |
-
}
|
| 104 |
-
});
|
| 105 |
-
} catch (err: unknown) {
|
| 106 |
-
logger.error('[WhatsAppService] Failed to log incoming message:', (err instanceof Error ? (err instanceof Error ? err.message : String(err)) : String(err)));
|
| 107 |
-
}
|
| 108 |
-
|
| 109 |
-
// 1.5. Testing / Cheat Codes (Only for registered users)
|
| 110 |
-
if (this.isFuzzyMatch(normalizedText, 'INSCRIPTION')) {
|
| 111 |
-
// 🚨 COUPE-CIRCUIT #1: Kill any active Time-Travel context BEFORE resetting progression
|
| 112 |
-
await clearTimeTravelContext(user.id);
|
| 113 |
-
await (prisma as any).userBadge.deleteMany({ where: { userProgress: { userId: user.id } } });
|
| 114 |
-
await (prisma as any).teamMember.deleteMany({ where: { businessProfile: { userId: user.id } } });
|
| 115 |
-
await prisma.enrollment.deleteMany({ where: { userId: user.id } });
|
| 116 |
-
await prisma.userProgress.deleteMany({ where: { userId: user.id } });
|
| 117 |
-
await prisma.response.deleteMany({ where: { userId: user.id } });
|
| 118 |
-
await prisma.message.deleteMany({ where: { userId: user.id } }); // Purge totale historique des messages
|
| 119 |
-
// Also explicitly clear business AI profile to prevent context leak on restart
|
| 120 |
-
await prisma.businessProfile.deleteMany({ where: { userId: user.id } });
|
| 121 |
-
user = await prisma.user.update({
|
| 122 |
-
where: { id: user.id },
|
| 123 |
-
data: { city: null, activity: null }
|
| 124 |
-
});
|
| 125 |
-
const { scheduleInteractiveButtons } = await import('./queue');
|
| 126 |
-
await scheduleInteractiveButtons(
|
| 127 |
-
user.id,
|
| 128 |
-
"Réinitialisation réussie. Quelle langue préférez-vous ?",
|
| 129 |
-
[
|
| 130 |
-
{ id: 'LANG_FR', title: 'Français 🇫🇷' },
|
| 131 |
-
{ id: 'LANG_WO', title: 'Wolof 🇸🇳' }
|
| 132 |
-
],
|
| 133 |
-
organizationId
|
| 134 |
-
);
|
| 135 |
-
return;
|
| 136 |
-
}
|
| 137 |
-
|
| 138 |
-
if (normalizedText === 'TEST_IMAGE') {
|
| 139 |
-
await whatsappQueue.add('send-image', {
|
| 140 |
-
to: user.phone,
|
| 141 |
-
imageUrl: 'https://r2.xamle.sn/branding/branding_xamle.png',
|
| 142 |
-
caption: 'Branding XAMLÉ - Industrialisation 2026'
|
| 143 |
-
});
|
| 144 |
-
return;
|
| 145 |
-
}
|
| 146 |
-
|
| 147 |
-
if (normalizedText.startsWith('TEST_VIDEO')) {
|
| 148 |
-
const parts = normalizedText.split(' ');
|
| 149 |
-
if (parts.length < 3) {
|
| 150 |
-
await scheduleMessage(user.id, "Usage: TEST_VIDEO <TrackId> <DayNumber>", 0, organizationId);
|
| 151 |
-
return;
|
| 152 |
-
}
|
| 153 |
-
const trackId = parts[1];
|
| 154 |
-
const dayNumber = parseFloat(parts[2]);
|
| 155 |
-
|
| 156 |
-
await scheduleMessage(user.id, `🧪 Test Video pour ${trackId} J${dayNumber}...`, 0, organizationId);
|
| 157 |
-
await whatsappQueue.add('send-content', {
|
| 158 |
-
userId: user.id,
|
| 159 |
-
trackId,
|
| 160 |
-
dayNumber
|
| 161 |
-
});
|
| 162 |
-
return;
|
| 163 |
-
}
|
| 164 |
-
|
| 165 |
-
const systemCommands = ['1', '2', 'SUITE', 'APPROFONDIR', 'INSCRIPTION', 'SEED'];
|
| 166 |
-
const isSystemCommand = systemCommands.some(cmd => this.isFuzzyMatch(normalizedText, cmd)) || normalizedText.includes('INSCRI');
|
| 167 |
-
|
| 168 |
-
// 🚨 Guardrail "Gibberish" Lite (Global)
|
| 169 |
-
if (text.length < 2 && !isSystemCommand) {
|
| 170 |
-
await scheduleMessage(user.id, user.language === 'WOLOF'
|
| 171 |
-
? "Dama lay xaar nga wax ma lu gën a yaatu ci sa mbir. Waxtaanal ak man !"
|
| 172 |
-
: "Je n'ai pas bien compris. Peux-tu me réexpliquer en quelques mots ?", 0, organizationId);
|
| 173 |
-
return;
|
| 174 |
-
}
|
| 175 |
-
|
| 176 |
-
if (this.isFuzzyMatch(normalizedText, 'SEED')) {
|
| 177 |
-
// Reply immediately so the webhook doesn't time out
|
| 178 |
-
logger.info(`[SEED] Triggered by user ${user.id}`);
|
| 179 |
-
try {
|
| 180 |
-
// @ts-ignore - dynamic import of sub-module
|
| 181 |
-
const { seedDatabase } = await import('@repo/database/seed');
|
| 182 |
-
const result = await seedDatabase(prisma);
|
| 183 |
-
logger.info('[SEED] Result:', result.message);
|
| 184 |
-
|
| 185 |
-
// 🚨 COGNITIVE CACHE CLEAR: Delete old BusinessProfile contexts to prevent agricultural hallucinations
|
| 186 |
-
try {
|
| 187 |
-
await prisma.businessProfile.deleteMany({ where: { userId: user.id } });
|
| 188 |
-
await prisma.user.update({ where: { id: user.id }, data: { activity: null } });
|
| 189 |
-
logger.info(`[SEED] Cleared cognitive cache for User ${user.id}`);
|
| 190 |
-
} catch (cacheErr: unknown) {
|
| 191 |
-
logger.error('[SEED] Failed to clear cognitive cache:', (cacheErr as Error).message);
|
| 192 |
-
}
|
| 193 |
-
|
| 194 |
-
await scheduleMessage(user.id, result.seeded
|
| 195 |
-
? "✅ Seeding terminé ! Le Cache Cognitif a été réinitialisé.\nEnvoie INSCRIPTION pour commencer."
|
| 196 |
-
: "ℹ️ Les données existent déjà. Cache Cognitif purgé. Envoie INSCRIPTION.",
|
| 197 |
-
0, organizationId
|
| 198 |
-
);
|
| 199 |
-
} catch (err: unknown) {
|
| 200 |
-
logger.error('[SEED] Error:', (err instanceof Error ? (err instanceof Error ? err.message : String(err)) : String(err)));
|
| 201 |
-
await scheduleMessage(user.id, `❌ Erreur seed : ${(err instanceof Error ? (err instanceof Error ? err.message : String(err)) : String(err))?.substring(0, 200)}`, 0, organizationId);
|
| 202 |
-
}
|
| 203 |
-
return;
|
| 204 |
-
}
|
| 205 |
-
|
| 206 |
-
// ─── Interactive LIST action IDs ──────────────────────────────────────
|
| 207 |
-
// Format: DAY{N}_EXERCISE | DAY{N}_REPLAY | DAY{N}_CONTINUE | DAY{N}_PROMPT
|
| 208 |
-
const dayActionMatch = normalizedText.match(/^DAY(\d+)_(EXERCISE|REPLAY|CONTINUE|PROMPT)$/);
|
| 209 |
-
if (dayActionMatch) {
|
| 210 |
-
const action = dayActionMatch[2];
|
| 211 |
-
|
| 212 |
-
if (action === 'REPLAY') {
|
| 213 |
-
const replayDay = parseFloat(dayActionMatch[1]); // DAY11_REPLAY → 11
|
| 214 |
-
const enrollment = await prisma.enrollment.findFirst({
|
| 215 |
-
where: { userId: user.id, status: 'ACTIVE' }
|
| 216 |
-
});
|
| 217 |
-
if (enrollment) {
|
| 218 |
-
// 🕰️ TIME-TRAVEL: Persist the replay context in Redis (30 min TTL)
|
| 219 |
-
await setTimeTravelContext(user.id, replayDay);
|
| 220 |
-
// ✅ UX: Confirmation FIRST, content delayed — message order guaranteed
|
| 221 |
-
await scheduleMessage(user.id, user.language === 'WOLOF'
|
| 222 |
-
? `🔁 Dinanu la yëgël lexon Bés ${Math.floor(replayDay)} ci kanam...`
|
| 223 |
-
: `🔁 Je te renvoie la Leçon ${Math.floor(replayDay)} dans quelques secondes...`,
|
| 224 |
-
0, organizationId
|
| 225 |
-
);
|
| 226 |
-
await whatsappQueue.add('send-content', {
|
| 227 |
-
userId: user.id,
|
| 228 |
-
trackId: enrollment.trackId,
|
| 229 |
-
dayNumber: replayDay,
|
| 230 |
-
skipProgressUpdate: true
|
| 231 |
-
}, { delay: 2000 });
|
| 232 |
-
}
|
| 233 |
-
return;
|
| 234 |
-
} else if (action === 'EXERCISE') {
|
| 235 |
-
await scheduleMessage(user.id, user.language === 'WOLOF'
|
| 236 |
-
? "🎙️ Yónnee sa tontu (audio walla bind) :"
|
| 237 |
-
: "🎙️ Envoie ta réponse (audio ou texte) :",
|
| 238 |
-
0, organizationId
|
| 239 |
-
);
|
| 240 |
-
return;
|
| 241 |
-
} else if (action === 'PROMPT') {
|
| 242 |
-
const enrollment = await prisma.enrollment.findFirst({
|
| 243 |
-
where: { userId: user.id, status: 'ACTIVE' }
|
| 244 |
-
});
|
| 245 |
-
if (enrollment) {
|
| 246 |
-
const trackDay = await prisma.trackDay.findFirst({
|
| 247 |
-
where: { trackId: enrollment.trackId, dayNumber: enrollment.currentDay }
|
| 248 |
-
});
|
| 249 |
-
if (trackDay?.exercisePrompt) {
|
| 250 |
-
await scheduleMessage(user.id, trackDay.exercisePrompt, 0, organizationId);
|
| 251 |
-
} else {
|
| 252 |
-
await scheduleMessage(user.id, user.language === 'WOLOF' ? "Amul lëjj" : "Pas d'exercice pour ce jour", 0, organizationId);
|
| 253 |
-
}
|
| 254 |
-
}
|
| 255 |
-
return;
|
| 256 |
-
} else if (action === 'CONTINUE') {
|
| 257 |
-
// Determine if there is a pending exercise before advancing
|
| 258 |
-
const pendingProgress = await prisma.userProgress.findFirst({
|
| 259 |
-
where: { userId: user.id, exerciseStatus: { in: ['PENDING', 'PENDING_REMEDIATION'] } }
|
| 260 |
-
});
|
| 261 |
-
if (pendingProgress) {
|
| 262 |
-
await scheduleMessage(user.id, user.language === 'WOLOF'
|
| 263 |
-
? "Danga wara tontu lëjj bi balaa nga dem ci kanam. Tànnal 'Yónni tontu'."
|
| 264 |
-
: "Tu dois d'abord répondre à l'exercice avant de continuer. Choisis 'Faire l'exercice' ou 'Répondre'.",
|
| 265 |
-
0, organizationId
|
| 266 |
-
);
|
| 267 |
-
} else {
|
| 268 |
-
// Safe to advance (either completed or dropped or already handled)
|
| 269 |
-
await scheduleMessage(user.id, user.language === 'WOLOF'
|
| 270 |
-
? "Waaw, ñuy dem ci kanam !"
|
| 271 |
-
: "C'est noté, on avance !",
|
| 272 |
-
0, organizationId
|
| 273 |
-
);
|
| 274 |
-
// To do: if advance needs to trigger scheduleTrackDay directly, it could be done here instead of tracking.
|
| 275 |
-
// However, normally `SUITE` moves the day forward.
|
| 276 |
-
}
|
| 277 |
-
return;
|
| 278 |
-
}
|
| 279 |
-
}
|
| 280 |
-
|
| 281 |
-
// 1.7. Language Selection (Interactive Buttons)
|
| 282 |
-
if (normalizedText === 'LANG_FR' || normalizedText === 'LANG_WO') {
|
| 283 |
-
const newLang = normalizedText === 'LANG_FR' ? 'FR' : 'WOLOF';
|
| 284 |
-
user = await prisma.user.update({
|
| 285 |
-
where: { id: user.id },
|
| 286 |
-
data: { language: newLang }
|
| 287 |
-
});
|
| 288 |
-
|
| 289 |
-
const promptText = newLang === 'FR'
|
| 290 |
-
? "Parfait, nous allons continuer en Français ! 🇫🇷\nDans quel domaine d'activité te trouves-tu ?"
|
| 291 |
-
: "Baax na, dinanu wéy ci Wolof ! 🇸🇳\nCi ban mbir ngay yëngu ?";
|
| 292 |
-
|
| 293 |
-
await scheduleInteractiveList(
|
| 294 |
-
user.id,
|
| 295 |
-
newLang === 'FR' ? "Ton secteur" : "Sa Mbir",
|
| 296 |
-
promptText,
|
| 297 |
-
newLang === 'FR' ? "Secteurs" : "Tànn",
|
| 298 |
-
[
|
| 299 |
-
{
|
| 300 |
-
title: newLang === 'FR' ? "Secteurs" : "Tànn",
|
| 301 |
-
rows: [
|
| 302 |
-
{ id: 'SEC_COMMERCE', title: newLang === 'FR' ? 'Commerce / Vente' : 'Njaay' },
|
| 303 |
-
{ id: 'SEC_AGRI', title: newLang === 'FR' ? 'Agriculture / Élevage' : 'Mbay' },
|
| 304 |
-
{ id: 'SEC_FOOD', title: newLang === 'FR' ? 'Alimentation / Restauration' : 'Lekk / Restauration' },
|
| 305 |
-
{ id: 'SEC_TECH', title: newLang === 'FR' ? 'Tech / Digital' : 'Tech / Digital' },
|
| 306 |
-
{ id: 'SEC_BEAUTE', title: newLang === 'FR' ? 'Beauté / Bien-être' : 'Rafet' },
|
| 307 |
-
{ id: 'SEC_COUTURE', title: newLang === 'FR' ? 'Couture / Mode' : 'Couture' },
|
| 308 |
-
{ id: 'SEC_TRANSPORT', title: newLang === 'FR' ? 'Transport / Livraison' : 'Transport / Yëgël' },
|
| 309 |
-
{ id: 'SEC_AUTRE', title: newLang === 'FR' ? 'Autre secteur' : 'Beneen mbir' }
|
| 310 |
-
]
|
| 311 |
-
}
|
| 312 |
-
],
|
| 313 |
-
organizationId
|
| 314 |
-
);
|
| 315 |
-
return;
|
| 316 |
-
}
|
| 317 |
-
|
| 318 |
-
// 2. Check Pending Exercise (User Progress)
|
| 319 |
-
// 2. Resolve sector LIST reply IDs → human-readable label
|
| 320 |
-
const SECTOR_LABELS: Record<string, { fr: string; wo: string }> = {
|
| 321 |
-
SEC_COMMERCE: { fr: 'Commerce / Vente', wo: 'Njaay' },
|
| 322 |
-
SEC_AGRI: { fr: 'Agriculture / Élevage', wo: 'Mbay' },
|
| 323 |
-
SEC_FOOD: { fr: 'Alimentation / Restauration', wo: 'Lekk / Restauration' },
|
| 324 |
-
SEC_TECH: { fr: 'Tech / Digital', wo: 'Tech / Digital' },
|
| 325 |
-
SEC_BEAUTE: { fr: 'Beauté / Bien-être', wo: 'Rafet' },
|
| 326 |
-
SEC_COUTURE: { fr: 'Couture / Mode', wo: 'Couture' },
|
| 327 |
-
SEC_TRANSPORT: { fr: 'Transport / Livraison', wo: 'Transport / Yëgël' },
|
| 328 |
-
};
|
| 329 |
-
|
| 330 |
-
if (normalizedText === 'SEC_AUTRE') {
|
| 331 |
-
await scheduleMessage(user.id, user.language === 'WOLOF'
|
| 332 |
-
? 'Waaw ! Wax ma ban mbir ngay def ci ab kàddu gatt :'
|
| 333 |
-
: 'Parfait ! Décris ton activité en quelques mots :',
|
| 334 |
-
0, organizationId
|
| 335 |
-
);
|
| 336 |
-
return;
|
| 337 |
-
}
|
| 338 |
-
|
| 339 |
-
const sectorLabel = SECTOR_LABELS[normalizedText];
|
| 340 |
-
|
| 341 |
-
// 🚨 Brique 1 (Immuabilité) : Vérifier si l'utilisateur est déjà inscrit.
|
| 342 |
-
const existingEnrollment = await prisma.enrollment.findFirst({
|
| 343 |
-
where: { userId: user.id, status: 'ACTIVE' }
|
| 344 |
-
});
|
| 345 |
-
|
| 346 |
-
if (existingEnrollment && (sectorLabel || normalizedText.startsWith('SEC_'))) {
|
| 347 |
-
logger.info(`[IMMUTABILITY] User ${user.id} tried to change sector but is already enrolled.`);
|
| 348 |
-
return; // Ignore and do not allow re-routing here
|
| 349 |
-
}
|
| 350 |
-
|
| 351 |
-
if (!existingEnrollment && (sectorLabel || (!user.activity && text.length > 2))) {
|
| 352 |
-
const activity = sectorLabel
|
| 353 |
-
? (user.language === 'WOLOF' ? sectorLabel.wo : sectorLabel.fr)
|
| 354 |
-
: text.trim();
|
| 355 |
-
|
| 356 |
-
user = await prisma.user.update({
|
| 357 |
-
where: { id: user.id },
|
| 358 |
-
data: { activity }
|
| 359 |
-
});
|
| 360 |
-
|
| 361 |
-
const welcomeMsg = user.language === 'FR'
|
| 362 |
-
? `Parfait ! Secteur noté : *${activity}*.\nJe t'inscris à ta formation personnalisée !`
|
| 363 |
-
: `Baax na ! Bind nanu la ci: *${activity}*.\nLéegi dinanu la dugal ci njàng mi !`;
|
| 364 |
-
|
| 365 |
-
await scheduleMessage(user.id, welcomeMsg, 0, organizationId);
|
| 366 |
-
|
| 367 |
-
const trackId = user.language === 'FR' ? "T1-FR" : "T1-WO";
|
| 368 |
-
const defaultTrack = await prisma.track.findUnique({ where: { id: trackId } });
|
| 369 |
-
if (defaultTrack) await enrollUser(user.id, defaultTrack.id, organizationId);
|
| 370 |
-
return;
|
| 371 |
-
}
|
| 372 |
-
|
| 373 |
-
// 3. Check Active Enrollment (Commands Priority)
|
| 374 |
-
const activeEnrollment = await prisma.enrollment.findFirst({
|
| 375 |
-
where: { userId: user.id, status: 'ACTIVE' },
|
| 376 |
-
include: { track: true }
|
| 377 |
});
|
| 378 |
-
|
| 379 |
-
if (activeEnrollment) {
|
| 380 |
-
const intent = this.detectIntent(text);
|
| 381 |
-
const isSuite = this.isFuzzyMatch(normalizedText, 'SUITE') || normalizedText === '2';
|
| 382 |
-
const isApproforcir = this.isFuzzyMatch(normalizedText, 'APPROFONDIR') || normalizedText === '1';
|
| 383 |
-
|
| 384 |
-
// Handle SUITE Priority
|
| 385 |
-
if (isSuite) {
|
| 386 |
-
// 🚨 COUPE-CIRCUIT #2: Kill Time-Travel context BEFORE any progression logic
|
| 387 |
-
await clearTimeTravelContext(user.id);
|
| 388 |
-
|
| 389 |
-
const userProgress = await prisma.userProgress.findUnique({
|
| 390 |
-
where: { userId_trackId: { userId: user.id, trackId: activeEnrollment.trackId } }
|
| 391 |
-
});
|
| 392 |
-
|
| 393 |
-
// 🚨 UNBLOCKING GUARD: Allow SUITE if a response has been recorded for the current day OR if status is valid.
|
| 394 |
-
const lastResponse = await prisma.response.findFirst({
|
| 395 |
-
where: { userId: user.id, dayNumber: activeEnrollment.currentDay },
|
| 396 |
-
orderBy: { createdAt: 'desc' }
|
| 397 |
-
});
|
| 398 |
-
|
| 399 |
-
if (userProgress?.exerciseStatus !== 'COMPLETED' && userProgress?.exerciseStatus !== 'PENDING_DEEPDIVE' && !lastResponse) {
|
| 400 |
-
logger.info(`[SUITE-BLOCKED] User ${user.id} tried SUITE but status is ${userProgress?.exerciseStatus || 'null'} and no response found.`);
|
| 401 |
-
await scheduleMessage(user.id, user.language === 'WOLOF'
|
| 402 |
-
? "Dafa laaj nga tontu laaj bi ci kaw dëbb (audio walla texte) balaa nga dem ci kanam ! 🎙️"
|
| 403 |
-
: "Tu dois d'abord répondre à l'exercice ci-dessus pour continuer ! 🎙️",
|
| 404 |
-
0, organizationId
|
| 405 |
-
);
|
| 406 |
-
return;
|
| 407 |
-
}
|
| 408 |
-
|
| 409 |
-
logger.info(`[SUITE-ALLOWED] User ${user.id} advancing from day ${activeEnrollment.currentDay}`);
|
| 410 |
-
const nextDay = activeEnrollment.currentDay % 1 !== 0
|
| 411 |
-
? Math.floor(activeEnrollment.currentDay) + 1
|
| 412 |
-
: activeEnrollment.currentDay + 1;
|
| 413 |
-
|
| 414 |
-
await prisma.enrollment.update({ where: { id: activeEnrollment.id }, data: { currentDay: nextDay } });
|
| 415 |
-
await prisma.userProgress.update({
|
| 416 |
-
where: { userId_trackId: { userId: user.id, trackId: activeEnrollment.trackId } },
|
| 417 |
-
data: {
|
| 418 |
-
exerciseStatus: 'PENDING',
|
| 419 |
-
iterationCount: 0 // Reset iteration count for the new day
|
| 420 |
-
}
|
| 421 |
-
});
|
| 422 |
-
await whatsappQueue.add('send-content', { userId: user.id, trackId: activeEnrollment.trackId, dayNumber: nextDay });
|
| 423 |
-
return;
|
| 424 |
-
}
|
| 425 |
-
|
| 426 |
-
// Handle APPROFONDIR (Deep Dive Initiation)
|
| 427 |
-
if (isApproforcir) {
|
| 428 |
-
const userProgress = await prisma.userProgress.findUnique({
|
| 429 |
-
where: { userId_trackId: { userId: user.id, trackId: activeEnrollment.trackId } }
|
| 430 |
-
});
|
| 431 |
-
|
| 432 |
-
// 🕰️ TIME-TRAVEL: Compute effective day — use override if provided (from HF gateway)
|
| 433 |
-
const timeTravelDay = timeTravelDayOverride ?? await getTimeTravelContext(user.id);
|
| 434 |
-
const effectiveDay = timeTravelDay ?? activeEnrollment.currentDay;
|
| 435 |
-
|
| 436 |
-
// 🚨 UNBLOCKING GUARD: Allow 1/APPROFONDIR strictly if a response exists for that effective day.
|
| 437 |
-
const lastResponse = await prisma.response.findFirst({
|
| 438 |
-
where: { userId: user.id, dayNumber: effectiveDay },
|
| 439 |
-
orderBy: { createdAt: 'desc' }
|
| 440 |
-
});
|
| 441 |
-
|
| 442 |
-
if (lastResponse) {
|
| 443 |
-
await prisma.userProgress.update({
|
| 444 |
-
where: { id: userProgress!.id },
|
| 445 |
-
data: { exerciseStatus: 'PENDING_DEEPDIVE' }
|
| 446 |
-
});
|
| 447 |
-
|
| 448 |
-
await scheduleMessage(user.id, user.language === 'WOLOF'
|
| 449 |
-
? "Baax na ! Wax ma ndox mi nga yor ci sa mbir (njëg, jafe-jafe, njëgëndal, njàngat, etc.) ngir ma gën a deesi njàngat bi :"
|
| 450 |
-
: "Très bien ! Quelle information précise issue de ton terrain veux-tu ajouter ? (ex: un prix précis, un obstacle, un fournisseur, etc.) :",
|
| 451 |
-
0, organizationId
|
| 452 |
-
);
|
| 453 |
-
return;
|
| 454 |
-
} else {
|
| 455 |
-
await scheduleMessage(user.id, user.language === 'WOLOF'
|
| 456 |
-
? "Dafa laaj nga tontu laaj bi ci kaw dëbb balaa nga natt nga tontu !"
|
| 457 |
-
: "Réponds d'abord à l'exercice principal avant d'approfondir !",
|
| 458 |
-
0, organizationId
|
| 459 |
-
);
|
| 460 |
-
return;
|
| 461 |
-
}
|
| 462 |
-
}
|
| 463 |
-
|
| 464 |
-
// Handle YES/NO Intents
|
| 465 |
-
if (intent === 'YES' && normalizedText.length < 15) {
|
| 466 |
-
const userProgress = await prisma.userProgress.findUnique({
|
| 467 |
-
where: { userId_trackId: { userId: user.id, trackId: activeEnrollment.trackId } }
|
| 468 |
-
});
|
| 469 |
-
if (userProgress?.exerciseStatus === 'COMPLETED') {
|
| 470 |
-
await scheduleMessage(user.id, user.language === 'WOLOF' ? "Waaw ! Lexon bi mu ngi ñëw..." : "C'est parti ! Voici la suite...", 0, organizationId);
|
| 471 |
-
await whatsappQueue.add('send-content', { userId: user.id, trackId: activeEnrollment.trackId, dayNumber: activeEnrollment.currentDay + 1, organizationId });
|
| 472 |
-
return;
|
| 473 |
-
}
|
| 474 |
-
}
|
| 475 |
-
|
| 476 |
-
if (intent === 'NO' && normalizedText.length < 15) {
|
| 477 |
-
await scheduleMessage(user.id, user.language === 'WOLOF' ? "Baax na, bu la neexee nga tontu laaj bi." : "Pas de souci, tu peux répondre à l'exercice quand tu es prêt.", 0, organizationId);
|
| 478 |
-
return;
|
| 479 |
-
}
|
| 480 |
-
|
| 481 |
-
// Fallback to Exercise Response if nothing else matched
|
| 482 |
-
// 🚨 COACHING GUARDRAIL: AI Coach only activated if profile (sector + language) is complete
|
| 483 |
-
if (!user.activity) {
|
| 484 |
-
await scheduleMessage(user.id, user.language === 'WOLOF'
|
| 485 |
-
? "Danga wara tànn sa mbiru liggeey balaa ñuy tàmbali coaching bi."
|
| 486 |
-
: "Tu dois d'abord définir ton activité avant que le coach AI ne puisse t'aider.",
|
| 487 |
-
0, organizationId
|
| 488 |
-
);
|
| 489 |
-
return;
|
| 490 |
-
}
|
| 491 |
-
|
| 492 |
-
// 🕰️ TIME-TRAVEL: Compute effectiveDay — single source of truth for AI prompt + Prisma saves
|
| 493 |
-
const timeTravelDay = timeTravelDayOverride ?? await getTimeTravelContext(user.id);
|
| 494 |
-
const effectiveDay = timeTravelDay ?? activeEnrollment.currentDay;
|
| 495 |
-
const isTimeTravelMode = timeTravelDay !== null && timeTravelDay !== activeEnrollment.currentDay;
|
| 496 |
-
if (isTimeTravelMode) {
|
| 497 |
-
logger.info(`[TIME-TRAVEL] 🕰️ User ${user.id} responding to replay Day ${effectiveDay} (real currentDay: ${activeEnrollment.currentDay})`);
|
| 498 |
-
}
|
| 499 |
-
|
| 500 |
-
// 🚨 TEXT RE-VALIDATION: Mirror the Worker-side `shouldForceRevalidation` logic.
|
| 501 |
-
// Image/Audio flows reset PENDING via WhatsAppLogic. For text, we do it here.
|
| 502 |
-
const userProgressState = await prisma.userProgress.findUnique({
|
| 503 |
-
where: { userId_trackId: { userId: user.id, trackId: activeEnrollment.trackId } }
|
| 504 |
-
});
|
| 505 |
-
const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000);
|
| 506 |
-
const isRecentlyCompleted = userProgressState?.exerciseStatus === 'COMPLETED'
|
| 507 |
-
&& userProgressState.updatedAt > tenMinutesAgo;
|
| 508 |
-
|
| 509 |
-
if (isRecentlyCompleted && !audioUrl && !imageUrl && text.length > 5 && !isSystemCommand) {
|
| 510 |
-
logger.info(`[TXT-FLOW] 🔄 Re-validation User ${user.id} Day ${activeEnrollment.currentDay} (COMPLETED → PENDING)`);
|
| 511 |
-
await prisma.userProgress.update({
|
| 512 |
-
where: { id: userProgressState!.id },
|
| 513 |
-
data: { exerciseStatus: 'PENDING' }
|
| 514 |
-
});
|
| 515 |
-
}
|
| 516 |
-
|
| 517 |
-
const pendingProgress = await prisma.userProgress.findFirst({
|
| 518 |
-
where: { userId: user.id, exerciseStatus: { in: ['PENDING', 'PENDING_REMEDIATION', 'PENDING_DEEPDIVE'] }, trackId: activeEnrollment.trackId },
|
| 519 |
-
});
|
| 520 |
-
|
| 521 |
-
if (pendingProgress) {
|
| 522 |
-
const isDeepDiveAction = pendingProgress.exerciseStatus === 'PENDING_DEEPDIVE';
|
| 523 |
-
|
| 524 |
-
const trackDay = await prisma.trackDay.findFirst({
|
| 525 |
-
where: { trackId: activeEnrollment.trackId, dayNumber: effectiveDay }
|
| 526 |
-
});
|
| 527 |
-
|
| 528 |
-
if (trackDay) {
|
| 529 |
-
// 🚨 Flexible Guardrail (Day 7 Fix)
|
| 530 |
-
const wordCount = (text || '').trim().split(/\s+/).length;
|
| 531 |
-
const validationKeyword = trackDay.validationKeyword || '';
|
| 532 |
-
const isOptionMatch = validationKeyword && this.isFuzzyMatch(normalizedText, validationKeyword);
|
| 533 |
-
|
| 534 |
-
// Specific bypass for known short answers in modules (WhatsApp, Boutique, etc.)
|
| 535 |
-
const commonOptions = ['WHATSAPP', 'BOUTIQUE', 'APPEL', 'VENTE', 'SERVICE', 'PRODUCTION'];
|
| 536 |
-
const isCommonOption = commonOptions.some(opt => this.isFuzzyMatch(normalizedText, opt));
|
| 537 |
-
|
| 538 |
-
if (wordCount < 3 && !isOptionMatch && !isCommonOption) {
|
| 539 |
-
await scheduleMessage(user.id, user.language === 'WOLOF'
|
| 540 |
-
? "Dama lay xaar nga wax ma lu gën a yaatu ci sa mbir (mbebetu 3 baat). Waxtaanal ak man !"
|
| 541 |
-
: "Ta réponse est un peu courte. Peux-tu m'en dire plus ? (Minimum 3 mots)", 0, organizationId);
|
| 542 |
-
return;
|
| 543 |
-
}
|
| 544 |
-
|
| 545 |
-
await scheduleMessage(user.id, user.language === 'WOLOF' ? "⏳ Defar ak sa tontu..." : "⏳ Analyse de votre réponse...", 0, organizationId);
|
| 546 |
-
|
| 547 |
-
// Update iteration count if it's a deep dive
|
| 548 |
-
let currentIterationCount = pendingProgress.iterationCount || 0;
|
| 549 |
-
if (isDeepDiveAction) {
|
| 550 |
-
currentIterationCount += 1;
|
| 551 |
-
await prisma.userProgress.update({
|
| 552 |
-
where: { id: pendingProgress.id },
|
| 553 |
-
data: { iterationCount: currentIterationCount } // Save the increment
|
| 554 |
-
});
|
| 555 |
-
}
|
| 556 |
-
|
| 557 |
-
// 🚨 Store response under effectiveDay (real day OR Time-Travel override day)
|
| 558 |
-
await prisma.response.create({
|
| 559 |
-
data: {
|
| 560 |
-
enrollmentId: activeEnrollment.id,
|
| 561 |
-
userId: user.id,
|
| 562 |
-
dayNumber: effectiveDay,
|
| 563 |
-
content: text,
|
| 564 |
-
organizationId
|
| 565 |
-
}
|
| 566 |
-
});
|
| 567 |
-
|
| 568 |
-
// Fetch previous responses to provide context to the AI Coach
|
| 569 |
-
const previousResponsesData = await prisma.response.findMany({
|
| 570 |
-
where: { userId: user.id, enrollmentId: activeEnrollment.id },
|
| 571 |
-
orderBy: { dayNumber: 'asc' },
|
| 572 |
-
take: 5 // Keep context size reasonable
|
| 573 |
-
});
|
| 574 |
-
const previousResponses = previousResponsesData.map(r => ({ day: r.dayNumber, response: r.content }));
|
| 575 |
-
|
| 576 |
-
await whatsappQueue.add('generate-feedback', {
|
| 577 |
-
userId: user.id, text, trackId: activeEnrollment.trackId, trackDayId: trackDay.id,
|
| 578 |
-
exercisePrompt: trackDay.exercisePrompt || '', lessonText: trackDay.lessonText || '',
|
| 579 |
-
exerciseCriteria: trackDay.exerciseCriteria, pendingProgressId: pendingProgress.id,
|
| 580 |
-
dayNumber: effectiveDay, // ← effectiveDay: single source of truth
|
| 581 |
-
currentDay: effectiveDay, // ← worker reads job.data.currentDay
|
| 582 |
-
totalDays: activeEnrollment.track.duration, language: user.language,
|
| 583 |
-
userActivity: user.activity,
|
| 584 |
-
userRegion: user.city,
|
| 585 |
-
previousResponses,
|
| 586 |
-
isDeepDive: isDeepDiveAction,
|
| 587 |
-
iterationCount: currentIterationCount,
|
| 588 |
-
imageUrl: imageUrl,
|
| 589 |
-
isTimeTravelMode, // ← Worker uses this to skip COMPLETED update
|
| 590 |
-
realCurrentDay: activeEnrollment.currentDay, // ← For logging only
|
| 591 |
-
organizationId
|
| 592 |
-
}, { attempts: 3, backoff: { type: 'exponential', delay: 2000 } });
|
| 593 |
-
return;
|
| 594 |
-
}
|
| 595 |
-
}
|
| 596 |
-
|
| 597 |
-
// Handle daily response (Fallback if no PENDING found earlier)
|
| 598 |
-
logger.info(`${traceId} User ${user.id} fallback daily response to effectiveDay ${effectiveDay}`);
|
| 599 |
-
await prisma.response.create({
|
| 600 |
-
data: {
|
| 601 |
-
enrollmentId: activeEnrollment.id,
|
| 602 |
-
userId: user.id,
|
| 603 |
-
dayNumber: effectiveDay,
|
| 604 |
-
content: text,
|
| 605 |
-
organizationId
|
| 606 |
-
}
|
| 607 |
-
});
|
| 608 |
-
|
| 609 |
-
const trackDayFallback = await prisma.trackDay.findFirst({
|
| 610 |
-
where: { trackId: activeEnrollment.trackId, dayNumber: effectiveDay }
|
| 611 |
-
});
|
| 612 |
-
|
| 613 |
-
if (trackDayFallback) {
|
| 614 |
-
// 🚨 Guardrail: Contenu Vide / Gibberish 🚨
|
| 615 |
-
const wordCount = text.trim().split(/\s+/).length;
|
| 616 |
-
if (wordCount < 3 || text.length < 5) {
|
| 617 |
-
logger.info(`${traceId} Guardrail: Input too short or potential gibberish: "${text}"`);
|
| 618 |
-
await scheduleMessage(user.id, user.language === 'WOLOF'
|
| 619 |
-
? "Ma déggul li nga wax mbir mi... Mën nga ko gën a firi ci ab kàddu gatt (3-4 kàddu) ?"
|
| 620 |
-
: "Je n'ai pas bien compris ton activité. Peux-tu me réexpliquer en quelques mots ce que tu vends et à qui ?",
|
| 621 |
-
0, organizationId
|
| 622 |
-
);
|
| 623 |
-
return;
|
| 624 |
-
}
|
| 625 |
-
|
| 626 |
-
// 🚨 Guardrail: Enrollment Priority 🚨
|
| 627 |
-
if (!user.activity || !user.language) {
|
| 628 |
-
logger.info(`${traceId} Blocking AI feedback: Enrollment incomplete for User ${user.id}`);
|
| 629 |
-
await scheduleMessage(user.id, user.language === 'WOLOF'
|
| 630 |
-
? "Baax na, waaye laaj bi des na... Bindal 'INSCRIPTION' ngir tàmbali."
|
| 631 |
-
: "C'est noté, mais il faut d'abord terminer ton inscription. Envoie 'INSCRIPTION' pour commencer.",
|
| 632 |
-
0, organizationId
|
| 633 |
-
);
|
| 634 |
-
return;
|
| 635 |
-
}
|
| 636 |
-
|
| 637 |
-
// Fetch previous responses to provide context to the AI Coach
|
| 638 |
-
const previousResponsesData = await prisma.response.findMany({
|
| 639 |
-
where: { userId: user.id, enrollmentId: activeEnrollment.id },
|
| 640 |
-
orderBy: { dayNumber: 'asc' },
|
| 641 |
-
take: 5
|
| 642 |
-
});
|
| 643 |
-
const previousResponses = previousResponsesData.map(r => ({ day: r.dayNumber, response: r.content }));
|
| 644 |
-
|
| 645 |
-
await whatsappQueue.add('generate-feedback', {
|
| 646 |
-
userId: user.id, text, trackId: activeEnrollment.trackId, trackDayId: trackDayFallback.id,
|
| 647 |
-
exercisePrompt: trackDayFallback.exercisePrompt || '', lessonText: trackDayFallback.lessonText || '',
|
| 648 |
-
exerciseCriteria: trackDayFallback.exerciseCriteria,
|
| 649 |
-
enrollmentId: activeEnrollment.id,
|
| 650 |
-
currentDay: effectiveDay, // ← effectiveDay: single source of truth
|
| 651 |
-
dayNumber: effectiveDay,
|
| 652 |
-
totalDays: activeEnrollment.track.duration, language: user.language,
|
| 653 |
-
userActivity: user.activity,
|
| 654 |
-
userRegion: user.city,
|
| 655 |
-
previousResponses,
|
| 656 |
-
imageUrl: imageUrl,
|
| 657 |
-
isTimeTravelMode,
|
| 658 |
-
realCurrentDay: activeEnrollment.currentDay,
|
| 659 |
-
organizationId
|
| 660 |
-
});
|
| 661 |
-
return;
|
| 662 |
-
}
|
| 663 |
-
|
| 664 |
-
await scheduleMessage(user.id, user.language === 'WOLOF'
|
| 665 |
-
? "Baax na ! Yónnee *SUITE* ngir dem ci kanam walla tontul laaj bi ci kaw."
|
| 666 |
-
: "✅ Message reçu ! Envoie *SUITE* pour avancer ou réponds à l'exercice ci-dessus.",
|
| 667 |
-
0, organizationId
|
| 668 |
-
);
|
| 669 |
-
return;
|
| 670 |
-
}
|
| 671 |
-
|
| 672 |
-
// 4. Default: fallback for generic unknown messages (not in onboarding, not in active enrollment)
|
| 673 |
-
logger.info(`${traceId} Unknown command from user ${user.id}: "${normalizedText}"`);
|
| 674 |
-
await scheduleMessage(user.id, user.language === 'WOLOF'
|
| 675 |
-
? "Bañ ma dégg. Yónnee *INSCRIPTION* ngir tàmbalee ci kanam walla bind *SUITE*."
|
| 676 |
-
: "Je n'ai pas compris. Envoie *INSCRIPTION* pour recommencer ou *SUITE* pour avancer.",
|
| 677 |
-
0, organizationId
|
| 678 |
-
);
|
| 679 |
}
|
| 680 |
}
|
|
|
|
| 1 |
import { logger } from '../logger';
|
| 2 |
+
import { whatsappQueue } from './queue';
|
|
|
|
| 3 |
|
| 4 |
export class WhatsAppService {
|
| 5 |
+
/**
|
| 6 |
+
* Unified entry point for incoming messages.
|
| 7 |
+
* Delegates all business logic to the background worker via BullMQ.
|
| 8 |
+
*/
|
| 9 |
+
static async handleIncomingMessage(
|
| 10 |
+
phone: string,
|
| 11 |
+
text: string,
|
| 12 |
+
audioUrl?: string,
|
| 13 |
+
imageUrl?: string,
|
| 14 |
+
timeTravelDayOverride?: number,
|
| 15 |
+
organizationId: string = 'default-org-id'
|
| 16 |
+
) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
const traceId = audioUrl ? `[STT-FLOW-${phone.slice(-4)}]` : imageUrl ? `[IMG-FLOW-${phone.slice(-4)}]` : `[TXT-FLOW-${phone.slice(-4)}]`;
|
| 18 |
+
logger.info(`${traceId} Enqueueing message for worker: "${text.substring(0, 50)}..." (Org: ${organizationId})`);
|
| 19 |
+
|
| 20 |
+
await whatsappQueue.add('handle-inbound', {
|
| 21 |
+
phone,
|
| 22 |
+
text,
|
| 23 |
+
audioUrl,
|
| 24 |
+
imageUrl,
|
| 25 |
+
isTimeTravelMode: timeTravelDayOverride !== undefined,
|
| 26 |
+
realCurrentDay: timeTravelDayOverride,
|
| 27 |
+
organizationId
|
| 28 |
+
}, {
|
| 29 |
+
priority: 1,
|
| 30 |
+
attempts: 3,
|
| 31 |
+
backoff: { type: 'exponential', delay: 2000 }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
}
|
| 34 |
}
|
apps/whatsapp-worker/package.json
CHANGED
|
@@ -5,7 +5,8 @@
|
|
| 5 |
"scripts": {
|
| 6 |
"dev": "tsx watch src/index.ts",
|
| 7 |
"build": "pnpm --filter @repo/database generate && tsc --build",
|
| 8 |
-
"start": "npx tsx ../api/src/index.ts & sleep 7 && node dist/index.js & wait"
|
|
|
|
| 9 |
},
|
| 10 |
"dependencies": {
|
| 11 |
"@aws-sdk/client-s3": "^3.995.0",
|
|
@@ -17,13 +18,16 @@
|
|
| 17 |
"dotenv": "^16.0.0",
|
| 18 |
"ioredis": "^5.9.3",
|
| 19 |
"node-cron": "^4.2.1",
|
|
|
|
| 20 |
"sharp": "^0.34.5"
|
| 21 |
},
|
| 22 |
"devDependencies": {
|
| 23 |
"@repo/tsconfig": "workspace:*",
|
| 24 |
"@types/node": "^20.0.0",
|
| 25 |
"@types/node-cron": "^3.0.11",
|
|
|
|
| 26 |
"tsx": "^3.0.0",
|
| 27 |
-
"typescript": "^5.0.0"
|
|
|
|
| 28 |
}
|
| 29 |
}
|
|
|
|
| 5 |
"scripts": {
|
| 6 |
"dev": "tsx watch src/index.ts",
|
| 7 |
"build": "pnpm --filter @repo/database generate && tsc --build",
|
| 8 |
+
"start": "npx tsx ../api/src/index.ts & sleep 7 && node dist/index.js & wait",
|
| 9 |
+
"test": "vitest run"
|
| 10 |
},
|
| 11 |
"dependencies": {
|
| 12 |
"@aws-sdk/client-s3": "^3.995.0",
|
|
|
|
| 18 |
"dotenv": "^16.0.0",
|
| 19 |
"ioredis": "^5.9.3",
|
| 20 |
"node-cron": "^4.2.1",
|
| 21 |
+
"node-fetch": "^2.6.7",
|
| 22 |
"sharp": "^0.34.5"
|
| 23 |
},
|
| 24 |
"devDependencies": {
|
| 25 |
"@repo/tsconfig": "workspace:*",
|
| 26 |
"@types/node": "^20.0.0",
|
| 27 |
"@types/node-cron": "^3.0.11",
|
| 28 |
+
"@types/node-fetch": "^2.6.2",
|
| 29 |
"tsx": "^3.0.0",
|
| 30 |
+
"typescript": "^5.0.0",
|
| 31 |
+
"vitest": "^1.0.0"
|
| 32 |
}
|
| 33 |
}
|
apps/whatsapp-worker/scratch/check_orgs.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const { PrismaClient } = require('../../packages/database');
|
| 2 |
+
const prisma = new PrismaClient();
|
| 3 |
+
|
| 4 |
+
async function main() {
|
| 5 |
+
const orgs = await prisma.organization.findMany();
|
| 6 |
+
console.log('Organizations:', JSON.stringify(orgs, null, 2));
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
main().catch(console.error).finally(() => prisma.$disconnect());
|
apps/whatsapp-worker/src/__tests__/OnboardingHandler.test.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
| 2 |
+
import { OnboardingHandler } from '../handlers/OnboardingHandler';
|
| 3 |
+
import { prisma } from '../services/prisma';
|
| 4 |
+
import { MessageContext } from '../handlers/types';
|
| 5 |
+
|
| 6 |
+
vi.mock('../services/prisma', () => ({
|
| 7 |
+
prisma: {
|
| 8 |
+
user: {
|
| 9 |
+
findUnique: vi.fn(),
|
| 10 |
+
create: vi.fn(),
|
| 11 |
+
update: vi.fn(),
|
| 12 |
+
},
|
| 13 |
+
enrollment: {
|
| 14 |
+
findFirst: vi.fn(),
|
| 15 |
+
deleteMany: vi.fn(),
|
| 16 |
+
},
|
| 17 |
+
userProgress: {
|
| 18 |
+
deleteMany: vi.fn(),
|
| 19 |
+
},
|
| 20 |
+
response: {
|
| 21 |
+
deleteMany: vi.fn(),
|
| 22 |
+
},
|
| 23 |
+
message: {
|
| 24 |
+
deleteMany: vi.fn(),
|
| 25 |
+
},
|
| 26 |
+
userBadge: {
|
| 27 |
+
deleteMany: vi.fn(),
|
| 28 |
+
},
|
| 29 |
+
teamMember: {
|
| 30 |
+
deleteMany: vi.fn(),
|
| 31 |
+
},
|
| 32 |
+
businessProfile: {
|
| 33 |
+
deleteMany: vi.fn(),
|
| 34 |
+
}
|
| 35 |
+
}
|
| 36 |
+
}));
|
| 37 |
+
|
| 38 |
+
vi.mock('../timeTravelContext', () => ({
|
| 39 |
+
clearTimeTravelContext: vi.fn(),
|
| 40 |
+
}));
|
| 41 |
+
|
| 42 |
+
describe('OnboardingHandler', () => {
|
| 43 |
+
let handler: OnboardingHandler;
|
| 44 |
+
let mockQueue: any;
|
| 45 |
+
|
| 46 |
+
beforeEach(() => {
|
| 47 |
+
handler = new OnboardingHandler();
|
| 48 |
+
mockQueue = {
|
| 49 |
+
add: vi.fn(),
|
| 50 |
+
};
|
| 51 |
+
vi.clearAllMocks();
|
| 52 |
+
});
|
| 53 |
+
|
| 54 |
+
it('should handle INSCRIPTION for a new user', async () => {
|
| 55 |
+
const ctx: MessageContext = {
|
| 56 |
+
phone: '221770000000',
|
| 57 |
+
text: 'INSCRIPTION',
|
| 58 |
+
normalizedText: 'INSCRIPTION',
|
| 59 |
+
traceId: 'test-trace',
|
| 60 |
+
organizationId: 'test-org',
|
| 61 |
+
whatsappQueue: mockQueue,
|
| 62 |
+
redis: {} as any,
|
| 63 |
+
};
|
| 64 |
+
|
| 65 |
+
// Mock: User doesn't exist
|
| 66 |
+
(prisma.user.findUnique as any).mockResolvedValue(null);
|
| 67 |
+
// Mock: Create user
|
| 68 |
+
(prisma.user.create as any).mockResolvedValue({ id: 'user-1', phone: '221770000000', language: 'FR' });
|
| 69 |
+
|
| 70 |
+
const handled = await handler.handle(ctx);
|
| 71 |
+
|
| 72 |
+
expect(handled).toBe(true);
|
| 73 |
+
expect(prisma.user.create).toHaveBeenCalled();
|
| 74 |
+
expect(mockQueue.add).toHaveBeenCalledWith('send-interactive-buttons', expect.objectContaining({
|
| 75 |
+
userId: 'user-1',
|
| 76 |
+
bodyText: expect.stringContaining('choisissez votre langue')
|
| 77 |
+
}));
|
| 78 |
+
});
|
| 79 |
+
|
| 80 |
+
it('should handle language selection', async () => {
|
| 81 |
+
const ctx: MessageContext = {
|
| 82 |
+
phone: '221770000000',
|
| 83 |
+
text: 'LANG_WO',
|
| 84 |
+
normalizedText: 'LANG_WO',
|
| 85 |
+
traceId: 'test-trace',
|
| 86 |
+
organizationId: 'test-org',
|
| 87 |
+
whatsappQueue: mockQueue,
|
| 88 |
+
redis: {} as any,
|
| 89 |
+
user: { id: 'user-1', phone: '221770000000', language: 'FR' } as any,
|
| 90 |
+
};
|
| 91 |
+
|
| 92 |
+
(prisma.user.update as any).mockResolvedValue({ id: 'user-1', language: 'WOLOF' });
|
| 93 |
+
|
| 94 |
+
const handled = await handler.handle(ctx);
|
| 95 |
+
|
| 96 |
+
expect(handled).toBe(true);
|
| 97 |
+
expect(prisma.user.update).toHaveBeenCalledWith(expect.objectContaining({
|
| 98 |
+
data: { language: 'WOLOF' }
|
| 99 |
+
}));
|
| 100 |
+
expect(mockQueue.add).toHaveBeenCalledWith('send-interactive-list', expect.objectContaining({
|
| 101 |
+
userId: 'user-1',
|
| 102 |
+
bodyText: expect.stringContaining('Ci ban mbir ngay yëngu')
|
| 103 |
+
}));
|
| 104 |
+
});
|
| 105 |
+
});
|
apps/whatsapp-worker/src/__tests__/normalizeWolof.test.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, it, expect } from 'vitest';
|
| 2 |
+
import { normalizeWolof, shortenForWhatsApp } from '../normalizeWolof';
|
| 3 |
+
|
| 4 |
+
describe('Wolof Normalization', () => {
|
| 5 |
+
it('should normalize common Wolof variations', () => {
|
| 6 |
+
const input = "Xalme est cool, woy !";
|
| 7 |
+
const { normalizedText } = normalizeWolof(input);
|
| 8 |
+
expect(normalizedText).toContain('Xamlé');
|
| 9 |
+
});
|
| 10 |
+
|
| 11 |
+
it('should fix "waaw" variations', () => {
|
| 12 |
+
expect(normalizeWolof('waaww').normalizedText).toBe('Waaw');
|
| 13 |
+
expect(normalizeWolof('waaaaw').normalizedText).toBe('Waaw');
|
| 14 |
+
});
|
| 15 |
+
|
| 16 |
+
it('should handle "déet" (no)', () => {
|
| 17 |
+
expect(normalizeWolof('deet').normalizedText).toBe('Déet');
|
| 18 |
+
});
|
| 19 |
+
|
| 20 |
+
it('should shorten long text for WhatsApp', () => {
|
| 21 |
+
const longText = "Ceci est une phrase très longue. ".repeat(20); // ~600 chars
|
| 22 |
+
const chunks = shortenForWhatsApp(longText);
|
| 23 |
+
expect(chunks.length).toBeGreaterThan(1);
|
| 24 |
+
expect(chunks[0].length).toBeLessThanOrEqual(280);
|
| 25 |
+
});
|
| 26 |
+
});
|
apps/whatsapp-worker/src/config.ts
CHANGED
|
@@ -1,104 +1,32 @@
|
|
| 1 |
-
import {
|
| 2 |
import dotenv from 'dotenv';
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
/
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
if (normalized.startsWith('http://') && !isLocal && !isInternal) {
|
| 27 |
-
throw new Error(`[CONFIG] ❌ CRITICAL: ${keyName} MUST use https:// (got ${normalized})`);
|
| 28 |
-
}
|
| 29 |
-
|
| 30 |
-
// Remove trailing slashes
|
| 31 |
-
while (normalized.endsWith('/')) {
|
| 32 |
-
normalized = normalized.slice(0, -1);
|
| 33 |
-
}
|
| 34 |
-
|
| 35 |
-
return normalized;
|
| 36 |
}
|
| 37 |
|
| 38 |
-
|
| 39 |
-
* Gets the AI API URL for internal webhook processing and endpoints.
|
| 40 |
-
*/
|
| 41 |
-
export function getApiUrl(): string {
|
| 42 |
-
return requireHttpUrl(process.env.AI_API_BASE_URL, 'AI_API_BASE_URL');
|
| 43 |
-
}
|
| 44 |
-
|
| 45 |
-
/**
|
| 46 |
-
* Checks if a specific feature is enabled via environment variables.
|
| 47 |
-
* Usage: FEATURE_BUSINESS_PROFILE=true
|
| 48 |
-
*/
|
| 49 |
-
export function isFeatureEnabled(featureName: string): boolean {
|
| 50 |
-
const val = process.env[featureName];
|
| 51 |
-
return val === 'true' || val === '1';
|
| 52 |
-
}
|
| 53 |
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
export function
|
| 58 |
-
const key = process.env.ADMIN_API_KEY;
|
| 59 |
-
if (!key) {
|
| 60 |
-
throw new Error(`[CONFIG] Missing environment variable: ADMIN_API_KEY`);
|
| 61 |
-
}
|
| 62 |
-
return key;
|
| 63 |
-
}
|
| 64 |
-
|
| 65 |
-
/**
|
| 66 |
-
* Validates critical environment variables at startup.
|
| 67 |
-
* Throws an explicit error if any are missing.
|
| 68 |
-
*/
|
| 69 |
-
export function validateEnvironment() {
|
| 70 |
-
logger.info('[CONFIG] Validating environment variables...');
|
| 71 |
-
|
| 72 |
-
const requiredVars = [
|
| 73 |
-
'AI_API_BASE_URL',
|
| 74 |
-
'OPENAI_API_KEY',
|
| 75 |
-
'ADMIN_API_KEY',
|
| 76 |
-
'WHATSAPP_ACCESS_TOKEN',
|
| 77 |
-
'WHATSAPP_PHONE_NUMBER_ID',
|
| 78 |
-
'REDIS_URL' // or REDIS_HOST
|
| 79 |
-
];
|
| 80 |
-
|
| 81 |
-
const missing = [];
|
| 82 |
-
|
| 83 |
-
for (const req of requiredVars) {
|
| 84 |
-
if (req === 'REDIS_URL') {
|
| 85 |
-
if (!process.env.REDIS_URL && !process.env.REDIS_HOST) {
|
| 86 |
-
missing.push('REDIS_URL or REDIS_HOST');
|
| 87 |
-
}
|
| 88 |
-
continue;
|
| 89 |
-
}
|
| 90 |
-
|
| 91 |
-
if (!process.env[req]) {
|
| 92 |
-
missing.push(req);
|
| 93 |
-
}
|
| 94 |
-
}
|
| 95 |
-
|
| 96 |
-
if (missing.length > 0) {
|
| 97 |
-
throw new Error(`[CONFIG] ❌ CRITICAL: Missing required environment variables: ${missing.join(', ')}`);
|
| 98 |
-
}
|
| 99 |
-
|
| 100 |
-
// Validate and print effective API URL
|
| 101 |
-
const effectiveApiUrl = getApiUrl();
|
| 102 |
-
logger.info(`[CONFIG] ✅ AI_API_BASE_URL effective: ${effectiveApiUrl}`);
|
| 103 |
-
logger.info(`[CONFIG] ✅ Environment validation passed.`);
|
| 104 |
-
}
|
|
|
|
| 1 |
+
import { z } from 'zod';
|
| 2 |
import dotenv from 'dotenv';
|
| 3 |
+
import path from 'path';
|
| 4 |
+
|
| 5 |
+
dotenv.config({ path: path.join(__dirname, '../../../../.env') });
|
| 6 |
+
|
| 7 |
+
const envSchema = z.object({
|
| 8 |
+
DATABASE_URL: z.string().url(),
|
| 9 |
+
REDIS_URL: z.string().url(),
|
| 10 |
+
ADMIN_API_KEY: z.string(),
|
| 11 |
+
API_URL: z.string().url(),
|
| 12 |
+
WHATSAPP_ACCESS_TOKEN: z.string().optional(),
|
| 13 |
+
WHATSAPP_PHONE_NUMBER_ID: z.string().optional(),
|
| 14 |
+
OPENAI_API_KEY: z.string().optional(),
|
| 15 |
+
GOOGLE_AI_API_KEY: z.string().optional(),
|
| 16 |
+
WORKER_CONCURRENCY: z.string().default('5').transform(Number),
|
| 17 |
+
NODE_ENV: z.enum(['development', 'production', 'test']).default('development')
|
| 18 |
+
});
|
| 19 |
+
|
| 20 |
+
const result = envSchema.safeParse(process.env);
|
| 21 |
+
|
| 22 |
+
if (!result.success) {
|
| 23 |
+
console.error('❌ Invalid worker environment variables:', JSON.stringify(result.error.format(), null, 2));
|
| 24 |
+
process.exit(1);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
}
|
| 26 |
|
| 27 |
+
export const config = result.data;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
|
| 29 |
+
export function getApiUrl() { return config.API_URL; }
|
| 30 |
+
export function getAdminApiKey() { return config.ADMIN_API_KEY; }
|
| 31 |
+
export function validateEnvironment() { return true; } // Kept for compatibility
|
| 32 |
+
export function isFeatureEnabled(feature: string) { return true; } // Placeholder
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
apps/whatsapp-worker/src/handlers/AdminHandler.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Job, Queue } from 'bullmq';
|
| 2 |
+
import Redis from 'ioredis';
|
| 3 |
+
import { JobHandler, JobData } from './types';
|
| 4 |
+
import { prisma } from '../services/prisma';
|
| 5 |
+
import { logger } from '../logger';
|
| 6 |
+
import { sendTextMessage } from '../whatsapp-cloud';
|
| 7 |
+
|
| 8 |
+
interface TenantConfig {
|
| 9 |
+
accessToken: string;
|
| 10 |
+
phoneNumberId: string;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
export class AdminHandler implements JobHandler {
|
| 14 |
+
private async getTenantConfig(organizationId: string, connection: Redis): Promise<TenantConfig | undefined> {
|
| 15 |
+
const cacheKey = `org:config:${organizationId}`;
|
| 16 |
+
try {
|
| 17 |
+
const cached = await connection.get(cacheKey);
|
| 18 |
+
if (cached) return JSON.parse(cached);
|
| 19 |
+
} catch (err) {}
|
| 20 |
+
|
| 21 |
+
const org = await prisma.organization.findUnique({
|
| 22 |
+
where: { id: organizationId },
|
| 23 |
+
include: { phoneNumbers: true }
|
| 24 |
+
});
|
| 25 |
+
|
| 26 |
+
if (!org || !org.systemUserToken || !org.phoneNumbers?.[0]?.id) return undefined;
|
| 27 |
+
|
| 28 |
+
const config = {
|
| 29 |
+
accessToken: org.systemUserToken,
|
| 30 |
+
phoneNumberId: org.phoneNumbers[0].id
|
| 31 |
+
};
|
| 32 |
+
await connection.set(cacheKey, JSON.stringify(config), 'EX', 3600);
|
| 33 |
+
return config;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
async handle(job: Job<JobData>, connection: Redis): Promise<void> {
|
| 37 |
+
const { userId, trackId, overrideAudioUrl, adminId, organizationId } = job.data;
|
| 38 |
+
if (!userId || !overrideAudioUrl) return;
|
| 39 |
+
|
| 40 |
+
const user = await prisma.user.findUnique({ where: { id: userId } });
|
| 41 |
+
const tenantConfig = await this.getTenantConfig(organizationId || '', connection);
|
| 42 |
+
|
| 43 |
+
if (user?.phone) {
|
| 44 |
+
const { sendAudioMessage } = await import('../whatsapp-cloud');
|
| 45 |
+
await sendAudioMessage(user.phone, overrideAudioUrl, tenantConfig);
|
| 46 |
+
|
| 47 |
+
await sendTextMessage(user.phone,
|
| 48 |
+
user.language === 'WOLOF'
|
| 49 |
+
? "Baax na ! Yónnee *SUITE* ngir dem ci kanam."
|
| 50 |
+
: "Bravo ! Envoyez *SUITE* pour passer à la leçon suivante.",
|
| 51 |
+
tenantConfig
|
| 52 |
+
);
|
| 53 |
+
|
| 54 |
+
await prisma.response.create({
|
| 55 |
+
data: {
|
| 56 |
+
userId: userId,
|
| 57 |
+
enrollmentId: (await prisma.enrollment.findFirst({ where: { userId, trackId, status: 'ACTIVE' } }))?.id || '',
|
| 58 |
+
dayNumber: (await prisma.enrollment.findFirst({ where: { userId, trackId, status: 'ACTIVE' } }))?.currentDay || 0,
|
| 59 |
+
content: `[AUDIO_OVERRIDE] ${overrideAudioUrl}`,
|
| 60 |
+
aiSource: `ADMIN_OVERRIDE:${adminId || 'unknown'}`,
|
| 61 |
+
organizationId: organizationId || user.organizationId
|
| 62 |
+
}
|
| 63 |
+
});
|
| 64 |
+
|
| 65 |
+
const enrollment = await prisma.enrollment.findFirst({
|
| 66 |
+
where: { userId, trackId, status: 'ACTIVE' }
|
| 67 |
+
});
|
| 68 |
+
|
| 69 |
+
if (enrollment) {
|
| 70 |
+
const nextDay = Math.floor(enrollment.currentDay) + 1;
|
| 71 |
+
const q = new Queue('whatsapp-queue', { connection: connection as any });
|
| 72 |
+
await q.add('send-content', { userId, trackId, dayNumber: nextDay, organizationId }, { delay: 2000 });
|
| 73 |
+
}
|
| 74 |
+
}
|
| 75 |
+
}
|
| 76 |
+
}
|
apps/whatsapp-worker/src/handlers/ContentHandler.ts
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Job, Queue } from 'bullmq';
|
| 2 |
+
import Redis from 'ioredis';
|
| 3 |
+
import { JobHandler, JobData } from './types';
|
| 4 |
+
import { prisma } from '../services/prisma';
|
| 5 |
+
import { logger } from '../logger';
|
| 6 |
+
import { sendLessonDay } from '../pedagogy';
|
| 7 |
+
import { sendTextMessage } from '../whatsapp-cloud';
|
| 8 |
+
|
| 9 |
+
interface TenantConfig {
|
| 10 |
+
accessToken: string;
|
| 11 |
+
phoneNumberId: string;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
export class ContentHandler implements JobHandler {
|
| 15 |
+
private async getTenantConfig(organizationId: string, connection: Redis): Promise<TenantConfig | undefined> {
|
| 16 |
+
const cacheKey = `org:config:${organizationId}`;
|
| 17 |
+
try {
|
| 18 |
+
const cached = await connection.get(cacheKey);
|
| 19 |
+
if (cached) return JSON.parse(cached);
|
| 20 |
+
} catch (err) {}
|
| 21 |
+
|
| 22 |
+
const org = await prisma.organization.findUnique({
|
| 23 |
+
where: { id: organizationId },
|
| 24 |
+
include: { phoneNumbers: true }
|
| 25 |
+
});
|
| 26 |
+
|
| 27 |
+
if (!org || !org.systemUserToken || !org.phoneNumbers?.[0]?.id) return undefined;
|
| 28 |
+
|
| 29 |
+
const config = {
|
| 30 |
+
accessToken: org.systemUserToken,
|
| 31 |
+
phoneNumberId: org.phoneNumbers[0].id
|
| 32 |
+
};
|
| 33 |
+
await connection.set(cacheKey, JSON.stringify(config), 'EX', 3600);
|
| 34 |
+
return config;
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
async handle(job: Job<JobData>, connection: Redis): Promise<void> {
|
| 38 |
+
const { userId, trackId, dayNumber, organizationId } = job.data;
|
| 39 |
+
if (!userId || !trackId || !dayNumber || !organizationId) {
|
| 40 |
+
logger.error(`[CONTENT_HANDLER] Missing data: userId=${userId}, trackId=${trackId}, dayNumber=${dayNumber}`);
|
| 41 |
+
return;
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
const user = await prisma.user.findUnique({ where: { id: userId } });
|
| 45 |
+
const trackDay = await prisma.trackDay.findFirst({
|
| 46 |
+
where: { trackId, dayNumber }
|
| 47 |
+
});
|
| 48 |
+
|
| 49 |
+
if (trackDay) {
|
| 50 |
+
await sendLessonDay(userId, trackId, dayNumber, organizationId, {
|
| 51 |
+
skipProgressUpdate: job.data.skipProgressUpdate === true
|
| 52 |
+
});
|
| 53 |
+
|
| 54 |
+
if (!job.data.skipProgressUpdate) {
|
| 55 |
+
await prisma.enrollment.updateMany({
|
| 56 |
+
where: { userId, trackId },
|
| 57 |
+
data: {
|
| 58 |
+
currentDay: dayNumber,
|
| 59 |
+
lastActivityAt: new Date()
|
| 60 |
+
}
|
| 61 |
+
});
|
| 62 |
+
} else {
|
| 63 |
+
await prisma.enrollment.updateMany({
|
| 64 |
+
where: { userId, trackId },
|
| 65 |
+
data: { lastActivityAt: new Date() }
|
| 66 |
+
});
|
| 67 |
+
logger.info(`[CONTENT_HANDLER] Replay Day ${dayNumber} sent read-only.`);
|
| 68 |
+
}
|
| 69 |
+
} else {
|
| 70 |
+
logger.info(`[CONTENT_HANDLER] No more content for Track ${trackId} Day ${dayNumber}. Completing enrollment.`);
|
| 71 |
+
await prisma.enrollment.updateMany({
|
| 72 |
+
where: { userId, trackId },
|
| 73 |
+
data: { status: 'COMPLETED', lastActivityAt: new Date() }
|
| 74 |
+
});
|
| 75 |
+
|
| 76 |
+
// Graduation Logic
|
| 77 |
+
if (trackId.match(/^T(\d)-(FR|WO)$/)) {
|
| 78 |
+
const trackMatch = trackId.match(/^T(\d)-(FR|WO)$/);
|
| 79 |
+
if (trackMatch) {
|
| 80 |
+
const currentLevel = parseInt(trackMatch[1]);
|
| 81 |
+
const lang = trackMatch[2];
|
| 82 |
+
const nextLevel = currentLevel + 1;
|
| 83 |
+
const nextTrackId = `T${nextLevel}-${lang}`;
|
| 84 |
+
|
| 85 |
+
const nextTrack = await prisma.track.findUnique({ where: { id: nextTrackId } });
|
| 86 |
+
if (nextTrack) {
|
| 87 |
+
const existingNext = await prisma.enrollment.findFirst({
|
| 88 |
+
where: { userId, trackId: nextTrackId }
|
| 89 |
+
});
|
| 90 |
+
|
| 91 |
+
if (!existingNext) {
|
| 92 |
+
const isWolof = lang === 'WO';
|
| 93 |
+
const congratsMsg = isWolof
|
| 94 |
+
? `🎉 Baraka Allahu fik ! Mat nga Niveau ${currentLevel}. Maangi lay tàmbaleel Niveau ${nextLevel} : *${nextTrack.title}*...`
|
| 95 |
+
: `🎉 Félicitations ! Vous avez validé le Niveau ${currentLevel}. Je vous inscris immédiatement au Niveau ${nextLevel} : *${nextTrack.title}*...`;
|
| 96 |
+
|
| 97 |
+
if (user?.phone) {
|
| 98 |
+
const tenantConfig = await this.getTenantConfig(user.organizationId, connection);
|
| 99 |
+
await sendTextMessage(user.phone, congratsMsg, tenantConfig);
|
| 100 |
+
|
| 101 |
+
if (!nextTrack.isPremium) {
|
| 102 |
+
await prisma.enrollment.create({
|
| 103 |
+
data: {
|
| 104 |
+
userId: userId,
|
| 105 |
+
trackId: nextTrackId,
|
| 106 |
+
status: 'ACTIVE',
|
| 107 |
+
currentDay: 1,
|
| 108 |
+
organizationId: user.organizationId
|
| 109 |
+
}
|
| 110 |
+
});
|
| 111 |
+
const q = new Queue('whatsapp-queue', { connection: connection as any });
|
| 112 |
+
await q.add('send-content', {
|
| 113 |
+
userId,
|
| 114 |
+
trackId: nextTrackId,
|
| 115 |
+
dayNumber: 1,
|
| 116 |
+
organizationId: user.organizationId
|
| 117 |
+
}, { delay: 10000 });
|
| 118 |
+
} else {
|
| 119 |
+
const payMsg = isWolof
|
| 120 |
+
? `💳 Niveau ${nextLevel} bi dafa laaj pass. Yónnee ma "PAYER" ngir tàmbaleeti.`
|
| 121 |
+
: `💳 Le Niveau ${nextLevel} est un module Premium. Envoyez "PAYER" pour le débloquer et continuer votre ascension !`;
|
| 122 |
+
const tenantConfig = await this.getTenantConfig(user.organizationId, connection);
|
| 123 |
+
await sendTextMessage(user.phone, payMsg, tenantConfig);
|
| 124 |
+
}
|
| 125 |
+
}
|
| 126 |
+
}
|
| 127 |
+
}
|
| 128 |
+
}
|
| 129 |
+
}
|
| 130 |
+
}
|
| 131 |
+
}
|
| 132 |
+
}
|
apps/whatsapp-worker/src/handlers/EnrollHandler.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Job, Queue } from 'bullmq';
|
| 2 |
+
import Redis from 'ioredis';
|
| 3 |
+
import { JobHandler, JobData } from './types';
|
| 4 |
+
import { prisma } from '../services/prisma';
|
| 5 |
+
import { logger } from '../logger';
|
| 6 |
+
import { sendTextMessage } from '../whatsapp-cloud';
|
| 7 |
+
import { getApiUrl, getAdminApiKey } from '../config';
|
| 8 |
+
import fetch from 'node-fetch';
|
| 9 |
+
|
| 10 |
+
interface TenantConfig {
|
| 11 |
+
accessToken: string;
|
| 12 |
+
phoneNumberId: string;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
export class EnrollHandler implements JobHandler {
|
| 16 |
+
private async getTenantConfig(organizationId: string, connection: Redis): Promise<TenantConfig | undefined> {
|
| 17 |
+
const cacheKey = `org:config:${organizationId}`;
|
| 18 |
+
try {
|
| 19 |
+
const cached = await connection.get(cacheKey);
|
| 20 |
+
if (cached) return JSON.parse(cached);
|
| 21 |
+
} catch (err) {}
|
| 22 |
+
|
| 23 |
+
const org = await prisma.organization.findUnique({
|
| 24 |
+
where: { id: organizationId },
|
| 25 |
+
include: { phoneNumbers: true }
|
| 26 |
+
});
|
| 27 |
+
|
| 28 |
+
if (!org || !org.systemUserToken || !org.phoneNumbers?.[0]?.id) return undefined;
|
| 29 |
+
|
| 30 |
+
const config = {
|
| 31 |
+
accessToken: org.systemUserToken,
|
| 32 |
+
phoneNumberId: org.phoneNumbers[0].id
|
| 33 |
+
};
|
| 34 |
+
await connection.set(cacheKey, JSON.stringify(config), 'EX', 3600);
|
| 35 |
+
return config;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
async handle(job: Job<JobData>, connection: Redis): Promise<void> {
|
| 39 |
+
const { userId, trackId, organizationId } = job.data;
|
| 40 |
+
const track = await prisma.track.findUnique({ where: { id: trackId } });
|
| 41 |
+
if (!track) return;
|
| 42 |
+
|
| 43 |
+
if (track.isPremium) {
|
| 44 |
+
try {
|
| 45 |
+
const AI_API_BASE_URL = getApiUrl();
|
| 46 |
+
const apiKey = getAdminApiKey();
|
| 47 |
+
const checkoutRes = await fetch(`${AI_API_BASE_URL.replace(/\/$/, "")}/v1/payments/checkout`, {
|
| 48 |
+
method: 'POST',
|
| 49 |
+
headers: {
|
| 50 |
+
'Content-Type': 'application/json',
|
| 51 |
+
'Authorization': `Bearer ${apiKey}`,
|
| 52 |
+
'x-organization-id': organizationId as string
|
| 53 |
+
},
|
| 54 |
+
body: JSON.stringify({ userId, trackId })
|
| 55 |
+
});
|
| 56 |
+
|
| 57 |
+
const checkoutData = await checkoutRes.json() as any;
|
| 58 |
+
if (checkoutRes.ok && checkoutData.url) {
|
| 59 |
+
const user = await prisma.user.findUnique({ where: { id: userId } });
|
| 60 |
+
if (user?.phone) {
|
| 61 |
+
const tenantConfig = await this.getTenantConfig(organizationId as string, connection);
|
| 62 |
+
await sendTextMessage(user.phone, `💳 Cette formation est Premium. Complétez votre paiement ici :\n${checkoutData.url}`, tenantConfig);
|
| 63 |
+
}
|
| 64 |
+
}
|
| 65 |
+
} catch (err) {}
|
| 66 |
+
} else {
|
| 67 |
+
const existing = await prisma.enrollment.findFirst({ where: { userId, trackId } });
|
| 68 |
+
if (!existing) {
|
| 69 |
+
await prisma.enrollment.create({
|
| 70 |
+
data: {
|
| 71 |
+
userId: userId || '',
|
| 72 |
+
trackId: trackId || '',
|
| 73 |
+
status: 'ACTIVE',
|
| 74 |
+
currentDay: 1,
|
| 75 |
+
organizationId: organizationId || 'default-org-id'
|
| 76 |
+
} as any
|
| 77 |
+
});
|
| 78 |
+
const user = await prisma.user.findUnique({ where: { id: userId } });
|
| 79 |
+
if (user?.phone) {
|
| 80 |
+
const tenantConfig = await this.getTenantConfig(organizationId as string, connection);
|
| 81 |
+
await sendTextMessage(user.phone, `🎉 Bienvenue dans *${track.title}* ! La génération de votre cours personnalisé (Jour 1) a commencé...`, tenantConfig);
|
| 82 |
+
|
| 83 |
+
const q = new Queue('whatsapp-queue', { connection: connection as any });
|
| 84 |
+
await q.add('send-content', { userId, trackId, dayNumber: 1, organizationId });
|
| 85 |
+
}
|
| 86 |
+
}
|
| 87 |
+
}
|
| 88 |
+
}
|
| 89 |
+
}
|
apps/whatsapp-worker/src/handlers/InboundHandler.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Job } from 'bullmq';
|
| 2 |
+
import { JobHandler, JobData } from './types';
|
| 3 |
+
import { logger } from '../logger';
|
| 4 |
+
import { WhatsAppLogic } from '../services/whatsapp-logic';
|
| 5 |
+
|
| 6 |
+
export class InboundHandler implements JobHandler {
|
| 7 |
+
async handle(job: Job<JobData>): Promise<void> {
|
| 8 |
+
const { phone, text, audioUrl, imageUrl, organizationId } = job.data;
|
| 9 |
+
|
| 10 |
+
if (!phone || text === undefined) {
|
| 11 |
+
logger.error(`[INBOUND_HANDLER] Missing data: phone=${phone}, text=${text}`);
|
| 12 |
+
return;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
logger.info(`[INBOUND_HANDLER] Processing inbound for ${phone} (Org: ${organizationId})`);
|
| 16 |
+
|
| 17 |
+
await WhatsAppLogic.handleIncomingMessage(
|
| 18 |
+
phone,
|
| 19 |
+
text,
|
| 20 |
+
audioUrl,
|
| 21 |
+
imageUrl,
|
| 22 |
+
organizationId
|
| 23 |
+
);
|
| 24 |
+
}
|
| 25 |
+
}
|
apps/whatsapp-worker/src/handlers/MediaHandler.ts
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Job } from 'bullmq';
|
| 2 |
+
import Redis from 'ioredis';
|
| 3 |
+
import { JobHandler, JobData } from './types';
|
| 4 |
+
import { prisma } from '../services/prisma';
|
| 5 |
+
import { logger } from '../logger';
|
| 6 |
+
import { downloadMedia, sendTextMessage } from '../whatsapp-cloud';
|
| 7 |
+
import { getApiUrl, getAdminApiKey } from '../config';
|
| 8 |
+
import { normalizeWolof } from '../normalizeWolof';
|
| 9 |
+
import { WhatsAppLogic } from '../services/whatsapp-logic';
|
| 10 |
+
import fetch from 'node-fetch';
|
| 11 |
+
|
| 12 |
+
interface TenantConfig {
|
| 13 |
+
accessToken: string;
|
| 14 |
+
phoneNumberId: string;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
export class MediaHandler implements JobHandler {
|
| 18 |
+
private async getTenantConfig(organizationId: string, connection: Redis): Promise<TenantConfig | undefined> {
|
| 19 |
+
const cacheKey = `org:config:${organizationId}`;
|
| 20 |
+
try {
|
| 21 |
+
const cached = await connection.get(cacheKey);
|
| 22 |
+
if (cached) return JSON.parse(cached);
|
| 23 |
+
} catch (err) {}
|
| 24 |
+
|
| 25 |
+
const org = await prisma.organization.findUnique({
|
| 26 |
+
where: { id: organizationId },
|
| 27 |
+
include: { phoneNumbers: true }
|
| 28 |
+
});
|
| 29 |
+
|
| 30 |
+
if (!org || !org.systemUserToken || !org.phoneNumbers?.[0]?.id) return undefined;
|
| 31 |
+
|
| 32 |
+
const config = {
|
| 33 |
+
accessToken: org.systemUserToken,
|
| 34 |
+
phoneNumberId: org.phoneNumbers[0].id
|
| 35 |
+
};
|
| 36 |
+
await connection.set(cacheKey, JSON.stringify(config), 'EX', 3600);
|
| 37 |
+
return config;
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
async handle(job: Job<JobData>, connection: Redis): Promise<void> {
|
| 41 |
+
const { mediaId, mimeType, phone, organizationId } = job.data;
|
| 42 |
+
if (!mediaId || !phone) {
|
| 43 |
+
logger.error(`[MEDIA_HANDLER] Missing data: mediaId=${mediaId}, phone=${phone}`);
|
| 44 |
+
return;
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
const tenantConfig = await this.getTenantConfig(organizationId || '', connection);
|
| 48 |
+
const traceId = `[STT-FLOW-${phone.slice(-4)}]`;
|
| 49 |
+
const accessToken = process.env.WHATSAPP_ACCESS_TOKEN || tenantConfig?.accessToken;
|
| 50 |
+
|
| 51 |
+
if (!accessToken) {
|
| 52 |
+
logger.error(`[MEDIA_HANDLER] Missing WhatsApp Access Token for media ${mediaId}`);
|
| 53 |
+
return;
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
try {
|
| 57 |
+
const { buffer } = await downloadMedia(mediaId, accessToken);
|
| 58 |
+
const AI_API_BASE_URL = getApiUrl();
|
| 59 |
+
const apiKey = getAdminApiKey();
|
| 60 |
+
|
| 61 |
+
let audioUrl = '';
|
| 62 |
+
// Store on R2
|
| 63 |
+
try {
|
| 64 |
+
const storeRes = await fetch(`${AI_API_BASE_URL.replace(/\/$/, "")}/v1/ai/store-audio`, {
|
| 65 |
+
method: 'POST',
|
| 66 |
+
headers: {
|
| 67 |
+
'Content-Type': 'application/json',
|
| 68 |
+
'Authorization': `Bearer ${apiKey}`,
|
| 69 |
+
'x-organization-id': organizationId as string
|
| 70 |
+
},
|
| 71 |
+
body: JSON.stringify({ audioBase64: buffer.toString('base64'), mimeType, phone })
|
| 72 |
+
});
|
| 73 |
+
if (storeRes.ok) {
|
| 74 |
+
const storeData = await storeRes.json() as any;
|
| 75 |
+
audioUrl = storeData.url;
|
| 76 |
+
}
|
| 77 |
+
} catch (err) {}
|
| 78 |
+
|
| 79 |
+
const user = await prisma.user.findFirst({ where: { phone } });
|
| 80 |
+
if (user) {
|
| 81 |
+
await prisma.message.create({
|
| 82 |
+
data: {
|
| 83 |
+
userId: user.id,
|
| 84 |
+
direction: 'INBOUND',
|
| 85 |
+
channel: 'WHATSAPP',
|
| 86 |
+
mediaUrl: audioUrl || null,
|
| 87 |
+
payload: job.data as any,
|
| 88 |
+
organizationId: organizationId || user.organizationId
|
| 89 |
+
}
|
| 90 |
+
});
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
if (mimeType?.startsWith('audio/')) {
|
| 94 |
+
const transcribeRes = await fetch(`${AI_API_BASE_URL.replace(/\/$/, "")}/v1/ai/transcribe`, {
|
| 95 |
+
method: 'POST',
|
| 96 |
+
headers: {
|
| 97 |
+
'Content-Type': 'application/json',
|
| 98 |
+
'Authorization': `Bearer ${apiKey}`,
|
| 99 |
+
'x-organization-id': organizationId as string
|
| 100 |
+
},
|
| 101 |
+
body: JSON.stringify({
|
| 102 |
+
audioBase64: buffer.toString('base64'),
|
| 103 |
+
filename: `msg.${mimeType?.includes('mp4') ? 'mp4' : 'ogg'}`,
|
| 104 |
+
language: user?.language
|
| 105 |
+
})
|
| 106 |
+
});
|
| 107 |
+
|
| 108 |
+
if (transcribeRes.ok) {
|
| 109 |
+
const data = await transcribeRes.json() as any;
|
| 110 |
+
let transcribedText = data.text || '';
|
| 111 |
+
const confidence = data.confidence || 0;
|
| 112 |
+
|
| 113 |
+
if (user?.language === 'WOLOF') {
|
| 114 |
+
const normResult = normalizeWolof(transcribedText);
|
| 115 |
+
transcribedText = normResult.normalizedText;
|
| 116 |
+
|
| 117 |
+
if (confidence <= 40 || transcribedText.split(/\s+/).length < 4) {
|
| 118 |
+
// Pending review logic
|
| 119 |
+
const activeEnrollment = await prisma.enrollment.findFirst({
|
| 120 |
+
where: { userId: user.id, status: 'ACTIVE' }
|
| 121 |
+
});
|
| 122 |
+
if (activeEnrollment) {
|
| 123 |
+
await prisma.userProgress.upsert({
|
| 124 |
+
where: { userId_trackId: { userId: user.id, trackId: activeEnrollment.trackId } },
|
| 125 |
+
update: { exerciseStatus: 'PENDING_REVIEW' as any, adminTranscription: transcribedText, confidenceScore: confidence },
|
| 126 |
+
create: { userId: user.id, trackId: activeEnrollment.trackId, exerciseStatus: 'PENDING_REVIEW' as any, adminTranscription: transcribedText, confidenceScore: confidence }
|
| 127 |
+
});
|
| 128 |
+
await sendTextMessage(phone, "🎙️ Nyangi jaxas sa kàddu. Xamle dina la tontu ci kanam !", tenantConfig);
|
| 129 |
+
return;
|
| 130 |
+
}
|
| 131 |
+
}
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
if (transcribedText) {
|
| 135 |
+
await WhatsAppLogic.handleIncomingMessage(phone, transcribedText, audioUrl, undefined, organizationId);
|
| 136 |
+
}
|
| 137 |
+
}
|
| 138 |
+
} else if (mimeType?.startsWith('image/')) {
|
| 139 |
+
await WhatsAppLogic.handleIncomingMessage(phone, job.data.caption || 'Image reçue', undefined, audioUrl, organizationId);
|
| 140 |
+
}
|
| 141 |
+
} catch (err) {
|
| 142 |
+
logger.error(`[MEDIA_HANDLER] download-media failed:`, err);
|
| 143 |
+
}
|
| 144 |
+
}
|
| 145 |
+
}
|
apps/whatsapp-worker/src/handlers/MessageHandler.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Job } from 'bullmq';
|
| 2 |
+
import Redis from 'ioredis';
|
| 3 |
+
import { JobHandler, JobData } from './types';
|
| 4 |
+
import { prisma } from '../services/prisma';
|
| 5 |
+
import { logger } from '../logger';
|
| 6 |
+
import { sendTextMessage, sendImageMessage, sendInteractiveButtonMessage, sendInteractiveListMessage } from '../whatsapp-cloud';
|
| 7 |
+
|
| 8 |
+
interface TenantConfig {
|
| 9 |
+
accessToken: string;
|
| 10 |
+
phoneNumberId: string;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
export class MessageHandler implements JobHandler {
|
| 14 |
+
private async getTenantConfig(organizationId: string, connection: Redis): Promise<TenantConfig | undefined> {
|
| 15 |
+
const cacheKey = `org:config:${organizationId}`;
|
| 16 |
+
try {
|
| 17 |
+
const cached = await connection.get(cacheKey);
|
| 18 |
+
if (cached) return JSON.parse(cached);
|
| 19 |
+
} catch (err) {}
|
| 20 |
+
|
| 21 |
+
const org = await prisma.organization.findUnique({
|
| 22 |
+
where: { id: organizationId },
|
| 23 |
+
include: { phoneNumbers: true }
|
| 24 |
+
});
|
| 25 |
+
|
| 26 |
+
if (!org || !org.systemUserToken || !org.phoneNumbers?.[0]?.id) return undefined;
|
| 27 |
+
|
| 28 |
+
const config = {
|
| 29 |
+
accessToken: org.systemUserToken,
|
| 30 |
+
phoneNumberId: org.phoneNumbers[0].id
|
| 31 |
+
};
|
| 32 |
+
await connection.set(cacheKey, JSON.stringify(config), 'EX', 3600);
|
| 33 |
+
return config;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
async handle(job: Job<JobData>, connection: Redis): Promise<void> {
|
| 37 |
+
const { organizationId } = job.data;
|
| 38 |
+
const tenantConfig = await this.getTenantConfig(organizationId || 'default-org-id', connection);
|
| 39 |
+
|
| 40 |
+
switch (job.name) {
|
| 41 |
+
case 'send-message': {
|
| 42 |
+
const { userId, text } = job.data;
|
| 43 |
+
if (!userId || !text) return;
|
| 44 |
+
const user = await prisma.user.findUnique({ where: { id: userId } });
|
| 45 |
+
if (user?.phone) {
|
| 46 |
+
await sendTextMessage(user.phone, text, tenantConfig);
|
| 47 |
+
}
|
| 48 |
+
break;
|
| 49 |
+
}
|
| 50 |
+
case 'send-message-direct': {
|
| 51 |
+
const { phone, text } = job.data;
|
| 52 |
+
if (!phone || !text) return;
|
| 53 |
+
await sendTextMessage(phone, text, tenantConfig);
|
| 54 |
+
break;
|
| 55 |
+
}
|
| 56 |
+
case 'send-image': {
|
| 57 |
+
const { to, imageUrl, caption } = job.data;
|
| 58 |
+
if (!to || !imageUrl) return;
|
| 59 |
+
await sendImageMessage(to, imageUrl, caption || '', tenantConfig);
|
| 60 |
+
break;
|
| 61 |
+
}
|
| 62 |
+
case 'send-interactive-buttons': {
|
| 63 |
+
const { userId, bodyText, buttons } = job.data;
|
| 64 |
+
const user = await prisma.user.findUnique({ where: { id: userId } });
|
| 65 |
+
if (user?.phone) {
|
| 66 |
+
await sendInteractiveButtonMessage(user.phone, bodyText || '', buttons || [], undefined, tenantConfig);
|
| 67 |
+
}
|
| 68 |
+
break;
|
| 69 |
+
}
|
| 70 |
+
case 'send-interactive-list': {
|
| 71 |
+
const { userId, headerText, bodyText, buttonLabel, sections } = job.data;
|
| 72 |
+
const user = await prisma.user.findUnique({ where: { id: userId } });
|
| 73 |
+
if (user?.phone) {
|
| 74 |
+
await sendInteractiveListMessage(user.phone, headerText || '', bodyText || '', buttonLabel || '', sections || [], undefined, tenantConfig);
|
| 75 |
+
}
|
| 76 |
+
break;
|
| 77 |
+
}
|
| 78 |
+
}
|
| 79 |
+
}
|
| 80 |
+
}
|
apps/whatsapp-worker/src/handlers/NudgeHandler.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Job } from 'bullmq';
|
| 2 |
+
import Redis from 'ioredis';
|
| 3 |
+
import { JobHandler, JobData } from './types';
|
| 4 |
+
import { prisma } from '../services/prisma';
|
| 5 |
+
import { logger } from '../logger';
|
| 6 |
+
import { sendTextMessage } from '../whatsapp-cloud';
|
| 7 |
+
|
| 8 |
+
interface TenantConfig {
|
| 9 |
+
accessToken: string;
|
| 10 |
+
phoneNumberId: string;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
export class NudgeHandler implements JobHandler {
|
| 14 |
+
private async getTenantConfig(organizationId: string, connection: Redis): Promise<TenantConfig | undefined> {
|
| 15 |
+
const cacheKey = `org:config:${organizationId}`;
|
| 16 |
+
try {
|
| 17 |
+
const cached = await connection.get(cacheKey);
|
| 18 |
+
if (cached) return JSON.parse(cached);
|
| 19 |
+
} catch (err) {}
|
| 20 |
+
|
| 21 |
+
const org = await prisma.organization.findUnique({
|
| 22 |
+
where: { id: organizationId },
|
| 23 |
+
include: { phoneNumbers: true }
|
| 24 |
+
});
|
| 25 |
+
|
| 26 |
+
if (!org || !org.systemUserToken || !org.phoneNumbers?.[0]?.id) return undefined;
|
| 27 |
+
|
| 28 |
+
const config = {
|
| 29 |
+
accessToken: org.systemUserToken,
|
| 30 |
+
phoneNumberId: org.phoneNumbers[0].id
|
| 31 |
+
};
|
| 32 |
+
await connection.set(cacheKey, JSON.stringify(config), 'EX', 3600);
|
| 33 |
+
return config;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
async handle(job: Job<JobData>, connection: Redis): Promise<void> {
|
| 37 |
+
const { userId, type, organizationId } = job.data;
|
| 38 |
+
const user = await prisma.user.findUnique({ where: { id: userId } });
|
| 39 |
+
const tenantConfig = await this.getTenantConfig(organizationId || '', connection);
|
| 40 |
+
if (!user?.phone) return;
|
| 41 |
+
|
| 42 |
+
const isWolof = user.language === 'WOLOF';
|
| 43 |
+
const messages = {
|
| 44 |
+
ENCOURAGEMENT: isWolof
|
| 45 |
+
? "Assalamuyalaykum ! Fatte wuñu sa mbir. Tontu bu gatt ngir wéy ? 💪"
|
| 46 |
+
: "Coucou ! On n'a pas oublié ton projet. Une petite réponse pour continuer ? 💪",
|
| 47 |
+
RESURRECTION: isWolof
|
| 48 |
+
? "Sa liggeey mu ngi lay xaar ! Am succès dafa laaj lëkkalë. Ñu tàmbaleeti ? 🚀"
|
| 49 |
+
: "Ton business t'attend ! Le succès vient de la régularité. On s'y remet ? 🚀"
|
| 50 |
+
};
|
| 51 |
+
|
| 52 |
+
const nudgeType = (type || 'ENCOURAGEMENT') as keyof typeof messages;
|
| 53 |
+
const text = messages[nudgeType] || messages.ENCOURAGEMENT;
|
| 54 |
+
await sendTextMessage(user.phone, text, tenantConfig);
|
| 55 |
+
logger.info(`[NUDGE_HANDLER] Nudge ${nudgeType} sent to ${user.phone}`);
|
| 56 |
+
}
|
| 57 |
+
}
|
apps/whatsapp-worker/src/handlers/types.ts
CHANGED
|
@@ -21,6 +21,12 @@ export interface MessageHandler {
|
|
| 21 |
handle(ctx: MessageContext): Promise<boolean>; // Returns true if it completely handled the message
|
| 22 |
}
|
| 23 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
export interface MultilangContent {
|
| 25 |
lessonText?: string;
|
| 26 |
exercisePrompt?: string;
|
|
|
|
| 21 |
handle(ctx: MessageContext): Promise<boolean>; // Returns true if it completely handled the message
|
| 22 |
}
|
| 23 |
|
| 24 |
+
import { Job } from 'bullmq';
|
| 25 |
+
|
| 26 |
+
export interface JobHandler {
|
| 27 |
+
handle(job: Job<JobData>, connection: Redis): Promise<void>;
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
export interface MultilangContent {
|
| 31 |
lessonText?: string;
|
| 32 |
exercisePrompt?: string;
|
apps/whatsapp-worker/src/index.ts
CHANGED
|
@@ -2,22 +2,25 @@ import { logger } from './logger';
|
|
| 2 |
import dns from 'node:dns';
|
| 3 |
dns.setDefaultResultOrder('ipv4first');
|
| 4 |
|
| 5 |
-
import { Worker, Job
|
| 6 |
import dotenv from 'dotenv';
|
| 7 |
import Redis from 'ioredis';
|
| 8 |
-
import {
|
| 9 |
-
import { sendLessonDay } from './pedagogy';
|
| 10 |
-
import { updateBehavioralScore } from './scoring';
|
| 11 |
-
import { normalizeWolof } from './normalizeWolof';
|
| 12 |
-
import { getApiUrl, getAdminApiKey, validateEnvironment, isFeatureEnabled } from './config';
|
| 13 |
-
import { WhatsAppLogic } from './services/whatsapp-logic';
|
| 14 |
import { startWorkerCleanupCron } from './services/cleanup';
|
| 15 |
-
import {
|
| 16 |
-
import { JobData, FeedbackData, ExerciseCriteria } from './handlers/types';
|
| 17 |
import { reportError } from './services/errors';
|
|
|
|
| 18 |
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
|
|
|
| 21 |
validateEnvironment();
|
| 22 |
startWorkerCleanupCron();
|
| 23 |
|
|
@@ -32,1132 +35,51 @@ const connection = process.env.REDIS_URL
|
|
| 32 |
maxRetriesPerRequest: null
|
| 33 |
});
|
| 34 |
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
}
|
| 49 |
-
} catch (err) {
|
| 50 |
-
logger.warn(`[CACHE] Failed to get tenant config from Redis: ${err}`);
|
| 51 |
-
}
|
| 52 |
-
|
| 53 |
-
const org = await prisma.organization.findUnique({
|
| 54 |
-
where: { id: organizationId },
|
| 55 |
-
include: { phoneNumbers: true }
|
| 56 |
-
});
|
| 57 |
-
|
| 58 |
-
if (!org || !org.systemUserToken || !org.phoneNumbers?.[0]?.id) return undefined;
|
| 59 |
-
|
| 60 |
-
const config: TenantConfig = {
|
| 61 |
-
accessToken: org.systemUserToken,
|
| 62 |
-
phoneNumberId: org.phoneNumbers[0].id
|
| 63 |
-
};
|
| 64 |
-
|
| 65 |
-
try {
|
| 66 |
-
await connection.set(cacheKey, JSON.stringify(config), 'EX', 3600); // 1 hour TTL
|
| 67 |
-
} catch (err) {
|
| 68 |
-
logger.warn(`[CACHE] Failed to set tenant config in Redis: ${err}`);
|
| 69 |
-
}
|
| 70 |
-
|
| 71 |
-
return config;
|
| 72 |
-
}
|
| 73 |
|
| 74 |
const worker = new Worker('whatsapp-queue', async (job: Job<JobData>) => {
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
try {
|
| 78 |
-
if (job.name === 'send-message') {
|
| 79 |
-
const { userId, text, organizationId } = job.data;
|
| 80 |
-
if (!userId || !text) {
|
| 81 |
-
logger.error(`[WORKER] Missing data for send-message: userId=${userId}, text=${text?.substring(0,20)}`);
|
| 82 |
-
return;
|
| 83 |
-
}
|
| 84 |
-
const user = await prisma.user.findUnique({ where: { id: userId } });
|
| 85 |
-
const tenantConfig = await getTenantConfig(organizationId);
|
| 86 |
-
if (user?.phone) {
|
| 87 |
-
await sendTextMessage(user.phone, text, tenantConfig);
|
| 88 |
-
} else {
|
| 89 |
-
logger.warn(`[WORKER] User ${userId} not found or missing phone — skipping send.`);
|
| 90 |
-
}
|
| 91 |
-
}
|
| 92 |
-
else if (job.name === 'send-message-direct') {
|
| 93 |
-
const { phone, text, organizationId } = job.data;
|
| 94 |
-
if (!phone || !text) {
|
| 95 |
-
logger.error(`[WORKER] Missing data for send-message-direct: phone=${phone}, text=${text?.substring(0,20)}`);
|
| 96 |
-
return;
|
| 97 |
-
}
|
| 98 |
-
const tenantConfig = await getTenantConfig(organizationId);
|
| 99 |
-
await sendTextMessage(phone, text, tenantConfig);
|
| 100 |
-
}
|
| 101 |
-
else if (job.name === 'handle-inbound') {
|
| 102 |
-
const { phone, text, audioUrl, imageUrl, messageId, organizationId } = job.data;
|
| 103 |
-
if (!phone) {
|
| 104 |
-
logger.error(`[WORKER] Missing phone for handle-inbound`);
|
| 105 |
-
return;
|
| 106 |
-
}
|
| 107 |
-
|
| 108 |
-
// 🚨 Idempotence Lock for Inbound Messages
|
| 109 |
-
if (messageId) {
|
| 110 |
-
const lockKey = `lock:inbound:${messageId}`;
|
| 111 |
-
const isLocked = await connection.set(lockKey, "1", "EX", 300, "NX");
|
| 112 |
-
if (!isLocked) {
|
| 113 |
-
logger.info(`[WORKER] 🔒 Lock inbound activé : message ${messageId} déjà traité.`);
|
| 114 |
-
return;
|
| 115 |
-
}
|
| 116 |
-
}
|
| 117 |
-
|
| 118 |
-
await WhatsAppLogic.handleIncomingMessage(phone, text || '', audioUrl, imageUrl, organizationId);
|
| 119 |
-
}
|
| 120 |
-
else if (job.name === 'generate-feedback') {
|
| 121 |
-
const { userId, text, trackId, exercisePrompt, lessonText, exerciseCriteria, totalDays, language, userActivity, userRegion, previousResponses, isDeepDive, iterationCount, imageUrl, isButtonChoice, isTimeTravelMode, realCurrentDay, organizationId } = job.data;
|
| 122 |
-
const currentDay = Number(job.data.currentDay || job.data.dayNumber || 0);
|
| 123 |
-
const enrollmentId = job.data.enrollmentId;
|
| 124 |
-
|
| 125 |
-
if (!userId || !trackId) return;
|
| 126 |
-
|
| 127 |
-
const user = await prisma.user.findUnique({
|
| 128 |
-
where: { id: userId },
|
| 129 |
-
include: {
|
| 130 |
-
businessProfile: true,
|
| 131 |
-
organization: {
|
| 132 |
-
include: { phoneNumbers: true }
|
| 133 |
-
}
|
| 134 |
-
}
|
| 135 |
-
});
|
| 136 |
-
if (!user?.phone) {
|
| 137 |
-
logger.error(`[WORKER] User ${userId} not found or missing phone for feedback generation`);
|
| 138 |
-
return;
|
| 139 |
-
}
|
| 140 |
-
|
| 141 |
-
// ─── 🚀 Idempotence Lock (Progression Bug Fix) ─────────
|
| 142 |
-
const Redis = (await import('ioredis')).default;
|
| 143 |
-
const redis = process.env.REDIS_URL
|
| 144 |
-
? new Redis(process.env.REDIS_URL, { maxRetriesPerRequest: null })
|
| 145 |
-
: new Redis({ host: process.env.REDIS_HOST || 'localhost', port: parseInt(process.env.REDIS_PORT || '6379'), maxRetriesPerRequest: null });
|
| 146 |
-
|
| 147 |
-
const textHash = text ? text.substring(0, 10).replace(/[^a-z0-9]/gi, '') : '';
|
| 148 |
-
const lockKey = `lock:feedback:${userId}:${currentDay}:${textHash}`;
|
| 149 |
-
|
| 150 |
-
const isLocked = await redis.set(lockKey, "1", "EX", 300, "NX");
|
| 151 |
-
if (!isLocked) {
|
| 152 |
-
logger.info(`[WORKER] 🔒 Lock activé : ignorer ce job de feedback en double (User ${userId}, Day ${currentDay})`);
|
| 153 |
-
return;
|
| 154 |
-
}
|
| 155 |
-
|
| 156 |
-
let feedbackMsg = '';
|
| 157 |
-
let feedbackData: any = null;
|
| 158 |
-
let trackDay: any = null;
|
| 159 |
-
let AI_API_BASE_URL = '';
|
| 160 |
-
let apiKey = '';
|
| 161 |
-
|
| 162 |
-
const tenantConfig: TenantConfig = {
|
| 163 |
-
accessToken: user.organization.systemUserToken || '',
|
| 164 |
-
phoneNumberId: user.organization.phoneNumbers?.[0]?.id || ''
|
| 165 |
-
};
|
| 166 |
-
|
| 167 |
-
if (!tenantConfig.accessToken || !tenantConfig.phoneNumberId) {
|
| 168 |
-
logger.error(`[WORKER] Missing WhatsApp credentials for Organization ${user.organizationId}`);
|
| 169 |
-
return;
|
| 170 |
-
}
|
| 171 |
-
|
| 172 |
-
try {
|
| 173 |
-
trackDay = await prisma.trackDay.findFirst({
|
| 174 |
-
where: { trackId, dayNumber: currentDay }
|
| 175 |
-
});
|
| 176 |
-
|
| 177 |
-
logger.info(`[WORKER] Generating expert feedback for User ${userId}`);
|
| 178 |
-
|
| 179 |
-
AI_API_BASE_URL = getApiUrl();
|
| 180 |
-
apiKey = getAdminApiKey();
|
| 181 |
-
|
| 182 |
-
logger.info(`[PIPELINE] Handing over text to Coach Engine... (User: ${userId}, Day: ${currentDay})`);
|
| 183 |
-
|
| 184 |
-
const feedbackRes = await fetch(`${AI_API_BASE_URL.replace(/\/$/, "")}/v1/ai/generate-feedback`, {
|
| 185 |
-
method: 'POST',
|
| 186 |
-
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` },
|
| 187 |
-
body: JSON.stringify({
|
| 188 |
-
answers: text,
|
| 189 |
-
lessonText,
|
| 190 |
-
exercisePrompt,
|
| 191 |
-
userLanguage: language,
|
| 192 |
-
businessProfile: user.businessProfile,
|
| 193 |
-
exerciseCriteria,
|
| 194 |
-
userActivity,
|
| 195 |
-
userRegion,
|
| 196 |
-
dayNumber: currentDay,
|
| 197 |
-
previousResponses,
|
| 198 |
-
isDeepDive: isDeepDive || false,
|
| 199 |
-
iterationCount: iterationCount || 0,
|
| 200 |
-
imageUrl: imageUrl,
|
| 201 |
-
isButtonChoice: isButtonChoice || false,
|
| 202 |
-
tenantPrompt: (user.organization as any)?.customPrompt,
|
| 203 |
-
tenantBranding: (user.organization as any)?.brandingData
|
| 204 |
-
})
|
| 205 |
-
});
|
| 206 |
-
|
| 207 |
-
if (feedbackRes.ok) {
|
| 208 |
-
feedbackData = await feedbackRes.json() as FeedbackData;
|
| 209 |
-
|
| 210 |
-
if (feedbackData.text) {
|
| 211 |
-
feedbackMsg = feedbackData.text;
|
| 212 |
-
} else if (feedbackData.validation && feedbackData.enrichedVersion && feedbackData.actionableAdvice) {
|
| 213 |
-
feedbackMsg = `🌟 ${feedbackData.validation}\n\n🚀 ${feedbackData.enrichedVersion}\n\n💡 Conseil de Terrain :\n${feedbackData.actionableAdvice}`;
|
| 214 |
-
const callToAction = language === 'WOLOF'
|
| 215 |
-
? "\n\nSoo 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*."
|
| 216 |
-
: "\n\nSi tu veux affiner ce point avec une donnée de ton propre terrain, tape 1️⃣ *APPROFONDIR*, sinon tape 2️⃣ *SUITE*.";
|
| 217 |
-
if (!isDeepDive || (isDeepDive && (iterationCount || 0) < 3 && !feedbackData.isForcedClosure)) {
|
| 218 |
-
feedbackMsg += callToAction;
|
| 219 |
-
}
|
| 220 |
-
} else {
|
| 221 |
-
feedbackMsg = '✅ Analyse terminée.';
|
| 222 |
-
}
|
| 223 |
-
|
| 224 |
-
} else if (feedbackRes.status === 429) {
|
| 225 |
-
logger.warn(`[WORKER] 429 Error during generate-feedback`);
|
| 226 |
-
const fallbackMsg = language === 'WOLOF'
|
| 227 |
-
? "Jërëjëf ci sa tontu ! (Analyse IA temporairement indisponible)"
|
| 228 |
-
: "Merci pour ta réponse ! (Analyse IA de la réponse temporairement indisponible suite à une surcharge, mais ta progression est sauvegardée).";
|
| 229 |
-
await sendTextMessage(user.phone, fallbackMsg, tenantConfig);
|
| 230 |
-
return;
|
| 231 |
-
} else {
|
| 232 |
-
const errText = await feedbackRes.text();
|
| 233 |
-
throw new Error(`generate-feedback failed HTTP ${feedbackRes.status}: ${errText}`);
|
| 234 |
-
}
|
| 235 |
-
} catch (err: unknown) {
|
| 236 |
-
logger.error(`[WORKER] generate-feedback failed:`, (err instanceof Error ? err.message : String(err)));
|
| 237 |
-
// 🚨 RACE CONDITION: Delete lock on error to allow immediate retry by BullMQ
|
| 238 |
-
await redis.del(lockKey);
|
| 239 |
-
throw err;
|
| 240 |
-
}
|
| 241 |
-
|
| 242 |
-
if (feedbackMsg) {
|
| 243 |
-
// 🌟 Adaptive Pedagogy: Dynamic Remediation & Diagnostic Logic v1.1 🌟
|
| 244 |
-
// 🚨 RACE CONDITION FIX: Update UserProgress strictly BEFORE sending the message over WhatsApp.
|
| 245 |
-
let nextDay = currentDay + 1;
|
| 246 |
-
const currentProgress = await prisma.userProgress.findUnique({
|
| 247 |
-
where: { userId_trackId: { userId, trackId } },
|
| 248 |
-
include: { userBadges: true }
|
| 249 |
-
});
|
| 250 |
-
const currentBadges = (currentProgress?.userBadges || []).map(b => b.name);
|
| 251 |
-
let updatedBadges = [...currentBadges];
|
| 252 |
-
|
| 253 |
-
const criteria = (exerciseCriteria as unknown) as ExerciseCriteria | undefined;
|
| 254 |
-
|
| 255 |
-
if (feedbackData?.isQualified === false) {
|
| 256 |
-
// Check for Adaptive Diagnostic Branching
|
| 257 |
-
const diagnosticTrigger = criteria?.diagnostic?.trigger;
|
| 258 |
-
const adaptiveModuleId = criteria?.diagnostic?.moduleId;
|
| 259 |
-
|
| 260 |
-
if (diagnosticTrigger && feedbackData?.missingElements?.includes(diagnosticTrigger) && adaptiveModuleId) {
|
| 261 |
-
logger.info(`[WORKER] Adaptive Diagnostic triggered for User ${userId}: Re-routing to module ${adaptiveModuleId}`);
|
| 262 |
-
// 🚀 Redirect to specific module
|
| 263 |
-
nextDay = 1; // Modules start at day 1
|
| 264 |
-
await prisma.enrollment.updateMany({
|
| 265 |
-
where: { userId, status: 'ACTIVE' },
|
| 266 |
-
data: { trackId: adaptiveModuleId, currentDay: 1 }
|
| 267 |
-
});
|
| 268 |
-
}
|
| 269 |
-
|
| 270 |
-
const remediationDay = criteria?.remediation?.dayNumber;
|
| 271 |
-
if (remediationDay && remediationDay !== currentDay) {
|
| 272 |
-
logger.info(`[WORKER] Dynamic remediation triggered for User ${userId}: Day ${currentDay} -> ${remediationDay}`);
|
| 273 |
-
nextDay = remediationDay;
|
| 274 |
-
} else {
|
| 275 |
-
logger.info(`[WORKER] Exercise not qualified but no remediation day defined. Staying on Day ${currentDay}.`);
|
| 276 |
-
nextDay = currentDay;
|
| 277 |
-
}
|
| 278 |
-
|
| 279 |
-
// 🚨 Hardening: If explicitly not qualified, we put the user in PENDING_REMEDIATION
|
| 280 |
-
// 🕰️ TIME-TRAVEL GUARD: Don't downgrade global status during a replay
|
| 281 |
-
if (!isTimeTravelMode) {
|
| 282 |
-
await prisma.userProgress.update({
|
| 283 |
-
where: { userId_trackId: { userId, trackId } },
|
| 284 |
-
data: {
|
| 285 |
-
exerciseStatus: 'PENDING_REMEDIATION', // Stay in remediation until final success
|
| 286 |
-
score: { increment: 0 }
|
| 287 |
-
}
|
| 288 |
-
});
|
| 289 |
-
}
|
| 290 |
-
|
| 291 |
-
// 🚨 Store Strategy Data in BusinessProfile (Guarded by Day 10+)
|
| 292 |
-
const hasStrategyData = feedbackData?.searchResults || feedbackData?.competitorList || feedbackData?.financialProjections || feedbackData?.fundingAsk || feedbackData?.teamMembers;
|
| 293 |
-
if (hasStrategyData && currentDay >= 10) {
|
| 294 |
-
const updatePayload: any = { lastUpdatedFromDay: currentDay };
|
| 295 |
-
if (feedbackData?.searchResults) updatePayload.marketData = feedbackData.searchResults;
|
| 296 |
-
if (feedbackData?.competitorList) updatePayload.competitorList = feedbackData.competitorList;
|
| 297 |
-
if (feedbackData?.financialProjections) updatePayload.financialProjections = feedbackData.financialProjections;
|
| 298 |
-
if (feedbackData?.fundingAsk) updatePayload.fundingAsk = feedbackData.fundingAsk;
|
| 299 |
-
if (feedbackData?.teamMembers && Array.isArray(feedbackData.teamMembers)) {
|
| 300 |
-
const existingProfile = await prisma.businessProfile.findUnique({ where: { userId } });
|
| 301 |
-
const existingTeam = Array.isArray(existingProfile?.teamMembers) ? (existingProfile?.teamMembers as any[]) : [];
|
| 302 |
-
updatePayload.teamMembers = [...existingTeam, ...feedbackData.teamMembers];
|
| 303 |
-
}
|
| 304 |
-
|
| 305 |
-
try {
|
| 306 |
-
await prisma.businessProfile.upsert({
|
| 307 |
-
where: { userId },
|
| 308 |
-
update: updatePayload,
|
| 309 |
-
create: { userId, ...updatePayload, organizationId }
|
| 310 |
-
});
|
| 311 |
-
} catch (bpErr: unknown) {
|
| 312 |
-
logger.error('[WORKER] BusinessProfile upsert failed (non-fatal, REMEDIATION path):', (bpErr as Error).message);
|
| 313 |
-
}
|
| 314 |
-
}
|
| 315 |
-
} else {
|
| 316 |
-
// Success! Award Badges & Mark Completed (or keep Pending for Deep Dive)
|
| 317 |
-
const trackDayBadges = (trackDay as any)?.badges as string[] || [];
|
| 318 |
-
for (const b of trackDayBadges) {
|
| 319 |
-
if (!updatedBadges.includes(b)) updatedBadges.push(b);
|
| 320 |
-
}
|
| 321 |
-
|
| 322 |
-
let newStatus = (isDeepDive && feedbackData?.isForcedClosure !== true) ? 'PENDING_DEEPDIVE' : 'COMPLETED';
|
| 323 |
-
|
| 324 |
-
// 🚨 Card/Button Bypass Logic (Lead Fullstack Developer Requirement)
|
| 325 |
-
// Ensure that a button click never completes the lesson, even if the AI validates it.
|
| 326 |
-
if (isButtonChoice) {
|
| 327 |
-
logger.info(`[WORKER] 🛡️ Button choice detected for User ${userId}. Overriding COMPLETED with PENDING.`);
|
| 328 |
-
newStatus = 'PENDING';
|
| 329 |
-
}
|
| 330 |
-
|
| 331 |
-
// 🕰️ TIME-TRAVEL GUARD: Skip COMPLETED update when replaying a historical lesson.
|
| 332 |
-
// BusinessProfile (One-Pager) IS updated below — only the global exerciseStatus is preserved.
|
| 333 |
-
if (!isTimeTravelMode) {
|
| 334 |
-
const newBadges = updatedBadges.filter(b => !currentBadges.includes(b));
|
| 335 |
-
|
| 336 |
-
await prisma.userProgress.update({
|
| 337 |
-
where: { userId_trackId: { userId, trackId } },
|
| 338 |
-
data: {
|
| 339 |
-
exerciseStatus: newStatus as any,
|
| 340 |
-
score: { increment: newStatus === 'COMPLETED' ? 1 : 0 },
|
| 341 |
-
badges: updatedBadges,
|
| 342 |
-
userBadges: {
|
| 343 |
-
create: newBadges.map(name => ({ name }))
|
| 344 |
-
},
|
| 345 |
-
behavioralScoring: updateBehavioralScore(currentProgress ? currentProgress.behavioralScoring as any : null, criteria?.scoring?.impact_success as any),
|
| 346 |
-
aiSource: feedbackData?.aiSource || 'OPENAI'
|
| 347 |
-
}
|
| 348 |
-
});
|
| 349 |
-
}
|
| 350 |
-
|
| 351 |
-
// 🚨 Store Strategy Data in BusinessProfile (Guarded by Day 10+)
|
| 352 |
-
const hasStrategyData = feedbackData?.searchResults || feedbackData?.competitorList || feedbackData?.financialProjections || feedbackData?.fundingAsk || feedbackData?.teamMembers;
|
| 353 |
-
if (hasStrategyData && currentDay >= 10) {
|
| 354 |
-
const updatePayload: any = { lastUpdatedFromDay: currentDay };
|
| 355 |
-
if (feedbackData?.searchResults) updatePayload.marketData = feedbackData.searchResults;
|
| 356 |
-
if (feedbackData?.competitorList) updatePayload.competitorList = feedbackData.competitorList;
|
| 357 |
-
if (feedbackData?.financialProjections) updatePayload.financialProjections = feedbackData.financialProjections;
|
| 358 |
-
if (feedbackData?.fundingAsk) updatePayload.fundingAsk = feedbackData.fundingAsk;
|
| 359 |
-
if (feedbackData?.teamMembers && Array.isArray(feedbackData.teamMembers)) {
|
| 360 |
-
const newMembers = feedbackData.teamMembers;
|
| 361 |
-
|
| 362 |
-
const profile = await prisma.businessProfile.findUnique({ where: { userId } });
|
| 363 |
-
if (profile) {
|
| 364 |
-
await prisma.teamMember.deleteMany({ where: { businessProfileId: profile.id } });
|
| 365 |
-
updatePayload.teamMembersList = {
|
| 366 |
-
create: newMembers.map((m: any) => ({
|
| 367 |
-
name: m.name || m.fullName || 'Unknown',
|
| 368 |
-
role: m.role || m.position,
|
| 369 |
-
bio: m.bio || m.description
|
| 370 |
-
}))
|
| 371 |
-
};
|
| 372 |
-
}
|
| 373 |
-
updatePayload.teamMembers = newMembers;
|
| 374 |
-
}
|
| 375 |
-
|
| 376 |
-
try {
|
| 377 |
-
await prisma.businessProfile.upsert({
|
| 378 |
-
where: { userId },
|
| 379 |
-
update: updatePayload,
|
| 380 |
-
create: { userId, ...updatePayload, organizationId }
|
| 381 |
-
});
|
| 382 |
-
} catch (bpErr: unknown) {
|
| 383 |
-
logger.error('[WORKER] BusinessProfile upsert failed (non-fatal, SUCCESS path):', (bpErr as Error).message);
|
| 384 |
-
}
|
| 385 |
-
}
|
| 386 |
-
|
| 387 |
-
// If we were in a remediation day (fractional) -> move to next integer day
|
| 388 |
-
if (!isTimeTravelMode && currentDay % 1 !== 0) {
|
| 389 |
-
nextDay = Math.floor(currentDay) + 1;
|
| 390 |
-
logger.info(`[WORKER] Remediation successful for User ${userId}. Moving to Day ${nextDay}.`);
|
| 391 |
-
}
|
| 392 |
-
|
| 393 |
-
} // end success else block
|
| 394 |
-
|
| 395 |
-
// THEN store the response
|
| 396 |
-
await prisma.response.create({
|
| 397 |
-
data: {
|
| 398 |
-
enrollmentId: enrollmentId || '',
|
| 399 |
-
userId: user.id,
|
| 400 |
-
dayNumber: currentDay,
|
| 401 |
-
content: feedbackMsg,
|
| 402 |
-
aiSource: feedbackData?.aiSource || 'OPENAI',
|
| 403 |
-
organizationId: organizationId || user.organizationId
|
| 404 |
-
}
|
| 405 |
-
});
|
| 406 |
-
|
| 407 |
-
// THEN send the WhatsApp message
|
| 408 |
-
await sendTextMessage(user.phone, feedbackMsg, tenantConfig);
|
| 409 |
-
|
| 410 |
-
if (isFeatureEnabled('FEATURE_BUSINESS_PROFILE') && currentDay) {
|
| 411 |
-
try {
|
| 412 |
-
const extractRes = await fetch(`${AI_API_BASE_URL.replace(/\/$/, "")}/v1/ai/extract-profile`, {
|
| 413 |
-
method: 'POST',
|
| 414 |
-
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` },
|
| 415 |
-
body: JSON.stringify({
|
| 416 |
-
userInput: text,
|
| 417 |
-
dayNumber: currentDay,
|
| 418 |
-
userLanguage: language
|
| 419 |
-
})
|
| 420 |
-
});
|
| 421 |
-
|
| 422 |
-
if (extractRes.ok) {
|
| 423 |
-
const { data } = await extractRes.json() as any;
|
| 424 |
-
// Clean up undefined/null values
|
| 425 |
-
const profileData = Object.fromEntries(Object.entries(data).filter(([_, v]) => v != null && v !== ''));
|
| 426 |
-
|
| 427 |
-
// 🚨 Sector Locking (Lead AI Engineer Requirement)
|
| 428 |
-
const lockedSectors = ["Organisation d'événements / PWA"];
|
| 429 |
-
if (lockedSectors.includes(user.activity || "")) {
|
| 430 |
-
if (profileData.activityLabel || profileData.activityType) {
|
| 431 |
-
logger.info(`[WORKER] Sector Locked for User ${userId}. Blocking activity update.`);
|
| 432 |
-
delete profileData.activityLabel;
|
| 433 |
-
delete profileData.activityType;
|
| 434 |
-
}
|
| 435 |
-
}
|
| 436 |
-
|
| 437 |
-
if (Object.keys(profileData).length > 0 || feedbackData?.searchResults) {
|
| 438 |
-
logger.info(`[WORKER] Updating BusinessProfile for User ${userId}:`, profileData);
|
| 439 |
-
const updatePayload: any = {
|
| 440 |
-
...profileData,
|
| 441 |
-
lastUpdatedFromDay: currentDay
|
| 442 |
-
};
|
| 443 |
-
|
| 444 |
-
if (feedbackData?.searchResults) {
|
| 445 |
-
updatePayload.marketData = feedbackData.searchResults;
|
| 446 |
-
logger.info(`[WORKER] Market Data (Enrichment) added to profile.`);
|
| 447 |
-
}
|
| 448 |
-
|
| 449 |
-
await prisma.businessProfile.upsert({
|
| 450 |
-
where: { userId },
|
| 451 |
-
update: updatePayload,
|
| 452 |
-
create: { userId, ...updatePayload, organizationId }
|
| 453 |
-
});
|
| 454 |
-
|
| 455 |
-
// 🌟 Visuals WOW: Generate Pitch Card for Day 1 🌟
|
| 456 |
-
const finalLabel = (profileData as any).activityLabel || user.activity || "Projet Entrepreneurial";
|
| 457 |
-
if (isFeatureEnabled('FEATURE_SHARE_CARD') && currentDay === 1 && finalLabel) {
|
| 458 |
-
try {
|
| 459 |
-
const { generatePitchCard } = await import('./visuals');
|
| 460 |
-
const { uploadFile } = await import('./storage');
|
| 461 |
-
const cardBuffer = await generatePitchCard(finalLabel);
|
| 462 |
-
const cardUrl = await uploadFile(cardBuffer, 'pitch-card.png', 'image/png');
|
| 463 |
-
|
| 464 |
-
const caption = language === 'WOLOF'
|
| 465 |
-
? "Sa kàrdu business mu neex ! ✨"
|
| 466 |
-
: "Ta carte business personnalisée ! ✨";
|
| 467 |
-
await sendImageMessage(user.phone, cardUrl, caption);
|
| 468 |
-
} catch (vErr: unknown) {
|
| 469 |
-
logger.error('[WORKER] Pitch Card generation failed:', (vErr as any)?.message);
|
| 470 |
-
}
|
| 471 |
-
}
|
| 472 |
-
}
|
| 473 |
-
}
|
| 474 |
-
} catch (err: unknown) {
|
| 475 |
-
logger.error('[WORKER] BusinessProfile extraction failed:', (err instanceof Error ? err.message : String(err)));
|
| 476 |
-
}
|
| 477 |
-
}
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
// 🚨 Hardening: We DO NOT update `currentDay` here for normal or remediation flows.
|
| 482 |
-
// The `send-content` job will update `enrollment.currentDay` right before sending the lesson!
|
| 483 |
-
// This prevents the 'double incrementation' bug (+2 days) when the user types SUITE.
|
| 484 |
-
|
| 485 |
-
// 🌟 Adaptive Pedagogy: Streak Management 🌟
|
| 486 |
-
const lastActivity = user.lastActivityAt ? new Date(user.lastActivityAt) : null;
|
| 487 |
-
const today = new Date();
|
| 488 |
-
const diffTime = lastActivity ? Math.abs(today.getTime() - lastActivity.getTime()) : Infinity;
|
| 489 |
-
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
| 490 |
-
|
| 491 |
-
let newStreak = (user as any).currentStreak || 0;
|
| 492 |
-
if (diffDays <= 1) {
|
| 493 |
-
newStreak += 1; // Continuous streak
|
| 494 |
-
} else if (diffDays > 1) {
|
| 495 |
-
newStreak = 1; // Streak broken
|
| 496 |
-
}
|
| 497 |
-
|
| 498 |
-
await prisma.user.update({
|
| 499 |
-
where: { id: userId },
|
| 500 |
-
data: {
|
| 501 |
-
lastActivityAt: new Date(),
|
| 502 |
-
currentStreak: newStreak,
|
| 503 |
-
longestStreak: Math.max((user as any).longestStreak || 0, newStreak)
|
| 504 |
-
} as any
|
| 505 |
-
});
|
| 506 |
-
|
| 507 |
-
if (currentDay >= (totalDays || 12) && feedbackData?.isQualified !== false) {
|
| 508 |
-
const tenantConfig = await getTenantConfig(organizationId);
|
| 509 |
-
await sendTextMessage(user.phone, language === 'WOLOF'
|
| 510 |
-
? "🎉 Baraka Allahu fik ! Jeex nga module bi. Dokumaan yi dinañu leen yónnee ci kanam !"
|
| 511 |
-
: "🎉 Félicitations ! Vous avez terminé ce module. Vos documents intelligents arrivent bientôt !",
|
| 512 |
-
tenantConfig
|
| 513 |
-
);
|
| 514 |
-
|
| 515 |
-
const q = new Queue('whatsapp-queue', { connection: connection as any });
|
| 516 |
-
await q.add('send-content', { userId, trackId, dayNumber: currentDay + 1, organizationId });
|
| 517 |
-
} else if (feedbackData?.isQualified === false) {
|
| 518 |
-
const tenantConfig = await getTenantConfig(organizationId);
|
| 519 |
-
await sendTextMessage(user.phone, language === 'WOLOF'
|
| 520 |
-
? "🚨 Am na lo xamni leerul bu baax. Xoolal missal yi ma la yónnee te tàmbaleet ko ndànk."
|
| 521 |
-
: "🚨 Certains points sont encore à renforcer pour valider. Regarde mes conseils et réessaie.",
|
| 522 |
-
tenantConfig
|
| 523 |
-
);
|
| 524 |
-
|
| 525 |
-
// Si on a un jour de remédiation (ex 1.5), on l'envoie automatiquement
|
| 526 |
-
if (nextDay !== currentDay && nextDay % 1 !== 0) {
|
| 527 |
-
const q = new Queue('whatsapp-queue', { connection: connection as any });
|
| 528 |
-
await q.add('send-content', { userId, trackId, dayNumber: nextDay, organizationId }, { delay: 2000 });
|
| 529 |
-
}
|
| 530 |
-
} else {
|
| 531 |
-
// 🕰️ TIME-TRAVEL MODE: Custom CTA — SUITE would be blocked, so give clear guidance
|
| 532 |
-
const tenantConfig = await getTenantConfig(organizationId);
|
| 533 |
-
if (isTimeTravelMode) {
|
| 534 |
-
await sendTextMessage(user.phone, language === 'WOLOF'
|
| 535 |
-
? `✅ Tànkâr ! Sa tôntu Jour ${currentDay} def na nu ci kos. Dànga dem ci Jour ${realCurrentDay || currentDay} — tôntu ci sén exercice ngir dem ci kanam.`
|
| 536 |
-
: `✅ Enregistré ! Ta réponse du Jour ${currentDay} a été sauvegardée. Tu es toujours au Jour ${realCurrentDay || currentDay} — réponds à son exercice pour continuer 📅`,
|
| 537 |
-
tenantConfig
|
| 538 |
-
);
|
| 539 |
-
} else {
|
| 540 |
-
await sendTextMessage(user.phone, language === 'WOLOF'
|
| 541 |
-
? "Baax na ! Yónnee *SUITE* ngir dem ci kanam."
|
| 542 |
-
: "Bravo ! Envoyez *SUITE* pour passer à la leçon suivante.",
|
| 543 |
-
tenantConfig
|
| 544 |
-
);
|
| 545 |
-
}
|
| 546 |
-
}
|
| 547 |
-
}
|
| 548 |
-
}
|
| 549 |
-
else if (job.name === 'send-nudge') {
|
| 550 |
-
const { userId, type, organizationId } = job.data;
|
| 551 |
-
const user = await prisma.user.findUnique({ where: { id: userId } });
|
| 552 |
-
const tenantConfig = await getTenantConfig(organizationId);
|
| 553 |
-
if (!user?.phone) return;
|
| 554 |
-
|
| 555 |
-
const isWolof = user.language === 'WOLOF';
|
| 556 |
|
| 557 |
-
|
| 558 |
-
|
| 559 |
-
? "Assalamuyalaykum ! Fatte wuñu sa mbir. Tontu bu gatt ngir wéy ? 💪"
|
| 560 |
-
: "Coucou ! On n'a pas oublié ton projet. Une petite réponse pour continuer ? 💪",
|
| 561 |
-
RESURRECTION: isWolof
|
| 562 |
-
? "Sa liggeey mu ngi lay xaar ! Am succès dafa laaj lëkkalë. Ñu tàmbaleeti ? 🚀"
|
| 563 |
-
: "Ton business t'attend ! Le succès vient de la régularité. On s'y remet ? 🚀"
|
| 564 |
-
};
|
| 565 |
|
| 566 |
-
|
| 567 |
-
const
|
| 568 |
-
|
| 569 |
-
|
| 570 |
-
}
|
| 571 |
-
else if (job.name === 'send-interactive-buttons') {
|
| 572 |
-
const { userId, bodyText, buttons, organizationId } = job.data;
|
| 573 |
-
const user = await prisma.user.findUnique({ where: { id: userId } });
|
| 574 |
-
const tenantConfig = await getTenantConfig(organizationId || 'default-org-id');
|
| 575 |
-
if (user?.phone) {
|
| 576 |
-
await sendInteractiveButtonMessage(user.phone, bodyText || '', buttons || [], undefined, tenantConfig);
|
| 577 |
-
}
|
| 578 |
-
}
|
| 579 |
-
else if (job.name === 'send-interactive-list') {
|
| 580 |
-
const { userId, headerText, bodyText, buttonLabel, sections, organizationId } = job.data;
|
| 581 |
-
const user = await prisma.user.findUnique({ where: { id: userId } });
|
| 582 |
-
const tenantConfig = await getTenantConfig(organizationId || 'default-org-id');
|
| 583 |
-
if (user?.phone) {
|
| 584 |
-
await sendInteractiveListMessage(user.phone, headerText || '', bodyText || '', buttonLabel || '', sections || [], undefined, tenantConfig);
|
| 585 |
-
}
|
| 586 |
-
}
|
| 587 |
-
else if (job.name === 'enroll-user') {
|
| 588 |
-
const { userId, trackId, organizationId } = job.data;
|
| 589 |
-
|
| 590 |
-
const track = await prisma.track.findUnique({ where: { id: trackId } });
|
| 591 |
-
if (!track) {
|
| 592 |
-
logger.error(`[WORKER] Enrollment failed: Track ${trackId} not found.`);
|
| 593 |
-
return;
|
| 594 |
-
}
|
| 595 |
-
|
| 596 |
-
if (track.isPremium) {
|
| 597 |
-
logger.info(`[WORKER] User ${userId} requested Premium Track ${trackId}. Generating Payment Link...`);
|
| 598 |
-
try {
|
| 599 |
-
const AI_API_BASE_URL = getApiUrl();
|
| 600 |
-
const apiKey = getAdminApiKey();
|
| 601 |
-
const checkoutRes = await fetch(`${AI_API_BASE_URL.replace(/\/$/, "")}/v1/payments/checkout`, {
|
| 602 |
-
method: 'POST',
|
| 603 |
-
headers: {
|
| 604 |
-
'Content-Type': 'application/json',
|
| 605 |
-
'Authorization': `Bearer ${apiKey}`
|
| 606 |
-
},
|
| 607 |
-
body: JSON.stringify({ userId, trackId })
|
| 608 |
-
});
|
| 609 |
-
|
| 610 |
-
const checkoutData = await checkoutRes.json() as any;
|
| 611 |
-
if (checkoutRes.ok && checkoutData.url) {
|
| 612 |
-
const user = await prisma.user.findUnique({ where: { id: userId } });
|
| 613 |
-
if (user?.phone) {
|
| 614 |
-
const tenantConfig = await getTenantConfig(organizationId);
|
| 615 |
-
await sendTextMessage(
|
| 616 |
-
user.phone,
|
| 617 |
-
`💳 Cette formation est Premium. Complétez votre paiement ici :\n${checkoutData.url}`,
|
| 618 |
-
tenantConfig
|
| 619 |
-
);
|
| 620 |
-
}
|
| 621 |
-
} else {
|
| 622 |
-
logger.error('[WORKER] Failed to get checkout URL', checkoutData);
|
| 623 |
-
}
|
| 624 |
-
} catch (err) {
|
| 625 |
-
logger.error('[WORKER] Error calling checkout endpoint', err);
|
| 626 |
-
}
|
| 627 |
} else {
|
| 628 |
-
logger.
|
| 629 |
-
|
| 630 |
-
|
| 631 |
-
|
| 632 |
-
|
| 633 |
-
|
| 634 |
-
|
| 635 |
-
|
| 636 |
-
currentDay: 1,
|
| 637 |
-
organizationId: organizationId || 'default-org-id'
|
| 638 |
-
} as any
|
| 639 |
-
});
|
| 640 |
-
const user = await prisma.user.findUnique({ where: { id: userId } });
|
| 641 |
-
if (user?.phone) {
|
| 642 |
-
const tenantConfig = await getTenantConfig(organizationId);
|
| 643 |
-
await sendTextMessage(
|
| 644 |
-
user.phone,
|
| 645 |
-
`🎉 Bienvenue dans *${track.title}* ! La génération de votre cours personnalisé (Jour 1) a commencé. Cela prendra environ 30 secondes...`,
|
| 646 |
-
tenantConfig
|
| 647 |
-
);
|
| 648 |
-
|
| 649 |
-
// Immediately trigger Day 1 content generation
|
| 650 |
-
const whatsappQueue = new Queue('whatsapp-queue', { connection: connection as any });
|
| 651 |
-
await whatsappQueue.add('send-content', {
|
| 652 |
-
userId,
|
| 653 |
-
trackId,
|
| 654 |
-
dayNumber: 1,
|
| 655 |
-
organizationId
|
| 656 |
-
});
|
| 657 |
-
}
|
| 658 |
-
}
|
| 659 |
-
}
|
| 660 |
-
}
|
| 661 |
-
else if (job.name === 'download-media') {
|
| 662 |
-
const { mediaId, mimeType, phone, organizationId } = job.data;
|
| 663 |
-
if (!mediaId || !phone) {
|
| 664 |
-
logger.error(`[WORKER] Missing data for download-media: mediaId=${mediaId}, phone=${phone}`);
|
| 665 |
-
return;
|
| 666 |
-
}
|
| 667 |
-
const tenantConfig = await getTenantConfig(organizationId);
|
| 668 |
-
const traceId = `[STT-FLOW-${phone.slice(-4)}]`;
|
| 669 |
-
|
| 670 |
-
// Prioritize the live environment variable if available
|
| 671 |
-
const accessToken = process.env.WHATSAPP_ACCESS_TOKEN || tenantConfig?.accessToken;
|
| 672 |
-
|
| 673 |
-
if (!accessToken) {
|
| 674 |
-
logger.error(`[WORKER] Missing WhatsApp Access Token for media ${mediaId} (Org: ${organizationId})`);
|
| 675 |
-
return;
|
| 676 |
-
}
|
| 677 |
-
|
| 678 |
-
let transcribedText = '';
|
| 679 |
-
let audioUrl = '';
|
| 680 |
-
try {
|
| 681 |
-
const { buffer } = await downloadMedia(mediaId, accessToken);
|
| 682 |
-
logger.info(`${traceId} Downloaded file size=${buffer.length} contentType=${mimeType}`);
|
| 683 |
-
|
| 684 |
-
const AI_API_BASE_URL = getApiUrl();
|
| 685 |
-
const apiKey = getAdminApiKey();
|
| 686 |
-
|
| 687 |
-
// ─── Hardening: Store audio on R2 via the API ─────────
|
| 688 |
-
try {
|
| 689 |
-
const storeRes = await fetch(`${AI_API_BASE_URL.replace(/\/$/, "")}/v1/ai/store-audio`, {
|
| 690 |
-
method: 'POST',
|
| 691 |
-
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` },
|
| 692 |
-
body: JSON.stringify({ audioBase64: buffer.toString('base64'), mimeType, phone })
|
| 693 |
-
});
|
| 694 |
-
if (storeRes.ok) {
|
| 695 |
-
const storeData = await storeRes.json() as any;
|
| 696 |
-
if (storeData.url) {
|
| 697 |
-
audioUrl = storeData.url;
|
| 698 |
-
logger.info(`[R2] Inbound audio uploaded: ${audioUrl}`);
|
| 699 |
-
}
|
| 700 |
-
}
|
| 701 |
-
} catch (err: unknown) {
|
| 702 |
-
logger.error('[WORKER] store-audio failed (inbound audio will not have a permanent link):', (err instanceof Error ? err.message : String(err)));
|
| 703 |
-
}
|
| 704 |
-
|
| 705 |
-
// ─── Hardening: Record Inbound Message in DB ──────────
|
| 706 |
-
const user = await prisma.user.findFirst({ where: { phone } });
|
| 707 |
-
if (user) {
|
| 708 |
-
try {
|
| 709 |
-
await prisma.message.create({
|
| 710 |
-
data: {
|
| 711 |
-
userId: user.id,
|
| 712 |
-
direction: 'INBOUND',
|
| 713 |
-
channel: 'WHATSAPP',
|
| 714 |
-
mediaUrl: audioUrl || null,
|
| 715 |
-
payload: job.data as any,
|
| 716 |
-
organizationId: organizationId || user.organizationId
|
| 717 |
-
}
|
| 718 |
-
});
|
| 719 |
-
logger.info(`[DB] Recorded inbound audio message for ${phone}`);
|
| 720 |
-
} catch (dbErr: unknown) {
|
| 721 |
-
logger.error('[DB] Failed to record inbound message:', (dbErr as Error).message);
|
| 722 |
-
}
|
| 723 |
-
}
|
| 724 |
-
|
| 725 |
-
// ─── Routing: Transcribe if Audio, Forward if Image ─────────
|
| 726 |
-
if (mimeType?.startsWith('audio/')) {
|
| 727 |
-
logger.info(`${traceId} Transcribe start calling ${AI_API_BASE_URL.replace(/\/$/, "")}/v1/ai/transcribe`);
|
| 728 |
-
const fileExtension = mimeType?.includes('mp4') ? 'mp4' : 'ogg';
|
| 729 |
-
const filename = `msg.${fileExtension}`;
|
| 730 |
-
const transcribeRes = await fetch(`${AI_API_BASE_URL.replace(/\/$/, "")}/v1/ai/transcribe`, {
|
| 731 |
-
method: 'POST',
|
| 732 |
-
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` },
|
| 733 |
-
body: JSON.stringify({
|
| 734 |
-
audioBase64: buffer.toString('base64'),
|
| 735 |
-
filename,
|
| 736 |
-
language: user?.language
|
| 737 |
-
})
|
| 738 |
-
});
|
| 739 |
-
|
| 740 |
-
if (transcribeRes.ok) {
|
| 741 |
-
const data = await transcribeRes.json() as any;
|
| 742 |
-
const isSuspect = data.isSuspect || false;
|
| 743 |
-
const confidence = data.confidence || 0;
|
| 744 |
-
transcribedText = data.text || '';
|
| 745 |
-
|
| 746 |
-
const user = await prisma.user.findFirst({ where: { phone } });
|
| 747 |
-
|
| 748 |
-
// 🌍 Normalisation Wolof STT v2.0 🌍
|
| 749 |
-
if (user?.language === 'WOLOF') {
|
| 750 |
-
const originalText = transcribedText;
|
| 751 |
-
const normResult = normalizeWolof(transcribedText);
|
| 752 |
-
transcribedText = normResult.normalizedText;
|
| 753 |
-
|
| 754 |
-
// Output correction feedback
|
| 755 |
-
logger.info(`[STT] Normalized: "${originalText}" -> "${transcribedText}"`);
|
| 756 |
-
|
| 757 |
-
// Soft Feedback UI
|
| 758 |
-
await sendTextMessage(phone, `Ma dégg na: "${transcribedText}" ✅\n(Confiance STT: ${confidence}%)`, tenantConfig);
|
| 759 |
-
if (normResult.changes.length > 0) {
|
| 760 |
-
const limitedChanges = normResult.changes.slice(0, 2).join(", ");
|
| 761 |
-
await sendTextMessage(phone, `Nataal bu gën: ${limitedChanges}`, tenantConfig);
|
| 762 |
-
}
|
| 763 |
-
|
| 764 |
-
// 🚨 Wolof Auto-Validation Logic (Target: > 40%) 🚨
|
| 765 |
-
const isTooShort = transcribedText.split(/\s+/).length < 4;
|
| 766 |
-
if (confidence <= 40 || isTooShort) {
|
| 767 |
-
logger.info(`[STT] Whisper Confidence (${confidence}%) <= 40 or isTooShort (${isTooShort}). Intercepting WOLOF audio for User ${user.id}. Shifting to PENDING_REVIEW.`);
|
| 768 |
-
|
| 769 |
-
// First, make sure there is an active enrollment to find the trackId
|
| 770 |
-
const activeEnrollment = await prisma.enrollment.findFirst({
|
| 771 |
-
where: { userId: user.id, status: 'ACTIVE' },
|
| 772 |
-
include: { track: true }
|
| 773 |
-
});
|
| 774 |
-
|
| 775 |
-
if (activeEnrollment) {
|
| 776 |
-
// 🚨 Brique 4 (Routage Deep Dive) : On ne bloque pas les audios itératifs
|
| 777 |
-
const currentProgress = await prisma.userProgress.findUnique({
|
| 778 |
-
where: { userId_trackId: { userId: user.id, trackId: activeEnrollment.trackId } }
|
| 779 |
-
});
|
| 780 |
-
|
| 781 |
-
if (currentProgress?.exerciseStatus !== 'PENDING_DEEPDIVE') {
|
| 782 |
-
await prisma.userProgress.upsert({
|
| 783 |
-
where: { userId_trackId: { userId: user.id, trackId: activeEnrollment.trackId } },
|
| 784 |
-
update: { exerciseStatus: 'PENDING_REVIEW' as any, adminTranscription: transcribedText, confidenceScore: confidence },
|
| 785 |
-
create: { userId: user.id, trackId: activeEnrollment.trackId, exerciseStatus: 'PENDING_REVIEW' as any, adminTranscription: transcribedText, confidenceScore: confidence }
|
| 786 |
-
});
|
| 787 |
-
const tenantConfig = await getTenantConfig(organizationId);
|
| 788 |
-
await sendTextMessage(phone, "🎙️ Nyangi jaxas sa kàddu. Xamle dina la tontu ci kanam ! (En cours d'analyse par l'équipe)", tenantConfig);
|
| 789 |
-
|
| 790 |
-
// Still save the audio URL to the message for the admin to read!
|
| 791 |
-
await prisma.message.updateMany({
|
| 792 |
-
where: { userId: user.id, direction: 'INBOUND', mediaUrl: audioUrl },
|
| 793 |
-
data: { content: transcribedText }
|
| 794 |
-
}).catch(() => { });
|
| 795 |
-
|
| 796 |
-
return; // Stop here, WAIT FOR ADMIN OVERRIDE
|
| 797 |
-
}
|
| 798 |
-
} else {
|
| 799 |
-
// Edge case: not enrolled but sent audio...
|
| 800 |
-
const tenantConfig = await getTenantConfig(organizationId);
|
| 801 |
-
await sendTextMessage(phone, "Dama jaxaso ci li nga wax... Mën nga ko waxaat ndànk ?", tenantConfig);
|
| 802 |
-
return;
|
| 803 |
-
}
|
| 804 |
-
}
|
| 805 |
-
|
| 806 |
-
logger.info(`[STT] transcribe result="${transcribedText.substring(0, 80)}" (suspect=${isSuspect}, confidence=${confidence}%)`);
|
| 807 |
-
|
| 808 |
-
// 🌟 STT Hardening: Handle suspect transcription 🌟
|
| 809 |
-
if (isSuspect && user) {
|
| 810 |
-
const fallbackMsg = user.language === 'WOLOF'
|
| 811 |
-
? "Dama jaxaso ci li nga wax... Mën nga ko waxaat ndànk ? (10-15s)"
|
| 812 |
-
: "Je n'ai pas bien compris ton vocal. Pourrais-tu le renvoyer en parlant bien distinctement ? (10-15s)";
|
| 813 |
-
await sendTextMessage(phone, fallbackMsg);
|
| 814 |
-
return; // Stop here, don't trigger AI generator
|
| 815 |
-
}
|
| 816 |
-
|
| 817 |
-
// Send an immediate confirmation to the user that the audio was understood
|
| 818 |
-
if (user && transcribedText) {
|
| 819 |
-
const confirmationText = `J'ai compris :\n"${transcribedText}"`;
|
| 820 |
-
await sendTextMessage(phone, confirmationText);
|
| 821 |
-
|
| 822 |
-
// Update the message record with transcribed text if found
|
| 823 |
-
await prisma.message.updateMany({
|
| 824 |
-
where: { userId: user.id, direction: 'INBOUND', mediaUrl: audioUrl },
|
| 825 |
-
data: { content: transcribedText }
|
| 826 |
-
}).catch(() => { });
|
| 827 |
-
}
|
| 828 |
-
} // end WOLOF block
|
| 829 |
-
|
| 830 |
-
// 🇫🇷 FR users: send confirmation (WOLOF users already got theirs above)
|
| 831 |
-
if (user?.language !== 'WOLOF' && user && transcribedText) {
|
| 832 |
-
logger.info(`[STT] transcribe result="${transcribedText.substring(0, 80)}" (suspect=${isSuspect}, confidence=${confidence}%)`);
|
| 833 |
-
|
| 834 |
-
if (isSuspect) {
|
| 835 |
-
await sendTextMessage(phone, "Je n'ai pas bien compris ton vocal. Pourrais-tu le renvoyer en parlant bien distinctement ? (10-15s)", tenantConfig);
|
| 836 |
-
return;
|
| 837 |
-
}
|
| 838 |
-
|
| 839 |
-
await sendTextMessage(phone, `🎙️ J'ai compris : "${transcribedText}"`, tenantConfig);
|
| 840 |
-
await prisma.message.updateMany({
|
| 841 |
-
where: { userId: user!.id, direction: 'INBOUND', mediaUrl: audioUrl },
|
| 842 |
-
data: { content: transcribedText }
|
| 843 |
-
}).catch(() => { });
|
| 844 |
-
}
|
| 845 |
-
|
| 846 |
-
// Process the transcribed text as a normal incoming message via API
|
| 847 |
-
// ─── Routing: Process transcribed text ─────────
|
| 848 |
-
if (transcribedText) {
|
| 849 |
-
const traceId = `[STT-FLOW-${phone.slice(-4)}]`;
|
| 850 |
-
logger.info(`${traceId} Processing transcribed text via WhatsAppLogic...`);
|
| 851 |
-
await WhatsAppLogic.handleIncomingMessage(phone, transcribedText, audioUrl, undefined, organizationId);
|
| 852 |
-
logger.info(`${traceId} Inbound audio processing complete.`);
|
| 853 |
-
}
|
| 854 |
-
} else if (transcribeRes.status === 429) {
|
| 855 |
-
// OpenAI quota exceeded — send fallback and do NOT requeue
|
| 856 |
-
logger.warn(`[WORKER] 429 Error during transcription`);
|
| 857 |
-
const user = await prisma.user.findFirst({ where: { phone } });
|
| 858 |
-
if (user) {
|
| 859 |
-
await sendTextMessage(phone, user.language === 'WOLOF'
|
| 860 |
-
? "⚠️ Mënuma dégg sa kàddu léegi (réseau bi dafa fees). Yónnee sa tontu ci bind (texte)."
|
| 861 |
-
: "⚠️ Le service audio est temporairement saturé. Envoie ta réponse en texte.",
|
| 862 |
-
tenantConfig
|
| 863 |
-
);
|
| 864 |
-
}
|
| 865 |
-
return; // Stop processing
|
| 866 |
-
} else {
|
| 867 |
-
const errText = await transcribeRes.text().catch(() => `HTTP ${transcribeRes.status}`);
|
| 868 |
-
logger.error(`[WORKER] /v1/ai/transcribe failed with HTTP ${transcribeRes.status}: ${errText}`);
|
| 869 |
-
throw new Error(`Transcription failed HTTP ${transcribeRes.status}`); // throw so BullMQ retries
|
| 870 |
-
}
|
| 871 |
-
} else if (mimeType?.startsWith('image/')) {
|
| 872 |
-
// 📸 IMAGE-FLOW: Build imageUrl from the R2 store result (same pattern as audioUrl for audio)
|
| 873 |
-
let imageUrl = '';
|
| 874 |
-
try {
|
| 875 |
-
const storeImgRes = await fetch(`${AI_API_BASE_URL.replace(/\/$/, '')}/v1/ai/store-audio`, {
|
| 876 |
-
method: 'POST',
|
| 877 |
-
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` },
|
| 878 |
-
body: JSON.stringify({ audioBase64: buffer.toString('base64'), mimeType, phone })
|
| 879 |
-
});
|
| 880 |
-
if (storeImgRes.ok) {
|
| 881 |
-
const storeImgData = await storeImgRes.json() as any;
|
| 882 |
-
if (storeImgData.url) {
|
| 883 |
-
imageUrl = storeImgData.url;
|
| 884 |
-
logger.info(`[IMAGE-FLOW] ✅ Image uploaded to R2: ${imageUrl}`);
|
| 885 |
-
}
|
| 886 |
-
}
|
| 887 |
-
} catch (imgStoreErr: unknown) {
|
| 888 |
-
logger.error('[IMAGE-FLOW] R2 store failed (image will be analyzed without permanent URL):', (imgStoreErr as Error).message);
|
| 889 |
-
}
|
| 890 |
-
|
| 891 |
-
logger.info(`[IMAGE-FLOW] 📸 Image detected for ${phone}. Routing to WhatsAppLogic (imageUrl: ${imageUrl || audioUrl || 'none'})...`);
|
| 892 |
-
// Fallback: si imageUrl (upload #2) échoue, utiliser audioUrl (upload #1, même buffer, déjà réussi)
|
| 893 |
-
const finalImageUrl = imageUrl || audioUrl || undefined;
|
| 894 |
-
await WhatsAppLogic.handleIncomingMessage(phone, job.data.caption || 'Image reçue', undefined, finalImageUrl, organizationId);
|
| 895 |
-
logger.info(`[IMAGE-FLOW] ✅ Inbound image processing complete.`);
|
| 896 |
-
}
|
| 897 |
-
} catch (err: unknown) {
|
| 898 |
-
logger.error(`[WORKER] download-media failed:`, err);
|
| 899 |
-
}
|
| 900 |
-
}
|
| 901 |
-
else if (job.name === 'send-image') {
|
| 902 |
-
const { to, imageUrl, caption, organizationId } = job.data;
|
| 903 |
-
if (!to || !imageUrl) return;
|
| 904 |
-
const tenantConfig = await getTenantConfig(organizationId || 'default-org-id');
|
| 905 |
-
try {
|
| 906 |
-
const { sendImageMessage } = await import('./whatsapp-cloud');
|
| 907 |
-
await sendImageMessage(to, imageUrl, caption || '', tenantConfig);
|
| 908 |
-
logger.info(`[WhatsApp] ✅ Image message sent to ${to}`);
|
| 909 |
-
} catch (err: unknown) {
|
| 910 |
-
logger.error(`[WORKER] send-image failed:`, (err instanceof Error ? err.message : String(err)));
|
| 911 |
-
}
|
| 912 |
-
}
|
| 913 |
-
else if (job.name === 'send-content') {
|
| 914 |
-
const { userId, trackId, dayNumber, organizationId } = job.data;
|
| 915 |
-
if (!userId || !trackId || !dayNumber || !organizationId) {
|
| 916 |
-
logger.error(`[WORKER] Missing data for send-content: userId=${userId}, trackId=${trackId}, dayNumber=${dayNumber}`);
|
| 917 |
-
return;
|
| 918 |
-
}
|
| 919 |
-
|
| 920 |
-
const user = await prisma.user.findUnique({ where: { id: userId } });
|
| 921 |
-
const trackDay = await prisma.trackDay.findFirst({
|
| 922 |
-
where: { trackId, dayNumber }
|
| 923 |
});
|
| 924 |
-
|
| 925 |
-
if (trackDay) {
|
| 926 |
-
await sendLessonDay(userId, trackId, dayNumber, organizationId, {
|
| 927 |
-
skipProgressUpdate: job.data.skipProgressUpdate === true
|
| 928 |
-
});
|
| 929 |
-
|
| 930 |
-
// 🕰️ TIME-TRAVEL GUARD: Only update currentDay if this is NOT a historical replay
|
| 931 |
-
if (!job.data.skipProgressUpdate) {
|
| 932 |
-
await prisma.enrollment.updateMany({
|
| 933 |
-
where: { userId, trackId },
|
| 934 |
-
data: {
|
| 935 |
-
currentDay: dayNumber,
|
| 936 |
-
lastActivityAt: new Date()
|
| 937 |
-
}
|
| 938 |
-
});
|
| 939 |
-
} else {
|
| 940 |
-
// Read-only replay: only touch lastActivityAt to keep heartbeat alive
|
| 941 |
-
await prisma.enrollment.updateMany({
|
| 942 |
-
where: { userId, trackId },
|
| 943 |
-
data: { lastActivityAt: new Date() }
|
| 944 |
-
});
|
| 945 |
-
logger.info(`[SEND-CONTENT] 🕰️ Replay Day ${dayNumber} sent read-only. currentDay unchanged.`);
|
| 946 |
-
}
|
| 947 |
-
} else {
|
| 948 |
-
logger.info(`[WORKER] No more content for Track ${trackId} Day ${dayNumber}. Marking enrollment as completed.`);
|
| 949 |
-
await prisma.enrollment.updateMany({
|
| 950 |
-
where: { userId, trackId },
|
| 951 |
-
data: {
|
| 952 |
-
status: 'COMPLETED',
|
| 953 |
-
lastActivityAt: new Date()
|
| 954 |
-
}
|
| 955 |
-
});
|
| 956 |
-
|
| 957 |
-
// 🎓 Graduation Flow: Successive Track Unlocking (v1.0) 🎓
|
| 958 |
-
if (trackId.match(/^T(\d)-(FR|WO)$/)) {
|
| 959 |
-
const trackMatch = trackId.match(/^T(\d)-(FR|WO)$/);
|
| 960 |
-
if (trackMatch) {
|
| 961 |
-
const currentLevel = parseInt(trackMatch[1]);
|
| 962 |
-
const lang = trackMatch[2];
|
| 963 |
-
const nextLevel = currentLevel + 1;
|
| 964 |
-
const nextTrackId = `T${nextLevel}-${lang}`;
|
| 965 |
-
|
| 966 |
-
const nextTrack = await prisma.track.findUnique({ where: { id: nextTrackId } });
|
| 967 |
-
if (nextTrack) {
|
| 968 |
-
const existingNextEnrollment = await prisma.enrollment.findFirst({
|
| 969 |
-
where: { userId, trackId: nextTrackId }
|
| 970 |
-
});
|
| 971 |
-
|
| 972 |
-
if (!existingNextEnrollment) {
|
| 973 |
-
logger.info(`[WORKER] Auto-graduating User ${userId}: ${trackId} -> ${nextTrackId}`);
|
| 974 |
-
const isWolof = lang === 'WO';
|
| 975 |
-
|
| 976 |
-
const congratsMsg = isWolof
|
| 977 |
-
? `🎉 Baraka Allahu fik ! Mat nga Niveau ${currentLevel}. Maangi lay tàmbaleel Niveau ${nextLevel} : *${nextTrack.title}*...`
|
| 978 |
-
: `🎉 Félicitations ! Vous avez validé le Niveau ${currentLevel}. Je vous inscris immédiatement au Niveau ${nextLevel} : *${nextTrack.title}*...`;
|
| 979 |
-
|
| 980 |
-
if (user?.phone) {
|
| 981 |
-
const tenantConfig = await getTenantConfig(user.organizationId);
|
| 982 |
-
await sendTextMessage(user.phone, congratsMsg, tenantConfig);
|
| 983 |
-
|
| 984 |
-
if (!nextTrack.isPremium) {
|
| 985 |
-
await prisma.enrollment.create({
|
| 986 |
-
data: {
|
| 987 |
-
userId: userId,
|
| 988 |
-
trackId: nextTrackId,
|
| 989 |
-
status: 'ACTIVE',
|
| 990 |
-
currentDay: 1,
|
| 991 |
-
organizationId: user.organizationId
|
| 992 |
-
}
|
| 993 |
-
});
|
| 994 |
-
// Trigger Day 1 for next track with 10s delay
|
| 995 |
-
const q = new Queue('whatsapp-queue', { connection: connection as any });
|
| 996 |
-
await q.add('send-content', {
|
| 997 |
-
userId,
|
| 998 |
-
trackId: nextTrackId,
|
| 999 |
-
dayNumber: 1,
|
| 1000 |
-
organizationId: user.organizationId
|
| 1001 |
-
}, { delay: 10000 });
|
| 1002 |
-
} else {
|
| 1003 |
-
const payMsg = isWolof
|
| 1004 |
-
? `💳 Niveau ${nextLevel} bi dafa laaj pass. Yónnee ma "PAYER" ngir tàmbaleeti.`
|
| 1005 |
-
: `💳 Le Niveau ${nextLevel} est un module Premium. Envoyez "PAYER" pour le débloquer et continuer votre ascension !`;
|
| 1006 |
-
const tenantConfig = await getTenantConfig(user.organizationId);
|
| 1007 |
-
await sendTextMessage(user.phone, payMsg, tenantConfig);
|
| 1008 |
-
}
|
| 1009 |
-
}
|
| 1010 |
-
}
|
| 1011 |
-
}
|
| 1012 |
-
}
|
| 1013 |
-
}
|
| 1014 |
-
|
| 1015 |
-
// 🌟 Trigger AI Document Generation 🌟
|
| 1016 |
-
logger.info(`[WORKER] Triggering AI Document Generation for User ${userId}...`);
|
| 1017 |
-
try {
|
| 1018 |
-
const userWithProfile = await prisma.user.findUnique({
|
| 1019 |
-
where: { id: userId },
|
| 1020 |
-
include: { businessProfile: true }
|
| 1021 |
-
}) as any;
|
| 1022 |
-
|
| 1023 |
-
const isWolof = userWithProfile?.language === 'WOLOF';
|
| 1024 |
-
const userLangPrefix = isWolof ? "MBIR : " : "ACTIVITÉ : ";
|
| 1025 |
-
|
| 1026 |
-
// Localize context to avoid English bias in LLM
|
| 1027 |
-
const userContext = `${userLangPrefix} ${userWithProfile?.businessProfile?.activityLabel || userWithProfile?.activity || 'Inconnue'}. Cet entrepreneur a terminé son parcours de formation XAMLÉ. Génère les documents basés sur son activité et les concepts appris.`;
|
| 1028 |
-
|
| 1029 |
-
const AI_API_BASE_URL = getApiUrl();
|
| 1030 |
-
const apiKey = getAdminApiKey();
|
| 1031 |
-
const authHeaders = {
|
| 1032 |
-
'Content-Type': 'application/json',
|
| 1033 |
-
'Authorization': `Bearer ${apiKey}`
|
| 1034 |
-
};
|
| 1035 |
-
|
| 1036 |
-
logger.info(`[WORKER] Calling ${AI_API_BASE_URL.replace(/\/$/, "")}/v1/ai/onepager (${userWithProfile?.language || 'FR'})...`);
|
| 1037 |
-
const opRes = await fetch(`${AI_API_BASE_URL.replace(/\/$/, "")}/v1/ai/onepager`, {
|
| 1038 |
-
method: 'POST',
|
| 1039 |
-
headers: authHeaders,
|
| 1040 |
-
body: JSON.stringify({
|
| 1041 |
-
userContext,
|
| 1042 |
-
language: userWithProfile?.language || 'FR',
|
| 1043 |
-
businessProfile: userWithProfile?.businessProfile
|
| 1044 |
-
})
|
| 1045 |
-
});
|
| 1046 |
-
const pdfData = await opRes.json() as any;
|
| 1047 |
-
|
| 1048 |
-
logger.info(`[WORKER] Calling ${AI_API_BASE_URL.replace(/\/$/, "")}/v1/ai/deck (${userWithProfile?.language || 'FR'})...`);
|
| 1049 |
-
const deckRes = await fetch(`${AI_API_BASE_URL.replace(/\/$/, "")}/v1/ai/deck`, {
|
| 1050 |
-
method: 'POST',
|
| 1051 |
-
headers: authHeaders,
|
| 1052 |
-
body: JSON.stringify({
|
| 1053 |
-
userContext,
|
| 1054 |
-
language: userWithProfile?.language || 'FR',
|
| 1055 |
-
businessProfile: userWithProfile?.businessProfile
|
| 1056 |
-
})
|
| 1057 |
-
});
|
| 1058 |
-
const pptxData = await deckRes.json() as any;
|
| 1059 |
-
|
| 1060 |
-
logger.info(`[AI DOCS READY] 📄 PDF: ${pdfData.url}`);
|
| 1061 |
-
logger.info(`[AI DOCS READY] 📊 PPTX: ${pptxData.url}`);
|
| 1062 |
-
|
| 1063 |
-
// Send documents to user via WhatsApp
|
| 1064 |
-
if (user?.phone) {
|
| 1065 |
-
const tenantConfig = await getTenantConfig(userWithProfile?.organizationId);
|
| 1066 |
-
if (pdfData.url) {
|
| 1067 |
-
await sendDocumentMessage(
|
| 1068 |
-
user.phone,
|
| 1069 |
-
pdfData.url,
|
| 1070 |
-
isWolof ? 'one-pager-xamle.pdf' : 'mon-business-plan.pdf',
|
| 1071 |
-
isWolof ? '📄 Sa One-Pager mu ngi nii !' : '📄 Votre One-Pager est prêt !',
|
| 1072 |
-
tenantConfig
|
| 1073 |
-
);
|
| 1074 |
-
}
|
| 1075 |
-
if (pptxData.url) {
|
| 1076 |
-
await sendDocumentMessage(
|
| 1077 |
-
user.phone,
|
| 1078 |
-
pptxData.url,
|
| 1079 |
-
isWolof ? 'pitch-deck-xamle.pptx' : 'mon-pitch-deck.pptx',
|
| 1080 |
-
isWolof ? '📊 Sa Pitch Deck mu ngi nii !' : '📊 Votre Pitch Deck est prêt !',
|
| 1081 |
-
tenantConfig
|
| 1082 |
-
);
|
| 1083 |
-
}
|
| 1084 |
-
|
| 1085 |
-
// 🚨 Track AI source for documents via Response table
|
| 1086 |
-
await prisma.response.create({
|
| 1087 |
-
data: {
|
| 1088 |
-
userId: userId,
|
| 1089 |
-
enrollmentId: userWithProfile?.enrollments?.[0]?.id || '',
|
| 1090 |
-
dayNumber: (dayNumber || 1) + 1, // Indicate graduation/end of track
|
| 1091 |
-
content: `AI Documents Generated. PDF: ${(pdfData as any)?.aiSource || '?'}, PPTX: ${(pptxData as any)?.aiSource || '?'}`,
|
| 1092 |
-
aiSource: 'SYSTEM',
|
| 1093 |
-
organizationId: organizationId || 'default-org-id'
|
| 1094 |
-
}
|
| 1095 |
-
}).catch(() => { });
|
| 1096 |
-
}
|
| 1097 |
-
} catch (aiError) {
|
| 1098 |
-
logger.error('[WORKER] Failed to generate AI documents:', aiError);
|
| 1099 |
-
}
|
| 1100 |
-
}
|
| 1101 |
-
}
|
| 1102 |
-
else if (job.name === 'send-admin-audio-override') {
|
| 1103 |
-
const { userId, trackId, overrideAudioUrl, adminId, organizationId } = job.data;
|
| 1104 |
-
if (!userId || !overrideAudioUrl) return;
|
| 1105 |
-
const user = await prisma.user.findUnique({ where: { id: userId } });
|
| 1106 |
-
const tenantConfig = await getTenantConfig(organizationId);
|
| 1107 |
-
|
| 1108 |
-
if (user?.phone) {
|
| 1109 |
-
// 1. Send the Admin's Voice Message
|
| 1110 |
-
const { sendAudioMessage } = await import('./whatsapp-cloud');
|
| 1111 |
-
await sendAudioMessage(user.phone, overrideAudioUrl, tenantConfig);
|
| 1112 |
-
|
| 1113 |
-
// 2. Send transition prompt
|
| 1114 |
-
await sendTextMessage(user.phone,
|
| 1115 |
-
user.language === 'WOLOF'
|
| 1116 |
-
? "Baax na ! Yónnee *SUITE* ngir dem ci kanam."
|
| 1117 |
-
: "Bravo ! Envoyez *SUITE* pour passer à la leçon suivante.",
|
| 1118 |
-
tenantConfig
|
| 1119 |
-
);
|
| 1120 |
-
|
| 1121 |
-
logger.info(`[WORKER] Admin ${adminId} Audio Overdrive sent to User ${userId}.`);
|
| 1122 |
-
|
| 1123 |
-
// 3. Record the override as a response
|
| 1124 |
-
await prisma.response.create({
|
| 1125 |
-
data: {
|
| 1126 |
-
userId: userId,
|
| 1127 |
-
enrollmentId: (await prisma.enrollment.findFirst({ where: { userId, trackId, status: 'ACTIVE' } }))?.id || '',
|
| 1128 |
-
dayNumber: (await prisma.enrollment.findFirst({ where: { userId, trackId, status: 'ACTIVE' } }))?.currentDay || 0,
|
| 1129 |
-
content: `[AUDIO_OVERRIDE] ${overrideAudioUrl}`,
|
| 1130 |
-
aiSource: `ADMIN_OVERRIDE:${adminId || 'unknown'}`,
|
| 1131 |
-
organizationId: organizationId || user.organizationId
|
| 1132 |
-
}
|
| 1133 |
-
});
|
| 1134 |
-
|
| 1135 |
-
// 3. Increment the logic via Queue so that user doesn't fall behind.
|
| 1136 |
-
const enrollment = await prisma.enrollment.findFirst({
|
| 1137 |
-
where: { userId, trackId, status: 'ACTIVE' }
|
| 1138 |
-
});
|
| 1139 |
-
|
| 1140 |
-
if (enrollment) {
|
| 1141 |
-
const nextDay = Math.floor(enrollment.currentDay) + 1;
|
| 1142 |
-
const q = new Queue('whatsapp-queue', { connection: connection as any });
|
| 1143 |
-
await q.add('send-content', { userId, trackId, dayNumber: nextDay }, { delay: 2000 });
|
| 1144 |
-
}
|
| 1145 |
-
}
|
| 1146 |
}
|
| 1147 |
-
}
|
| 1148 |
-
|
| 1149 |
-
|
| 1150 |
-
|
| 1151 |
-
|
| 1152 |
-
userId: job.data.userId || 'unknown'
|
| 1153 |
-
});
|
| 1154 |
-
throw err;
|
| 1155 |
-
}
|
| 1156 |
-
}, { connection: connection as any });
|
| 1157 |
|
| 1158 |
-
logger.info('WhatsApp Worker started...');
|
| 1159 |
|
| 1160 |
-
//
|
| 1161 |
import { startDailyScheduler } from './scheduler';
|
| 1162 |
startDailyScheduler();
|
| 1163 |
|
|
@@ -1166,5 +88,5 @@ worker.on('completed', job => {
|
|
| 1166 |
});
|
| 1167 |
|
| 1168 |
worker.on('failed', (job, err) => {
|
| 1169 |
-
logger.error(`[WORKER] Job ${job?.id}
|
| 1170 |
});
|
|
|
|
| 2 |
import dns from 'node:dns';
|
| 3 |
dns.setDefaultResultOrder('ipv4first');
|
| 4 |
|
| 5 |
+
import { Worker, Job } from 'bullmq';
|
| 6 |
import dotenv from 'dotenv';
|
| 7 |
import Redis from 'ioredis';
|
| 8 |
+
import { validateEnvironment } from './config';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
import { startWorkerCleanupCron } from './services/cleanup';
|
| 10 |
+
import { JobData, JobHandler } from './handlers/types';
|
|
|
|
| 11 |
import { reportError } from './services/errors';
|
| 12 |
+
import { runWithTenant } from '@repo/database';
|
| 13 |
|
| 14 |
+
// Handlers
|
| 15 |
+
import { MessageHandler } from './handlers/MessageHandler';
|
| 16 |
+
import { MediaHandler } from './handlers/MediaHandler';
|
| 17 |
+
import { ContentHandler } from './handlers/ContentHandler';
|
| 18 |
+
import { AdminHandler } from './handlers/AdminHandler';
|
| 19 |
+
import { NudgeHandler } from './handlers/NudgeHandler';
|
| 20 |
+
import { EnrollHandler } from './handlers/EnrollHandler';
|
| 21 |
+
import { InboundHandler } from './handlers/InboundHandler';
|
| 22 |
|
| 23 |
+
dotenv.config();
|
| 24 |
validateEnvironment();
|
| 25 |
startWorkerCleanupCron();
|
| 26 |
|
|
|
|
| 35 |
maxRetriesPerRequest: null
|
| 36 |
});
|
| 37 |
|
| 38 |
+
const handlers: Record<string, JobHandler> = {
|
| 39 |
+
'send-message': new MessageHandler(),
|
| 40 |
+
'send-message-direct': new MessageHandler(),
|
| 41 |
+
'send-image': new MessageHandler(),
|
| 42 |
+
'send-interactive-buttons': new MessageHandler(),
|
| 43 |
+
'send-interactive-list': new MessageHandler(),
|
| 44 |
+
'download-media': new MediaHandler(),
|
| 45 |
+
'send-content': new ContentHandler(),
|
| 46 |
+
'send-admin-audio-override': new AdminHandler(),
|
| 47 |
+
'send-nudge': new NudgeHandler(),
|
| 48 |
+
'enroll-user': new EnrollHandler(),
|
| 49 |
+
'handle-inbound': new InboundHandler()
|
| 50 |
+
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
|
| 52 |
const worker = new Worker('whatsapp-queue', async (job: Job<JobData>) => {
|
| 53 |
+
const organizationId = job.data.organizationId || 'default-org-id';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
|
| 55 |
+
return runWithTenant(organizationId, async () => {
|
| 56 |
+
logger.info(`[WORKER] Processing job: ${job.name} (${job.id}) for Org: ${organizationId}`);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
|
| 58 |
+
try {
|
| 59 |
+
const handler = handlers[job.name];
|
| 60 |
+
if (handler) {
|
| 61 |
+
await handler.handle(job, connection);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
} else {
|
| 63 |
+
logger.warn(`[WORKER] No handler found for job name: ${job.name}`);
|
| 64 |
+
}
|
| 65 |
+
} catch (err) {
|
| 66 |
+
reportError(err, {
|
| 67 |
+
jobId: job.id,
|
| 68 |
+
jobName: job.name,
|
| 69 |
+
organizationId,
|
| 70 |
+
userId: job.data.userId || 'unknown'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
});
|
| 72 |
+
throw err;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
}
|
| 74 |
+
});
|
| 75 |
+
}, {
|
| 76 |
+
connection: connection as any,
|
| 77 |
+
concurrency: parseInt(process.env.WORKER_CONCURRENCY || '5')
|
| 78 |
+
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
|
| 80 |
+
logger.info('🚀 WhatsApp Worker started (Modular Mode)...');
|
| 81 |
|
| 82 |
+
// Start the daily cron scheduler
|
| 83 |
import { startDailyScheduler } from './scheduler';
|
| 84 |
startDailyScheduler();
|
| 85 |
|
|
|
|
| 88 |
});
|
| 89 |
|
| 90 |
worker.on('failed', (job, err) => {
|
| 91 |
+
logger.error(`[WORKER] Job ${job?.id} failed: ${err.message}`);
|
| 92 |
});
|
apps/whatsapp-worker/src/logger.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
| 1 |
import pino from 'pino';
|
|
|
|
| 2 |
|
| 3 |
const pinoLogger = pino({
|
| 4 |
level: process.env.LOG_LEVEL || 'info',
|
|
@@ -12,38 +13,47 @@ const pinoLogger = pino({
|
|
| 12 |
} : undefined
|
| 13 |
});
|
| 14 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
export const logger = {
|
| 16 |
info: (first: any, ...rest: any[]) => {
|
|
|
|
| 17 |
if (typeof first === 'string') {
|
| 18 |
-
pinoLogger.info(first, ...rest);
|
| 19 |
} else {
|
| 20 |
-
pinoLogger.info(first, rest[0] || '', ...rest.slice(1));
|
| 21 |
}
|
| 22 |
},
|
| 23 |
error: (first: any, ...rest: any[]) => {
|
|
|
|
| 24 |
if (first instanceof Error) {
|
| 25 |
-
pinoLogger.error(first, rest[0] || first.message, ...rest.slice(1));
|
| 26 |
} else if (typeof first === 'string') {
|
| 27 |
-
pinoLogger.error(first, ...rest);
|
| 28 |
} else {
|
| 29 |
-
pinoLogger.error(first, rest[0] || '', ...rest.slice(1));
|
| 30 |
}
|
| 31 |
},
|
| 32 |
warn: (first: any, ...rest: any[]) => {
|
|
|
|
| 33 |
if (typeof first === 'string') {
|
| 34 |
-
pinoLogger.warn(first, ...rest);
|
| 35 |
} else {
|
| 36 |
-
pinoLogger.warn(first, rest[0] || '', ...rest.slice(1));
|
| 37 |
}
|
| 38 |
},
|
| 39 |
debug: (first: any, ...rest: any[]) => {
|
|
|
|
| 40 |
if (typeof first === 'string') {
|
| 41 |
-
pinoLogger.debug(first, ...rest);
|
| 42 |
} else {
|
| 43 |
-
pinoLogger.debug(first, rest[0] || '', ...rest.slice(1));
|
| 44 |
}
|
| 45 |
},
|
| 46 |
};
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
|
|
|
| 1 |
import pino from 'pino';
|
| 2 |
+
import { getOrganizationId } from '@repo/database';
|
| 3 |
|
| 4 |
const pinoLogger = pino({
|
| 5 |
level: process.env.LOG_LEVEL || 'info',
|
|
|
|
| 13 |
} : undefined
|
| 14 |
});
|
| 15 |
|
| 16 |
+
function getEnrichedObject(obj: any = {}) {
|
| 17 |
+
const organizationId = getOrganizationId();
|
| 18 |
+
if (organizationId) {
|
| 19 |
+
return { ...obj, organizationId };
|
| 20 |
+
}
|
| 21 |
+
return obj;
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
export const logger = {
|
| 25 |
info: (first: any, ...rest: any[]) => {
|
| 26 |
+
const orgId = getOrganizationId();
|
| 27 |
if (typeof first === 'string') {
|
| 28 |
+
pinoLogger.info(orgId ? { organizationId: orgId } : {}, first, ...rest);
|
| 29 |
} else {
|
| 30 |
+
pinoLogger.info(getEnrichedObject(first), rest[0] || '', ...rest.slice(1));
|
| 31 |
}
|
| 32 |
},
|
| 33 |
error: (first: any, ...rest: any[]) => {
|
| 34 |
+
const orgId = getOrganizationId();
|
| 35 |
if (first instanceof Error) {
|
| 36 |
+
pinoLogger.error(getEnrichedObject({ err: first }), rest[0] || first.message, ...rest.slice(1));
|
| 37 |
} else if (typeof first === 'string') {
|
| 38 |
+
pinoLogger.error(orgId ? { organizationId: orgId } : {}, first, ...rest);
|
| 39 |
} else {
|
| 40 |
+
pinoLogger.error(getEnrichedObject(first), rest[0] || '', ...rest.slice(1));
|
| 41 |
}
|
| 42 |
},
|
| 43 |
warn: (first: any, ...rest: any[]) => {
|
| 44 |
+
const orgId = getOrganizationId();
|
| 45 |
if (typeof first === 'string') {
|
| 46 |
+
pinoLogger.warn(orgId ? { organizationId: orgId } : {}, first, ...rest);
|
| 47 |
} else {
|
| 48 |
+
pinoLogger.warn(getEnrichedObject(first), rest[0] || '', ...rest.slice(1));
|
| 49 |
}
|
| 50 |
},
|
| 51 |
debug: (first: any, ...rest: any[]) => {
|
| 52 |
+
const orgId = getOrganizationId();
|
| 53 |
if (typeof first === 'string') {
|
| 54 |
+
pinoLogger.debug(orgId ? { organizationId: orgId } : {}, first, ...rest);
|
| 55 |
} else {
|
| 56 |
+
pinoLogger.debug(getEnrichedObject(first), rest[0] || '', ...rest.slice(1));
|
| 57 |
}
|
| 58 |
},
|
| 59 |
};
|
|
|
|
|
|
|
|
|
apps/whatsapp-worker/src/normalizeWolof.ts
CHANGED
|
@@ -60,6 +60,12 @@ const NORMALIZATION_RULES: Record<string, string> = {
|
|
| 60 |
"borom": "boroom",
|
| 61 |
"xaalisou": "xaalis",
|
| 62 |
"xaliss": "xaalis",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
};
|
| 64 |
|
| 65 |
const CAPITALIZED_PLACES = ["Yoff", "Dakar", "Pikine", "Guédiawaye"];
|
|
@@ -115,6 +121,9 @@ export function normalizeWolof(rawText: string): NormalizationResult {
|
|
| 115 |
// jaay (vendre), jënd (acheter), fey (payer), denc (épargner), lim (compter)
|
| 116 |
// These are already in NORMALIZATION_RULES.
|
| 117 |
|
|
|
|
|
|
|
|
|
|
| 118 |
return { normalizedText, changes: Array.from(new Set(changes)) };
|
| 119 |
}
|
| 120 |
|
|
|
|
| 60 |
"borom": "boroom",
|
| 61 |
"xaalisou": "xaalis",
|
| 62 |
"xaliss": "xaalis",
|
| 63 |
+
"xamle": "Xamlé",
|
| 64 |
+
"xalme": "Xamlé",
|
| 65 |
+
"waaw": "waaw",
|
| 66 |
+
"waaww": "waaw",
|
| 67 |
+
"deet": "déet",
|
| 68 |
+
"deet-deet": "déet",
|
| 69 |
};
|
| 70 |
|
| 71 |
const CAPITALIZED_PLACES = ["Yoff", "Dakar", "Pikine", "Guédiawaye"];
|
|
|
|
| 121 |
// jaay (vendre), jënd (acheter), fey (payer), denc (épargner), lim (compter)
|
| 122 |
// These are already in NORMALIZATION_RULES.
|
| 123 |
|
| 124 |
+
// Handle repeated vowels (e.g., waaaaw -> waaw)
|
| 125 |
+
normalizedText = normalizedText.replace(/(a|e|i|o|u)\1{2,}/gi, "$1$1");
|
| 126 |
+
|
| 127 |
return { normalizedText, changes: Array.from(new Set(changes)) };
|
| 128 |
}
|
| 129 |
|
apps/whatsapp-worker/src/pedagogy.ts
CHANGED
|
@@ -1,37 +1,30 @@
|
|
| 1 |
-
import { logger } from './logger';
|
| 2 |
import { prisma } from './services/prisma';
|
| 3 |
-
import { sendTextMessage, sendAudioMessage, sendInteractiveButtonMessage,
|
| 4 |
-
|
| 5 |
-
import { requireHttpUrl, getAdminApiKey, isFeatureEnabled } from './config';
|
| 6 |
import { shortenForWhatsApp } from './normalizeWolof';
|
| 7 |
-
import { ButtonsJson
|
| 8 |
import { castJson } from '@repo/shared-types';
|
| 9 |
-
|
| 10 |
-
|
| 11 |
|
| 12 |
const BADGE_EMOJIS: Record<string, string> = {
|
| 13 |
-
"CLARTÉ": "🏅",
|
| 14 |
-
"CONFIANCE": "🌟",
|
| 15 |
-
"CLIENT": "👥",
|
| 16 |
-
"OFFRE": "📦",
|
| 17 |
-
"PITCH": "🎙️"
|
| 18 |
};
|
| 19 |
|
| 20 |
-
interface TtsResponse {
|
| 21 |
-
url: string;
|
| 22 |
-
}
|
| 23 |
-
|
| 24 |
-
interface PersonalizeResponse {
|
| 25 |
-
text: string;
|
| 26 |
-
}
|
| 27 |
-
|
| 28 |
function generateProgressBar(current: number, total: number): string {
|
| 29 |
const size = 10;
|
| 30 |
const progress = Math.min(Math.max(Math.round((current / total) * size), 0), size);
|
| 31 |
-
const
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
}
|
| 36 |
|
| 37 |
export async function sendLessonDay(
|
|
@@ -41,8 +34,6 @@ export async function sendLessonDay(
|
|
| 41 |
organizationId: string,
|
| 42 |
options?: { skipProgressUpdate?: boolean }
|
| 43 |
) {
|
| 44 |
-
logger.info(`[PEDAGOGY] Preparing Lesson Day ${dayNumber} for User ${userId}`);
|
| 45 |
-
|
| 46 |
const user = await prisma.user.findUnique({
|
| 47 |
where: { id: userId },
|
| 48 |
include: {
|
|
@@ -51,390 +42,105 @@ export async function sendLessonDay(
|
|
| 51 |
organization: { include: { phoneNumbers: true } }
|
| 52 |
}
|
| 53 |
});
|
| 54 |
-
if (!user || !user.phone) {
|
| 55 |
-
logger.error(`[PEDAGOGY] User ${userId} not found or has no phone number.`);
|
| 56 |
-
return;
|
| 57 |
-
}
|
| 58 |
|
|
|
|
| 59 |
const isWolof = user.language === 'WOLOF';
|
| 60 |
const activeEnrollment = user.enrollments[0];
|
| 61 |
-
|
| 62 |
const tenantConfig = {
|
| 63 |
accessToken: user.organization?.systemUserToken || '',
|
| 64 |
phoneNumberId: user.organization?.phoneNumbers?.[0]?.id || ''
|
| 65 |
};
|
| 66 |
-
const trackTitle = activeEnrollment?.track?.title || (isWolof ? 'XAMLÉ' : 'XAMLÉ (FR)');
|
| 67 |
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
if (dayNumber - currentDay > 1) {
|
| 71 |
-
logger.error(`[CRITICAL] Cohérence Error: User ${userId} attempting to jump from ${currentDay} to ${dayNumber} sans remédiation.`);
|
| 72 |
-
await sendTextMessage(user.phone, isWolof
|
| 73 |
-
? "❌ Am na luy doxul ci sa njàng mi. Lëj-lëj la, dinañu ko lijjanti."
|
| 74 |
-
: "❌ Une erreur de synchronisation a été détectée sur ton parcours (Saut > 1). L'équipe technique a été notifiée.",
|
| 75 |
-
tenantConfig
|
| 76 |
-
);
|
| 77 |
-
return;
|
| 78 |
-
}
|
| 79 |
-
|
| 80 |
-
const trackDay = await prisma.trackDay.findFirst({
|
| 81 |
-
where: { trackId, dayNumber }
|
| 82 |
-
});
|
| 83 |
-
|
| 84 |
-
if (!trackDay) {
|
| 85 |
-
logger.error(`[PEDAGOGY] TrackDay not found for Track ${trackId} Day ${dayNumber}`);
|
| 86 |
-
return;
|
| 87 |
-
}
|
| 88 |
|
| 89 |
let lessonText = trackDay.lessonText || '';
|
| 90 |
let exercisePrompt = trackDay.exercisePrompt || '';
|
| 91 |
|
| 92 |
-
//
|
| 93 |
const buttonsJson = castJson<ButtonsJson>(trackDay.buttonsJson);
|
| 94 |
-
if (buttonsJson && buttonsJson.content) {
|
| 95 |
const langContent = (buttonsJson.content as any)[user.language];
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
exercisePrompt = langContent.exercisePrompt || exercisePrompt;
|
| 99 |
-
}
|
| 100 |
}
|
| 101 |
|
| 102 |
-
//
|
| 103 |
if (user.activity && lessonText) {
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
'Content-Type': 'application/json',
|
| 121 |
-
'Authorization': `Bearer ${apiKey}`
|
| 122 |
-
},
|
| 123 |
-
body: JSON.stringify({
|
| 124 |
-
lessonText,
|
| 125 |
-
userActivity: user.activity,
|
| 126 |
-
userLanguage: user.language,
|
| 127 |
-
businessProfile: user.businessProfile,
|
| 128 |
-
previousResponses,
|
| 129 |
-
tenantPrompt: (user.organization as any)?.customPrompt,
|
| 130 |
-
tenantBranding: (user.organization as any)?.brandingData
|
| 131 |
-
})
|
| 132 |
-
});
|
| 133 |
-
|
| 134 |
-
if (personalizeRes.ok) {
|
| 135 |
-
const personalizeData = await personalizeRes.json() as PersonalizeResponse;
|
| 136 |
-
if (personalizeData.text) {
|
| 137 |
-
lessonText = personalizeData.text;
|
| 138 |
-
}
|
| 139 |
-
}
|
| 140 |
-
} catch (err) {
|
| 141 |
-
logger.error({ err }, '[PEDAGOGY] Failed to personalize lesson');
|
| 142 |
-
}
|
| 143 |
}
|
| 144 |
|
| 145 |
-
//
|
| 146 |
const totalDays = activeEnrollment?.track?.duration || 12;
|
| 147 |
-
|
| 148 |
-
const userProgress = await prisma.userProgress.findUnique({
|
| 149 |
-
where: { userId_trackId: { userId, trackId } }
|
| 150 |
-
});
|
| 151 |
-
|
| 152 |
-
const progressBar = isFeatureEnabled('FEATURE_PROGRESS_BAR') ? `\n${generateProgressBar(dayNumber, totalDays)}` : '';
|
| 153 |
-
|
| 154 |
-
let badgeText = '';
|
| 155 |
const badges = (userProgress?.badges as string[]) || [];
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
if (badges.length > 0) {
|
| 159 |
-
let lastBadge = badges[badges.length - 1];
|
| 160 |
-
|
| 161 |
-
// 🚨 REMEDIATION BADGE GUARD: Hide 'REPRISE' on normal integer days
|
| 162 |
-
if (lastBadge === 'REPRISE' && dayNumber % 1 === 0) {
|
| 163 |
-
const nonRepriseBadges = badges.filter(b => b !== 'REPRISE');
|
| 164 |
-
if (nonRepriseBadges.length > 0) {
|
| 165 |
-
lastBadge = nonRepriseBadges[nonRepriseBadges.length - 1];
|
| 166 |
-
badgeText = `\nBadge : ${lastBadge} ${BADGE_EMOJIS[lastBadge] || '🏅'}`;
|
| 167 |
-
isVisible = true;
|
| 168 |
-
}
|
| 169 |
-
} else {
|
| 170 |
-
badgeText = `\nBadge : ${lastBadge} ${BADGE_EMOJIS[lastBadge] || '🏅'}`;
|
| 171 |
-
isVisible = true;
|
| 172 |
-
}
|
| 173 |
-
}
|
| 174 |
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
const dayDisplay = dayNumber === 1.5 ? '1bis' : Math.floor(dayNumber).toString();
|
| 178 |
-
|
| 179 |
-
const header = isWolof
|
| 180 |
-
? `*${trackTitle}*\n*Bés ${dayDisplay}* 🗓️${progressBar}${badgeText}\n\n`
|
| 181 |
-
: `*${trackTitle}*\n*Jour ${dayDisplay}* 🗓️${progressBar}${badgeText}\n\n`;
|
| 182 |
-
|
| 183 |
-
lessonText = header + lessonText;
|
| 184 |
-
|
| 185 |
-
// 🌟 Visuals WoW: Send day video if available (Production Ready v2) 🌟
|
| 186 |
-
let imageAlreadySent = false;
|
| 187 |
|
|
|
|
| 188 |
if (trackDay.videoUrl) {
|
| 189 |
-
const vUrl = trackDay.videoUrl;
|
| 190 |
-
const vCaption = trackDay.videoCaption || (isWolof ? "Xoolal vidéo bi !" : "Regarde cette vidéo !");
|
| 191 |
-
|
| 192 |
-
logger.info(`[VIDEO] Sending video day=${dayNumber} track=${trackId} url=${vUrl}`);
|
| 193 |
-
|
| 194 |
-
try {
|
| 195 |
-
await sendVideoMessage(user.phone, vUrl, vCaption, tenantConfig);
|
| 196 |
-
logger.info(`[VIDEO_OK] WhatsApp accepted video for ${user.phone}`);
|
| 197 |
-
} catch (vErr: unknown) {
|
| 198 |
-
logger.warn(`[VIDEO_FALLBACK] reason=${(vErr instanceof Error ? vErr.message : String(vErr))}. Sending image fallback for ${user.phone}`);
|
| 199 |
-
|
| 200 |
-
// Fallback: Image + Link + "Clique pour regarder"
|
| 201 |
-
const fallbackText = isWolof
|
| 202 |
-
? `⚠️ Vidéo bi mënul neex léegi. Klikal fii ngir seeti ko :\n${vUrl}\n(Xoolal nataal bi ci suuf)`
|
| 203 |
-
: `⚠️ La vidéo ne peut pas être affichée directement. Clique ici pour la voir :\n${vUrl}\n(Regarde l'image ci-dessous)`;
|
| 204 |
-
|
| 205 |
-
await sendTextMessage(user.phone, fallbackText, tenantConfig);
|
| 206 |
-
|
| 207 |
-
if (trackDay.imageUrl) {
|
| 208 |
-
await sendImageMessage(user.phone, trackDay.imageUrl, undefined, tenantConfig);
|
| 209 |
-
imageAlreadySent = true;
|
| 210 |
-
} else {
|
| 211 |
-
// Secondary fallback image if no specific day image exists
|
| 212 |
-
await sendImageMessage(user.phone, 'https://r2.xamle.sn/branding/bes1_welcome.png', undefined, tenantConfig);
|
| 213 |
-
}
|
| 214 |
-
}
|
| 215 |
-
}
|
| 216 |
-
|
| 217 |
-
// 🌟 Visuals WoW: Send day image as an infographic to keep (even if video was sent) 🌟
|
| 218 |
-
if (trackDay.imageUrl && !imageAlreadySent) {
|
| 219 |
-
logger.info(`[PEDAGOGY] Sending daily image infographic: ${trackDay.imageUrl}`);
|
| 220 |
-
await sendImageMessage(user.phone, trackDay.imageUrl, undefined, tenantConfig);
|
| 221 |
-
} else if (!imageAlreadySent) {
|
| 222 |
-
// FALLBACK: Inject missing image using the user sector
|
| 223 |
-
const sectorStr = user.activity ? user.activity.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/[^a-z0-9]/g, "_") : "general";
|
| 224 |
-
const fallbackImageUrl = `https://r2.xamle.sn/branding/${sectorStr}_generic.png`;
|
| 225 |
-
logger.info(`[PEDAGOGY] Missing imageUrl on Day ${dayNumber}! Using fallback: ${fallbackImageUrl}`);
|
| 226 |
-
try {
|
| 227 |
-
await sendImageMessage(user.phone, fallbackImageUrl, undefined, tenantConfig);
|
| 228 |
-
} catch (e: unknown) {
|
| 229 |
-
logger.warn(`[PEDAGOGY] Fallback image also failed: ${(e instanceof Error ? e.message : String(e))}`);
|
| 230 |
-
}
|
| 231 |
-
}
|
| 232 |
-
|
| 233 |
-
// 🌟 1. Send Lesson (audio or text) 🌟
|
| 234 |
-
let finalAudioUrl = trackDay.audioUrl;
|
| 235 |
-
|
| 236 |
-
if (!finalAudioUrl && lessonText) {
|
| 237 |
try {
|
| 238 |
-
|
| 239 |
-
const AI_API_BASE_URL = requireHttpUrl(process.env.AI_API_BASE_URL, 'AI_API_BASE_URL');
|
| 240 |
-
const apiKey = getAdminApiKey();
|
| 241 |
-
const ttsRes = await fetch(`${AI_API_BASE_URL.replace(/\/$/, "")}/v1/ai/tts`, {
|
| 242 |
-
method: 'POST',
|
| 243 |
-
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` },
|
| 244 |
-
body: JSON.stringify({ text: lessonText })
|
| 245 |
-
});
|
| 246 |
-
|
| 247 |
-
if (ttsRes.ok) {
|
| 248 |
-
const ttsData = await ttsRes.json() as TtsResponse;
|
| 249 |
-
finalAudioUrl = ttsData.url;
|
| 250 |
-
}
|
| 251 |
} catch (err) {
|
| 252 |
-
|
|
|
|
|
|
|
| 253 |
}
|
|
|
|
|
|
|
| 254 |
}
|
| 255 |
|
|
|
|
|
|
|
| 256 |
if (finalAudioUrl) {
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
// ─── Hardening: Record Outbound Audio in DB ──────────
|
| 263 |
-
try {
|
| 264 |
-
await prisma.message.create({
|
| 265 |
-
data: {
|
| 266 |
-
userId: user.id,
|
| 267 |
-
direction: 'OUTBOUND',
|
| 268 |
-
channel: 'WHATSAPP',
|
| 269 |
-
content: lessonText || null,
|
| 270 |
-
mediaUrl: finalAudioUrl,
|
| 271 |
-
organizationId
|
| 272 |
-
}
|
| 273 |
-
});
|
| 274 |
-
} catch (dbErr: unknown) {
|
| 275 |
-
logger.error({ err: dbErr }, '[DB] Failed to record outbound audio');
|
| 276 |
-
}
|
| 277 |
-
|
| 278 |
-
// Send the text as a separate short message
|
| 279 |
-
// ─── Format & Send 🌍 Bilingue v1.0 🌍 ─────────────
|
| 280 |
-
let textFR = '';
|
| 281 |
-
const bJson = castJson<ButtonsJson>(trackDay.buttonsJson);
|
| 282 |
-
if (!isWolof && bJson?.content?.FR) {
|
| 283 |
-
const frContent = bJson.content.FR;
|
| 284 |
-
if (frContent && !Array.isArray(frContent)) {
|
| 285 |
-
textFR = (frContent as MultilangContent).lessonText || '';
|
| 286 |
-
}
|
| 287 |
-
}
|
| 288 |
-
|
| 289 |
-
if (dayNumber === 1 || dayNumber === 1.0) {
|
| 290 |
-
// Heuristic: Send a branding or sector image on Day 1
|
| 291 |
-
await sendImageMessage(user.phone, 'https://r2.xamle.sn/branding/bes1_welcome.png', `Bés 1 - Dalal jàmm!`, tenantConfig);
|
| 292 |
-
logger.info(`[WhatsApp] ✅ Day 1 intro image sent to ${user.phone}`);
|
| 293 |
-
}
|
| 294 |
-
|
| 295 |
-
const lessonMsg = textFR ? `${lessonText}\n(FR) ${textFR}` : lessonText;
|
| 296 |
-
const formattedMessages = shortenForWhatsApp(lessonMsg);
|
| 297 |
-
for (const msg of formattedMessages) {
|
| 298 |
-
await sendTextMessage(user.phone, msg, tenantConfig);
|
| 299 |
-
}
|
| 300 |
-
} catch (err) {
|
| 301 |
-
logger.error({ err }, `[PEDAGOGY] Failed to send native audio, falling back to text`);
|
| 302 |
-
// Fallback: Send at least the text if audio fails entirely
|
| 303 |
-
if (lessonText) {
|
| 304 |
-
const alertMsg = isWolof ? "⚠️ Kàddu gi mënul a yónnee. Làng gi a ngi nii ci mbind:" : "⚠️ Impossible de charger l'audio de la leçon. Voici le contenu au format texte :";
|
| 305 |
-
await sendTextMessage(user.phone, alertMsg, tenantConfig);
|
| 306 |
-
let textFR = '';
|
| 307 |
-
const bJson = castJson<ButtonsJson>(trackDay.buttonsJson);
|
| 308 |
-
if (!isWolof && bJson?.content?.FR) {
|
| 309 |
-
const frContent = bJson.content.FR;
|
| 310 |
-
if (frContent && !Array.isArray(frContent)) {
|
| 311 |
-
textFR = (frContent as MultilangContent).lessonText || '';
|
| 312 |
-
}
|
| 313 |
-
}
|
| 314 |
-
const lessonMsg = textFR ? `${lessonText}\n(FR) ${textFR}` : lessonText;
|
| 315 |
-
const formattedMessages = shortenForWhatsApp(lessonMsg);
|
| 316 |
-
for (const msg of formattedMessages) {
|
| 317 |
-
await sendTextMessage(user.phone, msg, tenantConfig);
|
| 318 |
-
}
|
| 319 |
-
}
|
| 320 |
-
}
|
| 321 |
-
} else if (lessonText) {
|
| 322 |
-
// Fallback: Alert discreetly if no audio URL could be produced
|
| 323 |
-
const alertMsg = isWolof ? "⚠️ Kàddu gi mënul a yónnee. Làng gi a ngi nii ci mbind:" : "⚠️ Impossible de charger l'audio de la leçon. Voici le contenu au format texte :";
|
| 324 |
-
await sendTextMessage(user.phone, alertMsg, tenantConfig);
|
| 325 |
-
|
| 326 |
-
let textFR = '';
|
| 327 |
-
const bJson = castJson<ButtonsJson>(trackDay.buttonsJson);
|
| 328 |
-
if (!isWolof && bJson?.content?.FR) {
|
| 329 |
-
const frContent = bJson.content.FR;
|
| 330 |
-
if (frContent && !Array.isArray(frContent)) {
|
| 331 |
-
textFR = (frContent as MultilangContent).lessonText || '';
|
| 332 |
-
}
|
| 333 |
-
}
|
| 334 |
-
|
| 335 |
-
const lessonMsg = textFR ? `${lessonText}\n(FR) ${textFR}` : lessonText;
|
| 336 |
-
const formattedMessages = shortenForWhatsApp(lessonMsg);
|
| 337 |
-
for (const msg of formattedMessages) {
|
| 338 |
-
await sendTextMessage(user.phone, msg, tenantConfig);
|
| 339 |
-
}
|
| 340 |
}
|
| 341 |
|
| 342 |
-
|
|
|
|
|
|
|
|
|
|
| 343 |
if (exercisePrompt) {
|
| 344 |
if (trackDay.exerciseType === 'BUTTON' && trackDay.buttonsJson) {
|
| 345 |
-
|
| 346 |
-
await sendInteractiveButtonMessage(user.phone, exercisePrompt, buttons, undefined, tenantConfig);
|
| 347 |
} else {
|
| 348 |
await sendTextMessage(user.phone, exercisePrompt, tenantConfig);
|
| 349 |
}
|
| 350 |
}
|
| 351 |
|
| 352 |
-
//
|
| 353 |
-
|
| 354 |
-
const isHistorical = options?.skipProgressUpdate === true || dayNumber < currentDay;
|
| 355 |
-
|
| 356 |
if (dayNumber === 1 && !isHistorical) {
|
| 357 |
-
|
| 358 |
-
await sendTextMessage(
|
| 359 |
-
user.phone,
|
| 360 |
-
isWolof
|
| 361 |
-
? "🎙️ Lëjj bi: Tontul kàddu gi ci dëbb (vocal) walla mbind (texte)."
|
| 362 |
-
: "🎙️ À toi de jouer ! Réponds à l'exercice ci-dessus par message vocal ou texte.",
|
| 363 |
-
tenantConfig
|
| 364 |
-
);
|
| 365 |
} else {
|
| 366 |
-
const
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
title: isWolof ? "Revoir" : "Revue",
|
| 372 |
-
rows: [{
|
| 373 |
-
id: `DAY${dayNumber}_REPLAY`,
|
| 374 |
-
title: isWolof ? `🎧 Refaire Bés ${dayNumber}` : `🎧 Refaire Leçon ${dayNumber}`,
|
| 375 |
-
description: isWolof ? "Waxtu bi ci kaw" : "Réécouter la leçon"
|
| 376 |
-
}]
|
| 377 |
-
});
|
| 378 |
-
} else {
|
| 379 |
-
// MODE NOUVEAU: Let user answer or see history
|
| 380 |
-
sections.push({
|
| 381 |
-
title: isWolof ? "Jëfandikoo" : "Actions",
|
| 382 |
-
rows: [
|
| 383 |
-
{
|
| 384 |
-
id: `DAY${dayNumber}_EXERCISE`,
|
| 385 |
-
title: isWolof ? "📝 Tontul exercice" : "📝 Répondre",
|
| 386 |
-
description: isWolof ? "Dëbb (vocal) walla mbind" : "Message vocal ou texte"
|
| 387 |
-
},
|
| 388 |
-
{
|
| 389 |
-
id: `MENU_HISTORIQUE`,
|
| 390 |
-
title: isWolof ? "📚 Li nekk ci ginnaaw" : "📚 Revoir anciennes leçons",
|
| 391 |
-
description: isWolof ? "Seeti lu passé" : "Historique des leçons"
|
| 392 |
-
}
|
| 393 |
-
]
|
| 394 |
-
});
|
| 395 |
-
}
|
| 396 |
-
|
| 397 |
-
await sendInteractiveListMessage(
|
| 398 |
-
user.phone,
|
| 399 |
-
isWolof ? `Jour ${dayNumber}` : `Leçon ${dayNumber}`,
|
| 400 |
-
isWolof
|
| 401 |
-
? "Seetee ci suuf ban jëf ngay def :"
|
| 402 |
-
: "Que veux-tu faire maintenant ?",
|
| 403 |
-
isWolof ? "Tànnal" : "Choisir",
|
| 404 |
-
sections,
|
| 405 |
-
undefined,
|
| 406 |
-
tenantConfig
|
| 407 |
-
);
|
| 408 |
}
|
|
|
|
| 409 |
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
if (!options?.skipProgressUpdate) {
|
| 414 |
-
await prisma.userProgress.upsert({
|
| 415 |
-
where: { userId_trackId: { userId, trackId } },
|
| 416 |
-
update: {
|
| 417 |
-
exerciseStatus: 'PENDING',
|
| 418 |
-
lastInteraction: new Date()
|
| 419 |
-
},
|
| 420 |
-
create: {
|
| 421 |
-
userId,
|
| 422 |
-
trackId,
|
| 423 |
-
exerciseStatus: 'PENDING',
|
| 424 |
-
organizationId
|
| 425 |
-
}
|
| 426 |
-
});
|
| 427 |
-
|
| 428 |
-
await prisma.enrollment.updateMany({
|
| 429 |
-
where: { userId, trackId, status: 'ACTIVE' },
|
| 430 |
-
data: {
|
| 431 |
-
currentDay: dayNumber,
|
| 432 |
-
lastActivityAt: new Date()
|
| 433 |
-
}
|
| 434 |
-
});
|
| 435 |
-
|
| 436 |
-
logger.info(`[PEDAGOGY] Lesson Day ${dayNumber} sent. UserProgress set to PENDING.`);
|
| 437 |
-
} else {
|
| 438 |
-
logger.info(`[PEDAGOGY] Lesson Day ${dayNumber} replayed (read-only). currentDay unchanged.`);
|
| 439 |
-
}
|
| 440 |
}
|
|
|
|
|
|
|
| 1 |
import { prisma } from './services/prisma';
|
| 2 |
+
import { sendTextMessage, sendAudioMessage, sendInteractiveButtonMessage, sendImageMessage, sendVideoMessage } from './whatsapp-cloud';
|
| 3 |
+
import { isFeatureEnabled } from './config';
|
|
|
|
| 4 |
import { shortenForWhatsApp } from './normalizeWolof';
|
| 5 |
+
import { ButtonsJson } from './handlers/types';
|
| 6 |
import { castJson } from '@repo/shared-types';
|
| 7 |
+
import { AIPedagogyService } from './services/ai-pedagogy';
|
|
|
|
| 8 |
|
| 9 |
const BADGE_EMOJIS: Record<string, string> = {
|
| 10 |
+
"CLARTÉ": "🏅", "CONFIANCE": "🌟", "CLIENT": "👥", "OFFRE": "📦", "PITCH": "🎙️"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
};
|
| 12 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
function generateProgressBar(current: number, total: number): string {
|
| 14 |
const size = 10;
|
| 15 |
const progress = Math.min(Math.max(Math.round((current / total) * size), 0), size);
|
| 16 |
+
const bar = '█'.repeat(progress) + '░'.repeat(size - progress);
|
| 17 |
+
return `[${bar}] ${Math.round((current / total) * 100)}%`;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
function generateLessonHeader(isWolof: boolean, trackTitle: string, dayNumber: number, totalDays: number, badgeName?: string): string {
|
| 21 |
+
const progressBar = isFeatureEnabled('FEATURE_PROGRESS_BAR') ? `\n${generateProgressBar(dayNumber, totalDays)}` : '';
|
| 22 |
+
const badgeText = badgeName ? `\nBadge : ${badgeName} ${BADGE_EMOJIS[badgeName] || '🏅'}` : '';
|
| 23 |
+
const dayDisplay = dayNumber === 1.5 ? '1bis' : Math.floor(dayNumber).toString();
|
| 24 |
+
|
| 25 |
+
return isWolof
|
| 26 |
+
? `*${trackTitle}*\n*Bés ${dayDisplay}* 🗓️${progressBar}${badgeText}\n\n`
|
| 27 |
+
: `*${trackTitle}*\n*Jour ${dayDisplay}* 🗓️${progressBar}${badgeText}\n\n`;
|
| 28 |
}
|
| 29 |
|
| 30 |
export async function sendLessonDay(
|
|
|
|
| 34 |
organizationId: string,
|
| 35 |
options?: { skipProgressUpdate?: boolean }
|
| 36 |
) {
|
|
|
|
|
|
|
| 37 |
const user = await prisma.user.findUnique({
|
| 38 |
where: { id: userId },
|
| 39 |
include: {
|
|
|
|
| 42 |
organization: { include: { phoneNumbers: true } }
|
| 43 |
}
|
| 44 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
|
| 46 |
+
if (!user || !user.phone) return;
|
| 47 |
const isWolof = user.language === 'WOLOF';
|
| 48 |
const activeEnrollment = user.enrollments[0];
|
|
|
|
| 49 |
const tenantConfig = {
|
| 50 |
accessToken: user.organization?.systemUserToken || '',
|
| 51 |
phoneNumberId: user.organization?.phoneNumbers?.[0]?.id || ''
|
| 52 |
};
|
|
|
|
| 53 |
|
| 54 |
+
const trackDay = await prisma.trackDay.findFirst({ where: { trackId, dayNumber } });
|
| 55 |
+
if (!trackDay) return;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
|
| 57 |
let lessonText = trackDay.lessonText || '';
|
| 58 |
let exercisePrompt = trackDay.exercisePrompt || '';
|
| 59 |
|
| 60 |
+
// Multi-lang content
|
| 61 |
const buttonsJson = castJson<ButtonsJson>(trackDay.buttonsJson);
|
| 62 |
+
if (buttonsJson?.content && (buttonsJson.content as any)[user.language]) {
|
| 63 |
const langContent = (buttonsJson.content as any)[user.language];
|
| 64 |
+
lessonText = langContent.lessonText || lessonText;
|
| 65 |
+
exercisePrompt = langContent.exercisePrompt || exercisePrompt;
|
|
|
|
|
|
|
| 66 |
}
|
| 67 |
|
| 68 |
+
// AI Personalization
|
| 69 |
if (user.activity && lessonText) {
|
| 70 |
+
const previousResponses = await prisma.response.findMany({
|
| 71 |
+
where: { userId: user.id, enrollmentId: activeEnrollment.id, organizationId },
|
| 72 |
+
orderBy: { dayNumber: 'asc' },
|
| 73 |
+
take: 5
|
| 74 |
+
}).then(res => res.map(r => ({ day: r.dayNumber, response: r.content })));
|
| 75 |
+
|
| 76 |
+
lessonText = await AIPedagogyService.personalizeLesson({
|
| 77 |
+
lessonText,
|
| 78 |
+
userActivity: user.activity,
|
| 79 |
+
userLanguage: user.language,
|
| 80 |
+
businessProfile: user.businessProfile,
|
| 81 |
+
previousResponses,
|
| 82 |
+
tenantPrompt: (user.organization as any)?.customPrompt,
|
| 83 |
+
tenantBranding: (user.organization as any)?.brandingData,
|
| 84 |
+
organizationId
|
| 85 |
+
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
}
|
| 87 |
|
| 88 |
+
// Formatting
|
| 89 |
const totalDays = activeEnrollment?.track?.duration || 12;
|
| 90 |
+
const userProgress = await prisma.userProgress.findUnique({ where: { userId_trackId: { userId, trackId } } });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
const badges = (userProgress?.badges as string[]) || [];
|
| 92 |
+
const lastBadge = badges.length > 0 ? (badges[badges.length - 1] === 'REPRISE' && dayNumber % 1 === 0 ? badges[badges.length - 2] : badges[badges.length - 1]) : undefined;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
|
| 94 |
+
lessonText = generateLessonHeader(isWolof, activeEnrollment?.track?.title || 'XAMLÉ', dayNumber, totalDays, lastBadge) + lessonText;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
|
| 96 |
+
// Visuals Dispatch
|
| 97 |
if (trackDay.videoUrl) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
try {
|
| 99 |
+
await sendVideoMessage(user.phone, trackDay.videoUrl, trackDay.videoCaption || (isWolof ? "Xoolal vidéo bi !" : "Regarde cette vidéo !"), tenantConfig);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
} catch (err) {
|
| 101 |
+
const fallbackMsg = isWolof ? `⚠️ Vidéo bi mënul neex léegi. Klikal fii :\n${trackDay.videoUrl}` : `⚠️ La vidéo ne peut être affichée directement. Clique ici :\n${trackDay.videoUrl}`;
|
| 102 |
+
await sendTextMessage(user.phone, fallbackMsg, tenantConfig);
|
| 103 |
+
if (trackDay.imageUrl) await sendImageMessage(user.phone, trackDay.imageUrl, undefined, tenantConfig);
|
| 104 |
}
|
| 105 |
+
} else if (trackDay.imageUrl) {
|
| 106 |
+
await sendImageMessage(user.phone, trackDay.imageUrl, undefined, tenantConfig);
|
| 107 |
}
|
| 108 |
|
| 109 |
+
// Audio & Text Send
|
| 110 |
+
let finalAudioUrl = trackDay.audioUrl || await AIPedagogyService.generateLessonAudio(lessonText, organizationId);
|
| 111 |
if (finalAudioUrl) {
|
| 112 |
+
await sendAudioMessage(user.phone, finalAudioUrl, tenantConfig);
|
| 113 |
+
await prisma.message.create({
|
| 114 |
+
data: { userId: user.id, direction: 'OUTBOUND', channel: 'WHATSAPP', content: lessonText, mediaUrl: finalAudioUrl, organizationId }
|
| 115 |
+
}).catch(() => {});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
}
|
| 117 |
|
| 118 |
+
const messages = shortenForWhatsApp(lessonText);
|
| 119 |
+
for (const msg of messages) await sendTextMessage(user.phone, msg, tenantConfig);
|
| 120 |
+
|
| 121 |
+
// Exercise
|
| 122 |
if (exercisePrompt) {
|
| 123 |
if (trackDay.exerciseType === 'BUTTON' && trackDay.buttonsJson) {
|
| 124 |
+
await sendInteractiveButtonMessage(user.phone, exercisePrompt, trackDay.buttonsJson as any, undefined, tenantConfig);
|
|
|
|
| 125 |
} else {
|
| 126 |
await sendTextMessage(user.phone, exercisePrompt, tenantConfig);
|
| 127 |
}
|
| 128 |
}
|
| 129 |
|
| 130 |
+
// Action Menu
|
| 131 |
+
const isHistorical = options?.skipProgressUpdate === true || dayNumber < (activeEnrollment?.currentDay || 1);
|
|
|
|
|
|
|
| 132 |
if (dayNumber === 1 && !isHistorical) {
|
| 133 |
+
await sendTextMessage(user.phone, isWolof ? "🎙️ Tontul kàddu gi ci vocal walla mbind." : "🎙️ Réponds par message vocal ou texte.", tenantConfig);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
} else {
|
| 135 |
+
const rows = isHistorical ? [{ id: `DAY${dayNumber}_REPLAY`, title: isWolof ? `🎧 Refaire Bés ${dayNumber}` : `🎧 Refaire Leçon ${dayNumber}` }] : [
|
| 136 |
+
{ id: `DAY${dayNumber}_EXERCISE`, title: isWolof ? "📝 Tontul" : "📝 Répondre" },
|
| 137 |
+
{ id: `MENU_HISTORIQUE`, title: isWolof ? "📚 Li nekk ci ginnaaw" : "📚 Revoir leçons" }
|
| 138 |
+
];
|
| 139 |
+
await sendInteractiveListMessage(user.phone, isWolof ? "Sa Mbir" : "Actions", isWolof ? "Tànnal :" : "Choisis :", isWolof ? "Tànn" : "Menu", [{ title: "Menu", rows: rows as any }], undefined, tenantConfig);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
}
|
| 141 |
+
}
|
| 142 |
|
| 143 |
+
async function sendInteractiveListMessage(phone: string, header: string, body: string, label: string, sections: any[], metadata: any, config: any) {
|
| 144 |
+
const { sendInteractiveListMessage: sendList } = await import('./whatsapp-cloud');
|
| 145 |
+
return sendList(phone, header, body, label, sections, metadata, config);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
}
|
apps/whatsapp-worker/src/scratch/check_orgs.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { prisma } from '../services/prisma';
|
| 2 |
+
import * as dotenv from 'dotenv';
|
| 3 |
+
import path from 'path';
|
| 4 |
+
|
| 5 |
+
// Load .env from root
|
| 6 |
+
dotenv.config({ path: path.join(__dirname, '../../../../.env') });
|
| 7 |
+
|
| 8 |
+
async function main() {
|
| 9 |
+
const orgs = await prisma.organization.findMany();
|
| 10 |
+
console.log('Organizations:', JSON.stringify(orgs, null, 2));
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
main()
|
| 14 |
+
.catch(console.error)
|
| 15 |
+
.finally(async () => {
|
| 16 |
+
await prisma.$disconnect();
|
| 17 |
+
});
|
apps/whatsapp-worker/src/services/ai-pedagogy.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { logger } from '../logger';
|
| 2 |
+
import { getApiUrl, getAdminApiKey } from '../config';
|
| 3 |
+
import fetch from 'node-fetch';
|
| 4 |
+
|
| 5 |
+
export interface PersonalizeParams {
|
| 6 |
+
lessonText: string;
|
| 7 |
+
userActivity: string;
|
| 8 |
+
userLanguage: string;
|
| 9 |
+
businessProfile?: any;
|
| 10 |
+
previousResponses: Array<{ day: number; response: string | null }>;
|
| 11 |
+
tenantPrompt?: string;
|
| 12 |
+
tenantBranding?: any;
|
| 13 |
+
organizationId: string;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
export class AIPedagogyService {
|
| 17 |
+
static async personalizeLesson(params: PersonalizeParams): Promise<string> {
|
| 18 |
+
try {
|
| 19 |
+
const AI_API_BASE_URL = getApiUrl();
|
| 20 |
+
const apiKey = getAdminApiKey();
|
| 21 |
+
|
| 22 |
+
const res = await fetch(`${AI_API_BASE_URL.replace(/\/$/, "")}/v1/ai/personalize-lesson`, {
|
| 23 |
+
method: 'POST',
|
| 24 |
+
headers: {
|
| 25 |
+
'Content-Type': 'application/json',
|
| 26 |
+
'Authorization': `Bearer ${apiKey}`,
|
| 27 |
+
'x-organization-id': params.organizationId
|
| 28 |
+
},
|
| 29 |
+
body: JSON.stringify({
|
| 30 |
+
lessonText: params.lessonText,
|
| 31 |
+
userActivity: params.userActivity,
|
| 32 |
+
userLanguage: params.userLanguage,
|
| 33 |
+
businessProfile: params.businessProfile,
|
| 34 |
+
previousResponses: params.previousResponses,
|
| 35 |
+
tenantPrompt: params.tenantPrompt,
|
| 36 |
+
tenantBranding: params.tenantBranding
|
| 37 |
+
})
|
| 38 |
+
});
|
| 39 |
+
|
| 40 |
+
if (res.ok) {
|
| 41 |
+
const data = await res.json() as { text: string };
|
| 42 |
+
return data.text || params.lessonText;
|
| 43 |
+
}
|
| 44 |
+
} catch (err) {
|
| 45 |
+
logger.error(`[AI_PEDAGOGY] Personalization failed: ${err}`);
|
| 46 |
+
}
|
| 47 |
+
return params.lessonText;
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
static async generateLessonAudio(text: string, organizationId: string): Promise<string | undefined> {
|
| 51 |
+
try {
|
| 52 |
+
const AI_API_BASE_URL = getApiUrl();
|
| 53 |
+
const apiKey = getAdminApiKey();
|
| 54 |
+
|
| 55 |
+
const res = await fetch(`${AI_API_BASE_URL.replace(/\/$/, "")}/v1/ai/tts`, {
|
| 56 |
+
method: 'POST',
|
| 57 |
+
headers: {
|
| 58 |
+
'Content-Type': 'application/json',
|
| 59 |
+
'Authorization': `Bearer ${apiKey}`,
|
| 60 |
+
'x-organization-id': organizationId
|
| 61 |
+
},
|
| 62 |
+
body: JSON.stringify({ text })
|
| 63 |
+
});
|
| 64 |
+
|
| 65 |
+
if (res.ok) {
|
| 66 |
+
const data = await res.json() as { url: string };
|
| 67 |
+
return data.url;
|
| 68 |
+
}
|
| 69 |
+
} catch (err) {
|
| 70 |
+
logger.error(`[AI_PEDAGOGY] TTS failed: ${err}`);
|
| 71 |
+
}
|
| 72 |
+
return undefined;
|
| 73 |
+
}
|
| 74 |
+
}
|
apps/whatsapp-worker/src/services/prisma.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
| 1 |
-
import { PrismaClient } from '@repo/database';
|
| 2 |
|
| 3 |
-
|
|
|
|
|
|
| 1 |
+
import { PrismaClient, withTenantIsolation } from '@repo/database';
|
| 2 |
|
| 3 |
+
const basePrisma = new PrismaClient();
|
| 4 |
+
export const prisma = withTenantIsolation(basePrisma);
|
packages/database/index.ts
CHANGED
|
@@ -1 +1,3 @@
|
|
| 1 |
export * from '@prisma/client';
|
|
|
|
|
|
|
|
|
| 1 |
export * from '@prisma/client';
|
| 2 |
+
export * from './src/extension';
|
| 3 |
+
export * from './src/context';
|
packages/database/prisma/schema.prisma
CHANGED
|
@@ -13,6 +13,7 @@ model Organization {
|
|
| 13 |
wabaId String? @unique
|
| 14 |
systemUserToken String?
|
| 15 |
customPrompt String? @db.Text
|
|
|
|
| 16 |
brandingData Json?
|
| 17 |
createdAt DateTime @default(now())
|
| 18 |
updatedAt DateTime @updatedAt
|
|
@@ -24,6 +25,10 @@ model Organization {
|
|
| 24 |
payments Payment[]
|
| 25 |
responses Response[]
|
| 26 |
progress UserProgress[]
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
}
|
| 28 |
|
| 29 |
model WhatsAppPhoneNumber {
|
|
@@ -56,6 +61,8 @@ model User {
|
|
| 56 |
payments Payment[]
|
| 57 |
responses Response[]
|
| 58 |
progress UserProgress[]
|
|
|
|
|
|
|
| 59 |
}
|
| 60 |
|
| 61 |
model BusinessProfile {
|
|
@@ -74,11 +81,15 @@ model BusinessProfile {
|
|
| 74 |
financialProjections Json?
|
| 75 |
fundingAsk String?
|
| 76 |
lastUpdatedFromDay Int @default(0)
|
|
|
|
| 77 |
createdAt DateTime @default(now())
|
| 78 |
updatedAt DateTime @updatedAt
|
| 79 |
teamMembers Json? @map("teamMembers")
|
| 80 |
teamMembersList TeamMember[]
|
| 81 |
user User @relation(fields: [userId], references: [id])
|
|
|
|
|
|
|
|
|
|
| 82 |
}
|
| 83 |
|
| 84 |
model TeamMember {
|
|
@@ -87,7 +98,11 @@ model TeamMember {
|
|
| 87 |
name String?
|
| 88 |
role String?
|
| 89 |
bio String?
|
|
|
|
| 90 |
businessProfile BusinessProfile @relation(fields: [businessProfileId], references: [id], onDelete: Cascade)
|
|
|
|
|
|
|
|
|
|
| 91 |
}
|
| 92 |
|
| 93 |
model Track {
|
|
@@ -107,6 +122,8 @@ model Track {
|
|
| 107 |
payments Payment[]
|
| 108 |
days TrackDay[]
|
| 109 |
progress UserProgress[]
|
|
|
|
|
|
|
| 110 |
}
|
| 111 |
|
| 112 |
model TrackDay {
|
|
@@ -126,9 +143,13 @@ model TrackDay {
|
|
| 126 |
exerciseCriteria Json?
|
| 127 |
badges Json?
|
| 128 |
unlockCondition String?
|
|
|
|
| 129 |
createdAt DateTime @default(now())
|
| 130 |
updatedAt DateTime @updatedAt
|
| 131 |
track Track @relation(fields: [trackId], references: [id])
|
|
|
|
|
|
|
|
|
|
| 132 |
}
|
| 133 |
|
| 134 |
model UserProgress {
|
|
@@ -155,6 +176,7 @@ model UserProgress {
|
|
| 155 |
user User @relation(fields: [userId], references: [id])
|
| 156 |
|
| 157 |
@@unique([userId, trackId])
|
|
|
|
| 158 |
}
|
| 159 |
|
| 160 |
model Enrollment {
|
|
@@ -171,6 +193,8 @@ model Enrollment {
|
|
| 171 |
track Track @relation(fields: [trackId], references: [id])
|
| 172 |
user User @relation(fields: [userId], references: [id])
|
| 173 |
responses Response[]
|
|
|
|
|
|
|
| 174 |
}
|
| 175 |
|
| 176 |
model Response {
|
|
@@ -186,6 +210,8 @@ model Response {
|
|
| 186 |
enrollment Enrollment @relation(fields: [enrollmentId], references: [id], onDelete: Cascade)
|
| 187 |
organization Organization @relation(fields: [organizationId], references: [id])
|
| 188 |
user User @relation(fields: [userId], references: [id])
|
|
|
|
|
|
|
| 189 |
}
|
| 190 |
|
| 191 |
model Message {
|
|
@@ -202,6 +228,7 @@ model Message {
|
|
| 202 |
user User @relation(fields: [userId], references: [id])
|
| 203 |
|
| 204 |
@@index([userId, createdAt])
|
|
|
|
| 205 |
}
|
| 206 |
|
| 207 |
model Payment {
|
|
@@ -218,6 +245,8 @@ model Payment {
|
|
| 218 |
organization Organization @relation(fields: [organizationId], references: [id])
|
| 219 |
track Track @relation(fields: [trackId], references: [id])
|
| 220 |
user User @relation(fields: [userId], references: [id])
|
|
|
|
|
|
|
| 221 |
}
|
| 222 |
|
| 223 |
model TrainingData {
|
|
@@ -292,5 +321,9 @@ model UserBadge {
|
|
| 292 |
userProgressId String
|
| 293 |
name String
|
| 294 |
earnedAt DateTime @default(now())
|
|
|
|
| 295 |
userProgress UserProgress @relation(fields: [userProgressId], references: [id], onDelete: Cascade)
|
|
|
|
|
|
|
|
|
|
| 296 |
}
|
|
|
|
| 13 |
wabaId String? @unique
|
| 14 |
systemUserToken String?
|
| 15 |
customPrompt String? @db.Text
|
| 16 |
+
personalityConfig Json? // Dynamic personality variables
|
| 17 |
brandingData Json?
|
| 18 |
createdAt DateTime @default(now())
|
| 19 |
updatedAt DateTime @updatedAt
|
|
|
|
| 25 |
payments Payment[]
|
| 26 |
responses Response[]
|
| 27 |
progress UserProgress[]
|
| 28 |
+
trackDays TrackDay[]
|
| 29 |
+
businessProfiles BusinessProfile[]
|
| 30 |
+
teamMembers TeamMember[]
|
| 31 |
+
userBadges UserBadge[]
|
| 32 |
}
|
| 33 |
|
| 34 |
model WhatsAppPhoneNumber {
|
|
|
|
| 61 |
payments Payment[]
|
| 62 |
responses Response[]
|
| 63 |
progress UserProgress[]
|
| 64 |
+
|
| 65 |
+
@@index([organizationId])
|
| 66 |
}
|
| 67 |
|
| 68 |
model BusinessProfile {
|
|
|
|
| 81 |
financialProjections Json?
|
| 82 |
fundingAsk String?
|
| 83 |
lastUpdatedFromDay Int @default(0)
|
| 84 |
+
organizationId String @default("default-org-id")
|
| 85 |
createdAt DateTime @default(now())
|
| 86 |
updatedAt DateTime @updatedAt
|
| 87 |
teamMembers Json? @map("teamMembers")
|
| 88 |
teamMembersList TeamMember[]
|
| 89 |
user User @relation(fields: [userId], references: [id])
|
| 90 |
+
organization Organization @relation(fields: [organizationId], references: [id])
|
| 91 |
+
|
| 92 |
+
@@index([organizationId])
|
| 93 |
}
|
| 94 |
|
| 95 |
model TeamMember {
|
|
|
|
| 98 |
name String?
|
| 99 |
role String?
|
| 100 |
bio String?
|
| 101 |
+
organizationId String @default("default-org-id")
|
| 102 |
businessProfile BusinessProfile @relation(fields: [businessProfileId], references: [id], onDelete: Cascade)
|
| 103 |
+
organization Organization @relation(fields: [organizationId], references: [id])
|
| 104 |
+
|
| 105 |
+
@@index([organizationId])
|
| 106 |
}
|
| 107 |
|
| 108 |
model Track {
|
|
|
|
| 122 |
payments Payment[]
|
| 123 |
days TrackDay[]
|
| 124 |
progress UserProgress[]
|
| 125 |
+
|
| 126 |
+
@@index([organizationId])
|
| 127 |
}
|
| 128 |
|
| 129 |
model TrackDay {
|
|
|
|
| 143 |
exerciseCriteria Json?
|
| 144 |
badges Json?
|
| 145 |
unlockCondition String?
|
| 146 |
+
organizationId String @default("default-org-id")
|
| 147 |
createdAt DateTime @default(now())
|
| 148 |
updatedAt DateTime @updatedAt
|
| 149 |
track Track @relation(fields: [trackId], references: [id])
|
| 150 |
+
organization Organization @relation(fields: [organizationId], references: [id])
|
| 151 |
+
|
| 152 |
+
@@index([organizationId])
|
| 153 |
}
|
| 154 |
|
| 155 |
model UserProgress {
|
|
|
|
| 176 |
user User @relation(fields: [userId], references: [id])
|
| 177 |
|
| 178 |
@@unique([userId, trackId])
|
| 179 |
+
@@index([organizationId])
|
| 180 |
}
|
| 181 |
|
| 182 |
model Enrollment {
|
|
|
|
| 193 |
track Track @relation(fields: [trackId], references: [id])
|
| 194 |
user User @relation(fields: [userId], references: [id])
|
| 195 |
responses Response[]
|
| 196 |
+
|
| 197 |
+
@@index([organizationId])
|
| 198 |
}
|
| 199 |
|
| 200 |
model Response {
|
|
|
|
| 210 |
enrollment Enrollment @relation(fields: [enrollmentId], references: [id], onDelete: Cascade)
|
| 211 |
organization Organization @relation(fields: [organizationId], references: [id])
|
| 212 |
user User @relation(fields: [userId], references: [id])
|
| 213 |
+
|
| 214 |
+
@@index([organizationId])
|
| 215 |
}
|
| 216 |
|
| 217 |
model Message {
|
|
|
|
| 228 |
user User @relation(fields: [userId], references: [id])
|
| 229 |
|
| 230 |
@@index([userId, createdAt])
|
| 231 |
+
@@index([organizationId])
|
| 232 |
}
|
| 233 |
|
| 234 |
model Payment {
|
|
|
|
| 245 |
organization Organization @relation(fields: [organizationId], references: [id])
|
| 246 |
track Track @relation(fields: [trackId], references: [id])
|
| 247 |
user User @relation(fields: [userId], references: [id])
|
| 248 |
+
|
| 249 |
+
@@index([organizationId])
|
| 250 |
}
|
| 251 |
|
| 252 |
model TrainingData {
|
|
|
|
| 321 |
userProgressId String
|
| 322 |
name String
|
| 323 |
earnedAt DateTime @default(now())
|
| 324 |
+
organizationId String @default("default-org-id")
|
| 325 |
userProgress UserProgress @relation(fields: [userProgressId], references: [id], onDelete: Cascade)
|
| 326 |
+
organization Organization @relation(fields: [organizationId], references: [id])
|
| 327 |
+
|
| 328 |
+
@@index([organizationId])
|
| 329 |
}
|
packages/database/src/context.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { AsyncLocalStorage } from 'async_hooks';
|
| 2 |
+
|
| 3 |
+
export const tenantContext = new AsyncLocalStorage<{ organizationId: string }>();
|
| 4 |
+
|
| 5 |
+
export const runWithTenant = <T>(organizationId: string, fn: () => T): T => {
|
| 6 |
+
return tenantContext.run({ organizationId }, fn);
|
| 7 |
+
};
|
| 8 |
+
|
| 9 |
+
export const getOrganizationId = (): string | undefined => {
|
| 10 |
+
return tenantContext.getStore()?.organizationId;
|
| 11 |
+
};
|
packages/database/src/extension.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { PrismaClient } from '@prisma/client';
|
| 2 |
+
import { getOrganizationId } from './context';
|
| 3 |
+
|
| 4 |
+
export const createTenantExtension = (explicitOrganizationId?: string) => {
|
| 5 |
+
return {
|
| 6 |
+
query: {
|
| 7 |
+
$allModels: {
|
| 8 |
+
async $allOperations({ operation, args, query }: any) {
|
| 9 |
+
const organizationId = explicitOrganizationId || getOrganizationId();
|
| 10 |
+
|
| 11 |
+
if (!organizationId) {
|
| 12 |
+
return query(args);
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
// List of operations where we want to enforce organizationId
|
| 16 |
+
const filteredOperations = [
|
| 17 |
+
'findMany',
|
| 18 |
+
'findFirst',
|
| 19 |
+
'count',
|
| 20 |
+
'updateMany',
|
| 21 |
+
'deleteMany',
|
| 22 |
+
'aggregate',
|
| 23 |
+
'groupBy'
|
| 24 |
+
];
|
| 25 |
+
|
| 26 |
+
if (filteredOperations.includes(operation)) {
|
| 27 |
+
args.where = { ...args.where, organizationId };
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
// Special handling for create: automatically inject organizationId
|
| 31 |
+
if (operation === 'create') {
|
| 32 |
+
args.data = { ...args.data, organizationId };
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
// For update/delete/findUnique
|
| 36 |
+
if (['update', 'delete', 'findUnique'].includes(operation)) {
|
| 37 |
+
args.where = { ...args.where, organizationId };
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
return query(args);
|
| 41 |
+
},
|
| 42 |
+
},
|
| 43 |
+
},
|
| 44 |
+
};
|
| 45 |
+
};
|
| 46 |
+
|
| 47 |
+
/**
|
| 48 |
+
* Extends a PrismaClient with a helper to get a tenant-bound client
|
| 49 |
+
*/
|
| 50 |
+
export function withTenantIsolation(prisma: PrismaClient) {
|
| 51 |
+
return prisma.$extends({
|
| 52 |
+
client: {
|
| 53 |
+
$forOrganization(organizationId: string) {
|
| 54 |
+
return prisma.$extends(createTenantExtension(organizationId));
|
| 55 |
+
}
|
| 56 |
+
}
|
| 57 |
+
}).$extends(createTenantExtension()); // Also apply the automatic context-based extension
|
| 58 |
+
}
|
packages/prompts/src/index.ts
CHANGED
|
@@ -1,12 +1,30 @@
|
|
| 1 |
import fs from 'fs';
|
| 2 |
import path from 'path';
|
| 3 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
export class PromptLoader {
|
| 5 |
private static templatesDir = path.join(__dirname, 'templates');
|
| 6 |
|
| 7 |
static getTemplate(name: string): string {
|
| 8 |
const filePath = path.join(this.templatesDir, `${name}.md`);
|
| 9 |
try {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
return fs.readFileSync(filePath, 'utf-8');
|
| 11 |
} catch (err) {
|
| 12 |
console.error(`[PROMPT_LOADER] Error loading template "${name}":`, err);
|
|
@@ -14,10 +32,35 @@ export class PromptLoader {
|
|
| 14 |
}
|
| 15 |
}
|
| 16 |
|
| 17 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
let template = this.getTemplate(templateName);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
|
| 20 |
-
for (const [key, value] of Object.entries(
|
| 21 |
const placeholder = new RegExp(`{{${key}}}`, 'g');
|
| 22 |
template = template.replace(placeholder, String(value));
|
| 23 |
}
|
|
|
|
| 1 |
import fs from 'fs';
|
| 2 |
import path from 'path';
|
| 3 |
|
| 4 |
+
export interface PersonalityConfig {
|
| 5 |
+
botName: string;
|
| 6 |
+
coreMission: string;
|
| 7 |
+
toneDescription: string;
|
| 8 |
+
constraints?: string[];
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
export const DEFAULT_PERSONALITY: PersonalityConfig = {
|
| 12 |
+
botName: 'XAMLÉ COACH',
|
| 13 |
+
coreMission: "expert business pour entrepreneurs d'Afrique de l'Ouest",
|
| 14 |
+
toneDescription: "direct, dynamique et encourageant. Style WhatsApp (gras *texte*, emojis).",
|
| 15 |
+
constraints: ["JAMAIS ANGLAIS", "Ne jamais citer 'Manga Deaf'"]
|
| 16 |
+
};
|
| 17 |
+
|
| 18 |
export class PromptLoader {
|
| 19 |
private static templatesDir = path.join(__dirname, 'templates');
|
| 20 |
|
| 21 |
static getTemplate(name: string): string {
|
| 22 |
const filePath = path.join(this.templatesDir, `${name}.md`);
|
| 23 |
try {
|
| 24 |
+
if (!fs.existsSync(filePath)) {
|
| 25 |
+
console.warn(`[PROMPT_LOADER] Template "${name}" not found at ${filePath}`);
|
| 26 |
+
return '';
|
| 27 |
+
}
|
| 28 |
return fs.readFileSync(filePath, 'utf-8');
|
| 29 |
} catch (err) {
|
| 30 |
console.error(`[PROMPT_LOADER] Error loading template "${name}":`, err);
|
|
|
|
| 32 |
}
|
| 33 |
}
|
| 34 |
|
| 35 |
+
/**
|
| 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 {
|
| 43 |
let template = this.getTemplate(templateName);
|
| 44 |
+
if (!template) return '';
|
| 45 |
+
|
| 46 |
+
// Merge personality with defaults
|
| 47 |
+
const config: PersonalityConfig = {
|
| 48 |
+
botName: personality.botName || DEFAULT_PERSONALITY.botName,
|
| 49 |
+
coreMission: personality.coreMission || DEFAULT_PERSONALITY.coreMission,
|
| 50 |
+
toneDescription: personality.toneDescription || DEFAULT_PERSONALITY.toneDescription,
|
| 51 |
+
constraints: [...(DEFAULT_PERSONALITY.constraints || []), ...(personality.constraints || [])]
|
| 52 |
+
};
|
| 53 |
+
|
| 54 |
+
// Inject personality variables
|
| 55 |
+
const allVariables = {
|
| 56 |
+
...variables,
|
| 57 |
+
botName: config.botName,
|
| 58 |
+
coreMission: config.coreMission,
|
| 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 |
}
|
packages/prompts/src/templates/personalized-lesson.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
Tu es
|
| 2 |
Réécris la leçon ci-dessous pour qu'elle parle DIRECTEMENT au business de cet entrepreneur.
|
| 3 |
|
| 4 |
{{businessContext}}
|
|
@@ -16,9 +16,9 @@ STT Cleanup : Applique les règles de normalisation Wolof (ex: 'damae' -> 'damay
|
|
| 16 |
Utilisation du Glossaire Officiel : Utilise exclusivement les termes validés (ex: Ñàkk pour perte, Xaalis pour argent, Denc pour épargne).
|
| 17 |
|
| 18 |
CONTRAINTES DE FORMAT :
|
| 19 |
-
- STYLE :
|
| 20 |
- LANGUE : {{languageLabel}}.
|
| 21 |
-
-
|
| 22 |
|
| 23 |
LEÇON À ADAPTER :
|
| 24 |
{{lessonText}}
|
|
|
|
| 1 |
+
Tu es {{botName}}, {{coreMission}}.
|
| 2 |
Réécris la leçon ci-dessous pour qu'elle parle DIRECTEMENT au business de cet entrepreneur.
|
| 3 |
|
| 4 |
{{businessContext}}
|
|
|
|
| 16 |
Utilisation du Glossaire Officiel : Utilise exclusivement les termes validés (ex: Ñàkk pour perte, Xaalis pour argent, Denc pour épargne).
|
| 17 |
|
| 18 |
CONTRAINTES DE FORMAT :
|
| 19 |
+
- STYLE : {{toneDescription}}.
|
| 20 |
- LANGUE : {{languageLabel}}.
|
| 21 |
+
- {{constraints}}
|
| 22 |
|
| 23 |
LEÇON À ADAPTER :
|
| 24 |
{{lessonText}}
|
pnpm-lock.yaml
CHANGED
|
@@ -243,6 +243,9 @@ importers:
|
|
| 243 |
node-cron:
|
| 244 |
specifier: ^4.2.1
|
| 245 |
version: 4.2.1
|
|
|
|
|
|
|
|
|
|
| 246 |
sharp:
|
| 247 |
specifier: ^0.34.5
|
| 248 |
version: 0.34.5
|
|
@@ -256,12 +259,18 @@ importers:
|
|
| 256 |
'@types/node-cron':
|
| 257 |
specifier: ^3.0.11
|
| 258 |
version: 3.0.11
|
|
|
|
|
|
|
|
|
|
| 259 |
tsx:
|
| 260 |
specifier: ^3.0.0
|
| 261 |
version: 3.14.0
|
| 262 |
typescript:
|
| 263 |
specifier: ^5.0.0
|
| 264 |
version: 5.9.3
|
|
|
|
|
|
|
|
|
|
| 265 |
|
| 266 |
packages/database:
|
| 267 |
dependencies:
|
|
@@ -1163,6 +1172,10 @@ packages:
|
|
| 1163 |
'@ioredis/commands@1.5.0':
|
| 1164 |
resolution: {integrity: sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow==}
|
| 1165 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1166 |
'@jridgewell/gen-mapping@0.3.13':
|
| 1167 |
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
|
| 1168 |
|
|
@@ -1392,6 +1405,9 @@ packages:
|
|
| 1392 |
cpu: [x64]
|
| 1393 |
os: [win32]
|
| 1394 |
|
|
|
|
|
|
|
|
|
|
| 1395 |
'@smithy/abort-controller@4.2.8':
|
| 1396 |
resolution: {integrity: sha512-peuVfkYHAmS5ybKxWcfraK7WBBP0J+rkfUcbHJJKQ4ir3UAUNQI+Y4Vt/PqSzGqgloJ5O1dk7+WzNL8wcCSXbw==}
|
| 1397 |
engines: {node: '>=18.0.0'}
|
|
@@ -1681,6 +1697,9 @@ packages:
|
|
| 1681 |
peerDependencies:
|
| 1682 |
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
|
| 1683 |
|
|
|
|
|
|
|
|
|
|
| 1684 |
'@vitest/expect@4.0.18':
|
| 1685 |
resolution: {integrity: sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==}
|
| 1686 |
|
|
@@ -1698,12 +1717,21 @@ packages:
|
|
| 1698 |
'@vitest/pretty-format@4.0.18':
|
| 1699 |
resolution: {integrity: sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==}
|
| 1700 |
|
|
|
|
|
|
|
|
|
|
| 1701 |
'@vitest/runner@4.0.18':
|
| 1702 |
resolution: {integrity: sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==}
|
| 1703 |
|
|
|
|
|
|
|
|
|
|
| 1704 |
'@vitest/snapshot@4.0.18':
|
| 1705 |
resolution: {integrity: sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==}
|
| 1706 |
|
|
|
|
|
|
|
|
|
|
| 1707 |
'@vitest/spy@4.0.18':
|
| 1708 |
resolution: {integrity: sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==}
|
| 1709 |
|
|
@@ -1712,6 +1740,9 @@ packages:
|
|
| 1712 |
peerDependencies:
|
| 1713 |
vitest: 4.0.18
|
| 1714 |
|
|
|
|
|
|
|
|
|
|
| 1715 |
'@vitest/utils@4.0.18':
|
| 1716 |
resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==}
|
| 1717 |
|
|
@@ -1722,6 +1753,15 @@ packages:
|
|
| 1722 |
abstract-logging@2.0.1:
|
| 1723 |
resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==}
|
| 1724 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1725 |
agent-base@7.1.4:
|
| 1726 |
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
|
| 1727 |
engines: {node: '>= 14'}
|
|
@@ -1757,6 +1797,10 @@ packages:
|
|
| 1757 |
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
|
| 1758 |
engines: {node: '>=8'}
|
| 1759 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1760 |
any-promise@1.3.0:
|
| 1761 |
resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==}
|
| 1762 |
|
|
@@ -1770,6 +1814,9 @@ packages:
|
|
| 1770 |
argparse@2.0.1:
|
| 1771 |
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
|
| 1772 |
|
|
|
|
|
|
|
|
|
|
| 1773 |
assertion-error@2.0.1:
|
| 1774 |
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
| 1775 |
engines: {node: '>=12'}
|
|
@@ -1893,6 +1940,10 @@ packages:
|
|
| 1893 |
bullmq@5.69.3:
|
| 1894 |
resolution: {integrity: sha512-P9uLsR7fDvejH/1m6uur6j7U9mqY6nNt+XvhlhStOUe7jdwbZoP/c2oWNtE+8ljOlubw4pRUKymtRqkyvloc4A==}
|
| 1895 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1896 |
call-bind-apply-helpers@1.0.2:
|
| 1897 |
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
|
| 1898 |
engines: {node: '>= 0.4'}
|
|
@@ -1908,10 +1959,17 @@ packages:
|
|
| 1908 |
caniuse-lite@1.0.30001770:
|
| 1909 |
resolution: {integrity: sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw==}
|
| 1910 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1911 |
chai@6.2.2:
|
| 1912 |
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
|
| 1913 |
engines: {node: '>=18'}
|
| 1914 |
|
|
|
|
|
|
|
|
|
|
| 1915 |
chokidar@3.6.0:
|
| 1916 |
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
|
| 1917 |
engines: {node: '>= 8.10.0'}
|
|
@@ -1947,6 +2005,9 @@ packages:
|
|
| 1947 |
resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==}
|
| 1948 |
engines: {node: '>= 6'}
|
| 1949 |
|
|
|
|
|
|
|
|
|
|
| 1950 |
convert-source-map@2.0.0:
|
| 1951 |
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
|
| 1952 |
|
|
@@ -1970,6 +2031,10 @@ packages:
|
|
| 1970 |
resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==}
|
| 1971 |
engines: {node: '>=12.0.0'}
|
| 1972 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1973 |
cssesc@3.0.0:
|
| 1974 |
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
|
| 1975 |
engines: {node: '>=4'}
|
|
@@ -1994,6 +2059,10 @@ packages:
|
|
| 1994 |
supports-color:
|
| 1995 |
optional: true
|
| 1996 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1997 |
degenerator@5.0.1:
|
| 1998 |
resolution: {integrity: sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==}
|
| 1999 |
engines: {node: '>= 14'}
|
|
@@ -2016,6 +2085,10 @@ packages:
|
|
| 2016 |
didyoumean@1.2.2:
|
| 2017 |
resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
|
| 2018 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2019 |
diff@8.0.3:
|
| 2020 |
resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==}
|
| 2021 |
engines: {node: '>=0.3.1'}
|
|
@@ -2113,6 +2186,10 @@ packages:
|
|
| 2113 |
events-universal@1.0.1:
|
| 2114 |
resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==}
|
| 2115 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2116 |
expect-type@1.3.0:
|
| 2117 |
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
|
| 2118 |
engines: {node: '>=12.0.0'}
|
|
@@ -2251,6 +2328,9 @@ packages:
|
|
| 2251 |
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
|
| 2252 |
engines: {node: 6.* || 8.* || >= 10.*}
|
| 2253 |
|
|
|
|
|
|
|
|
|
|
| 2254 |
get-intrinsic@1.3.0:
|
| 2255 |
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
|
| 2256 |
engines: {node: '>= 0.4'}
|
|
@@ -2263,6 +2343,10 @@ packages:
|
|
| 2263 |
resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==}
|
| 2264 |
engines: {node: '>=8'}
|
| 2265 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2266 |
get-tsconfig@4.13.6:
|
| 2267 |
resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==}
|
| 2268 |
|
|
@@ -2313,6 +2397,10 @@ packages:
|
|
| 2313 |
https@1.0.0:
|
| 2314 |
resolution: {integrity: sha512-4EC57ddXrkaF0x83Oj8sM6SLQHAWXw90Skqu2M4AEWENZ3F02dFJE/GARA8igO79tcgYqGrD7ae4f5L3um2lgg==}
|
| 2315 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2316 |
humanize-ms@1.2.1:
|
| 2317 |
resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==}
|
| 2318 |
|
|
@@ -2384,9 +2472,16 @@ packages:
|
|
| 2384 |
resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
|
| 2385 |
engines: {node: '>=0.12.0'}
|
| 2386 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2387 |
isarray@1.0.0:
|
| 2388 |
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
|
| 2389 |
|
|
|
|
|
|
|
|
|
|
| 2390 |
jiti@1.21.7:
|
| 2391 |
resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==}
|
| 2392 |
hasBin: true
|
|
@@ -2398,6 +2493,9 @@ packages:
|
|
| 2398 |
js-tokens@4.0.0:
|
| 2399 |
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
| 2400 |
|
|
|
|
|
|
|
|
|
|
| 2401 |
js-yaml@4.1.1:
|
| 2402 |
resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
|
| 2403 |
hasBin: true
|
|
@@ -2437,6 +2535,10 @@ packages:
|
|
| 2437 |
lines-and-columns@1.2.4:
|
| 2438 |
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
|
| 2439 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2440 |
lodash.defaults@4.2.0:
|
| 2441 |
resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==}
|
| 2442 |
|
|
@@ -2450,6 +2552,9 @@ packages:
|
|
| 2450 |
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
|
| 2451 |
hasBin: true
|
| 2452 |
|
|
|
|
|
|
|
|
|
|
| 2453 |
lru-cache@5.1.1:
|
| 2454 |
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
|
| 2455 |
|
|
@@ -2473,6 +2578,9 @@ packages:
|
|
| 2473 |
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
| 2474 |
engines: {node: '>= 0.4'}
|
| 2475 |
|
|
|
|
|
|
|
|
|
|
| 2476 |
merge2@1.4.1:
|
| 2477 |
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
|
| 2478 |
engines: {node: '>= 8'}
|
|
@@ -2489,6 +2597,10 @@ packages:
|
|
| 2489 |
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
|
| 2490 |
engines: {node: '>= 0.6'}
|
| 2491 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2492 |
minimatch@5.1.6:
|
| 2493 |
resolution: {integrity: sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==}
|
| 2494 |
engines: {node: '>=10'}
|
|
@@ -2499,6 +2611,9 @@ packages:
|
|
| 2499 |
mitt@3.0.1:
|
| 2500 |
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
|
| 2501 |
|
|
|
|
|
|
|
|
|
|
| 2502 |
mnemonist@0.39.6:
|
| 2503 |
resolution: {integrity: sha512-A/0v5Z59y63US00cRSLiloEIw3t5G+MiKz4BhX21FI+YBJXBOGW0ohFxTxO08dsOYlzxo87T7vGfZKYp2bcAWA==}
|
| 2504 |
|
|
@@ -2563,6 +2678,10 @@ packages:
|
|
| 2563 |
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
|
| 2564 |
engines: {node: '>=0.10.0'}
|
| 2565 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2566 |
object-assign@4.1.1:
|
| 2567 |
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
| 2568 |
engines: {node: '>=0.10.0'}
|
|
@@ -2584,6 +2703,10 @@ packages:
|
|
| 2584 |
once@1.4.0:
|
| 2585 |
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
|
| 2586 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2587 |
openai@4.104.0:
|
| 2588 |
resolution: {integrity: sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA==}
|
| 2589 |
hasBin: true
|
|
@@ -2596,6 +2719,10 @@ packages:
|
|
| 2596 |
zod:
|
| 2597 |
optional: true
|
| 2598 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2599 |
pac-proxy-agent@7.2.0:
|
| 2600 |
resolution: {integrity: sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==}
|
| 2601 |
engines: {node: '>= 14'}
|
|
@@ -2615,15 +2742,29 @@ packages:
|
|
| 2615 |
resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==}
|
| 2616 |
engines: {node: '>=8'}
|
| 2617 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2618 |
path-parse@1.0.7:
|
| 2619 |
resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
|
| 2620 |
|
| 2621 |
path@0.12.7:
|
| 2622 |
resolution: {integrity: sha512-aXXC6s+1w7otVF9UletFkFcDsJeO7lSZBPUQhtb5O0xJe8LtYhj/GxldoL09bBj9+ZmE2hNoHqQSFMN5fikh4Q==}
|
| 2623 |
|
|
|
|
|
|
|
|
|
|
| 2624 |
pathe@2.0.3:
|
| 2625 |
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
|
| 2626 |
|
|
|
|
|
|
|
|
|
|
| 2627 |
pend@1.2.0:
|
| 2628 |
resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==}
|
| 2629 |
|
|
@@ -2667,6 +2808,9 @@ packages:
|
|
| 2667 |
resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==}
|
| 2668 |
engines: {node: '>= 6'}
|
| 2669 |
|
|
|
|
|
|
|
|
|
|
| 2670 |
postcss-import@15.1.0:
|
| 2671 |
resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==}
|
| 2672 |
engines: {node: '>=14.0.0'}
|
|
@@ -2722,6 +2866,10 @@ packages:
|
|
| 2722 |
engines: {node: '>=14'}
|
| 2723 |
hasBin: true
|
| 2724 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2725 |
prisma@5.22.0:
|
| 2726 |
resolution: {integrity: sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==}
|
| 2727 |
engines: {node: '>=16.13'}
|
|
@@ -2782,6 +2930,9 @@ packages:
|
|
| 2782 |
peerDependencies:
|
| 2783 |
react: ^18.3.1
|
| 2784 |
|
|
|
|
|
|
|
|
|
|
| 2785 |
react-refresh@0.17.0:
|
| 2786 |
resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
|
| 2787 |
engines: {node: '>=0.10.0'}
|
|
@@ -2902,9 +3053,21 @@ packages:
|
|
| 2902 |
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
|
| 2903 |
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
| 2904 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2905 |
siginfo@2.0.0:
|
| 2906 |
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
|
| 2907 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2908 |
sirv@3.0.2:
|
| 2909 |
resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==}
|
| 2910 |
engines: {node: '>=18'}
|
|
@@ -2962,10 +3125,17 @@ packages:
|
|
| 2962 |
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
|
| 2963 |
engines: {node: '>=8'}
|
| 2964 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2965 |
strip-json-comments@5.0.3:
|
| 2966 |
resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==}
|
| 2967 |
engines: {node: '>=14.16'}
|
| 2968 |
|
|
|
|
|
|
|
|
|
|
| 2969 |
stripe@20.3.1:
|
| 2970 |
resolution: {integrity: sha512-k990yOT5G5rhX3XluRPw5Y8RLdJDW4dzQ29wWT66piHrbnM2KyamJ1dKgPsw4HzGHRWjDiSSdcI2WdxQUPV3aQ==}
|
| 2971 |
engines: {node: '>=16'}
|
|
@@ -3032,10 +3202,18 @@ packages:
|
|
| 3032 |
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
|
| 3033 |
engines: {node: '>=12.0.0'}
|
| 3034 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3035 |
tinyrainbow@3.0.3:
|
| 3036 |
resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==}
|
| 3037 |
engines: {node: '>=14.0.0'}
|
| 3038 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3039 |
to-regex-range@5.0.1:
|
| 3040 |
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
|
| 3041 |
engines: {node: '>=8.0'}
|
|
@@ -3095,11 +3273,18 @@ packages:
|
|
| 3095 |
resolution: {integrity: sha512-1q7+9UJABuBAHrcC4Sxp5lOqYS5mvxRrwa33wpIyM18hlOCpRD/fTJNxZ0vhbMcJmz15o9kkVm743mPn7p6jpQ==}
|
| 3096 |
hasBin: true
|
| 3097 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3098 |
typescript@5.9.3:
|
| 3099 |
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
|
| 3100 |
engines: {node: '>=14.17'}
|
| 3101 |
hasBin: true
|
| 3102 |
|
|
|
|
|
|
|
|
|
|
| 3103 |
unbzip2-stream@1.4.3:
|
| 3104 |
resolution: {integrity: sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg==}
|
| 3105 |
|
|
@@ -3132,6 +3317,11 @@ packages:
|
|
| 3132 |
resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==}
|
| 3133 |
hasBin: true
|
| 3134 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3135 |
vite@5.4.21:
|
| 3136 |
resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==}
|
| 3137 |
engines: {node: ^18.0.0 || >=20.0.0}
|
|
@@ -3203,6 +3393,31 @@ packages:
|
|
| 3203 |
yaml:
|
| 3204 |
optional: true
|
| 3205 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3206 |
vitest@4.0.18:
|
| 3207 |
resolution: {integrity: sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==}
|
| 3208 |
engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
|
|
@@ -3247,6 +3462,11 @@ packages:
|
|
| 3247 |
whatwg-url@5.0.0:
|
| 3248 |
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
|
| 3249 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3250 |
why-is-node-running@2.3.0:
|
| 3251 |
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
|
| 3252 |
engines: {node: '>=8'}
|
|
@@ -3289,6 +3509,10 @@ packages:
|
|
| 3289 |
yauzl@2.10.0:
|
| 3290 |
resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==}
|
| 3291 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3292 |
zod@3.23.8:
|
| 3293 |
resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==}
|
| 3294 |
|
|
@@ -4252,6 +4476,10 @@ snapshots:
|
|
| 4252 |
|
| 4253 |
'@ioredis/commands@1.5.0': {}
|
| 4254 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4255 |
'@jridgewell/gen-mapping@0.3.13':
|
| 4256 |
dependencies:
|
| 4257 |
'@jridgewell/sourcemap-codec': 1.5.5
|
|
@@ -4427,6 +4655,8 @@ snapshots:
|
|
| 4427 |
'@rollup/rollup-win32-x64-msvc@4.57.1':
|
| 4428 |
optional: true
|
| 4429 |
|
|
|
|
|
|
|
| 4430 |
'@smithy/abort-controller@4.2.8':
|
| 4431 |
dependencies:
|
| 4432 |
'@smithy/types': 4.12.0
|
|
@@ -4857,6 +5087,12 @@ snapshots:
|
|
| 4857 |
transitivePeerDependencies:
|
| 4858 |
- supports-color
|
| 4859 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4860 |
'@vitest/expect@4.0.18':
|
| 4861 |
dependencies:
|
| 4862 |
'@standard-schema/spec': 1.1.0
|
|
@@ -4878,17 +5114,33 @@ snapshots:
|
|
| 4878 |
dependencies:
|
| 4879 |
tinyrainbow: 3.0.3
|
| 4880 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4881 |
'@vitest/runner@4.0.18':
|
| 4882 |
dependencies:
|
| 4883 |
'@vitest/utils': 4.0.18
|
| 4884 |
pathe: 2.0.3
|
| 4885 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4886 |
'@vitest/snapshot@4.0.18':
|
| 4887 |
dependencies:
|
| 4888 |
'@vitest/pretty-format': 4.0.18
|
| 4889 |
magic-string: 0.30.21
|
| 4890 |
pathe: 2.0.3
|
| 4891 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4892 |
'@vitest/spy@4.0.18': {}
|
| 4893 |
|
| 4894 |
'@vitest/ui@4.0.18(vitest@4.0.18)':
|
|
@@ -4902,6 +5154,13 @@ snapshots:
|
|
| 4902 |
tinyrainbow: 3.0.3
|
| 4903 |
vitest: 4.0.18(@types/node@20.19.33)(@vitest/ui@4.0.18)(jiti@1.21.7)(tsx@3.14.0)
|
| 4904 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4905 |
'@vitest/utils@4.0.18':
|
| 4906 |
dependencies:
|
| 4907 |
'@vitest/pretty-format': 4.0.18
|
|
@@ -4913,6 +5172,12 @@ snapshots:
|
|
| 4913 |
|
| 4914 |
abstract-logging@2.0.1: {}
|
| 4915 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4916 |
agent-base@7.1.4: {}
|
| 4917 |
|
| 4918 |
agentkeepalive@4.6.0:
|
|
@@ -4940,6 +5205,8 @@ snapshots:
|
|
| 4940 |
dependencies:
|
| 4941 |
color-convert: 2.0.1
|
| 4942 |
|
|
|
|
|
|
|
| 4943 |
any-promise@1.3.0: {}
|
| 4944 |
|
| 4945 |
anymatch@3.1.3:
|
|
@@ -4951,6 +5218,8 @@ snapshots:
|
|
| 4951 |
|
| 4952 |
argparse@2.0.1: {}
|
| 4953 |
|
|
|
|
|
|
|
| 4954 |
assertion-error@2.0.1: {}
|
| 4955 |
|
| 4956 |
ast-types@0.13.4:
|
|
@@ -5086,6 +5355,8 @@ snapshots:
|
|
| 5086 |
transitivePeerDependencies:
|
| 5087 |
- supports-color
|
| 5088 |
|
|
|
|
|
|
|
| 5089 |
call-bind-apply-helpers@1.0.2:
|
| 5090 |
dependencies:
|
| 5091 |
es-errors: 1.3.0
|
|
@@ -5097,8 +5368,22 @@ snapshots:
|
|
| 5097 |
|
| 5098 |
caniuse-lite@1.0.30001770: {}
|
| 5099 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5100 |
chai@6.2.2: {}
|
| 5101 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5102 |
chokidar@3.6.0:
|
| 5103 |
dependencies:
|
| 5104 |
anymatch: 3.1.3
|
|
@@ -5140,6 +5425,8 @@ snapshots:
|
|
| 5140 |
|
| 5141 |
commander@4.1.1: {}
|
| 5142 |
|
|
|
|
|
|
|
| 5143 |
convert-source-map@2.0.0: {}
|
| 5144 |
|
| 5145 |
cookie@0.7.2: {}
|
|
@@ -5159,6 +5446,12 @@ snapshots:
|
|
| 5159 |
dependencies:
|
| 5160 |
luxon: 3.7.2
|
| 5161 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5162 |
cssesc@3.0.0: {}
|
| 5163 |
|
| 5164 |
csstype@3.2.3: {}
|
|
@@ -5171,6 +5464,10 @@ snapshots:
|
|
| 5171 |
dependencies:
|
| 5172 |
ms: 2.1.3
|
| 5173 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5174 |
degenerator@5.0.1:
|
| 5175 |
dependencies:
|
| 5176 |
ast-types: 0.13.4
|
|
@@ -5187,6 +5484,8 @@ snapshots:
|
|
| 5187 |
|
| 5188 |
didyoumean@1.2.2: {}
|
| 5189 |
|
|
|
|
|
|
|
| 5190 |
diff@8.0.3: {}
|
| 5191 |
|
| 5192 |
dlv@1.1.3: {}
|
|
@@ -5338,6 +5637,18 @@ snapshots:
|
|
| 5338 |
transitivePeerDependencies:
|
| 5339 |
- bare-abort-controller
|
| 5340 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5341 |
expect-type@1.3.0: {}
|
| 5342 |
|
| 5343 |
extract-zip@2.0.1:
|
|
@@ -5479,6 +5790,8 @@ snapshots:
|
|
| 5479 |
|
| 5480 |
get-caller-file@2.0.5: {}
|
| 5481 |
|
|
|
|
|
|
|
| 5482 |
get-intrinsic@1.3.0:
|
| 5483 |
dependencies:
|
| 5484 |
call-bind-apply-helpers: 1.0.2
|
|
@@ -5501,6 +5814,8 @@ snapshots:
|
|
| 5501 |
dependencies:
|
| 5502 |
pump: 3.0.3
|
| 5503 |
|
|
|
|
|
|
|
| 5504 |
get-tsconfig@4.13.6:
|
| 5505 |
dependencies:
|
| 5506 |
resolve-pkg-maps: 1.0.0
|
|
@@ -5559,6 +5874,8 @@ snapshots:
|
|
| 5559 |
|
| 5560 |
https@1.0.0: {}
|
| 5561 |
|
|
|
|
|
|
|
| 5562 |
humanize-ms@1.2.1:
|
| 5563 |
dependencies:
|
| 5564 |
ms: 2.1.3
|
|
@@ -5637,14 +5954,20 @@ snapshots:
|
|
| 5637 |
|
| 5638 |
is-number@7.0.0: {}
|
| 5639 |
|
|
|
|
|
|
|
| 5640 |
isarray@1.0.0: {}
|
| 5641 |
|
|
|
|
|
|
|
| 5642 |
jiti@1.21.7: {}
|
| 5643 |
|
| 5644 |
joycon@3.1.1: {}
|
| 5645 |
|
| 5646 |
js-tokens@4.0.0: {}
|
| 5647 |
|
|
|
|
|
|
|
| 5648 |
js-yaml@4.1.1:
|
| 5649 |
dependencies:
|
| 5650 |
argparse: 2.0.1
|
|
@@ -5682,6 +6005,11 @@ snapshots:
|
|
| 5682 |
|
| 5683 |
lines-and-columns@1.2.4: {}
|
| 5684 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5685 |
lodash.defaults@4.2.0: {}
|
| 5686 |
|
| 5687 |
lodash.isarguments@3.1.0: {}
|
|
@@ -5692,6 +6020,10 @@ snapshots:
|
|
| 5692 |
dependencies:
|
| 5693 |
js-tokens: 4.0.0
|
| 5694 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5695 |
lru-cache@5.1.1:
|
| 5696 |
dependencies:
|
| 5697 |
yallist: 3.1.1
|
|
@@ -5710,6 +6042,8 @@ snapshots:
|
|
| 5710 |
|
| 5711 |
math-intrinsics@1.1.0: {}
|
| 5712 |
|
|
|
|
|
|
|
| 5713 |
merge2@1.4.1: {}
|
| 5714 |
|
| 5715 |
micromatch@4.0.8:
|
|
@@ -5723,6 +6057,8 @@ snapshots:
|
|
| 5723 |
dependencies:
|
| 5724 |
mime-db: 1.52.0
|
| 5725 |
|
|
|
|
|
|
|
| 5726 |
minimatch@5.1.6:
|
| 5727 |
dependencies:
|
| 5728 |
brace-expansion: 2.0.2
|
|
@@ -5731,6 +6067,13 @@ snapshots:
|
|
| 5731 |
|
| 5732 |
mitt@3.0.1: {}
|
| 5733 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5734 |
mnemonist@0.39.6:
|
| 5735 |
dependencies:
|
| 5736 |
obliterator: 2.0.5
|
|
@@ -5788,6 +6131,10 @@ snapshots:
|
|
| 5788 |
|
| 5789 |
normalize-path@3.0.0: {}
|
| 5790 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5791 |
object-assign@4.1.1: {}
|
| 5792 |
|
| 5793 |
object-hash@3.0.0: {}
|
|
@@ -5802,6 +6149,10 @@ snapshots:
|
|
| 5802 |
dependencies:
|
| 5803 |
wrappy: 1.0.2
|
| 5804 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5805 |
openai@4.104.0(ws@8.19.0)(zod@3.25.76):
|
| 5806 |
dependencies:
|
| 5807 |
'@types/node': 18.19.130
|
|
@@ -5817,6 +6168,10 @@ snapshots:
|
|
| 5817 |
transitivePeerDependencies:
|
| 5818 |
- encoding
|
| 5819 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5820 |
pac-proxy-agent@7.2.0:
|
| 5821 |
dependencies:
|
| 5822 |
'@tootallnate/quickjs-emscripten': 0.23.0
|
|
@@ -5848,6 +6203,10 @@ snapshots:
|
|
| 5848 |
json-parse-even-better-errors: 2.3.1
|
| 5849 |
lines-and-columns: 1.2.4
|
| 5850 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5851 |
path-parse@1.0.7: {}
|
| 5852 |
|
| 5853 |
path@0.12.7:
|
|
@@ -5855,8 +6214,12 @@ snapshots:
|
|
| 5855 |
process: 0.11.10
|
| 5856 |
util: 0.10.4
|
| 5857 |
|
|
|
|
|
|
|
| 5858 |
pathe@2.0.3: {}
|
| 5859 |
|
|
|
|
|
|
|
| 5860 |
pend@1.2.0: {}
|
| 5861 |
|
| 5862 |
picocolors@1.1.1: {}
|
|
@@ -5923,6 +6286,12 @@ snapshots:
|
|
| 5923 |
|
| 5924 |
pirates@4.0.7: {}
|
| 5925 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5926 |
postcss-import@15.1.0(postcss@8.5.6):
|
| 5927 |
dependencies:
|
| 5928 |
postcss: 8.5.6
|
|
@@ -5969,6 +6338,12 @@ snapshots:
|
|
| 5969 |
|
| 5970 |
prettier@3.8.1: {}
|
| 5971 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5972 |
prisma@5.22.0:
|
| 5973 |
dependencies:
|
| 5974 |
'@prisma/engines': 5.22.0
|
|
@@ -6054,6 +6429,8 @@ snapshots:
|
|
| 6054 |
react: 18.3.1
|
| 6055 |
scheduler: 0.23.2
|
| 6056 |
|
|
|
|
|
|
|
| 6057 |
react-refresh@0.17.0: {}
|
| 6058 |
|
| 6059 |
react-router-dom@6.30.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
|
@@ -6208,8 +6585,16 @@ snapshots:
|
|
| 6208 |
'@img/sharp-win32-ia32': 0.34.5
|
| 6209 |
'@img/sharp-win32-x64': 0.34.5
|
| 6210 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6211 |
siginfo@2.0.0: {}
|
| 6212 |
|
|
|
|
|
|
|
| 6213 |
sirv@3.0.2:
|
| 6214 |
dependencies:
|
| 6215 |
'@polka/url': 1.0.0-next.29
|
|
@@ -6275,8 +6660,14 @@ snapshots:
|
|
| 6275 |
dependencies:
|
| 6276 |
ansi-regex: 5.0.1
|
| 6277 |
|
|
|
|
|
|
|
| 6278 |
strip-json-comments@5.0.3: {}
|
| 6279 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6280 |
stripe@20.3.1(@types/node@20.19.33):
|
| 6281 |
optionalDependencies:
|
| 6282 |
'@types/node': 20.19.33
|
|
@@ -6385,8 +6776,12 @@ snapshots:
|
|
| 6385 |
fdir: 6.5.0(picomatch@4.0.3)
|
| 6386 |
picomatch: 4.0.3
|
| 6387 |
|
|
|
|
|
|
|
| 6388 |
tinyrainbow@3.0.3: {}
|
| 6389 |
|
|
|
|
|
|
|
| 6390 |
to-regex-range@5.0.1:
|
| 6391 |
dependencies:
|
| 6392 |
is-number: 7.0.0
|
|
@@ -6436,8 +6831,12 @@ snapshots:
|
|
| 6436 |
turbo-windows-64: 1.13.4
|
| 6437 |
turbo-windows-arm64: 1.13.4
|
| 6438 |
|
|
|
|
|
|
|
| 6439 |
typescript@5.9.3: {}
|
| 6440 |
|
|
|
|
|
|
|
| 6441 |
unbzip2-stream@1.4.3:
|
| 6442 |
dependencies:
|
| 6443 |
buffer: 5.7.1
|
|
@@ -6465,6 +6864,33 @@ snapshots:
|
|
| 6465 |
|
| 6466 |
uuid@9.0.1: {}
|
| 6467 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6468 |
vite@5.4.21(@types/node@22.19.11):
|
| 6469 |
dependencies:
|
| 6470 |
esbuild: 0.21.5
|
|
@@ -6488,6 +6914,40 @@ snapshots:
|
|
| 6488 |
jiti: 1.21.7
|
| 6489 |
tsx: 3.14.0
|
| 6490 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6491 |
vitest@4.0.18(@types/node@20.19.33)(@vitest/ui@4.0.18)(jiti@1.21.7)(tsx@3.14.0):
|
| 6492 |
dependencies:
|
| 6493 |
'@vitest/expect': 4.0.18
|
|
@@ -6535,6 +6995,10 @@ snapshots:
|
|
| 6535 |
tr46: 0.0.3
|
| 6536 |
webidl-conversions: 3.0.1
|
| 6537 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6538 |
why-is-node-running@2.3.0:
|
| 6539 |
dependencies:
|
| 6540 |
siginfo: 2.0.0
|
|
@@ -6571,6 +7035,8 @@ snapshots:
|
|
| 6571 |
buffer-crc32: 0.2.13
|
| 6572 |
fd-slicer: 1.1.0
|
| 6573 |
|
|
|
|
|
|
|
| 6574 |
zod@3.23.8: {}
|
| 6575 |
|
| 6576 |
zod@3.25.76: {}
|
|
|
|
| 243 |
node-cron:
|
| 244 |
specifier: ^4.2.1
|
| 245 |
version: 4.2.1
|
| 246 |
+
node-fetch:
|
| 247 |
+
specifier: ^2.6.7
|
| 248 |
+
version: 2.7.0
|
| 249 |
sharp:
|
| 250 |
specifier: ^0.34.5
|
| 251 |
version: 0.34.5
|
|
|
|
| 259 |
'@types/node-cron':
|
| 260 |
specifier: ^3.0.11
|
| 261 |
version: 3.0.11
|
| 262 |
+
'@types/node-fetch':
|
| 263 |
+
specifier: ^2.6.2
|
| 264 |
+
version: 2.6.13
|
| 265 |
tsx:
|
| 266 |
specifier: ^3.0.0
|
| 267 |
version: 3.14.0
|
| 268 |
typescript:
|
| 269 |
specifier: ^5.0.0
|
| 270 |
version: 5.9.3
|
| 271 |
+
vitest:
|
| 272 |
+
specifier: ^1.0.0
|
| 273 |
+
version: 1.6.1(@types/node@20.19.33)
|
| 274 |
|
| 275 |
packages/database:
|
| 276 |
dependencies:
|
|
|
|
| 1172 |
'@ioredis/commands@1.5.0':
|
| 1173 |
resolution: {integrity: sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow==}
|
| 1174 |
|
| 1175 |
+
'@jest/schemas@29.6.3':
|
| 1176 |
+
resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==}
|
| 1177 |
+
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
|
| 1178 |
+
|
| 1179 |
'@jridgewell/gen-mapping@0.3.13':
|
| 1180 |
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
|
| 1181 |
|
|
|
|
| 1405 |
cpu: [x64]
|
| 1406 |
os: [win32]
|
| 1407 |
|
| 1408 |
+
'@sinclair/typebox@0.27.10':
|
| 1409 |
+
resolution: {integrity: sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==}
|
| 1410 |
+
|
| 1411 |
'@smithy/abort-controller@4.2.8':
|
| 1412 |
resolution: {integrity: sha512-peuVfkYHAmS5ybKxWcfraK7WBBP0J+rkfUcbHJJKQ4ir3UAUNQI+Y4Vt/PqSzGqgloJ5O1dk7+WzNL8wcCSXbw==}
|
| 1413 |
engines: {node: '>=18.0.0'}
|
|
|
|
| 1697 |
peerDependencies:
|
| 1698 |
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
|
| 1699 |
|
| 1700 |
+
'@vitest/expect@1.6.1':
|
| 1701 |
+
resolution: {integrity: sha512-jXL+9+ZNIJKruofqXuuTClf44eSpcHlgj3CiuNihUF3Ioujtmc0zIa3UJOW5RjDK1YLBJZnWBlPuqhYycLioog==}
|
| 1702 |
+
|
| 1703 |
'@vitest/expect@4.0.18':
|
| 1704 |
resolution: {integrity: sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==}
|
| 1705 |
|
|
|
|
| 1717 |
'@vitest/pretty-format@4.0.18':
|
| 1718 |
resolution: {integrity: sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==}
|
| 1719 |
|
| 1720 |
+
'@vitest/runner@1.6.1':
|
| 1721 |
+
resolution: {integrity: sha512-3nSnYXkVkf3mXFfE7vVyPmi3Sazhb/2cfZGGs0JRzFsPFvAMBEcrweV1V1GsrstdXeKCTXlJbvnQwGWgEIHmOA==}
|
| 1722 |
+
|
| 1723 |
'@vitest/runner@4.0.18':
|
| 1724 |
resolution: {integrity: sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==}
|
| 1725 |
|
| 1726 |
+
'@vitest/snapshot@1.6.1':
|
| 1727 |
+
resolution: {integrity: sha512-WvidQuWAzU2p95u8GAKlRMqMyN1yOJkGHnx3M1PL9Raf7AQ1kwLKg04ADlCa3+OXUZE7BceOhVZiuWAbzCKcUQ==}
|
| 1728 |
+
|
| 1729 |
'@vitest/snapshot@4.0.18':
|
| 1730 |
resolution: {integrity: sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==}
|
| 1731 |
|
| 1732 |
+
'@vitest/spy@1.6.1':
|
| 1733 |
+
resolution: {integrity: sha512-MGcMmpGkZebsMZhbQKkAf9CX5zGvjkBTqf8Zx3ApYWXr3wG+QvEu2eXWfnIIWYSJExIp4V9FCKDEeygzkYrXMw==}
|
| 1734 |
+
|
| 1735 |
'@vitest/spy@4.0.18':
|
| 1736 |
resolution: {integrity: sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==}
|
| 1737 |
|
|
|
|
| 1740 |
peerDependencies:
|
| 1741 |
vitest: 4.0.18
|
| 1742 |
|
| 1743 |
+
'@vitest/utils@1.6.1':
|
| 1744 |
+
resolution: {integrity: sha512-jOrrUvXM4Av9ZWiG1EajNto0u96kWAhJ1LmPmJhXXQx/32MecEKd10pOLYgS2BQx1TgkGhloPU1ArDW2vvaY6g==}
|
| 1745 |
+
|
| 1746 |
'@vitest/utils@4.0.18':
|
| 1747 |
resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==}
|
| 1748 |
|
|
|
|
| 1753 |
abstract-logging@2.0.1:
|
| 1754 |
resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==}
|
| 1755 |
|
| 1756 |
+
acorn-walk@8.3.5:
|
| 1757 |
+
resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==}
|
| 1758 |
+
engines: {node: '>=0.4.0'}
|
| 1759 |
+
|
| 1760 |
+
acorn@8.16.0:
|
| 1761 |
+
resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==}
|
| 1762 |
+
engines: {node: '>=0.4.0'}
|
| 1763 |
+
hasBin: true
|
| 1764 |
+
|
| 1765 |
agent-base@7.1.4:
|
| 1766 |
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
|
| 1767 |
engines: {node: '>= 14'}
|
|
|
|
| 1797 |
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
|
| 1798 |
engines: {node: '>=8'}
|
| 1799 |
|
| 1800 |
+
ansi-styles@5.2.0:
|
| 1801 |
+
resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==}
|
| 1802 |
+
engines: {node: '>=10'}
|
| 1803 |
+
|
| 1804 |
any-promise@1.3.0:
|
| 1805 |
resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==}
|
| 1806 |
|
|
|
|
| 1814 |
argparse@2.0.1:
|
| 1815 |
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
|
| 1816 |
|
| 1817 |
+
assertion-error@1.1.0:
|
| 1818 |
+
resolution: {integrity: sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==}
|
| 1819 |
+
|
| 1820 |
assertion-error@2.0.1:
|
| 1821 |
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
| 1822 |
engines: {node: '>=12'}
|
|
|
|
| 1940 |
bullmq@5.69.3:
|
| 1941 |
resolution: {integrity: sha512-P9uLsR7fDvejH/1m6uur6j7U9mqY6nNt+XvhlhStOUe7jdwbZoP/c2oWNtE+8ljOlubw4pRUKymtRqkyvloc4A==}
|
| 1942 |
|
| 1943 |
+
cac@6.7.14:
|
| 1944 |
+
resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
|
| 1945 |
+
engines: {node: '>=8'}
|
| 1946 |
+
|
| 1947 |
call-bind-apply-helpers@1.0.2:
|
| 1948 |
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
|
| 1949 |
engines: {node: '>= 0.4'}
|
|
|
|
| 1959 |
caniuse-lite@1.0.30001770:
|
| 1960 |
resolution: {integrity: sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw==}
|
| 1961 |
|
| 1962 |
+
chai@4.5.0:
|
| 1963 |
+
resolution: {integrity: sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==}
|
| 1964 |
+
engines: {node: '>=4'}
|
| 1965 |
+
|
| 1966 |
chai@6.2.2:
|
| 1967 |
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
|
| 1968 |
engines: {node: '>=18'}
|
| 1969 |
|
| 1970 |
+
check-error@1.0.3:
|
| 1971 |
+
resolution: {integrity: sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==}
|
| 1972 |
+
|
| 1973 |
chokidar@3.6.0:
|
| 1974 |
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
|
| 1975 |
engines: {node: '>= 8.10.0'}
|
|
|
|
| 2005 |
resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==}
|
| 2006 |
engines: {node: '>= 6'}
|
| 2007 |
|
| 2008 |
+
confbox@0.1.8:
|
| 2009 |
+
resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==}
|
| 2010 |
+
|
| 2011 |
convert-source-map@2.0.0:
|
| 2012 |
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
|
| 2013 |
|
|
|
|
| 2031 |
resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==}
|
| 2032 |
engines: {node: '>=12.0.0'}
|
| 2033 |
|
| 2034 |
+
cross-spawn@7.0.6:
|
| 2035 |
+
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
| 2036 |
+
engines: {node: '>= 8'}
|
| 2037 |
+
|
| 2038 |
cssesc@3.0.0:
|
| 2039 |
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
|
| 2040 |
engines: {node: '>=4'}
|
|
|
|
| 2059 |
supports-color:
|
| 2060 |
optional: true
|
| 2061 |
|
| 2062 |
+
deep-eql@4.1.4:
|
| 2063 |
+
resolution: {integrity: sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==}
|
| 2064 |
+
engines: {node: '>=6'}
|
| 2065 |
+
|
| 2066 |
degenerator@5.0.1:
|
| 2067 |
resolution: {integrity: sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==}
|
| 2068 |
engines: {node: '>= 14'}
|
|
|
|
| 2085 |
didyoumean@1.2.2:
|
| 2086 |
resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
|
| 2087 |
|
| 2088 |
+
diff-sequences@29.6.3:
|
| 2089 |
+
resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==}
|
| 2090 |
+
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
|
| 2091 |
+
|
| 2092 |
diff@8.0.3:
|
| 2093 |
resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==}
|
| 2094 |
engines: {node: '>=0.3.1'}
|
|
|
|
| 2186 |
events-universal@1.0.1:
|
| 2187 |
resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==}
|
| 2188 |
|
| 2189 |
+
execa@8.0.1:
|
| 2190 |
+
resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==}
|
| 2191 |
+
engines: {node: '>=16.17'}
|
| 2192 |
+
|
| 2193 |
expect-type@1.3.0:
|
| 2194 |
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
|
| 2195 |
engines: {node: '>=12.0.0'}
|
|
|
|
| 2328 |
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
|
| 2329 |
engines: {node: 6.* || 8.* || >= 10.*}
|
| 2330 |
|
| 2331 |
+
get-func-name@2.0.2:
|
| 2332 |
+
resolution: {integrity: sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==}
|
| 2333 |
+
|
| 2334 |
get-intrinsic@1.3.0:
|
| 2335 |
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
|
| 2336 |
engines: {node: '>= 0.4'}
|
|
|
|
| 2343 |
resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==}
|
| 2344 |
engines: {node: '>=8'}
|
| 2345 |
|
| 2346 |
+
get-stream@8.0.1:
|
| 2347 |
+
resolution: {integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==}
|
| 2348 |
+
engines: {node: '>=16'}
|
| 2349 |
+
|
| 2350 |
get-tsconfig@4.13.6:
|
| 2351 |
resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==}
|
| 2352 |
|
|
|
|
| 2397 |
https@1.0.0:
|
| 2398 |
resolution: {integrity: sha512-4EC57ddXrkaF0x83Oj8sM6SLQHAWXw90Skqu2M4AEWENZ3F02dFJE/GARA8igO79tcgYqGrD7ae4f5L3um2lgg==}
|
| 2399 |
|
| 2400 |
+
human-signals@5.0.0:
|
| 2401 |
+
resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==}
|
| 2402 |
+
engines: {node: '>=16.17.0'}
|
| 2403 |
+
|
| 2404 |
humanize-ms@1.2.1:
|
| 2405 |
resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==}
|
| 2406 |
|
|
|
|
| 2472 |
resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
|
| 2473 |
engines: {node: '>=0.12.0'}
|
| 2474 |
|
| 2475 |
+
is-stream@3.0.0:
|
| 2476 |
+
resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==}
|
| 2477 |
+
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
| 2478 |
+
|
| 2479 |
isarray@1.0.0:
|
| 2480 |
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
|
| 2481 |
|
| 2482 |
+
isexe@2.0.0:
|
| 2483 |
+
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
|
| 2484 |
+
|
| 2485 |
jiti@1.21.7:
|
| 2486 |
resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==}
|
| 2487 |
hasBin: true
|
|
|
|
| 2493 |
js-tokens@4.0.0:
|
| 2494 |
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
| 2495 |
|
| 2496 |
+
js-tokens@9.0.1:
|
| 2497 |
+
resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==}
|
| 2498 |
+
|
| 2499 |
js-yaml@4.1.1:
|
| 2500 |
resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
|
| 2501 |
hasBin: true
|
|
|
|
| 2535 |
lines-and-columns@1.2.4:
|
| 2536 |
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
|
| 2537 |
|
| 2538 |
+
local-pkg@0.5.1:
|
| 2539 |
+
resolution: {integrity: sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==}
|
| 2540 |
+
engines: {node: '>=14'}
|
| 2541 |
+
|
| 2542 |
lodash.defaults@4.2.0:
|
| 2543 |
resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==}
|
| 2544 |
|
|
|
|
| 2552 |
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
|
| 2553 |
hasBin: true
|
| 2554 |
|
| 2555 |
+
loupe@2.3.7:
|
| 2556 |
+
resolution: {integrity: sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==}
|
| 2557 |
+
|
| 2558 |
lru-cache@5.1.1:
|
| 2559 |
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
|
| 2560 |
|
|
|
|
| 2578 |
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
| 2579 |
engines: {node: '>= 0.4'}
|
| 2580 |
|
| 2581 |
+
merge-stream@2.0.0:
|
| 2582 |
+
resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==}
|
| 2583 |
+
|
| 2584 |
merge2@1.4.1:
|
| 2585 |
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
|
| 2586 |
engines: {node: '>= 8'}
|
|
|
|
| 2597 |
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
|
| 2598 |
engines: {node: '>= 0.6'}
|
| 2599 |
|
| 2600 |
+
mimic-fn@4.0.0:
|
| 2601 |
+
resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==}
|
| 2602 |
+
engines: {node: '>=12'}
|
| 2603 |
+
|
| 2604 |
minimatch@5.1.6:
|
| 2605 |
resolution: {integrity: sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==}
|
| 2606 |
engines: {node: '>=10'}
|
|
|
|
| 2611 |
mitt@3.0.1:
|
| 2612 |
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
|
| 2613 |
|
| 2614 |
+
mlly@1.8.2:
|
| 2615 |
+
resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==}
|
| 2616 |
+
|
| 2617 |
mnemonist@0.39.6:
|
| 2618 |
resolution: {integrity: sha512-A/0v5Z59y63US00cRSLiloEIw3t5G+MiKz4BhX21FI+YBJXBOGW0ohFxTxO08dsOYlzxo87T7vGfZKYp2bcAWA==}
|
| 2619 |
|
|
|
|
| 2678 |
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
|
| 2679 |
engines: {node: '>=0.10.0'}
|
| 2680 |
|
| 2681 |
+
npm-run-path@5.3.0:
|
| 2682 |
+
resolution: {integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==}
|
| 2683 |
+
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
| 2684 |
+
|
| 2685 |
object-assign@4.1.1:
|
| 2686 |
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
| 2687 |
engines: {node: '>=0.10.0'}
|
|
|
|
| 2703 |
once@1.4.0:
|
| 2704 |
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
|
| 2705 |
|
| 2706 |
+
onetime@6.0.0:
|
| 2707 |
+
resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==}
|
| 2708 |
+
engines: {node: '>=12'}
|
| 2709 |
+
|
| 2710 |
openai@4.104.0:
|
| 2711 |
resolution: {integrity: sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA==}
|
| 2712 |
hasBin: true
|
|
|
|
| 2719 |
zod:
|
| 2720 |
optional: true
|
| 2721 |
|
| 2722 |
+
p-limit@5.0.0:
|
| 2723 |
+
resolution: {integrity: sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==}
|
| 2724 |
+
engines: {node: '>=18'}
|
| 2725 |
+
|
| 2726 |
pac-proxy-agent@7.2.0:
|
| 2727 |
resolution: {integrity: sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==}
|
| 2728 |
engines: {node: '>= 14'}
|
|
|
|
| 2742 |
resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==}
|
| 2743 |
engines: {node: '>=8'}
|
| 2744 |
|
| 2745 |
+
path-key@3.1.1:
|
| 2746 |
+
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
|
| 2747 |
+
engines: {node: '>=8'}
|
| 2748 |
+
|
| 2749 |
+
path-key@4.0.0:
|
| 2750 |
+
resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==}
|
| 2751 |
+
engines: {node: '>=12'}
|
| 2752 |
+
|
| 2753 |
path-parse@1.0.7:
|
| 2754 |
resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
|
| 2755 |
|
| 2756 |
path@0.12.7:
|
| 2757 |
resolution: {integrity: sha512-aXXC6s+1w7otVF9UletFkFcDsJeO7lSZBPUQhtb5O0xJe8LtYhj/GxldoL09bBj9+ZmE2hNoHqQSFMN5fikh4Q==}
|
| 2758 |
|
| 2759 |
+
pathe@1.1.2:
|
| 2760 |
+
resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==}
|
| 2761 |
+
|
| 2762 |
pathe@2.0.3:
|
| 2763 |
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
|
| 2764 |
|
| 2765 |
+
pathval@1.1.1:
|
| 2766 |
+
resolution: {integrity: sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==}
|
| 2767 |
+
|
| 2768 |
pend@1.2.0:
|
| 2769 |
resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==}
|
| 2770 |
|
|
|
|
| 2808 |
resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==}
|
| 2809 |
engines: {node: '>= 6'}
|
| 2810 |
|
| 2811 |
+
pkg-types@1.3.1:
|
| 2812 |
+
resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==}
|
| 2813 |
+
|
| 2814 |
postcss-import@15.1.0:
|
| 2815 |
resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==}
|
| 2816 |
engines: {node: '>=14.0.0'}
|
|
|
|
| 2866 |
engines: {node: '>=14'}
|
| 2867 |
hasBin: true
|
| 2868 |
|
| 2869 |
+
pretty-format@29.7.0:
|
| 2870 |
+
resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==}
|
| 2871 |
+
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
|
| 2872 |
+
|
| 2873 |
prisma@5.22.0:
|
| 2874 |
resolution: {integrity: sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==}
|
| 2875 |
engines: {node: '>=16.13'}
|
|
|
|
| 2930 |
peerDependencies:
|
| 2931 |
react: ^18.3.1
|
| 2932 |
|
| 2933 |
+
react-is@18.3.1:
|
| 2934 |
+
resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==}
|
| 2935 |
+
|
| 2936 |
react-refresh@0.17.0:
|
| 2937 |
resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
|
| 2938 |
engines: {node: '>=0.10.0'}
|
|
|
|
| 3053 |
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
|
| 3054 |
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
| 3055 |
|
| 3056 |
+
shebang-command@2.0.0:
|
| 3057 |
+
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
|
| 3058 |
+
engines: {node: '>=8'}
|
| 3059 |
+
|
| 3060 |
+
shebang-regex@3.0.0:
|
| 3061 |
+
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
|
| 3062 |
+
engines: {node: '>=8'}
|
| 3063 |
+
|
| 3064 |
siginfo@2.0.0:
|
| 3065 |
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
|
| 3066 |
|
| 3067 |
+
signal-exit@4.1.0:
|
| 3068 |
+
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
|
| 3069 |
+
engines: {node: '>=14'}
|
| 3070 |
+
|
| 3071 |
sirv@3.0.2:
|
| 3072 |
resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==}
|
| 3073 |
engines: {node: '>=18'}
|
|
|
|
| 3125 |
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
|
| 3126 |
engines: {node: '>=8'}
|
| 3127 |
|
| 3128 |
+
strip-final-newline@3.0.0:
|
| 3129 |
+
resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==}
|
| 3130 |
+
engines: {node: '>=12'}
|
| 3131 |
+
|
| 3132 |
strip-json-comments@5.0.3:
|
| 3133 |
resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==}
|
| 3134 |
engines: {node: '>=14.16'}
|
| 3135 |
|
| 3136 |
+
strip-literal@2.1.1:
|
| 3137 |
+
resolution: {integrity: sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==}
|
| 3138 |
+
|
| 3139 |
stripe@20.3.1:
|
| 3140 |
resolution: {integrity: sha512-k990yOT5G5rhX3XluRPw5Y8RLdJDW4dzQ29wWT66piHrbnM2KyamJ1dKgPsw4HzGHRWjDiSSdcI2WdxQUPV3aQ==}
|
| 3141 |
engines: {node: '>=16'}
|
|
|
|
| 3202 |
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
|
| 3203 |
engines: {node: '>=12.0.0'}
|
| 3204 |
|
| 3205 |
+
tinypool@0.8.4:
|
| 3206 |
+
resolution: {integrity: sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==}
|
| 3207 |
+
engines: {node: '>=14.0.0'}
|
| 3208 |
+
|
| 3209 |
tinyrainbow@3.0.3:
|
| 3210 |
resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==}
|
| 3211 |
engines: {node: '>=14.0.0'}
|
| 3212 |
|
| 3213 |
+
tinyspy@2.2.1:
|
| 3214 |
+
resolution: {integrity: sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==}
|
| 3215 |
+
engines: {node: '>=14.0.0'}
|
| 3216 |
+
|
| 3217 |
to-regex-range@5.0.1:
|
| 3218 |
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
|
| 3219 |
engines: {node: '>=8.0'}
|
|
|
|
| 3273 |
resolution: {integrity: sha512-1q7+9UJABuBAHrcC4Sxp5lOqYS5mvxRrwa33wpIyM18hlOCpRD/fTJNxZ0vhbMcJmz15o9kkVm743mPn7p6jpQ==}
|
| 3274 |
hasBin: true
|
| 3275 |
|
| 3276 |
+
type-detect@4.1.0:
|
| 3277 |
+
resolution: {integrity: sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==}
|
| 3278 |
+
engines: {node: '>=4'}
|
| 3279 |
+
|
| 3280 |
typescript@5.9.3:
|
| 3281 |
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
|
| 3282 |
engines: {node: '>=14.17'}
|
| 3283 |
hasBin: true
|
| 3284 |
|
| 3285 |
+
ufo@1.6.3:
|
| 3286 |
+
resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==}
|
| 3287 |
+
|
| 3288 |
unbzip2-stream@1.4.3:
|
| 3289 |
resolution: {integrity: sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg==}
|
| 3290 |
|
|
|
|
| 3317 |
resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==}
|
| 3318 |
hasBin: true
|
| 3319 |
|
| 3320 |
+
vite-node@1.6.1:
|
| 3321 |
+
resolution: {integrity: sha512-YAXkfvGtuTzwWbDSACdJSg4A4DZiAqckWe90Zapc/sEX3XvHcw1NdurM/6od8J207tSDqNbSsgdCacBgvJKFuA==}
|
| 3322 |
+
engines: {node: ^18.0.0 || >=20.0.0}
|
| 3323 |
+
hasBin: true
|
| 3324 |
+
|
| 3325 |
vite@5.4.21:
|
| 3326 |
resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==}
|
| 3327 |
engines: {node: ^18.0.0 || >=20.0.0}
|
|
|
|
| 3393 |
yaml:
|
| 3394 |
optional: true
|
| 3395 |
|
| 3396 |
+
vitest@1.6.1:
|
| 3397 |
+
resolution: {integrity: sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==}
|
| 3398 |
+
engines: {node: ^18.0.0 || >=20.0.0}
|
| 3399 |
+
hasBin: true
|
| 3400 |
+
peerDependencies:
|
| 3401 |
+
'@edge-runtime/vm': '*'
|
| 3402 |
+
'@types/node': ^18.0.0 || >=20.0.0
|
| 3403 |
+
'@vitest/browser': 1.6.1
|
| 3404 |
+
'@vitest/ui': 1.6.1
|
| 3405 |
+
happy-dom: '*'
|
| 3406 |
+
jsdom: '*'
|
| 3407 |
+
peerDependenciesMeta:
|
| 3408 |
+
'@edge-runtime/vm':
|
| 3409 |
+
optional: true
|
| 3410 |
+
'@types/node':
|
| 3411 |
+
optional: true
|
| 3412 |
+
'@vitest/browser':
|
| 3413 |
+
optional: true
|
| 3414 |
+
'@vitest/ui':
|
| 3415 |
+
optional: true
|
| 3416 |
+
happy-dom:
|
| 3417 |
+
optional: true
|
| 3418 |
+
jsdom:
|
| 3419 |
+
optional: true
|
| 3420 |
+
|
| 3421 |
vitest@4.0.18:
|
| 3422 |
resolution: {integrity: sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==}
|
| 3423 |
engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
|
|
|
|
| 3462 |
whatwg-url@5.0.0:
|
| 3463 |
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
|
| 3464 |
|
| 3465 |
+
which@2.0.2:
|
| 3466 |
+
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
|
| 3467 |
+
engines: {node: '>= 8'}
|
| 3468 |
+
hasBin: true
|
| 3469 |
+
|
| 3470 |
why-is-node-running@2.3.0:
|
| 3471 |
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
|
| 3472 |
engines: {node: '>=8'}
|
|
|
|
| 3509 |
yauzl@2.10.0:
|
| 3510 |
resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==}
|
| 3511 |
|
| 3512 |
+
yocto-queue@1.2.2:
|
| 3513 |
+
resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==}
|
| 3514 |
+
engines: {node: '>=12.20'}
|
| 3515 |
+
|
| 3516 |
zod@3.23.8:
|
| 3517 |
resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==}
|
| 3518 |
|
|
|
|
| 4476 |
|
| 4477 |
'@ioredis/commands@1.5.0': {}
|
| 4478 |
|
| 4479 |
+
'@jest/schemas@29.6.3':
|
| 4480 |
+
dependencies:
|
| 4481 |
+
'@sinclair/typebox': 0.27.10
|
| 4482 |
+
|
| 4483 |
'@jridgewell/gen-mapping@0.3.13':
|
| 4484 |
dependencies:
|
| 4485 |
'@jridgewell/sourcemap-codec': 1.5.5
|
|
|
|
| 4655 |
'@rollup/rollup-win32-x64-msvc@4.57.1':
|
| 4656 |
optional: true
|
| 4657 |
|
| 4658 |
+
'@sinclair/typebox@0.27.10': {}
|
| 4659 |
+
|
| 4660 |
'@smithy/abort-controller@4.2.8':
|
| 4661 |
dependencies:
|
| 4662 |
'@smithy/types': 4.12.0
|
|
|
|
| 5087 |
transitivePeerDependencies:
|
| 5088 |
- supports-color
|
| 5089 |
|
| 5090 |
+
'@vitest/expect@1.6.1':
|
| 5091 |
+
dependencies:
|
| 5092 |
+
'@vitest/spy': 1.6.1
|
| 5093 |
+
'@vitest/utils': 1.6.1
|
| 5094 |
+
chai: 4.5.0
|
| 5095 |
+
|
| 5096 |
'@vitest/expect@4.0.18':
|
| 5097 |
dependencies:
|
| 5098 |
'@standard-schema/spec': 1.1.0
|
|
|
|
| 5114 |
dependencies:
|
| 5115 |
tinyrainbow: 3.0.3
|
| 5116 |
|
| 5117 |
+
'@vitest/runner@1.6.1':
|
| 5118 |
+
dependencies:
|
| 5119 |
+
'@vitest/utils': 1.6.1
|
| 5120 |
+
p-limit: 5.0.0
|
| 5121 |
+
pathe: 1.1.2
|
| 5122 |
+
|
| 5123 |
'@vitest/runner@4.0.18':
|
| 5124 |
dependencies:
|
| 5125 |
'@vitest/utils': 4.0.18
|
| 5126 |
pathe: 2.0.3
|
| 5127 |
|
| 5128 |
+
'@vitest/snapshot@1.6.1':
|
| 5129 |
+
dependencies:
|
| 5130 |
+
magic-string: 0.30.21
|
| 5131 |
+
pathe: 1.1.2
|
| 5132 |
+
pretty-format: 29.7.0
|
| 5133 |
+
|
| 5134 |
'@vitest/snapshot@4.0.18':
|
| 5135 |
dependencies:
|
| 5136 |
'@vitest/pretty-format': 4.0.18
|
| 5137 |
magic-string: 0.30.21
|
| 5138 |
pathe: 2.0.3
|
| 5139 |
|
| 5140 |
+
'@vitest/spy@1.6.1':
|
| 5141 |
+
dependencies:
|
| 5142 |
+
tinyspy: 2.2.1
|
| 5143 |
+
|
| 5144 |
'@vitest/spy@4.0.18': {}
|
| 5145 |
|
| 5146 |
'@vitest/ui@4.0.18(vitest@4.0.18)':
|
|
|
|
| 5154 |
tinyrainbow: 3.0.3
|
| 5155 |
vitest: 4.0.18(@types/node@20.19.33)(@vitest/ui@4.0.18)(jiti@1.21.7)(tsx@3.14.0)
|
| 5156 |
|
| 5157 |
+
'@vitest/utils@1.6.1':
|
| 5158 |
+
dependencies:
|
| 5159 |
+
diff-sequences: 29.6.3
|
| 5160 |
+
estree-walker: 3.0.3
|
| 5161 |
+
loupe: 2.3.7
|
| 5162 |
+
pretty-format: 29.7.0
|
| 5163 |
+
|
| 5164 |
'@vitest/utils@4.0.18':
|
| 5165 |
dependencies:
|
| 5166 |
'@vitest/pretty-format': 4.0.18
|
|
|
|
| 5172 |
|
| 5173 |
abstract-logging@2.0.1: {}
|
| 5174 |
|
| 5175 |
+
acorn-walk@8.3.5:
|
| 5176 |
+
dependencies:
|
| 5177 |
+
acorn: 8.16.0
|
| 5178 |
+
|
| 5179 |
+
acorn@8.16.0: {}
|
| 5180 |
+
|
| 5181 |
agent-base@7.1.4: {}
|
| 5182 |
|
| 5183 |
agentkeepalive@4.6.0:
|
|
|
|
| 5205 |
dependencies:
|
| 5206 |
color-convert: 2.0.1
|
| 5207 |
|
| 5208 |
+
ansi-styles@5.2.0: {}
|
| 5209 |
+
|
| 5210 |
any-promise@1.3.0: {}
|
| 5211 |
|
| 5212 |
anymatch@3.1.3:
|
|
|
|
| 5218 |
|
| 5219 |
argparse@2.0.1: {}
|
| 5220 |
|
| 5221 |
+
assertion-error@1.1.0: {}
|
| 5222 |
+
|
| 5223 |
assertion-error@2.0.1: {}
|
| 5224 |
|
| 5225 |
ast-types@0.13.4:
|
|
|
|
| 5355 |
transitivePeerDependencies:
|
| 5356 |
- supports-color
|
| 5357 |
|
| 5358 |
+
cac@6.7.14: {}
|
| 5359 |
+
|
| 5360 |
call-bind-apply-helpers@1.0.2:
|
| 5361 |
dependencies:
|
| 5362 |
es-errors: 1.3.0
|
|
|
|
| 5368 |
|
| 5369 |
caniuse-lite@1.0.30001770: {}
|
| 5370 |
|
| 5371 |
+
chai@4.5.0:
|
| 5372 |
+
dependencies:
|
| 5373 |
+
assertion-error: 1.1.0
|
| 5374 |
+
check-error: 1.0.3
|
| 5375 |
+
deep-eql: 4.1.4
|
| 5376 |
+
get-func-name: 2.0.2
|
| 5377 |
+
loupe: 2.3.7
|
| 5378 |
+
pathval: 1.1.1
|
| 5379 |
+
type-detect: 4.1.0
|
| 5380 |
+
|
| 5381 |
chai@6.2.2: {}
|
| 5382 |
|
| 5383 |
+
check-error@1.0.3:
|
| 5384 |
+
dependencies:
|
| 5385 |
+
get-func-name: 2.0.2
|
| 5386 |
+
|
| 5387 |
chokidar@3.6.0:
|
| 5388 |
dependencies:
|
| 5389 |
anymatch: 3.1.3
|
|
|
|
| 5425 |
|
| 5426 |
commander@4.1.1: {}
|
| 5427 |
|
| 5428 |
+
confbox@0.1.8: {}
|
| 5429 |
+
|
| 5430 |
convert-source-map@2.0.0: {}
|
| 5431 |
|
| 5432 |
cookie@0.7.2: {}
|
|
|
|
| 5446 |
dependencies:
|
| 5447 |
luxon: 3.7.2
|
| 5448 |
|
| 5449 |
+
cross-spawn@7.0.6:
|
| 5450 |
+
dependencies:
|
| 5451 |
+
path-key: 3.1.1
|
| 5452 |
+
shebang-command: 2.0.0
|
| 5453 |
+
which: 2.0.2
|
| 5454 |
+
|
| 5455 |
cssesc@3.0.0: {}
|
| 5456 |
|
| 5457 |
csstype@3.2.3: {}
|
|
|
|
| 5464 |
dependencies:
|
| 5465 |
ms: 2.1.3
|
| 5466 |
|
| 5467 |
+
deep-eql@4.1.4:
|
| 5468 |
+
dependencies:
|
| 5469 |
+
type-detect: 4.1.0
|
| 5470 |
+
|
| 5471 |
degenerator@5.0.1:
|
| 5472 |
dependencies:
|
| 5473 |
ast-types: 0.13.4
|
|
|
|
| 5484 |
|
| 5485 |
didyoumean@1.2.2: {}
|
| 5486 |
|
| 5487 |
+
diff-sequences@29.6.3: {}
|
| 5488 |
+
|
| 5489 |
diff@8.0.3: {}
|
| 5490 |
|
| 5491 |
dlv@1.1.3: {}
|
|
|
|
| 5637 |
transitivePeerDependencies:
|
| 5638 |
- bare-abort-controller
|
| 5639 |
|
| 5640 |
+
execa@8.0.1:
|
| 5641 |
+
dependencies:
|
| 5642 |
+
cross-spawn: 7.0.6
|
| 5643 |
+
get-stream: 8.0.1
|
| 5644 |
+
human-signals: 5.0.0
|
| 5645 |
+
is-stream: 3.0.0
|
| 5646 |
+
merge-stream: 2.0.0
|
| 5647 |
+
npm-run-path: 5.3.0
|
| 5648 |
+
onetime: 6.0.0
|
| 5649 |
+
signal-exit: 4.1.0
|
| 5650 |
+
strip-final-newline: 3.0.0
|
| 5651 |
+
|
| 5652 |
expect-type@1.3.0: {}
|
| 5653 |
|
| 5654 |
extract-zip@2.0.1:
|
|
|
|
| 5790 |
|
| 5791 |
get-caller-file@2.0.5: {}
|
| 5792 |
|
| 5793 |
+
get-func-name@2.0.2: {}
|
| 5794 |
+
|
| 5795 |
get-intrinsic@1.3.0:
|
| 5796 |
dependencies:
|
| 5797 |
call-bind-apply-helpers: 1.0.2
|
|
|
|
| 5814 |
dependencies:
|
| 5815 |
pump: 3.0.3
|
| 5816 |
|
| 5817 |
+
get-stream@8.0.1: {}
|
| 5818 |
+
|
| 5819 |
get-tsconfig@4.13.6:
|
| 5820 |
dependencies:
|
| 5821 |
resolve-pkg-maps: 1.0.0
|
|
|
|
| 5874 |
|
| 5875 |
https@1.0.0: {}
|
| 5876 |
|
| 5877 |
+
human-signals@5.0.0: {}
|
| 5878 |
+
|
| 5879 |
humanize-ms@1.2.1:
|
| 5880 |
dependencies:
|
| 5881 |
ms: 2.1.3
|
|
|
|
| 5954 |
|
| 5955 |
is-number@7.0.0: {}
|
| 5956 |
|
| 5957 |
+
is-stream@3.0.0: {}
|
| 5958 |
+
|
| 5959 |
isarray@1.0.0: {}
|
| 5960 |
|
| 5961 |
+
isexe@2.0.0: {}
|
| 5962 |
+
|
| 5963 |
jiti@1.21.7: {}
|
| 5964 |
|
| 5965 |
joycon@3.1.1: {}
|
| 5966 |
|
| 5967 |
js-tokens@4.0.0: {}
|
| 5968 |
|
| 5969 |
+
js-tokens@9.0.1: {}
|
| 5970 |
+
|
| 5971 |
js-yaml@4.1.1:
|
| 5972 |
dependencies:
|
| 5973 |
argparse: 2.0.1
|
|
|
|
| 6005 |
|
| 6006 |
lines-and-columns@1.2.4: {}
|
| 6007 |
|
| 6008 |
+
local-pkg@0.5.1:
|
| 6009 |
+
dependencies:
|
| 6010 |
+
mlly: 1.8.2
|
| 6011 |
+
pkg-types: 1.3.1
|
| 6012 |
+
|
| 6013 |
lodash.defaults@4.2.0: {}
|
| 6014 |
|
| 6015 |
lodash.isarguments@3.1.0: {}
|
|
|
|
| 6020 |
dependencies:
|
| 6021 |
js-tokens: 4.0.0
|
| 6022 |
|
| 6023 |
+
loupe@2.3.7:
|
| 6024 |
+
dependencies:
|
| 6025 |
+
get-func-name: 2.0.2
|
| 6026 |
+
|
| 6027 |
lru-cache@5.1.1:
|
| 6028 |
dependencies:
|
| 6029 |
yallist: 3.1.1
|
|
|
|
| 6042 |
|
| 6043 |
math-intrinsics@1.1.0: {}
|
| 6044 |
|
| 6045 |
+
merge-stream@2.0.0: {}
|
| 6046 |
+
|
| 6047 |
merge2@1.4.1: {}
|
| 6048 |
|
| 6049 |
micromatch@4.0.8:
|
|
|
|
| 6057 |
dependencies:
|
| 6058 |
mime-db: 1.52.0
|
| 6059 |
|
| 6060 |
+
mimic-fn@4.0.0: {}
|
| 6061 |
+
|
| 6062 |
minimatch@5.1.6:
|
| 6063 |
dependencies:
|
| 6064 |
brace-expansion: 2.0.2
|
|
|
|
| 6067 |
|
| 6068 |
mitt@3.0.1: {}
|
| 6069 |
|
| 6070 |
+
mlly@1.8.2:
|
| 6071 |
+
dependencies:
|
| 6072 |
+
acorn: 8.16.0
|
| 6073 |
+
pathe: 2.0.3
|
| 6074 |
+
pkg-types: 1.3.1
|
| 6075 |
+
ufo: 1.6.3
|
| 6076 |
+
|
| 6077 |
mnemonist@0.39.6:
|
| 6078 |
dependencies:
|
| 6079 |
obliterator: 2.0.5
|
|
|
|
| 6131 |
|
| 6132 |
normalize-path@3.0.0: {}
|
| 6133 |
|
| 6134 |
+
npm-run-path@5.3.0:
|
| 6135 |
+
dependencies:
|
| 6136 |
+
path-key: 4.0.0
|
| 6137 |
+
|
| 6138 |
object-assign@4.1.1: {}
|
| 6139 |
|
| 6140 |
object-hash@3.0.0: {}
|
|
|
|
| 6149 |
dependencies:
|
| 6150 |
wrappy: 1.0.2
|
| 6151 |
|
| 6152 |
+
onetime@6.0.0:
|
| 6153 |
+
dependencies:
|
| 6154 |
+
mimic-fn: 4.0.0
|
| 6155 |
+
|
| 6156 |
openai@4.104.0(ws@8.19.0)(zod@3.25.76):
|
| 6157 |
dependencies:
|
| 6158 |
'@types/node': 18.19.130
|
|
|
|
| 6168 |
transitivePeerDependencies:
|
| 6169 |
- encoding
|
| 6170 |
|
| 6171 |
+
p-limit@5.0.0:
|
| 6172 |
+
dependencies:
|
| 6173 |
+
yocto-queue: 1.2.2
|
| 6174 |
+
|
| 6175 |
pac-proxy-agent@7.2.0:
|
| 6176 |
dependencies:
|
| 6177 |
'@tootallnate/quickjs-emscripten': 0.23.0
|
|
|
|
| 6203 |
json-parse-even-better-errors: 2.3.1
|
| 6204 |
lines-and-columns: 1.2.4
|
| 6205 |
|
| 6206 |
+
path-key@3.1.1: {}
|
| 6207 |
+
|
| 6208 |
+
path-key@4.0.0: {}
|
| 6209 |
+
|
| 6210 |
path-parse@1.0.7: {}
|
| 6211 |
|
| 6212 |
path@0.12.7:
|
|
|
|
| 6214 |
process: 0.11.10
|
| 6215 |
util: 0.10.4
|
| 6216 |
|
| 6217 |
+
pathe@1.1.2: {}
|
| 6218 |
+
|
| 6219 |
pathe@2.0.3: {}
|
| 6220 |
|
| 6221 |
+
pathval@1.1.1: {}
|
| 6222 |
+
|
| 6223 |
pend@1.2.0: {}
|
| 6224 |
|
| 6225 |
picocolors@1.1.1: {}
|
|
|
|
| 6286 |
|
| 6287 |
pirates@4.0.7: {}
|
| 6288 |
|
| 6289 |
+
pkg-types@1.3.1:
|
| 6290 |
+
dependencies:
|
| 6291 |
+
confbox: 0.1.8
|
| 6292 |
+
mlly: 1.8.2
|
| 6293 |
+
pathe: 2.0.3
|
| 6294 |
+
|
| 6295 |
postcss-import@15.1.0(postcss@8.5.6):
|
| 6296 |
dependencies:
|
| 6297 |
postcss: 8.5.6
|
|
|
|
| 6338 |
|
| 6339 |
prettier@3.8.1: {}
|
| 6340 |
|
| 6341 |
+
pretty-format@29.7.0:
|
| 6342 |
+
dependencies:
|
| 6343 |
+
'@jest/schemas': 29.6.3
|
| 6344 |
+
ansi-styles: 5.2.0
|
| 6345 |
+
react-is: 18.3.1
|
| 6346 |
+
|
| 6347 |
prisma@5.22.0:
|
| 6348 |
dependencies:
|
| 6349 |
'@prisma/engines': 5.22.0
|
|
|
|
| 6429 |
react: 18.3.1
|
| 6430 |
scheduler: 0.23.2
|
| 6431 |
|
| 6432 |
+
react-is@18.3.1: {}
|
| 6433 |
+
|
| 6434 |
react-refresh@0.17.0: {}
|
| 6435 |
|
| 6436 |
react-router-dom@6.30.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
|
|
|
| 6585 |
'@img/sharp-win32-ia32': 0.34.5
|
| 6586 |
'@img/sharp-win32-x64': 0.34.5
|
| 6587 |
|
| 6588 |
+
shebang-command@2.0.0:
|
| 6589 |
+
dependencies:
|
| 6590 |
+
shebang-regex: 3.0.0
|
| 6591 |
+
|
| 6592 |
+
shebang-regex@3.0.0: {}
|
| 6593 |
+
|
| 6594 |
siginfo@2.0.0: {}
|
| 6595 |
|
| 6596 |
+
signal-exit@4.1.0: {}
|
| 6597 |
+
|
| 6598 |
sirv@3.0.2:
|
| 6599 |
dependencies:
|
| 6600 |
'@polka/url': 1.0.0-next.29
|
|
|
|
| 6660 |
dependencies:
|
| 6661 |
ansi-regex: 5.0.1
|
| 6662 |
|
| 6663 |
+
strip-final-newline@3.0.0: {}
|
| 6664 |
+
|
| 6665 |
strip-json-comments@5.0.3: {}
|
| 6666 |
|
| 6667 |
+
strip-literal@2.1.1:
|
| 6668 |
+
dependencies:
|
| 6669 |
+
js-tokens: 9.0.1
|
| 6670 |
+
|
| 6671 |
stripe@20.3.1(@types/node@20.19.33):
|
| 6672 |
optionalDependencies:
|
| 6673 |
'@types/node': 20.19.33
|
|
|
|
| 6776 |
fdir: 6.5.0(picomatch@4.0.3)
|
| 6777 |
picomatch: 4.0.3
|
| 6778 |
|
| 6779 |
+
tinypool@0.8.4: {}
|
| 6780 |
+
|
| 6781 |
tinyrainbow@3.0.3: {}
|
| 6782 |
|
| 6783 |
+
tinyspy@2.2.1: {}
|
| 6784 |
+
|
| 6785 |
to-regex-range@5.0.1:
|
| 6786 |
dependencies:
|
| 6787 |
is-number: 7.0.0
|
|
|
|
| 6831 |
turbo-windows-64: 1.13.4
|
| 6832 |
turbo-windows-arm64: 1.13.4
|
| 6833 |
|
| 6834 |
+
type-detect@4.1.0: {}
|
| 6835 |
+
|
| 6836 |
typescript@5.9.3: {}
|
| 6837 |
|
| 6838 |
+
ufo@1.6.3: {}
|
| 6839 |
+
|
| 6840 |
unbzip2-stream@1.4.3:
|
| 6841 |
dependencies:
|
| 6842 |
buffer: 5.7.1
|
|
|
|
| 6864 |
|
| 6865 |
uuid@9.0.1: {}
|
| 6866 |
|
| 6867 |
+
vite-node@1.6.1(@types/node@20.19.33):
|
| 6868 |
+
dependencies:
|
| 6869 |
+
cac: 6.7.14
|
| 6870 |
+
debug: 4.4.3
|
| 6871 |
+
pathe: 1.1.2
|
| 6872 |
+
picocolors: 1.1.1
|
| 6873 |
+
vite: 5.4.21(@types/node@20.19.33)
|
| 6874 |
+
transitivePeerDependencies:
|
| 6875 |
+
- '@types/node'
|
| 6876 |
+
- less
|
| 6877 |
+
- lightningcss
|
| 6878 |
+
- sass
|
| 6879 |
+
- sass-embedded
|
| 6880 |
+
- stylus
|
| 6881 |
+
- sugarss
|
| 6882 |
+
- supports-color
|
| 6883 |
+
- terser
|
| 6884 |
+
|
| 6885 |
+
vite@5.4.21(@types/node@20.19.33):
|
| 6886 |
+
dependencies:
|
| 6887 |
+
esbuild: 0.21.5
|
| 6888 |
+
postcss: 8.5.6
|
| 6889 |
+
rollup: 4.57.1
|
| 6890 |
+
optionalDependencies:
|
| 6891 |
+
'@types/node': 20.19.33
|
| 6892 |
+
fsevents: 2.3.3
|
| 6893 |
+
|
| 6894 |
vite@5.4.21(@types/node@22.19.11):
|
| 6895 |
dependencies:
|
| 6896 |
esbuild: 0.21.5
|
|
|
|
| 6914 |
jiti: 1.21.7
|
| 6915 |
tsx: 3.14.0
|
| 6916 |
|
| 6917 |
+
vitest@1.6.1(@types/node@20.19.33):
|
| 6918 |
+
dependencies:
|
| 6919 |
+
'@vitest/expect': 1.6.1
|
| 6920 |
+
'@vitest/runner': 1.6.1
|
| 6921 |
+
'@vitest/snapshot': 1.6.1
|
| 6922 |
+
'@vitest/spy': 1.6.1
|
| 6923 |
+
'@vitest/utils': 1.6.1
|
| 6924 |
+
acorn-walk: 8.3.5
|
| 6925 |
+
chai: 4.5.0
|
| 6926 |
+
debug: 4.4.3
|
| 6927 |
+
execa: 8.0.1
|
| 6928 |
+
local-pkg: 0.5.1
|
| 6929 |
+
magic-string: 0.30.21
|
| 6930 |
+
pathe: 1.1.2
|
| 6931 |
+
picocolors: 1.1.1
|
| 6932 |
+
std-env: 3.10.0
|
| 6933 |
+
strip-literal: 2.1.1
|
| 6934 |
+
tinybench: 2.9.0
|
| 6935 |
+
tinypool: 0.8.4
|
| 6936 |
+
vite: 5.4.21(@types/node@20.19.33)
|
| 6937 |
+
vite-node: 1.6.1(@types/node@20.19.33)
|
| 6938 |
+
why-is-node-running: 2.3.0
|
| 6939 |
+
optionalDependencies:
|
| 6940 |
+
'@types/node': 20.19.33
|
| 6941 |
+
transitivePeerDependencies:
|
| 6942 |
+
- less
|
| 6943 |
+
- lightningcss
|
| 6944 |
+
- sass
|
| 6945 |
+
- sass-embedded
|
| 6946 |
+
- stylus
|
| 6947 |
+
- sugarss
|
| 6948 |
+
- supports-color
|
| 6949 |
+
- terser
|
| 6950 |
+
|
| 6951 |
vitest@4.0.18(@types/node@20.19.33)(@vitest/ui@4.0.18)(jiti@1.21.7)(tsx@3.14.0):
|
| 6952 |
dependencies:
|
| 6953 |
'@vitest/expect': 4.0.18
|
|
|
|
| 6995 |
tr46: 0.0.3
|
| 6996 |
webidl-conversions: 3.0.1
|
| 6997 |
|
| 6998 |
+
which@2.0.2:
|
| 6999 |
+
dependencies:
|
| 7000 |
+
isexe: 2.0.0
|
| 7001 |
+
|
| 7002 |
why-is-node-running@2.3.0:
|
| 7003 |
dependencies:
|
| 7004 |
siginfo: 2.0.0
|
|
|
|
| 7035 |
buffer-crc32: 0.2.13
|
| 7036 |
fd-slicer: 1.1.0
|
| 7037 |
|
| 7038 |
+
yocto-queue@1.2.2: {}
|
| 7039 |
+
|
| 7040 |
zod@3.23.8: {}
|
| 7041 |
|
| 7042 |
zod@3.25.76: {}
|