CognxSafeTrack commited on
Commit
e289c5c
·
1 Parent(s): 1a00f18

feat: B2B SaaS Multi-tenant architecture & Tech Debt Resolution

Browse files
Files changed (46) hide show
  1. apps/admin/src/App.tsx +38 -8
  2. apps/admin/src/lib/tenant.tsx +33 -0
  3. apps/admin/src/pages/ClientsManagementView.tsx +132 -0
  4. apps/admin/src/pages/TrackDaysPage.tsx +7 -1
  5. apps/admin/src/pages/TrackFormPage.tsx +14 -3
  6. apps/admin/src/pages/TrackListPage.tsx +12 -2
  7. apps/api/src/config.ts +30 -0
  8. apps/api/src/index.ts +21 -4
  9. apps/api/src/logger.ts +22 -12
  10. apps/api/src/middleware/tenant.ts +25 -0
  11. apps/api/src/routes/ai.ts +3 -3
  12. apps/api/src/routes/internal.ts +20 -162
  13. apps/api/src/routes/organizations.ts +40 -4
  14. apps/api/src/services/ai/ProviderRegistry.ts +40 -0
  15. apps/api/src/services/ai/index.ts +124 -243
  16. apps/api/src/services/organization.ts +28 -6
  17. apps/api/src/services/prisma.ts +3 -2
  18. apps/api/src/services/whatsapp-utils.ts +50 -0
  19. apps/api/src/services/whatsapp.ts +27 -673
  20. apps/whatsapp-worker/package.json +6 -2
  21. apps/whatsapp-worker/scratch/check_orgs.js +9 -0
  22. apps/whatsapp-worker/src/__tests__/OnboardingHandler.test.ts +105 -0
  23. apps/whatsapp-worker/src/__tests__/normalizeWolof.test.ts +26 -0
  24. apps/whatsapp-worker/src/config.ts +28 -100
  25. apps/whatsapp-worker/src/handlers/AdminHandler.ts +76 -0
  26. apps/whatsapp-worker/src/handlers/ContentHandler.ts +132 -0
  27. apps/whatsapp-worker/src/handlers/EnrollHandler.ts +89 -0
  28. apps/whatsapp-worker/src/handlers/InboundHandler.ts +25 -0
  29. apps/whatsapp-worker/src/handlers/MediaHandler.ts +145 -0
  30. apps/whatsapp-worker/src/handlers/MessageHandler.ts +80 -0
  31. apps/whatsapp-worker/src/handlers/NudgeHandler.ts +57 -0
  32. apps/whatsapp-worker/src/handlers/types.ts +6 -0
  33. apps/whatsapp-worker/src/index.ts +50 -1128
  34. apps/whatsapp-worker/src/logger.ts +22 -12
  35. apps/whatsapp-worker/src/normalizeWolof.ts +9 -0
  36. apps/whatsapp-worker/src/pedagogy.ts +75 -369
  37. apps/whatsapp-worker/src/scratch/check_orgs.ts +17 -0
  38. apps/whatsapp-worker/src/services/ai-pedagogy.ts +74 -0
  39. apps/whatsapp-worker/src/services/prisma.ts +3 -2
  40. packages/database/index.ts +2 -0
  41. packages/database/prisma/schema.prisma +33 -0
  42. packages/database/src/context.ts +11 -0
  43. packages/database/src/extension.ts +58 -0
  44. packages/prompts/src/index.ts +45 -2
  45. packages/prompts/src/templates/personalized-lesson.md +3 -3
  46. 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-8 flex items-center gap-2"><span className="text-2xl">🎓</span>EdTech Admin</div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- <Router>
71
- <Routes>
72
- <Route path="/login" element={<LoginPage />} />
73
- <Route path="/*" element={<ProtectedRoute><AppShell /></ProtectedRoute>} />
74
- </Routes>
75
- </Router>
 
 
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) => ({ 'Authorization': `Bearer ${k}`, 'Content-Type': 'application/json' });
 
 
 
 
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) => ({ 'Authorization': `Bearer ${k}`, 'Content-Type': 'application/json' });
 
 
 
 
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 type="submit" disabled={saving} className="flex-1 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">
82
- <Save className="w-4 h-4" />{saving ? 'Enregistrement...' : 'Enregistrer'}
 
 
 
 
 
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) => ({ 'Authorization': `Bearer ${k}`, 'Content-Type': 'application/json' });
 
 
 
 
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(() => { load(); }, []);
 
 
 
 
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 { PrismaClient } from '@repo/database';
16
 
17
  declare module 'fastify' {
18
  interface FastifyInstance {
19
- prisma: PrismaClient;
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, reply) => {
116
- return reply.code(200).type('application/json').send({ ok: true });
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, businessProfile, 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, businessProfile, previousResponses);
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, isButtonChoice
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
- // ─── Zod Schema for WhatsApp Webhook Payload ─────────────────────────────────
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
- contacts: z.array(z.any()).optional(),
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
- if (!parsed.success) {
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 messages = change.value.messages ?? [];
 
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
- await q.add('download-media', {
106
- mediaId: message.audio.id,
107
- mimeType: message.audio.mime_type || 'audio/ogg',
108
- phone,
109
- organizationId,
110
- ...(accessToken ? { accessToken } : {})
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
- // 🕰️ TIME-TRAVEL: Detect if user is in replay mode before routing to service
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
- fastify.log.error(`[INTERNAL-WEBHOOK] Async processing error: ${error instanceof Error ? error.message : String(error)}`);
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
- request.log.info(`${traceId} Successfully processed message`);
203
- } catch (err: unknown) {
204
- const errorMsg = err instanceof Error ? err.message : String(err);
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
- // ── Simple Ping for Token/Connectivity Verification ──────────────────────
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 redis.del(`org:config:${id}`);
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 { wabaId, accessToken, phoneNumber, phoneNumberId } = body.data;
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 redis.del(`org:config:${id}`);
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 { LLMProvider, OnePagerData, OnePagerSchema, PitchDeckData, PitchDeckSchema, PersonalizedLessonSchema, FeedbackSchema, FeedbackData } from './types';
 
 
 
 
 
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 primaryProvider: LLMProvider;
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.mockProvider = new MockLLMProvider();
 
 
18
 
 
19
  const geminiApiKey = process.env.GOOGLE_AI_API_KEY;
20
  const openAiApiKey = process.env.OPENAI_API_KEY;
21
 
22
  if (geminiApiKey) {
23
- logger.info('[AI_SERVICE] Initializing Gemini as Primary Provider...');
24
- this.primaryProvider = new GeminiProvider(geminiApiKey);
25
- if (openAiApiKey) {
26
- logger.info('[AI_SERVICE] Initializing OpenAI as Fallback & A/V Provider...');
27
- const openai = new OpenAIProvider(openAiApiKey);
28
- this.fallbackProvider = openai;
29
- this.avProvider = openai;
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.primaryProvider = openai;
35
- this.avProvider = openai;
36
- } else {
37
- logger.info('[AI_SERVICE] No AI API Keys found. Initializing MOCK Provider...');
38
- this.primaryProvider = this.mockProvider;
39
- this.avProvider = this.mockProvider;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- try {
53
- const data = await this.primaryProvider.generateStructuredData(prompt, schema, temperature, imageUrl);
54
- const source = (this.primaryProvider instanceof GeminiProvider) ? 'GEMINI' :
55
- (this.primaryProvider instanceof OpenAIProvider) ? 'OPENAI' : 'MOCK';
56
- logger.info(`[AI_INFO] ${source} used successfully. (Vision: ${!!imageUrl})`);
57
- return { data, source };
58
- } catch (err) {
59
- if (this.fallbackProvider) {
60
- logger.warn('[AI_WARNING] Primary provider failed, falling back to OpenAI...', (err as Error).message);
61
- const data = await this.fallbackProvider.generateStructuredData(prompt, schema, temperature, imageUrl);
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 marketDataInjected = businessProfile?.marketData
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é v4.0' : 'Français institutionnel',
82
- languageInstruction: language === 'WOLOF' ? 'WOLOF (ñ, ë, é) suivi de la traduction FR' : 'French'
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 marketDataInjected = businessProfile?.marketData
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
- // 🚀 Question Detection Logic (Lead AI Engineer Requirement)
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
- if (!isDay7Choice) {
172
- logger.info(`[AI_SERVICE] 🔍 Triggering Market Search for Day ${dayNumber}...`);
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(query);
187
- if (results && results.length > 0) {
188
  searchResults = results;
189
- searchContext = `\n🌐 DONNÉES DE MARCHÉ RÉELLES (Google Search) :\n${results.map(r => `- ${r.title}: ${r.snippet}`).join('\n')}\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 criteriaContext = exerciseCriteria
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
- if (iterationCount >= 3) {
206
- actionPrompt = PromptLoader.compile('action-feedback-deepdive-limit', {});
207
- } else if (dayNumber === 11) {
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: expectedExercise || '',
223
- previousResponsesContext,
224
- questionDetectionBlock: hasQuestion ? '🚨 DOUBLE INTENTION DÉTECTÉE...' : '',
225
- day8VisualBlock: dayNumber === 8 ? '🚨 COACHING VISUEL...' : '',
226
- visionMultimodalBlock: imageUrl ? '📸 ANALYSE VISUELLE...' : '',
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
- languageSpecificInstruction: userLanguage === 'WOLOF' ? 'ALERTE MAXIMALE : Wolof Only...' : 'ALERTE MAXIMALE : French Only...'
247
- });
248
 
249
- // 📸 VISION HARDENING: Always use OpenAI (GPT-4o) for image-based feedback (more reliable multimodal JSON)
250
- let result;
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 activityLabel = userActivity || businessProfile?.activityLabel || 'entrepreneuriat';
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 (avec ñ, ë, é). Wolof en priorité, suivi de la traduction française précédée de (FR)' : 'Français'
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
- // Transcriptions are currently exclusively via OpenAI Whisper (or mock)
318
- const provider = this.avProvider || this.mockProvider;
319
- return provider.transcribeAudio(audioBuffer, filename, language);
320
  }
321
 
322
- /**
323
- * Extracts business profile details from user input based on the current day.
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
- dayNumber,
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.avProvider || this.mockProvider;
358
- return provider.generateSpeech(text);
 
359
  }
360
 
361
- /**
362
- * Generates a realistic image based on a prompt.
363
- */
364
  async generateImage(prompt: string): Promise<string> {
365
- const provider = this.avProvider || this.mockProvider;
366
- return provider.generateImage(prompt);
 
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 = 3600; // 1 hour
11
 
12
  export async function getOrganizationByPhoneNumberId(phoneNumberId: string): Promise<string> {
13
  const cacheKey = `org:phone:${phoneNumberId}`;
14
 
15
  // 1. Check Redis Cache
16
- const cached = await redis.get(cacheKey);
17
- if (cached) return cached;
 
 
 
 
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
- await redis.set(cacheKey, phoneRecord.organizationId, 'EX', CACHE_TTL);
 
 
 
 
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
- export const prisma = new PrismaClient();
 
 
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 { prisma } from './prisma';
3
- import { scheduleMessage, enrollUser, whatsappQueue, scheduleInteractiveList, setTimeTravelContext, getTimeTravelContext, clearTimeTravelContext } from './queue';
4
 
5
  export class WhatsAppService {
6
- private static normalizeCommand(text: string): string {
7
- return text
8
- .trim()
9
- .toLowerCase()
10
- .replace(/[.,!?;:]+$/, "") // Remove trailing punctuation
11
- .toUpperCase();
12
- }
13
-
14
- private static detectIntent(text: string): 'YES' | 'NO' | 'UNKNOWN' {
15
- const normalized = text.trim().toLowerCase().replace(/[.,!?;:]+$/, "");
16
- const yesWords = ['oui', 'ouais', 'wi', 'waaw', 'yes', 'yep', 'ok', 'd’accord', 'daccord', 'da’accord'];
17
- const noWords = ['non', 'déet', 'deet', 'no', 'nah', 'nein'];
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
- const normalizedText = this.normalizeCommand(text);
60
- logger.info(`${traceId} Received: ${normalizedText} (Audio: ${audioUrl || 'N/A'}, Image: ${imageUrl || 'N/A'})`);
61
-
62
- // 1. Find or Create User
63
- let user = await prisma.user.findUnique({ where: { phone } });
64
-
65
- if (!user) {
66
- const isInscription = this.isFuzzyMatch(normalizedText, 'INSCRIPTION') || normalizedText.includes('INSCRI') || normalizedText.includes('INSCRI');
67
-
68
- if (isInscription) {
69
- logger.info(`${traceId} New user registration triggered for ${phone}`);
70
- user = await prisma.user.create({ data: { phone, organizationId } });
71
- const { scheduleInteractiveButtons } = await import('./queue');
72
- await scheduleInteractiveButtons(
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 { logger } from './logger';
2
  import dotenv from 'dotenv';
3
- dotenv.config();
4
-
5
- /**
6
- * Strictly requires a base URL to start with https://
7
- * and removes any trailing slashes.
8
- */
9
- export function requireHttpUrl(url: string | undefined, keyName: string): string {
10
- if (!url) {
11
- throw new Error(`[CONFIG] Missing environment variable: ${keyName}`);
12
- }
13
-
14
- let normalized = url.trim();
15
-
16
- // Auto-prefix with https:// if it doesn't start with http
17
- if (!normalized.startsWith('http')) {
18
- normalized = `https://${normalized}`;
19
- logger.warn(`[CONFIG] Warning: Auto-prefixed ${keyName} with https://`);
20
- }
21
-
22
- // Strictly forbid http:// in production, except for local or internal private networking
23
- const isLocal = normalized.includes('localhost') || normalized.includes('127.0.0.1');
24
- const isInternal = normalized.includes('.internal');
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
- * Gets the Admin API key used for internal protected routes.
56
- */
57
- export function getAdminApiKey(): string {
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, Queue } from 'bullmq';
6
  import dotenv from 'dotenv';
7
  import Redis from 'ioredis';
8
- import { sendTextMessage, sendDocumentMessage, downloadMedia, sendInteractiveButtonMessage, sendInteractiveListMessage, sendImageMessage } from './whatsapp-cloud';
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 { prisma } from './services/prisma';
16
- import { JobData, FeedbackData, ExerciseCriteria } from './handlers/types';
17
  import { reportError } from './services/errors';
 
18
 
19
- dotenv.config();
 
 
 
 
 
 
 
20
 
 
21
  validateEnvironment();
22
  startWorkerCleanupCron();
23
 
@@ -32,1132 +35,51 @@ const connection = process.env.REDIS_URL
32
  maxRetriesPerRequest: null
33
  });
34
 
35
- interface TenantConfig {
36
- accessToken: string;
37
- phoneNumberId: string;
38
- }
39
-
40
- async function getTenantConfig(organizationId?: string): Promise<TenantConfig | undefined> {
41
- if (!organizationId) return undefined;
42
-
43
- const cacheKey = `org:config:${organizationId}`;
44
- try {
45
- const cached = await connection.get(cacheKey);
46
- if (cached) {
47
- return JSON.parse(cached) as TenantConfig;
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
- logger.info('Processing job:', job.name, job.id);
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
- const messages = {
558
- ENCOURAGEMENT: isWolof
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
- const nudgeType = (type || 'ENCOURAGEMENT') as keyof typeof messages;
567
- const text = messages[nudgeType] || messages.ENCOURAGEMENT;
568
- await sendTextMessage(user.phone, text, tenantConfig);
569
- logger.info(`[WORKER] Nudge ${nudgeType} sent to ${user.phone}`);
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.info(`[WORKER] Enrolling User ${userId} in Free Track ${trackId}...`);
629
- const existing = await prisma.enrollment.findFirst({ where: { userId, trackId } });
630
- if (!existing) {
631
- await prisma.enrollment.create({
632
- data: {
633
- userId: userId || '',
634
- trackId: trackId || '',
635
- status: 'ACTIVE',
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
- } catch (err) {
1148
- reportError(err, {
1149
- jobId: job.id,
1150
- jobName: job.name,
1151
- organizationId: job.data.organizationId || 'unknown',
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
- // 🚀 Start the daily cron scheduler
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} has failed with ${(err instanceof Error ? err.message : String(err))}`);
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, sendInteractiveListMessage, sendImageMessage, sendVideoMessage } from './whatsapp-cloud';
4
-
5
- import { requireHttpUrl, getAdminApiKey, isFeatureEnabled } from './config';
6
  import { shortenForWhatsApp } from './normalizeWolof';
7
- import { ButtonsJson, MultilangContent } from './handlers/types';
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 empty = size - progress;
32
- const bar = '█'.repeat(progress) + '░'.repeat(empty);
33
- const percentage = Math.round((current / total) * 100);
34
- return `[${bar}] ${percentage}%`;
 
 
 
 
 
 
 
 
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
- // 🚨 COHÉRENCE CHECK: Prevent jumps > 1 point
69
- const currentDay = activeEnrollment?.currentDay || 1;
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
- // 🌍 Parallel Multi-Lang Support v1.0 🌍
93
  const buttonsJson = castJson<ButtonsJson>(trackDay.buttonsJson);
94
- if (buttonsJson && buttonsJson.content) {
95
  const langContent = (buttonsJson.content as any)[user.language];
96
- if (langContent) {
97
- lessonText = langContent.lessonText || lessonText;
98
- exercisePrompt = langContent.exercisePrompt || exercisePrompt;
99
- }
100
  }
101
 
102
- // 🌟 Personalize Lesson Content 🌟
103
  if (user.activity && lessonText) {
104
- try {
105
- logger.info(`[PEDAGOGY] Personalizing lesson for User ${userId}'s activity: ${user.activity}`);
106
-
107
- // Fetch previous responses to inform the lesson examples
108
- const previousResponsesData = await prisma.response.findMany({
109
- where: { userId: user.id, enrollmentId: activeEnrollment.id, organizationId },
110
- orderBy: { dayNumber: 'asc' },
111
- take: 5
112
- });
113
- const previousResponses = previousResponsesData.map(r => ({ day: r.dayNumber, response: r.content }));
114
-
115
- const AI_API_BASE_URL = requireHttpUrl(process.env.AI_API_BASE_URL, 'AI_API_BASE_URL');
116
- const apiKey = getAdminApiKey();
117
- const personalizeRes = await fetch(`${AI_API_BASE_URL.replace(/\/$/, "")}/v1/ai/personalize-lesson`, {
118
- method: 'POST',
119
- headers: {
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
- // 🌟 Formatting: Add Header & Progress & Badges 🌟
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
- let isVisible = false;
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
- logger.info(`[Badge Guard] Day: ${dayNumber} - Badge Visible: ${isVisible}`);
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
- logger.info(`[PEDAGOGY] Generating TTS Audio for User ${userId}...`);
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
- logger.error({ err }, '[PEDAGOGY] Failed to generate TTS');
 
 
253
  }
 
 
254
  }
255
 
 
 
256
  if (finalAudioUrl) {
257
- try {
258
- logger.info(`[TTS] audioUrl=${finalAudioUrl} sending to WhatsApp...`);
259
- await sendAudioMessage(user.phone, finalAudioUrl, tenantConfig);
260
- logger.info(`[WhatsApp] Audio message sent to ${user.phone}`);
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
- // 🌟 2. Send Interactive Exercise 🌟
 
 
 
343
  if (exercisePrompt) {
344
  if (trackDay.exerciseType === 'BUTTON' && trackDay.buttonsJson) {
345
- const buttons = trackDay.buttonsJson as Array<{ id: string; title: string }>;
346
- await sendInteractiveButtonMessage(user.phone, exercisePrompt, buttons, undefined, tenantConfig);
347
  } else {
348
  await sendTextMessage(user.phone, exercisePrompt, tenantConfig);
349
  }
350
  }
351
 
352
- // 🌟 3. Send Action LIST menu ────────────────────────────────────────────────
353
- // Shown after every lesson so the user knows their options
354
- const isHistorical = options?.skipProgressUpdate === true || dayNumber < currentDay;
355
-
356
  if (dayNumber === 1 && !isHistorical) {
357
- // Direct invitation to respond for Day 1 to reduce friction
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 sections = [];
367
-
368
- if (isHistorical) {
369
- // MODE REPLAY: Just show the Refaire button
370
- sections.push({
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
- // 🌟 4 & 5. Update User Progress and Enrollment.currentDay 🌟
412
- // ⚠️ Skipped when skipProgressUpdate=true (REPLAY of a historical lesson)
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
- export const prisma = new PrismaClient();
 
 
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
- static compile(templateName: string, variables: Record<string, string | number | boolean>): string {
 
 
 
 
 
 
 
18
  let template = this.getTemplate(templateName);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
 
20
- for (const [key, value] of Object.entries(variables)) {
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 XAMLÉ COACH, expert business pour entrepreneurs d'Afrique de l'Ouest.
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 : WhatsApp (gras *texte*, emojis). Sois direct, dynamique et encourageant.
20
  - LANGUE : {{languageLabel}}.
21
- - JAMAIS ANGLAIS. Ne jamais citer "Manga Deaf".
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: {}