CognxSafeTrack commited on
Commit
3bf9adc
·
1 Parent(s): f7aefa7

feat: finalize multi-tenant WhatsApp architecture for Meta Tech Provider review

Browse files
apps/admin/src/App.tsx CHANGED
@@ -1,6 +1,6 @@
1
  import React from 'react';
2
  import { BrowserRouter as Router, Routes, Route, Link, Navigate } from 'react-router-dom';
3
- import { Users, BookOpen, Lightbulb, BarChart2, Mic, Activity } from 'lucide-react';
4
 
5
  import { AuthProvider, useAuth } from './lib/auth';
6
 
@@ -13,6 +13,7 @@ import UserListPage from './pages/UserListPage';
13
  import SettingsPage from './pages/SettingsPage';
14
  import LiveFeed from './pages/LiveFeed';
15
  import TrainingLab from './pages/TrainingLab';
 
16
 
17
  function ProtectedRoute({ children }: { children: React.ReactNode }) {
18
  const { apiKey } = useAuth();
@@ -26,6 +27,7 @@ function AppShell() {
26
  { to: '/', label: 'Dashboard', icon: <BarChart2 className="w-4 h-4" /> },
27
  { to: '/content', label: 'Parcours', icon: <BookOpen className="w-4 h-4" /> },
28
  { to: '/live-feed', label: 'Modération', icon: <Mic className="w-4 h-4 text-emerald-500" /> },
 
29
  { to: '/training', label: 'Training Lab', icon: <Activity className="w-4 h-4 text-purple-400" /> },
30
  { to: '/users', label: 'Utilisateurs', icon: <Users className="w-4 h-4" /> },
31
  { to: '/settings', label: 'Paramètres', icon: <Lightbulb className="w-4 h-4" /> },
@@ -52,6 +54,7 @@ function AppShell() {
52
  <Route path="/content/:id" element={<TrackFormPage />} />
53
  <Route path="/content/:trackId/days" element={<TrackDaysPage />} />
54
  <Route path="/live-feed" element={<LiveFeed />} />
 
55
  <Route path="/training" element={<TrainingLab />} />
56
  <Route path="/users" element={<UserListPage />} />
57
  <Route path="/settings" element={<SettingsPage />} />
 
1
  import React from 'react';
2
  import { BrowserRouter as Router, Routes, Route, Link, Navigate } from 'react-router-dom';
3
+ import { Users, BookOpen, Lightbulb, BarChart2, Mic, Activity, Building2 } from 'lucide-react';
4
 
5
  import { AuthProvider, useAuth } from './lib/auth';
6
 
 
13
  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();
 
27
  { to: '/', label: 'Dashboard', icon: <BarChart2 className="w-4 h-4" /> },
28
  { to: '/content', label: 'Parcours', icon: <BookOpen className="w-4 h-4" /> },
29
  { to: '/live-feed', label: 'Modération', icon: <Mic className="w-4 h-4 text-emerald-500" /> },
30
+ { to: '/clients', label: 'Clients B2B', icon: <Building2 className="w-4 h-4 text-indigo-400" /> },
31
  { to: '/training', label: 'Training Lab', icon: <Activity className="w-4 h-4 text-purple-400" /> },
32
  { to: '/users', label: 'Utilisateurs', icon: <Users className="w-4 h-4" /> },
33
  { to: '/settings', label: 'Paramètres', icon: <Lightbulb className="w-4 h-4" /> },
 
54
  <Route path="/content/:id" element={<TrackFormPage />} />
55
  <Route path="/content/:trackId/days" element={<TrackDaysPage />} />
56
  <Route path="/live-feed" element={<LiveFeed />} />
57
+ <Route path="/clients" element={<ClientsManagementView />} />
58
  <Route path="/training" element={<TrainingLab />} />
59
  <Route path="/users" element={<UserListPage />} />
60
  <Route path="/settings" element={<SettingsPage />} />
apps/admin/src/pages/ClientsManagementView.tsx ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from 'react';
2
+ import { Building2, Plus, MessageSquare, ShieldCheck, ExternalLink, Activity } from 'lucide-react';
3
+
4
+ interface Organization {
5
+ id: string;
6
+ name: string;
7
+ status: 'ACTIVE' | 'PENDING' | 'CONFIG_REQUIRED';
8
+ wabaId?: string;
9
+ phoneNumber?: string;
10
+ lastActivity?: string;
11
+ }
12
+
13
+ const MOCK_CLIENTS: Organization[] = [
14
+ { id: '1', name: 'AgroBusiness Senegal', status: 'ACTIVE', wabaId: '23849102394', phoneNumber: '+221 77 123 45 67', lastActivity: 'Il y a 5 min' },
15
+ { id: '2', name: 'Education Pour Tous', status: 'CONFIG_REQUIRED', wabaId: undefined, phoneNumber: undefined },
16
+ { id: '3', name: 'Sammante Tech', status: 'ACTIVE', wabaId: '99283102312', phoneNumber: '+221 70 987 65 43', lastActivity: 'Hier' },
17
+ ];
18
+
19
+ export default function ClientsManagementView() {
20
+ const [clients] = useState<Organization[]>(MOCK_CLIENTS);
21
+
22
+ const handleEmbeddedSignup = () => {
23
+ // Simulation du Meta Embedded Signup Flow
24
+ alert("Ouverture du flux d'inscription intégré Meta (Embedded Signup)...\n\n1. Connexion au compte Business\n2. Sélection du WABA\n3. Vérification du numéro de téléphone");
25
+ console.log("Meta SDK Trigger: FB.login with extras={setup: 'whatsapp_business_account'}");
26
+ };
27
+
28
+ return (
29
+ <div className="p-8 max-w-6xl mx-auto">
30
+ <div className="flex items-center justify-between mb-8">
31
+ <div>
32
+ <h1 className="text-3xl font-bold tracking-tight text-slate-900">Gestion des Clients B2B</h1>
33
+ <p className="text-slate-500 mt-2">Gérez les organisations partenaires et leurs actifs WhatsApp.</p>
34
+ </div>
35
+ <button className="flex items-center gap-2 bg-slate-900 text-white px-4 py-2 rounded-xl font-medium hover:bg-slate-800 transition shadow-sm">
36
+ <Plus className="w-4 h-4" /> Nouvelle Organisation
37
+ </button>
38
+ </div>
39
+
40
+ <div className="grid gap-6">
41
+ {clients.map(client => (
42
+ <div key={client.id} className="bg-white rounded-2xl border border-slate-200 p-6 shadow-sm hover:shadow-md transition">
43
+ <div className="flex items-start justify-between">
44
+ <div className="flex gap-4">
45
+ <div className="w-12 h-12 bg-slate-100 rounded-2xl flex items-center justify-center">
46
+ <Building2 className="w-6 h-6 text-slate-600" />
47
+ </div>
48
+ <div>
49
+ <h3 className="text-xl font-bold text-slate-900">{client.name}</h3>
50
+ <div className="flex items-center gap-3 mt-1">
51
+ <span className={`text-xs px-2.5 py-0.5 rounded-full font-semibold ${
52
+ client.status === 'ACTIVE' ? 'bg-emerald-50 text-emerald-700' : 'bg-amber-50 text-amber-700'
53
+ }`}>
54
+ {client.status === 'ACTIVE' ? 'Opérationnel' : 'Configuration requise'}
55
+ </span>
56
+ <span className="text-xs text-slate-400">ID: {client.id}</span>
57
+ </div>
58
+ </div>
59
+ </div>
60
+ <div className="flex gap-2">
61
+ {client.status === 'ACTIVE' ? (
62
+ <div className="flex items-center gap-4 text-sm">
63
+ <div className="text-right">
64
+ <p className="font-medium text-slate-700">{client.phoneNumber}</p>
65
+ <p className="text-xs text-slate-400">WABA: {client.wabaId}</p>
66
+ </div>
67
+ <div className="h-10 w-px bg-slate-100"></div>
68
+ <div className="flex items-center gap-2 text-emerald-600">
69
+ <Activity className="w-4 h-4" />
70
+ <span className="font-medium text-xs">{client.lastActivity}</span>
71
+ </div>
72
+ </div>
73
+ ) : (
74
+ <button
75
+ onClick={handleEmbeddedSignup}
76
+ className="flex items-center gap-2 bg-indigo-600 text-white px-5 py-2.5 rounded-xl font-semibold hover:bg-indigo-700 transition shadow-lg shadow-indigo-100"
77
+ >
78
+ <MessageSquare className="w-4 h-4" /> Connecter WhatsApp (Meta)
79
+ </button>
80
+ )}
81
+ </div>
82
+ </div>
83
+
84
+ <div className="mt-8 pt-6 border-t border-slate-50 grid grid-cols-3 gap-8">
85
+ <div className="flex items-center gap-3">
86
+ <div className="p-2 bg-slate-50 rounded-lg">
87
+ <ShieldCheck className="w-4 h-4 text-slate-500" />
88
+ </div>
89
+ <div>
90
+ <p className="text-[10px] uppercase font-bold text-slate-400 tracking-wider">Accès API</p>
91
+ <p className="text-sm font-medium text-slate-700">Vérifié par Meta</p>
92
+ </div>
93
+ </div>
94
+ <div className="flex items-center gap-3">
95
+ <div className="p-2 bg-slate-50 rounded-lg">
96
+ <ExternalLink className="w-4 h-4 text-slate-500" />
97
+ </div>
98
+ <div>
99
+ <p className="text-[10px] uppercase font-bold text-slate-400 tracking-wider">Webhook Status</p>
100
+ <p className="text-sm font-medium text-emerald-600">Connecté (v19.0)</p>
101
+ </div>
102
+ </div>
103
+ <div className="flex items-center justify-end">
104
+ <button className="text-sm font-bold text-indigo-600 hover:text-indigo-700 underline underline-offset-4">
105
+ Détails & Facturation
106
+ </button>
107
+ </div>
108
+ </div>
109
+ </div>
110
+ ))}
111
+ </div>
112
+
113
+ {/* Meta Compliance Footer */}
114
+ <div className="mt-12 p-6 bg-slate-50 rounded-2xl border border-slate-100">
115
+ <h4 className="text-xs font-bold text-slate-400 uppercase tracking-widest mb-4">Meta Tech Provider Compliance</h4>
116
+ <div className="flex gap-8 opacity-60 grayscale hover:grayscale-0 transition duration-500">
117
+ <img src="https://upload.wikimedia.org/wikipedia/commons/b/be/Facebook_Messenger_logo_2020.svg" className="h-6" alt="Meta" />
118
+ <img src="https://upload.wikimedia.org/wikipedia/commons/6/6b/WhatsApp.svg" className="h-6" alt="WhatsApp" />
119
+ </div>
120
+ </div>
121
+ </div>
122
+ );
123
+ }
apps/api/src/routes/internal.ts CHANGED
@@ -2,6 +2,7 @@ import { FastifyInstance } from 'fastify';
2
  import { WhatsAppService } from '../services/whatsapp';
3
  import { prisma } from '../services/prisma';
4
  import { z } from 'zod';
 
5
  // ─── Zod Schema for WhatsApp Webhook Payload ─────────────────────────────────
6
  const WhatsAppMessageSchema = z.object({
7
  from: z.string(),
@@ -74,6 +75,9 @@ export async function internalRoutes(fastify: FastifyInstance) {
74
  for (const message of messages) {
75
  const phone = message.from;
76
  let text = '';
 
 
 
77
 
78
  if (message.type === 'text' && message.text) {
79
  text = message.text.body;
@@ -102,6 +106,7 @@ export async function internalRoutes(fastify: FastifyInstance) {
102
  mediaId: message.audio.id,
103
  mimeType: message.audio.mime_type || 'audio/ogg',
104
  phone,
 
105
  ...(accessToken ? { accessToken } : {})
106
  }, { priority: 1, attempts: 3, backoff: { type: 'exponential', delay: 2000 } });
107
 
@@ -129,6 +134,7 @@ export async function internalRoutes(fastify: FastifyInstance) {
129
  mediaId: message.image.id,
130
  mimeType: 'image/jpeg',
131
  phone,
 
132
  caption: message.image.caption || undefined,
133
  ...(accessToken ? { accessToken } : {})
134
  }, { priority: 1, attempts: 3, backoff: { type: 'exponential', delay: 2000 } });
@@ -150,7 +156,7 @@ export async function internalRoutes(fastify: FastifyInstance) {
150
  const { getTimeTravelContext } = await import('../services/queue');
151
  ttDay = (await getTimeTravelContext(user.id)) ?? undefined;
152
  }
153
- await WhatsAppService.handleIncomingMessage(phone, text, undefined, undefined, ttDay);
154
  }
155
  }
156
  }
@@ -163,7 +169,7 @@ export async function internalRoutes(fastify: FastifyInstance) {
163
 
164
  // ── Handle standard transcribed messages from worker (Railway) ───────────
165
  fastify.post<{
166
- Body: { phone: string; text: string; audioUrl?: string; imageUrl?: string }
167
  }>('/v1/internal/handle-message', {
168
  config: { requireAuth: true },
169
  schema: {
@@ -174,12 +180,13 @@ export async function internalRoutes(fastify: FastifyInstance) {
174
  phone: { type: 'string' },
175
  text: { type: 'string' },
176
  audioUrl: { type: 'string' },
177
- imageUrl: { type: 'string' }
 
178
  }
179
  }
180
  }
181
  }, async (request, reply) => {
182
- const { phone, text, audioUrl, imageUrl } = request.body;
183
  const traceId = `[INTERNAL-TX-${phone.slice(-4)}]`;
184
 
185
  if (!phone || text === undefined) {
@@ -191,7 +198,7 @@ export async function internalRoutes(fastify: FastifyInstance) {
191
 
192
  // Fire and await - ensuring the worker knows if it failed
193
  try {
194
- await WhatsAppService.handleIncomingMessage(phone, text, audioUrl, imageUrl);
195
  request.log.info(`${traceId} Successfully processed message`);
196
  } catch (err: unknown) {
197
  const errorMsg = err instanceof Error ? err.message : String(err);
 
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(),
 
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;
 
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
 
 
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 } });
 
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
  }
 
169
 
170
  // ── Handle standard transcribed messages from worker (Railway) ───────────
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: {
 
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) {
 
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);
apps/api/src/routes/whatsapp.ts CHANGED
@@ -2,6 +2,7 @@ import { logger } from '../logger';
2
  import { FastifyInstance } from 'fastify';
3
  import crypto from 'crypto';
4
  import { z } from 'zod';
 
5
 
6
  // ─── Zod Schema for WhatsApp Webhook Payload ─────────────────────────────────
7
  const WhatsAppMessageSchema = z.object({
@@ -148,7 +149,8 @@ export async function whatsappRoutes(fastify: FastifyInstance) {
148
  method: 'POST',
149
  headers: {
150
  'Content-Type': 'application/json',
151
- 'Authorization': `Bearer ${process.env.ADMIN_API_KEY || ''}`
 
152
  },
153
  body: request.body ? JSON.stringify(request.body) : ''
154
  }).then(res => {
@@ -193,7 +195,12 @@ export async function whatsappRoutes(fastify: FastifyInstance) {
193
  for (const message of change.value.messages) {
194
  const phone = message.from;
195
  const messageId = message.id;
196
- logger.info("[WEBHOOK-TRACE] Processing message for phone:", phone);
 
 
 
 
 
197
 
198
  let text: string | undefined;
199
  if (message.type === 'text' && message.text) {
@@ -207,7 +214,7 @@ export async function whatsappRoutes(fastify: FastifyInstance) {
207
  }
208
 
209
  if (text) {
210
- await scheduleInboundMessage({ phone, text, messageId });
211
  } else if (message.type === 'audio' && message.audio) {
212
  const accessToken = process.env.WHATSAPP_ACCESS_TOKEN || undefined;
213
  const { Queue } = await import('bullmq');
@@ -222,12 +229,14 @@ export async function whatsappRoutes(fastify: FastifyInstance) {
222
  mediaId: message.audio.id,
223
  mimeType: message.audio.mime_type || 'audio/ogg',
224
  phone,
 
225
  ...(accessToken ? { accessToken } : {})
226
  }, { priority: 1, attempts: 3, backoff: { type: 'exponential', delay: 2000 } });
227
 
228
  await q.add('send-message-direct', {
229
  phone,
230
- text: "⏳ J'analyse ton audio..."
 
231
  });
232
  } else if (message.type === 'image' && message.image) {
233
  logger.info(`[IMAGE-FLOW] Image detected! ID: ${message.image.id}`);
@@ -246,13 +255,15 @@ export async function whatsappRoutes(fastify: FastifyInstance) {
246
  mediaId: message.image.id,
247
  mimeType: 'image/jpeg',
248
  phone,
 
249
  caption: message.image.caption || undefined,
250
  ...(accessToken ? { accessToken } : {})
251
  }, { priority: 1, attempts: 3, backoff: { type: 'exponential', delay: 2000 } });
252
 
253
  await q.add('send-message-direct', {
254
  phone,
255
- text: "⏳ J'analyse ton image..."
 
256
  });
257
  }
258
  }
 
2
  import { FastifyInstance } from 'fastify';
3
  import crypto from 'crypto';
4
  import { z } from 'zod';
5
+ import { getOrganizationByPhoneNumberId } from '../services/organization';
6
 
7
  // ─── Zod Schema for WhatsApp Webhook Payload ─────────────────────────────────
8
  const WhatsAppMessageSchema = z.object({
 
149
  method: 'POST',
150
  headers: {
151
  'Content-Type': 'application/json',
152
+ 'Authorization': `Bearer ${process.env.ADMIN_API_KEY || ''}`,
153
+ 'X-Organization-Id': request.headers['x-organization-id'] as string || 'default-org-id'
154
  },
155
  body: request.body ? JSON.stringify(request.body) : ''
156
  }).then(res => {
 
195
  for (const message of change.value.messages) {
196
  const phone = message.from;
197
  const messageId = message.id;
198
+
199
+ // 🏢 Multi-Tenant Routing
200
+ const phoneNumberId = change.value.metadata?.phone_number_id || 'unknown';
201
+ const organizationId = await getOrganizationByPhoneNumberId(phoneNumberId);
202
+
203
+ logger.info(`[WEBHOOK-TRACE] Processing message for phone: ${phone} (Org: ${organizationId})`);
204
 
205
  let text: string | undefined;
206
  if (message.type === 'text' && message.text) {
 
214
  }
215
 
216
  if (text) {
217
+ await scheduleInboundMessage({ phone, text, messageId, organizationId });
218
  } else if (message.type === 'audio' && message.audio) {
219
  const accessToken = process.env.WHATSAPP_ACCESS_TOKEN || undefined;
220
  const { Queue } = await import('bullmq');
 
229
  mediaId: message.audio.id,
230
  mimeType: message.audio.mime_type || 'audio/ogg',
231
  phone,
232
+ organizationId,
233
  ...(accessToken ? { accessToken } : {})
234
  }, { priority: 1, attempts: 3, backoff: { type: 'exponential', delay: 2000 } });
235
 
236
  await q.add('send-message-direct', {
237
  phone,
238
+ text: "⏳ J'analyse ton audio...",
239
+ organizationId
240
  });
241
  } else if (message.type === 'image' && message.image) {
242
  logger.info(`[IMAGE-FLOW] Image detected! ID: ${message.image.id}`);
 
255
  mediaId: message.image.id,
256
  mimeType: 'image/jpeg',
257
  phone,
258
+ organizationId,
259
  caption: message.image.caption || undefined,
260
  ...(accessToken ? { accessToken } : {})
261
  }, { priority: 1, attempts: 3, backoff: { type: 'exponential', delay: 2000 } });
262
 
263
  await q.add('send-message-direct', {
264
  phone,
265
+ text: "⏳ J'analyse ton image...",
266
+ organizationId
267
  });
268
  }
269
  }
apps/api/src/services/organization.ts ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { PrismaClient } from '@repo/database';
2
+ import Redis from 'ioredis';
3
+ import { logger } from '../logger';
4
+
5
+ const prisma = new PrismaClient();
6
+ 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.whatsappPhoneNumber.findUnique({
21
+ where: { id: phoneNumberId },
22
+ select: { organizationId: true }
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
+ }
apps/api/src/services/queue.ts CHANGED
@@ -38,41 +38,42 @@ export async function clearTimeTravelContext(userId: string): Promise<void> {
38
  if (n > 0) logger.info(`[TIME-TRAVEL] 🗑️ CLEARED User ${userId}`);
39
  }
40
 
41
- export async function scheduleMessage(userId: string, text: string, delayMs: number = 0) {
42
  if (process.env.DISABLE_WHATSAPP_SEND === 'true') {
43
  logger.warn(`[QUEUE] DISABLE_WHATSAPP_SEND is true. Skipping 'send-message' for user ${userId}`);
44
  return;
45
  }
46
- await whatsappQueue.add('send-message', { userId, text }, { delay: delayMs });
47
  }
48
 
49
- export async function scheduleTrackDay(userId: string, trackId: string, dayNumber: number, delayMs: number = 0) {
50
  if (process.env.DISABLE_WHATSAPP_SEND === 'true') {
51
  logger.warn(`[QUEUE] DISABLE_WHATSAPP_SEND is true. Skipping 'send-content' for user ${userId}`);
52
  return;
53
  }
54
- await whatsappQueue.add('send-content', { userId, trackId, dayNumber }, { delay: delayMs });
55
  }
56
 
57
- export async function enrollUser(userId: string, trackId: string) {
58
  if (process.env.DISABLE_WHATSAPP_SEND === 'true') {
59
  logger.warn(`[QUEUE] DISABLE_WHATSAPP_SEND is true. Skipping 'enroll-user' for user ${userId}`);
60
  return;
61
  }
62
- await whatsappQueue.add('enroll-user', { userId, trackId });
63
  }
64
 
65
  /** Send a WhatsApp interactive BUTTON message (max 3 buttons). */
66
  export async function scheduleInteractiveButtons(
67
  userId: string,
68
  bodyText: string,
69
- buttons: Array<{ id: string; title: string }>
 
70
  ) {
71
  if (process.env.DISABLE_WHATSAPP_SEND === 'true') {
72
  logger.warn(`[QUEUE] DISABLE_WHATSAPP_SEND is true. Skipping 'send-interactive-buttons' for user ${userId}`);
73
  return;
74
  }
75
- await whatsappQueue.add('send-interactive-buttons', { userId, bodyText, buttons });
76
  }
77
 
78
  /** Send a WhatsApp interactive LIST message (up to 10 rows, grouped in sections). */
@@ -81,17 +82,18 @@ export async function scheduleInteractiveList(
81
  headerText: string,
82
  bodyText: string,
83
  buttonLabel: string,
84
- sections: Array<{ title: string; rows: Array<{ id: string; title: string; description?: string }> }>
 
85
  ) {
86
  if (process.env.DISABLE_WHATSAPP_SEND === 'true') {
87
  logger.warn(`[QUEUE] DISABLE_WHATSAPP_SEND is true. Skipping 'send-interactive-list' for user ${userId}`);
88
  return;
89
  }
90
- await whatsappQueue.add('send-interactive-list', { userId, headerText, bodyText, buttonLabel, sections });
91
  }
92
 
93
  /** 🚨 ASYNC HANDOVER: Send inbound message for background processing in the worker. */
94
- export async function scheduleInboundMessage(payload: { phone: string, text: string, audioUrl?: string, imageUrl?: string, messageId?: string }) {
95
  await whatsappQueue.add('handle-inbound', payload, {
96
  attempts: 3,
97
  backoff: { type: 'exponential', delay: 1000 },
 
38
  if (n > 0) logger.info(`[TIME-TRAVEL] 🗑️ CLEARED User ${userId}`);
39
  }
40
 
41
+ export async function scheduleMessage(userId: string, text: string, delayMs: number = 0, organizationId?: string) {
42
  if (process.env.DISABLE_WHATSAPP_SEND === 'true') {
43
  logger.warn(`[QUEUE] DISABLE_WHATSAPP_SEND is true. Skipping 'send-message' for user ${userId}`);
44
  return;
45
  }
46
+ await whatsappQueue.add('send-message', { userId, text, organizationId }, { delay: delayMs });
47
  }
48
 
49
+ export async function scheduleTrackDay(userId: string, trackId: string, dayNumber: number, delayMs: number = 0, organizationId?: string) {
50
  if (process.env.DISABLE_WHATSAPP_SEND === 'true') {
51
  logger.warn(`[QUEUE] DISABLE_WHATSAPP_SEND is true. Skipping 'send-content' for user ${userId}`);
52
  return;
53
  }
54
+ await whatsappQueue.add('send-content', { userId, trackId, dayNumber, organizationId }, { delay: delayMs });
55
  }
56
 
57
+ export async function enrollUser(userId: string, trackId: string, organizationId?: string) {
58
  if (process.env.DISABLE_WHATSAPP_SEND === 'true') {
59
  logger.warn(`[QUEUE] DISABLE_WHATSAPP_SEND is true. Skipping 'enroll-user' for user ${userId}`);
60
  return;
61
  }
62
+ await whatsappQueue.add('enroll-user', { userId, trackId, organizationId });
63
  }
64
 
65
  /** Send a WhatsApp interactive BUTTON message (max 3 buttons). */
66
  export async function scheduleInteractiveButtons(
67
  userId: string,
68
  bodyText: string,
69
+ buttons: Array<{ id: string; title: string }>,
70
+ organizationId?: string
71
  ) {
72
  if (process.env.DISABLE_WHATSAPP_SEND === 'true') {
73
  logger.warn(`[QUEUE] DISABLE_WHATSAPP_SEND is true. Skipping 'send-interactive-buttons' for user ${userId}`);
74
  return;
75
  }
76
+ await whatsappQueue.add('send-interactive-buttons', { userId, bodyText, buttons, organizationId });
77
  }
78
 
79
  /** Send a WhatsApp interactive LIST message (up to 10 rows, grouped in sections). */
 
82
  headerText: string,
83
  bodyText: string,
84
  buttonLabel: string,
85
+ sections: Array<{ title: string; rows: Array<{ id: string; title: string; description?: string }> }>,
86
+ organizationId?: string
87
  ) {
88
  if (process.env.DISABLE_WHATSAPP_SEND === 'true') {
89
  logger.warn(`[QUEUE] DISABLE_WHATSAPP_SEND is true. Skipping 'send-interactive-list' for user ${userId}`);
90
  return;
91
  }
92
+ await whatsappQueue.add('send-interactive-list', { userId, headerText, bodyText, buttonLabel, sections, organizationId });
93
  }
94
 
95
  /** 🚨 ASYNC HANDOVER: Send inbound message for background processing in the worker. */
96
+ export async function scheduleInboundMessage(payload: { phone: string, text: string, audioUrl?: string, imageUrl?: string, messageId?: string, organizationId?: string }) {
97
  await whatsappQueue.add('handle-inbound', payload, {
98
  attempts: 3,
99
  backoff: { type: 'exponential', delay: 1000 },
apps/api/src/services/whatsapp.ts CHANGED
@@ -54,7 +54,7 @@ export class WhatsAppService {
54
  return similarity >= threshold;
55
  }
56
 
57
- static async handleIncomingMessage(phone: string, text: string, audioUrl?: string, imageUrl?: string, timeTravelDayOverride?: number) {
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'})`);
@@ -67,13 +67,10 @@ export class WhatsAppService {
67
 
68
  if (isInscription) {
69
  logger.info(`${traceId} New user registration triggered for ${phone}`);
70
- user = await prisma.user.create({ data: { phone } });
71
- await scheduleInteractiveButtons(user.id,
72
- "Dalal jàmm! Xamle ngay tàmbali. ⏳ 30s.\n(FR) Ton cours se prépare (30s).",
73
- [
74
- { id: 'LANG_FR', title: 'Français 🇫🇷' },
75
  { id: 'LANG_WO', title: 'Wolof 🇸🇳' }
76
- ]
 
77
  );
78
  return;
79
  } else {
@@ -95,7 +92,8 @@ export class WhatsAppService {
95
  content: text,
96
  mediaUrl: audioUrl || imageUrl,
97
  direction: 'INBOUND',
98
- userId: user.id
 
99
  }
100
  });
101
  } catch (err: unknown) {
@@ -118,12 +116,9 @@ export class WhatsAppService {
118
  where: { id: user.id },
119
  data: { city: null, activity: null }
120
  });
121
- await scheduleInteractiveButtons(user.id,
122
- "Réinitialisation réussie – choisissez votre langue / Tànnal sa làkk :",
123
- [
124
- { id: 'LANG_FR', title: 'Français 🇫🇷' },
125
  { id: 'LANG_WO', title: 'Wolof 🇸🇳' }
126
- ]
 
127
  );
128
  return;
129
  }
@@ -140,13 +135,13 @@ export class WhatsAppService {
140
  if (normalizedText.startsWith('TEST_VIDEO')) {
141
  const parts = normalizedText.split(' ');
142
  if (parts.length < 3) {
143
- await scheduleMessage(user.id, "Usage: TEST_VIDEO <TrackId> <DayNumber>");
144
  return;
145
  }
146
  const trackId = parts[1];
147
  const dayNumber = parseFloat(parts[2]);
148
 
149
- await scheduleMessage(user.id, `🧪 Test Video pour ${trackId} J${dayNumber}...`);
150
  await whatsappQueue.add('send-content', {
151
  userId: user.id,
152
  trackId,
@@ -162,7 +157,7 @@ export class WhatsAppService {
162
  if (text.length < 2 && !isSystemCommand) {
163
  await scheduleMessage(user.id, user.language === 'WOLOF'
164
  ? "Dama lay xaar nga wax ma lu gën a yaatu ci sa mbir. Waxtaanal ak man !"
165
- : "Je n'ai pas bien compris. Peux-tu me réexpliquer en quelques mots ?");
166
  return;
167
  }
168
 
@@ -186,11 +181,12 @@ export class WhatsAppService {
186
 
187
  await scheduleMessage(user.id, result.seeded
188
  ? "✅ Seeding terminé ! Le Cache Cognitif a été réinitialisé.\nEnvoie INSCRIPTION pour commencer."
189
- : "ℹ️ Les données existent déjà. Cache Cognitif purgé. Envoie INSCRIPTION."
 
190
  );
191
  } catch (err: unknown) {
192
  logger.error('[SEED] Error:', (err instanceof Error ? (err instanceof Error ? err.message : String(err)) : String(err)));
193
- await scheduleMessage(user.id, `❌ Erreur seed : ${(err instanceof Error ? (err instanceof Error ? err.message : String(err)) : String(err))?.substring(0, 200)}`);
194
  }
195
  return;
196
  }
@@ -212,7 +208,8 @@ export class WhatsAppService {
212
  // ✅ UX: Confirmation FIRST, content delayed — message order guaranteed
213
  await scheduleMessage(user.id, user.language === 'WOLOF'
214
  ? `🔁 Dinanu la yëgël lexon Bés ${Math.floor(replayDay)} ci kanam...`
215
- : `🔁 Je te renvoie la Leçon ${Math.floor(replayDay)} dans quelques secondes...`
 
216
  );
217
  await whatsappQueue.add('send-content', {
218
  userId: user.id,
@@ -225,7 +222,8 @@ export class WhatsAppService {
225
  } else if (action === 'EXERCISE') {
226
  await scheduleMessage(user.id, user.language === 'WOLOF'
227
  ? "🎙️ Yónnee sa tontu (audio walla bind) :"
228
- : "🎙️ Envoie ta réponse (audio ou texte) :"
 
229
  );
230
  return;
231
  } else if (action === 'PROMPT') {
@@ -237,9 +235,9 @@ export class WhatsAppService {
237
  where: { trackId: enrollment.trackId, dayNumber: enrollment.currentDay }
238
  });
239
  if (trackDay?.exercisePrompt) {
240
- await scheduleMessage(user.id, trackDay.exercisePrompt);
241
  } else {
242
- await scheduleMessage(user.id, user.language === 'WOLOF' ? "Amul lëjj" : "Pas d'exercice pour ce jour");
243
  }
244
  }
245
  return;
@@ -251,13 +249,15 @@ export class WhatsAppService {
251
  if (pendingProgress) {
252
  await scheduleMessage(user.id, user.language === 'WOLOF'
253
  ? "Danga wara tontu lëjj bi balaa nga dem ci kanam. Tànnal 'Yónni tontu'."
254
- : "Tu dois d'abord répondre à l'exercice avant de continuer. Choisis 'Faire l'exercice' ou 'Répondre'."
 
255
  );
256
  } else {
257
  // Safe to advance (either completed or dropped or already handled)
258
  await scheduleMessage(user.id, user.language === 'WOLOF'
259
  ? "Waaw, ñuy dem ci kanam !"
260
- : "C'est noté, on avance !"
 
261
  );
262
  // To do: if advance needs to trigger scheduleTrackDay directly, it could be done here instead of tracking.
263
  // However, normally `SUITE` moves the day forward.
@@ -283,19 +283,22 @@ export class WhatsAppService {
283
  newLang === 'FR' ? "Ton secteur" : "Sa Mbir",
284
  promptText,
285
  newLang === 'FR' ? "Secteurs" : "Tànn",
286
- [{
287
- title: newLang === 'FR' ? 'Liste' : 'Mbir',
288
- rows: [
289
- { id: 'SEC_COMMERCE', title: newLang === 'FR' ? 'Commerce / Vente' : 'Njaay' },
290
- { id: 'SEC_AGRI', title: newLang === 'FR' ? 'Agri / Élevage' : 'Mbay / Samm' },
291
- { id: 'SEC_FOOD', title: newLang === 'FR' ? 'Alimentation / Rest.' : 'Lekk / Restauration' },
292
- { id: 'SEC_COUTURE', title: newLang === 'FR' ? 'Couture / Mode' : 'Couture' },
293
- { id: 'SEC_BEAUTE', title: newLang === 'FR' ? 'Beauté / Bien-être' : 'Rafet' },
294
- { id: 'SEC_TRANSPORT', title: newLang === 'FR' ? 'Transport / Livr.' : 'Transport / Yëgël' },
295
- { id: 'SEC_TECH', title: newLang === 'FR' ? 'Tech / Digital' : 'Tech / Digital' },
296
- { id: 'SEC_AUTRE', title: newLang === 'FR' ? 'Autre secteur' : 'Beneen mbir' }
297
- ]
298
- }]
 
 
 
299
  );
300
  return;
301
  }
@@ -315,7 +318,8 @@ export class WhatsAppService {
315
  if (normalizedText === 'SEC_AUTRE') {
316
  await scheduleMessage(user.id, user.language === 'WOLOF'
317
  ? 'Waaw ! Wax ma ban mbir ngay def ci ab kàddu gatt :'
318
- : 'Parfait ! Décris ton activité en quelques mots :'
 
319
  );
320
  return;
321
  }
@@ -346,11 +350,11 @@ export class WhatsAppService {
346
  ? `Parfait ! Secteur noté : *${activity}*.\nJe t'inscris à ta formation personnalisée !`
347
  : `Baax na ! Bind nanu la ci: *${activity}*.\nLéegi dinanu la dugal ci njàng mi !`;
348
 
349
- await scheduleMessage(user.id, welcomeMsg);
350
 
351
  const trackId = user.language === 'FR' ? "T1-FR" : "T1-WO";
352
  const defaultTrack = await prisma.track.findUnique({ where: { id: trackId } });
353
- if (defaultTrack) await enrollUser(user.id, defaultTrack.id);
354
  return;
355
  }
356
 
@@ -363,7 +367,7 @@ export class WhatsAppService {
363
  if (activeEnrollment) {
364
  const intent = this.detectIntent(text);
365
  const isSuite = this.isFuzzyMatch(normalizedText, 'SUITE') || normalizedText === '2';
366
- const isApprofondir = this.isFuzzyMatch(normalizedText, 'APPROFONDIR') || normalizedText === '1';
367
 
368
  // Handle SUITE Priority
369
  if (isSuite) {
@@ -384,7 +388,8 @@ export class WhatsAppService {
384
  logger.info(`[SUITE-BLOCKED] User ${user.id} tried SUITE but status is ${userProgress?.exerciseStatus || 'null'} and no response found.`);
385
  await scheduleMessage(user.id, user.language === 'WOLOF'
386
  ? "Dafa laaj nga tontu laaj bi ci kaw dëbb (audio walla texte) balaa nga dem ci kanam ! 🎙️"
387
- : "Tu dois d'abord répondre à l'exercice ci-dessus pour continuer ! 🎙️"
 
388
  );
389
  return;
390
  }
@@ -407,7 +412,7 @@ export class WhatsAppService {
407
  }
408
 
409
  // Handle APPROFONDIR (Deep Dive Initiation)
410
- if (isApprofondir) {
411
  const userProgress = await prisma.userProgress.findUnique({
412
  where: { userId_trackId: { userId: user.id, trackId: activeEnrollment.trackId } }
413
  });
@@ -430,13 +435,15 @@ export class WhatsAppService {
430
 
431
  await scheduleMessage(user.id, user.language === 'WOLOF'
432
  ? "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 :"
433
- : "Très bien ! Quelle information précise issue de ton terrain veux-tu ajouter ? (ex: un prix précis, un obstacle, un fournisseur, etc.) :"
 
434
  );
435
  return;
436
  } else {
437
  await scheduleMessage(user.id, user.language === 'WOLOF'
438
  ? "Dafa laaj nga tontu laaj bi ci kaw dëbb balaa nga natt nga tontu !"
439
- : "Réponds d'abord à l'exercice principal avant d'approfondir !"
 
440
  );
441
  return;
442
  }
@@ -448,14 +455,14 @@ export class WhatsAppService {
448
  where: { userId_trackId: { userId: user.id, trackId: activeEnrollment.trackId } }
449
  });
450
  if (userProgress?.exerciseStatus === 'COMPLETED') {
451
- await scheduleMessage(user.id, user.language === 'WOLOF' ? "Waaw ! Lexon bi mu ngi ñëw..." : "C'est parti ! Voici la suite...");
452
- await whatsappQueue.add('send-content', { userId: user.id, trackId: activeEnrollment.trackId, dayNumber: activeEnrollment.currentDay + 1 });
453
  return;
454
  }
455
  }
456
 
457
  if (intent === 'NO' && normalizedText.length < 15) {
458
- 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.");
459
  return;
460
  }
461
 
@@ -464,7 +471,9 @@ export class WhatsAppService {
464
  if (!user.activity) {
465
  await scheduleMessage(user.id, user.language === 'WOLOF'
466
  ? "Danga wara tànn sa mbiru liggeey balaa ñuy tàmbali coaching bi."
467
- : "Tu dois d'abord définir ton activité avant que le coach AI ne puisse t'aider.");
 
 
468
  return;
469
  }
470
 
@@ -517,11 +526,11 @@ export class WhatsAppService {
517
  if (wordCount < 3 && !isOptionMatch && !isCommonOption) {
518
  await scheduleMessage(user.id, user.language === 'WOLOF'
519
  ? "Dama lay xaar nga wax ma lu gën a yaatu ci sa mbir (mbebetu 3 baat). Waxtaanal ak man !"
520
- : "Ta réponse est un peu courte. Peux-tu m'en dire plus ? (Minimum 3 mots)");
521
  return;
522
  }
523
 
524
- await scheduleMessage(user.id, user.language === 'WOLOF' ? "⏳ Defar ak sa tontu..." : "⏳ Analyse de votre réponse...");
525
 
526
  // Update iteration count if it's a deep dive
527
  let currentIterationCount = pendingProgress.iterationCount || 0;
@@ -539,7 +548,8 @@ export class WhatsAppService {
539
  enrollmentId: activeEnrollment.id,
540
  userId: user.id,
541
  dayNumber: effectiveDay,
542
- content: text
 
543
  }
544
  });
545
 
@@ -565,7 +575,8 @@ export class WhatsAppService {
565
  iterationCount: currentIterationCount,
566
  imageUrl: imageUrl,
567
  isTimeTravelMode, // ← Worker uses this to skip COMPLETED update
568
- realCurrentDay: activeEnrollment.currentDay // ← For logging only
 
569
  }, { attempts: 3, backoff: { type: 'exponential', delay: 2000 } });
570
  return;
571
  }
@@ -578,7 +589,8 @@ export class WhatsAppService {
578
  enrollmentId: activeEnrollment.id,
579
  userId: user.id,
580
  dayNumber: effectiveDay,
581
- content: text
 
582
  }
583
  });
584
 
@@ -593,7 +605,9 @@ export class WhatsAppService {
593
  logger.info(`${traceId} Guardrail: Input too short or potential gibberish: "${text}"`);
594
  await scheduleMessage(user.id, user.language === 'WOLOF'
595
  ? "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) ?"
596
- : "Je n'ai pas bien compris ton activité. Peux-tu me réexpliquer en quelques mots ce que tu vends et à qui ?");
 
 
597
  return;
598
  }
599
 
@@ -602,7 +616,9 @@ export class WhatsAppService {
602
  logger.info(`${traceId} Blocking AI feedback: Enrollment incomplete for User ${user.id}`);
603
  await scheduleMessage(user.id, user.language === 'WOLOF'
604
  ? "Baax na, waaye laaj bi des na... Bindal 'INSCRIPTION' ngir tàmbali."
605
- : "C'est noté, mais il faut d'abord terminer ton inscription. Envoie 'INSCRIPTION' pour commencer.");
 
 
606
  return;
607
  }
608
 
@@ -627,14 +643,16 @@ export class WhatsAppService {
627
  previousResponses,
628
  imageUrl: imageUrl,
629
  isTimeTravelMode,
630
- realCurrentDay: activeEnrollment.currentDay
 
631
  });
632
  return;
633
  }
634
 
635
  await scheduleMessage(user.id, user.language === 'WOLOF'
636
  ? "Baax na ! Yónnee *SUITE* ngir dem ci kanam walla tontul laaj bi ci kaw."
637
- : "✅ Message reçu ! Envoie *SUITE* pour avancer ou réponds à l'exercice ci-dessus."
 
638
  );
639
  return;
640
  }
@@ -643,7 +661,8 @@ export class WhatsAppService {
643
  logger.info(`${traceId} Unknown command from user ${user.id}: "${normalizedText}"`);
644
  await scheduleMessage(user.id, user.language === 'WOLOF'
645
  ? "Bañ ma dégg. Yónnee *INSCRIPTION* ngir tàmbalee ci kanam walla bind *SUITE*."
646
- : "Je n'ai pas compris. Envoie *INSCRIPTION* pour recommencer ou *SUITE* pour avancer."
 
647
  );
648
  }
649
  }
 
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'})`);
 
67
 
68
  if (isInscription) {
69
  logger.info(`${traceId} New user registration triggered for ${phone}`);
70
+ user = await prisma.user.create({ data: { phone, organizationId } });
 
 
 
 
71
  { id: 'LANG_WO', title: 'Wolof 🇸🇳' }
72
+ ],
73
+ organizationId
74
  );
75
  return;
76
  } else {
 
92
  content: text,
93
  mediaUrl: audioUrl || imageUrl,
94
  direction: 'INBOUND',
95
+ userId: user.id,
96
+ organizationId
97
  }
98
  });
99
  } catch (err: unknown) {
 
116
  where: { id: user.id },
117
  data: { city: null, activity: null }
118
  });
 
 
 
 
119
  { id: 'LANG_WO', title: 'Wolof 🇸🇳' }
120
+ ],
121
+ organizationId
122
  );
123
  return;
124
  }
 
135
  if (normalizedText.startsWith('TEST_VIDEO')) {
136
  const parts = normalizedText.split(' ');
137
  if (parts.length < 3) {
138
+ await scheduleMessage(user.id, "Usage: TEST_VIDEO <TrackId> <DayNumber>", 0, organizationId);
139
  return;
140
  }
141
  const trackId = parts[1];
142
  const dayNumber = parseFloat(parts[2]);
143
 
144
+ await scheduleMessage(user.id, `🧪 Test Video pour ${trackId} J${dayNumber}...`, 0, organizationId);
145
  await whatsappQueue.add('send-content', {
146
  userId: user.id,
147
  trackId,
 
157
  if (text.length < 2 && !isSystemCommand) {
158
  await scheduleMessage(user.id, user.language === 'WOLOF'
159
  ? "Dama lay xaar nga wax ma lu gën a yaatu ci sa mbir. Waxtaanal ak man !"
160
+ : "Je n'ai pas bien compris. Peux-tu me réexpliquer en quelques mots ?", 0, organizationId);
161
  return;
162
  }
163
 
 
181
 
182
  await scheduleMessage(user.id, result.seeded
183
  ? "✅ Seeding terminé ! Le Cache Cognitif a été réinitialisé.\nEnvoie INSCRIPTION pour commencer."
184
+ : "ℹ️ Les données existent déjà. Cache Cognitif purgé. Envoie INSCRIPTION.",
185
+ 0, organizationId
186
  );
187
  } catch (err: unknown) {
188
  logger.error('[SEED] Error:', (err instanceof Error ? (err instanceof Error ? err.message : String(err)) : String(err)));
189
+ await scheduleMessage(user.id, `❌ Erreur seed : ${(err instanceof Error ? (err instanceof Error ? err.message : String(err)) : String(err))?.substring(0, 200)}`, 0, organizationId);
190
  }
191
  return;
192
  }
 
208
  // ✅ UX: Confirmation FIRST, content delayed — message order guaranteed
209
  await scheduleMessage(user.id, user.language === 'WOLOF'
210
  ? `🔁 Dinanu la yëgël lexon Bés ${Math.floor(replayDay)} ci kanam...`
211
+ : `🔁 Je te renvoie la Leçon ${Math.floor(replayDay)} dans quelques secondes...`,
212
+ 0, organizationId
213
  );
214
  await whatsappQueue.add('send-content', {
215
  userId: user.id,
 
222
  } else if (action === 'EXERCISE') {
223
  await scheduleMessage(user.id, user.language === 'WOLOF'
224
  ? "🎙️ Yónnee sa tontu (audio walla bind) :"
225
+ : "🎙️ Envoie ta réponse (audio ou texte) :",
226
+ 0, organizationId
227
  );
228
  return;
229
  } else if (action === 'PROMPT') {
 
235
  where: { trackId: enrollment.trackId, dayNumber: enrollment.currentDay }
236
  });
237
  if (trackDay?.exercisePrompt) {
238
+ await scheduleMessage(user.id, trackDay.exercisePrompt, 0, organizationId);
239
  } else {
240
+ await scheduleMessage(user.id, user.language === 'WOLOF' ? "Amul lëjj" : "Pas d'exercice pour ce jour", 0, organizationId);
241
  }
242
  }
243
  return;
 
249
  if (pendingProgress) {
250
  await scheduleMessage(user.id, user.language === 'WOLOF'
251
  ? "Danga wara tontu lëjj bi balaa nga dem ci kanam. Tànnal 'Yónni tontu'."
252
+ : "Tu dois d'abord répondre à l'exercice avant de continuer. Choisis 'Faire l'exercice' ou 'Répondre'.",
253
+ 0, organizationId
254
  );
255
  } else {
256
  // Safe to advance (either completed or dropped or already handled)
257
  await scheduleMessage(user.id, user.language === 'WOLOF'
258
  ? "Waaw, ñuy dem ci kanam !"
259
+ : "C'est noté, on avance !",
260
+ 0, organizationId
261
  );
262
  // To do: if advance needs to trigger scheduleTrackDay directly, it could be done here instead of tracking.
263
  // However, normally `SUITE` moves the day forward.
 
283
  newLang === 'FR' ? "Ton secteur" : "Sa Mbir",
284
  promptText,
285
  newLang === 'FR' ? "Secteurs" : "Tànn",
286
+ [
287
+ {
288
+ title: newLang === 'FR' ? "Secteurs" : "Tànn",
289
+ rows: [
290
+ { id: 'SEC_COMMERCE', title: newLang === 'FR' ? 'Commerce / Vente' : 'Njaay' },
291
+ { id: 'SEC_AGRI', title: newLang === 'FR' ? 'Agriculture / Élevage' : 'Mbay' },
292
+ { id: 'SEC_FOOD', title: newLang === 'FR' ? 'Alimentation / Restauration' : 'Lekk / Restauration' },
293
+ { id: 'SEC_TECH', title: newLang === 'FR' ? 'Tech / Digital' : 'Tech / Digital' },
294
+ { id: 'SEC_BEAUTE', title: newLang === 'FR' ? 'Beauté / Bien-être' : 'Rafet' },
295
+ { id: 'SEC_COUTURE', title: newLang === 'FR' ? 'Couture / Mode' : 'Couture' },
296
+ { id: 'SEC_TRANSPORT', title: newLang === 'FR' ? 'Transport / Livraison' : 'Transport / Yëgël' },
297
+ { id: 'SEC_AUTRE', title: newLang === 'FR' ? 'Autre secteur' : 'Beneen mbir' }
298
+ ]
299
+ }
300
+ ],
301
+ organizationId
302
  );
303
  return;
304
  }
 
318
  if (normalizedText === 'SEC_AUTRE') {
319
  await scheduleMessage(user.id, user.language === 'WOLOF'
320
  ? 'Waaw ! Wax ma ban mbir ngay def ci ab kàddu gatt :'
321
+ : 'Parfait ! Décris ton activité en quelques mots :',
322
+ 0, organizationId
323
  );
324
  return;
325
  }
 
350
  ? `Parfait ! Secteur noté : *${activity}*.\nJe t'inscris à ta formation personnalisée !`
351
  : `Baax na ! Bind nanu la ci: *${activity}*.\nLéegi dinanu la dugal ci njàng mi !`;
352
 
353
+ await scheduleMessage(user.id, welcomeMsg, 0, organizationId);
354
 
355
  const trackId = user.language === 'FR' ? "T1-FR" : "T1-WO";
356
  const defaultTrack = await prisma.track.findUnique({ where: { id: trackId } });
357
+ if (defaultTrack) await enrollUser(user.id, defaultTrack.id, organizationId);
358
  return;
359
  }
360
 
 
367
  if (activeEnrollment) {
368
  const intent = this.detectIntent(text);
369
  const isSuite = this.isFuzzyMatch(normalizedText, 'SUITE') || normalizedText === '2';
370
+ const isApproforcir = this.isFuzzyMatch(normalizedText, 'APPROFONDIR') || normalizedText === '1';
371
 
372
  // Handle SUITE Priority
373
  if (isSuite) {
 
388
  logger.info(`[SUITE-BLOCKED] User ${user.id} tried SUITE but status is ${userProgress?.exerciseStatus || 'null'} and no response found.`);
389
  await scheduleMessage(user.id, user.language === 'WOLOF'
390
  ? "Dafa laaj nga tontu laaj bi ci kaw dëbb (audio walla texte) balaa nga dem ci kanam ! 🎙️"
391
+ : "Tu dois d'abord répondre à l'exercice ci-dessus pour continuer ! 🎙️",
392
+ 0, organizationId
393
  );
394
  return;
395
  }
 
412
  }
413
 
414
  // Handle APPROFONDIR (Deep Dive Initiation)
415
+ if (isApproforcir) {
416
  const userProgress = await prisma.userProgress.findUnique({
417
  where: { userId_trackId: { userId: user.id, trackId: activeEnrollment.trackId } }
418
  });
 
435
 
436
  await scheduleMessage(user.id, user.language === 'WOLOF'
437
  ? "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 :"
438
+ : "Très bien ! Quelle information précise issue de ton terrain veux-tu ajouter ? (ex: un prix précis, un obstacle, un fournisseur, etc.) :",
439
+ 0, organizationId
440
  );
441
  return;
442
  } else {
443
  await scheduleMessage(user.id, user.language === 'WOLOF'
444
  ? "Dafa laaj nga tontu laaj bi ci kaw dëbb balaa nga natt nga tontu !"
445
+ : "Réponds d'abord à l'exercice principal avant d'approfondir !",
446
+ 0, organizationId
447
  );
448
  return;
449
  }
 
455
  where: { userId_trackId: { userId: user.id, trackId: activeEnrollment.trackId } }
456
  });
457
  if (userProgress?.exerciseStatus === 'COMPLETED') {
458
+ await scheduleMessage(user.id, user.language === 'WOLOF' ? "Waaw ! Lexon bi mu ngi ñëw..." : "C'est parti ! Voici la suite...", 0, organizationId);
459
+ await whatsappQueue.add('send-content', { userId: user.id, trackId: activeEnrollment.trackId, dayNumber: activeEnrollment.currentDay + 1, organizationId });
460
  return;
461
  }
462
  }
463
 
464
  if (intent === 'NO' && normalizedText.length < 15) {
465
+ 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);
466
  return;
467
  }
468
 
 
471
  if (!user.activity) {
472
  await scheduleMessage(user.id, user.language === 'WOLOF'
473
  ? "Danga wara tànn sa mbiru liggeey balaa ñuy tàmbali coaching bi."
474
+ : "Tu dois d'abord définir ton activité avant que le coach AI ne puisse t'aider.",
475
+ 0, organizationId
476
+ );
477
  return;
478
  }
479
 
 
526
  if (wordCount < 3 && !isOptionMatch && !isCommonOption) {
527
  await scheduleMessage(user.id, user.language === 'WOLOF'
528
  ? "Dama lay xaar nga wax ma lu gën a yaatu ci sa mbir (mbebetu 3 baat). Waxtaanal ak man !"
529
+ : "Ta réponse est un peu courte. Peux-tu m'en dire plus ? (Minimum 3 mots)", 0, organizationId);
530
  return;
531
  }
532
 
533
+ await scheduleMessage(user.id, user.language === 'WOLOF' ? "⏳ Defar ak sa tontu..." : "⏳ Analyse de votre réponse...", 0, organizationId);
534
 
535
  // Update iteration count if it's a deep dive
536
  let currentIterationCount = pendingProgress.iterationCount || 0;
 
548
  enrollmentId: activeEnrollment.id,
549
  userId: user.id,
550
  dayNumber: effectiveDay,
551
+ content: text,
552
+ organizationId
553
  }
554
  });
555
 
 
575
  iterationCount: currentIterationCount,
576
  imageUrl: imageUrl,
577
  isTimeTravelMode, // ← Worker uses this to skip COMPLETED update
578
+ realCurrentDay: activeEnrollment.currentDay, // ← For logging only
579
+ organizationId
580
  }, { attempts: 3, backoff: { type: 'exponential', delay: 2000 } });
581
  return;
582
  }
 
589
  enrollmentId: activeEnrollment.id,
590
  userId: user.id,
591
  dayNumber: effectiveDay,
592
+ content: text,
593
+ organizationId
594
  }
595
  });
596
 
 
605
  logger.info(`${traceId} Guardrail: Input too short or potential gibberish: "${text}"`);
606
  await scheduleMessage(user.id, user.language === 'WOLOF'
607
  ? "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) ?"
608
+ : "Je n'ai pas bien compris ton activité. Peux-tu me réexpliquer en quelques mots ce que tu vends et à qui ?",
609
+ 0, organizationId
610
+ );
611
  return;
612
  }
613
 
 
616
  logger.info(`${traceId} Blocking AI feedback: Enrollment incomplete for User ${user.id}`);
617
  await scheduleMessage(user.id, user.language === 'WOLOF'
618
  ? "Baax na, waaye laaj bi des na... Bindal 'INSCRIPTION' ngir tàmbali."
619
+ : "C'est noté, mais il faut d'abord terminer ton inscription. Envoie 'INSCRIPTION' pour commencer.",
620
+ 0, organizationId
621
+ );
622
  return;
623
  }
624
 
 
643
  previousResponses,
644
  imageUrl: imageUrl,
645
  isTimeTravelMode,
646
+ realCurrentDay: activeEnrollment.currentDay,
647
+ organizationId
648
  });
649
  return;
650
  }
651
 
652
  await scheduleMessage(user.id, user.language === 'WOLOF'
653
  ? "Baax na ! Yónnee *SUITE* ngir dem ci kanam walla tontul laaj bi ci kaw."
654
+ : "✅ Message reçu ! Envoie *SUITE* pour avancer ou réponds à l'exercice ci-dessus.",
655
+ 0, organizationId
656
  );
657
  return;
658
  }
 
661
  logger.info(`${traceId} Unknown command from user ${user.id}: "${normalizedText}"`);
662
  await scheduleMessage(user.id, user.language === 'WOLOF'
663
  ? "Bañ ma dégg. Yónnee *INSCRIPTION* ngir tàmbalee ci kanam walla bind *SUITE*."
664
+ : "Je n'ai pas compris. Envoie *INSCRIPTION* pour recommencer ou *SUITE* pour avancer.",
665
+ 0, organizationId
666
  );
667
  }
668
  }
apps/whatsapp-worker/src/handlers/CommandHandler.ts CHANGED
@@ -104,7 +104,8 @@ export class CommandHandler implements MessageHandler {
104
  userId: user.id,
105
  trackId: enrollment.trackId,
106
  dayNumber: parseFloat(dayActionMatch[1]),
107
- isHistorical: true // Signal to skip progress update
 
108
  });
109
  return true;
110
  } else if (action === 'EXERCISE') {
 
104
  userId: user.id,
105
  trackId: enrollment.trackId,
106
  dayNumber: parseFloat(dayActionMatch[1]),
107
+ isHistorical: true, // Signal to skip progress update
108
+ organizationId: ctx.organizationId
109
  });
110
  return true;
111
  } else if (action === 'EXERCISE') {
apps/whatsapp-worker/src/handlers/ExerciseHandler.ts CHANGED
@@ -114,7 +114,7 @@ export class ExerciseHandler implements MessageHandler {
114
  await prisma.userProgress.update({ where: { id: pendingProgress.id }, data: { iterationCount: currentIterationCount } });
115
  }
116
 
117
- await prisma.response.create({ data: { enrollmentId: activeEnrollment.id, userId: user.id, dayNumber: Math.floor(effectiveDay), content: text } });
118
 
119
  const previousResponsesData = await prisma.response.findMany({ where: { userId: user.id, enrollmentId: activeEnrollment.id }, orderBy: { dayNumber: 'asc' }, take: 5 });
120
  const previousResponses = previousResponsesData.map(r => ({ day: r.dayNumber, response: r.content }));
@@ -132,7 +132,8 @@ export class ExerciseHandler implements MessageHandler {
132
  isDeepDive: isDeepDiveAction, iterationCount: currentIterationCount, imageUrl: imageUrl,
133
  isButtonChoice: isButtonChoice,
134
  isTimeTravelMode,
135
- realCurrentDay: activeEnrollment.currentDay
 
136
  }, { attempts: 3, backoff: { type: 'exponential', delay: 2000 } });
137
 
138
  return true;
 
114
  await prisma.userProgress.update({ where: { id: pendingProgress.id }, data: { iterationCount: currentIterationCount } });
115
  }
116
 
117
+ await prisma.response.create({ data: { enrollmentId: activeEnrollment.id, userId: user.id, dayNumber: Math.floor(effectiveDay), content: text, organizationId: ctx.organizationId } });
118
 
119
  const previousResponsesData = await prisma.response.findMany({ where: { userId: user.id, enrollmentId: activeEnrollment.id }, orderBy: { dayNumber: 'asc' }, take: 5 });
120
  const previousResponses = previousResponsesData.map(r => ({ day: r.dayNumber, response: r.content }));
 
132
  isDeepDive: isDeepDiveAction, iterationCount: currentIterationCount, imageUrl: imageUrl,
133
  isButtonChoice: isButtonChoice,
134
  isTimeTravelMode,
135
+ realCurrentDay: activeEnrollment.currentDay,
136
+ organizationId: ctx.organizationId
137
  }, { attempts: 3, backoff: { type: 'exponential', delay: 2000 } });
138
 
139
  return true;
apps/whatsapp-worker/src/handlers/NavigationHandler.ts CHANGED
@@ -50,7 +50,7 @@ export class NavigationHandler implements MessageHandler {
50
  data: { exerciseStatus: 'PENDING', iterationCount: 0 }
51
  });
52
 
53
- await whatsappQueue.add('send-content', { userId: user.id, trackId: activeEnrollment.trackId, dayNumber: nextDay });
54
  return true;
55
  }
56
 
 
50
  data: { exerciseStatus: 'PENDING', iterationCount: 0 }
51
  });
52
 
53
+ await whatsappQueue.add('send-content', { userId: user.id, trackId: activeEnrollment.trackId, dayNumber: nextDay, organizationId: ctx.organizationId });
54
  return true;
55
  }
56
 
apps/whatsapp-worker/src/handlers/OnboardingHandler.ts CHANGED
@@ -58,7 +58,7 @@ export class OnboardingHandler implements MessageHandler {
58
  data: { city: null, activity: null }
59
  });
60
  } else {
61
- user = await prisma.user.create({ data: { phone } });
62
  }
63
 
64
  await whatsappQueue.add('send-interactive-buttons', {
@@ -113,7 +113,7 @@ export class OnboardingHandler implements MessageHandler {
113
  await whatsappQueue.add('send-message', { userId: user.id, text: msg });
114
 
115
  const trackId = user.language === 'FR' ? "T1-FR" : "T1-WO";
116
- await whatsappQueue.add('enroll-user', { userId: user.id, trackId });
117
  return true;
118
  }
119
 
@@ -124,7 +124,7 @@ export class OnboardingHandler implements MessageHandler {
124
  await whatsappQueue.add('send-message', { userId: user.id, text: msg });
125
 
126
  const trackId = user.language === 'FR' ? "T1-FR" : "T1-WO";
127
- await whatsappQueue.add('enroll-user', { userId: user.id, trackId });
128
  return true;
129
  }
130
 
 
58
  data: { city: null, activity: null }
59
  });
60
  } else {
61
+ user = await prisma.user.create({ data: { phone, organizationId: ctx.organizationId } });
62
  }
63
 
64
  await whatsappQueue.add('send-interactive-buttons', {
 
113
  await whatsappQueue.add('send-message', { userId: user.id, text: msg });
114
 
115
  const trackId = user.language === 'FR' ? "T1-FR" : "T1-WO";
116
+ await whatsappQueue.add('enroll-user', { userId: user.id, trackId, organizationId: ctx.organizationId });
117
  return true;
118
  }
119
 
 
124
  await whatsappQueue.add('send-message', { userId: user.id, text: msg });
125
 
126
  const trackId = user.language === 'FR' ? "T1-FR" : "T1-WO";
127
+ await whatsappQueue.add('enroll-user', { userId: user.id, trackId, organizationId: ctx.organizationId });
128
  return true;
129
  }
130
 
apps/whatsapp-worker/src/handlers/types.ts CHANGED
@@ -13,6 +13,7 @@ export interface MessageContext {
13
  activeEnrollment?: Enrollment & { track: Track };
14
  redis: Redis;
15
  whatsappQueue: Queue;
 
16
  }
17
 
18
  export interface MessageHandler {
 
13
  activeEnrollment?: Enrollment & { track: Track };
14
  redis: Redis;
15
  whatsappQueue: Queue;
16
+ organizationId: string;
17
  }
18
 
19
  export interface MessageHandler {
apps/whatsapp-worker/src/index.ts CHANGED
@@ -21,6 +21,19 @@ startWorkerCleanupCron();
21
 
22
  const prisma = new PrismaClient();
23
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  import Redis from 'ioredis';
25
 
26
  const connection = process.env.REDIS_URL
@@ -39,20 +52,22 @@ const worker = new Worker('whatsapp-queue', async (job: Job) => {
39
 
40
  try {
41
  if (job.name === 'send-message') {
42
- const { userId, text } = job.data;
43
  const user = await prisma.user.findUnique({ where: { id: userId } });
 
44
  if (user?.phone) {
45
- await sendTextMessage(user.phone, text);
46
  } else {
47
  logger.warn(`[WORKER] User ${userId} not found or missing phone — skipping send.`);
48
  }
49
  }
50
  else if (job.name === 'send-message-direct') {
51
- const { phone, text } = job.data;
52
- await sendTextMessage(phone, text);
 
53
  }
54
  else if (job.name === 'handle-inbound') {
55
- const { phone, text, audioUrl, imageUrl, messageId } = job.data;
56
 
57
  // 🚨 Idempotence Lock for Inbound Messages
58
  if (messageId) {
@@ -64,16 +79,21 @@ const worker = new Worker('whatsapp-queue', async (job: Job) => {
64
  }
65
  }
66
 
67
- await WhatsAppLogic.handleIncomingMessage(phone, text, audioUrl, imageUrl);
68
  }
69
  else if (job.name === 'generate-feedback') {
70
- const { userId, text, trackId, exercisePrompt, lessonText, exerciseCriteria, totalDays, language, userActivity, userRegion, previousResponses, isDeepDive, iterationCount, imageUrl, isButtonChoice, isTimeTravelMode, realCurrentDay } = job.data;
71
  const currentDay = Number(job.data.currentDay || job.data.dayNumber || 0);
72
  const enrollmentId = job.data.enrollmentId;
73
 
74
  const user = await prisma.user.findUnique({
75
  where: { id: userId },
76
- include: { businessProfile: true }
 
 
 
 
 
77
  }) as any;
78
  if (!user?.phone) return;
79
 
@@ -98,6 +118,11 @@ const worker = new Worker('whatsapp-queue', async (job: Job) => {
98
  let AI_API_BASE_URL = '';
99
  let apiKey = '';
100
 
 
 
 
 
 
101
  try {
102
  trackDay = await prisma.trackDay.findFirst({
103
  where: { trackId, dayNumber: currentDay }
@@ -127,7 +152,9 @@ const worker = new Worker('whatsapp-queue', async (job: Job) => {
127
  isDeepDive: isDeepDive || false,
128
  iterationCount: iterationCount || 0,
129
  imageUrl: imageUrl,
130
- isButtonChoice: isButtonChoice || false
 
 
131
  })
132
  });
133
 
@@ -155,7 +182,7 @@ const worker = new Worker('whatsapp-queue', async (job: Job) => {
155
  const fallbackMsg = language === 'WOLOF'
156
  ? "Jërëjëf ci sa tontu ! (Analyse IA temporairement indisponible)"
157
  : "Merci pour ta réponse ! (Analyse IA de la réponse temporairement indisponible suite à une surcharge, mais ta progression est sauvegardée).";
158
- await sendTextMessage(user.phone, fallbackMsg);
159
  return;
160
  } else {
161
  const errText = await feedbackRes.text();
@@ -233,7 +260,7 @@ const worker = new Worker('whatsapp-queue', async (job: Job) => {
233
  await prisma.businessProfile.upsert({
234
  where: { userId },
235
  update: updatePayload,
236
- create: { userId, ...updatePayload }
237
  });
238
  } catch (bpErr: unknown) {
239
  logger.error('[WORKER] BusinessProfile upsert failed (non-fatal, REMEDIATION path):', (bpErr as Error).message);
@@ -307,7 +334,7 @@ const worker = new Worker('whatsapp-queue', async (job: Job) => {
307
  await prisma.businessProfile.upsert({
308
  where: { userId },
309
  update: updatePayload,
310
- create: { userId, ...updatePayload }
311
  });
312
  } catch (bpErr: unknown) {
313
  logger.error('[WORKER] BusinessProfile upsert failed (non-fatal, SUCCESS path):', (bpErr as Error).message);
@@ -330,12 +357,13 @@ const worker = new Worker('whatsapp-queue', async (job: Job) => {
330
  userId: user.id,
331
  dayNumber: currentDay,
332
  content: feedbackMsg,
333
- aiSource: feedbackData?.aiSource || 'OPENAI'
 
334
  }
335
  });
336
 
337
  // THEN send the WhatsApp message
338
- await sendTextMessage(user.phone, feedbackMsg);
339
 
340
  if (isFeatureEnabled('FEATURE_BUSINESS_PROFILE') && currentDay) {
341
  try {
@@ -379,7 +407,7 @@ const worker = new Worker('whatsapp-queue', async (job: Job) => {
379
  await prisma.businessProfile.upsert({
380
  where: { userId },
381
  update: updatePayload,
382
- create: { userId, ...updatePayload }
383
  });
384
 
385
  // 🌟 Visuals WOW: Generate Pitch Card for Day 1 🌟
@@ -435,43 +463,51 @@ const worker = new Worker('whatsapp-queue', async (job: Job) => {
435
  });
436
 
437
  if (currentDay >= totalDays && feedbackData?.isQualified !== false) {
 
438
  await sendTextMessage(user.phone, language === 'WOLOF'
439
  ? "🎉 Baraka Allahu fik ! Jeex nga module bi. Dokumaan yi dinañu leen yónnee ci kanam !"
440
- : "🎉 Félicitations ! Vous avez terminé ce module. Vos documents intelligents arrivent bientôt !"
 
441
  );
442
 
443
  const q = new Queue('whatsapp-queue', { connection: connection as any });
444
- await q.add('send-content', { userId, trackId, dayNumber: currentDay + 1 });
445
  } else if (feedbackData?.isQualified === false) {
 
446
  await sendTextMessage(user.phone, language === 'WOLOF'
447
  ? "🚨 Am na lo xamni leerul bu baax. Xoolal missal yi ma la yónnee te tàmbaleet ko ndànk."
448
- : "🚨 Certains points sont encore à renforcer pour valider. Regarde mes conseils et réessaie."
 
449
  );
450
 
451
  // Si on a un jour de remédiation (ex 1.5), on l'envoie automatiquement
452
  if (nextDay !== currentDay && nextDay % 1 !== 0) {
453
  const q = new Queue('whatsapp-queue', { connection: connection as any });
454
- await q.add('send-content', { userId, trackId, dayNumber: nextDay }, { delay: 2000 });
455
  }
456
  } else {
457
  // 🕰️ TIME-TRAVEL MODE: Custom CTA — SUITE would be blocked, so give clear guidance
 
458
  if (isTimeTravelMode) {
459
  await sendTextMessage(user.phone, language === 'WOLOF'
460
  ? `✅ 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.`
461
- : `✅ Enregistré ! Ta réponse du Jour ${currentDay} a été sauvegardée. Tu es toujours au Jour ${realCurrentDay || currentDay} — réponds à son exercice pour continuer 📅`
 
462
  );
463
  } else {
464
  await sendTextMessage(user.phone, language === 'WOLOF'
465
  ? "Baax na ! Yónnee *SUITE* ngir dem ci kanam."
466
- : "Bravo ! Envoyez *SUITE* pour passer à la leçon suivante."
 
467
  );
468
  }
469
  }
470
  }
471
  }
472
  else if (job.name === 'send-nudge') {
473
- const { userId, type } = job.data;
474
  const user = await prisma.user.findUnique({ where: { id: userId } });
 
475
  if (!user?.phone) return;
476
 
477
  const isWolof = user.language === 'WOLOF';
@@ -486,25 +522,27 @@ const worker = new Worker('whatsapp-queue', async (job: Job) => {
486
  };
487
 
488
  const text = (messages as any)[type] || messages.ENCOURAGEMENT;
489
- await sendTextMessage(user.phone, text);
490
  logger.info(`[WORKER] Nudge ${type} sent to ${user.phone}`);
491
  }
492
  else if (job.name === 'send-interactive-buttons') {
493
- const { userId, bodyText, buttons } = job.data;
494
  const user = await prisma.user.findUnique({ where: { id: userId } });
 
495
  if (user?.phone) {
496
- await sendInteractiveButtonMessage(user.phone, bodyText, buttons);
497
  }
498
  }
499
  else if (job.name === 'send-interactive-list') {
500
- const { userId, headerText, bodyText, buttonLabel, sections } = job.data;
501
  const user = await prisma.user.findUnique({ where: { id: userId } });
 
502
  if (user?.phone) {
503
- await sendInteractiveListMessage(user.phone, headerText, bodyText, buttonLabel, sections);
504
  }
505
  }
506
  else if (job.name === 'enroll-user') {
507
- const { userId, trackId } = job.data;
508
 
509
  const track = await prisma.track.findUnique({ where: { id: trackId } });
510
  if (!track) {
@@ -530,9 +568,11 @@ const worker = new Worker('whatsapp-queue', async (job: Job) => {
530
  if (checkoutRes.ok && checkoutData.url) {
531
  const user = await prisma.user.findUnique({ where: { id: userId } });
532
  if (user?.phone) {
 
533
  await sendTextMessage(
534
  user.phone,
535
- `💳 Cette formation est Premium. Complétez votre paiement ici :\n${checkoutData.url}`
 
536
  );
537
  }
538
  } else {
@@ -546,13 +586,15 @@ const worker = new Worker('whatsapp-queue', async (job: Job) => {
546
  const existing = await prisma.enrollment.findFirst({ where: { userId, trackId } });
547
  if (!existing) {
548
  await prisma.enrollment.create({
549
- data: { userId, trackId, status: 'ACTIVE', currentDay: 1 }
550
  });
551
  const user = await prisma.user.findUnique({ where: { id: userId } });
552
  if (user?.phone) {
 
553
  await sendTextMessage(
554
  user.phone,
555
- `🎉 Bienvenue dans *${track.title}* ! La génération de votre cours personnalisé (Jour 1) a commencé. Cela prendra environ 30 secondes...`
 
556
  );
557
 
558
  // Immediately trigger Day 1 content generation
@@ -560,7 +602,8 @@ const worker = new Worker('whatsapp-queue', async (job: Job) => {
560
  await whatsappQueue.add('send-content', {
561
  userId,
562
  trackId,
563
- dayNumber: 1
 
564
  });
565
  }
566
  }
@@ -568,7 +611,8 @@ const worker = new Worker('whatsapp-queue', async (job: Job) => {
568
  }
569
  else if (job.name === 'download-media') {
570
  // ─── Audio download from Meta Graph API — Railway only ─────────────
571
- const { mediaId, mimeType, phone } = job.data;
 
572
  const traceId = `[STT-FLOW-${phone.slice(-4)}]`;
573
  // Always prioritize the live environment variable over stale job data from Redis
574
  const accessToken = process.env.WHATSAPP_ACCESS_TOKEN || job.data.accessToken;
@@ -616,7 +660,8 @@ const worker = new Worker('whatsapp-queue', async (job: Job) => {
616
  direction: 'INBOUND',
617
  channel: 'WHATSAPP',
618
  mediaUrl: audioUrl || null,
619
- payload: job.data // Raw Meta payload from job
 
620
  }
621
  });
622
  logger.info(`[DB] Recorded inbound audio message for ${phone}`);
@@ -656,10 +701,10 @@ const worker = new Worker('whatsapp-queue', async (job: Job) => {
656
  logger.info(`[STT] Normalized: "${originalText}" -> "${transcribedText}"`);
657
 
658
  // Soft Feedback UI
659
- await sendTextMessage(phone, `Ma dégg na: "${transcribedText}" ✅\n(Confiance STT: ${confidence}%)`);
660
  if (normResult.changes.length > 0) {
661
  const limitedChanges = normResult.changes.slice(0, 2).join(", ");
662
- await sendTextMessage(phone, `Nataal bu gën: ${limitedChanges}`);
663
  }
664
 
665
  // 🚨 Wolof Auto-Validation Logic (Target: > 40%) 🚨
@@ -685,7 +730,8 @@ const worker = new Worker('whatsapp-queue', async (job: Job) => {
685
  update: { exerciseStatus: 'PENDING_REVIEW' as any, adminTranscription: transcribedText, confidenceScore: confidence },
686
  create: { userId: user.id, trackId: activeEnrollment.trackId, exerciseStatus: 'PENDING_REVIEW' as any, adminTranscription: transcribedText, confidenceScore: confidence }
687
  });
688
- await sendTextMessage(phone, "🎙️ Nyangi jaxas sa kàddu. Xamle dina la tontu ci kanam ! (En cours d'analyse par l'équipe)");
 
689
 
690
  // Still save the audio URL to the message for the admin to read!
691
  await prisma.message.updateMany({
@@ -697,7 +743,8 @@ const worker = new Worker('whatsapp-queue', async (job: Job) => {
697
  }
698
  } else {
699
  // Edge case: not enrolled but sent audio...
700
- await sendTextMessage(phone, "Dama jaxaso ci li nga wax... Mën nga ko waxaat ndànk ?");
 
701
  return;
702
  }
703
  }
@@ -731,11 +778,11 @@ const worker = new Worker('whatsapp-queue', async (job: Job) => {
731
  logger.info(`[STT] transcribe result="${transcribedText.substring(0, 80)}" (suspect=${isSuspect}, confidence=${confidence}%)`);
732
 
733
  if (isSuspect) {
734
- await sendTextMessage(phone, "Je n'ai pas bien compris ton vocal. Pourrais-tu le renvoyer en parlant bien distinctement ? (10-15s)");
735
  return;
736
  }
737
 
738
- await sendTextMessage(phone, `🎙️ J'ai compris : "${transcribedText}"`);
739
  await prisma.message.updateMany({
740
  where: { userId: user!.id, direction: 'INBOUND', mediaUrl: audioUrl },
741
  data: { content: transcribedText }
@@ -747,7 +794,7 @@ const worker = new Worker('whatsapp-queue', async (job: Job) => {
747
  if (transcribedText) {
748
  const traceId = `[STT-FLOW-${phone.slice(-4)}]`;
749
  logger.info(`${traceId} Processing transcribed text via WhatsAppLogic...`);
750
- await WhatsAppLogic.handleIncomingMessage(phone, transcribedText, audioUrl);
751
  logger.info(`${traceId} Inbound audio processing complete.`);
752
  }
753
  } else if (transcribeRes.status === 429) {
@@ -757,7 +804,8 @@ const worker = new Worker('whatsapp-queue', async (job: Job) => {
757
  if (user) {
758
  await sendTextMessage(phone, user.language === 'WOLOF'
759
  ? "⚠️ Mënuma dégg sa kàddu léegi (réseau bi dafa fees). Yónnee sa tontu ci bind (texte)."
760
- : "⚠️ Le service audio est temporairement saturé. Envoie ta réponse en texte."
 
761
  );
762
  }
763
  return; // Stop processing
@@ -789,7 +837,7 @@ const worker = new Worker('whatsapp-queue', async (job: Job) => {
789
  logger.info(`[IMAGE-FLOW] 📸 Image detected for ${phone}. Routing to WhatsAppLogic (imageUrl: ${imageUrl || audioUrl || 'none'})...`);
790
  // Fallback: si imageUrl (upload #2) échoue, utiliser audioUrl (upload #1, même buffer, déjà réussi)
791
  const finalImageUrl = imageUrl || audioUrl || undefined;
792
- await WhatsAppLogic.handleIncomingMessage(phone, job.data.caption || 'Image reçue', undefined, finalImageUrl);
793
  logger.info(`[IMAGE-FLOW] ✅ Inbound image processing complete.`);
794
  }
795
  } catch (err: unknown) {
@@ -806,7 +854,7 @@ const worker = new Worker('whatsapp-queue', async (job: Job) => {
806
  }
807
  }
808
  else if (job.name === 'send-content') {
809
- const { userId, trackId, dayNumber } = job.data;
810
 
811
 
812
  const user = await prisma.user.findUnique({ where: { id: userId } });
@@ -815,7 +863,7 @@ const worker = new Worker('whatsapp-queue', async (job: Job) => {
815
  });
816
 
817
  if (trackDay) {
818
- await sendLessonDay(userId, trackId, dayNumber, {
819
  skipProgressUpdate: job.data.skipProgressUpdate === true
820
  });
821
 
@@ -870,7 +918,8 @@ const worker = new Worker('whatsapp-queue', async (job: Job) => {
870
  : `🎉 Félicitations ! Vous avez validé le Niveau ${currentLevel}. Je vous inscris immédiatement au Niveau ${nextLevel} : *${nextTrack.title}*...`;
871
 
872
  if (user?.phone) {
873
- await sendTextMessage(user.phone, congratsMsg);
 
874
 
875
  if (!nextTrack.isPremium) {
876
  await prisma.enrollment.create({
@@ -883,7 +932,8 @@ const worker = new Worker('whatsapp-queue', async (job: Job) => {
883
  const payMsg = isWolof
884
  ? `💳 Niveau ${nextLevel} bi dafa laaj pass. Yónnee ma "PAYER" ngir tàmbaleeti.`
885
  : `💳 Le Niveau ${nextLevel} est un module Premium. Envoyez "PAYER" pour le débloquer et continuer votre ascension !`;
886
- await sendTextMessage(user.phone, payMsg);
 
887
  }
888
  }
889
  }
@@ -940,12 +990,14 @@ const worker = new Worker('whatsapp-queue', async (job: Job) => {
940
 
941
  // Send documents to user via WhatsApp
942
  if (user?.phone) {
 
943
  if (pdfData.url) {
944
  await sendDocumentMessage(
945
  user.phone,
946
  pdfData.url,
947
  isWolof ? 'one-pager-xamle.pdf' : 'mon-business-plan.pdf',
948
- isWolof ? '📄 Sa One-Pager mu ngi nii !' : '📄 Votre One-Pager est prêt !'
 
949
  );
950
  }
951
  if (pptxData.url) {
@@ -953,7 +1005,8 @@ const worker = new Worker('whatsapp-queue', async (job: Job) => {
953
  user.phone,
954
  pptxData.url,
955
  isWolof ? 'pitch-deck-xamle.pptx' : 'mon-pitch-deck.pptx',
956
- isWolof ? '📊 Sa Pitch Deck mu ngi nii !' : '📊 Votre Pitch Deck est prêt !'
 
957
  );
958
  }
959
 
@@ -975,19 +1028,21 @@ const worker = new Worker('whatsapp-queue', async (job: Job) => {
975
  }
976
  }
977
  else if (job.name === 'send-admin-audio-override') {
978
- const { userId, trackId, overrideAudioUrl, adminId } = job.data;
979
  const user = await prisma.user.findUnique({ where: { id: userId } });
 
980
 
981
  if (user?.phone) {
982
  // 1. Send the Admin's Voice Message
983
  const { sendAudioMessage } = await import('./whatsapp-cloud');
984
- await sendAudioMessage(user.phone, overrideAudioUrl);
985
 
986
  // 2. Send transition prompt
987
  await sendTextMessage(user.phone,
988
  user.language === 'WOLOF'
989
  ? "Baax na ! Yónnee *SUITE* ngir dem ci kanam."
990
- : "Bravo ! Envoyez *SUITE* pour passer à la leçon suivante."
 
991
  );
992
 
993
  logger.info(`[WORKER] Admin ${adminId} Audio Overdrive sent to User ${userId}.`);
 
21
 
22
  const prisma = new PrismaClient();
23
 
24
+ async function getTenantConfig(organizationId?: string) {
25
+ if (!organizationId) return undefined;
26
+ const org = await prisma.organization.findUnique({
27
+ where: { id: organizationId },
28
+ include: { phoneNumbers: true }
29
+ });
30
+ if (!org) return undefined;
31
+ return {
32
+ accessToken: org.systemUserToken || undefined,
33
+ phoneNumberId: org.phoneNumbers?.[0]?.id || undefined
34
+ };
35
+ }
36
+
37
  import Redis from 'ioredis';
38
 
39
  const connection = process.env.REDIS_URL
 
52
 
53
  try {
54
  if (job.name === 'send-message') {
55
+ const { userId, text, organizationId } = job.data;
56
  const user = await prisma.user.findUnique({ where: { id: userId } });
57
+ const tenantConfig = await getTenantConfig(organizationId);
58
  if (user?.phone) {
59
+ await sendTextMessage(user.phone, text, tenantConfig);
60
  } else {
61
  logger.warn(`[WORKER] User ${userId} not found or missing phone — skipping send.`);
62
  }
63
  }
64
  else if (job.name === 'send-message-direct') {
65
+ const { phone, text, organizationId } = job.data;
66
+ const tenantConfig = await getTenantConfig(organizationId);
67
+ await sendTextMessage(phone, text, tenantConfig);
68
  }
69
  else if (job.name === 'handle-inbound') {
70
+ const { phone, text, audioUrl, imageUrl, messageId, organizationId } = job.data;
71
 
72
  // 🚨 Idempotence Lock for Inbound Messages
73
  if (messageId) {
 
79
  }
80
  }
81
 
82
+ await WhatsAppLogic.handleIncomingMessage(phone, text, audioUrl, imageUrl, organizationId);
83
  }
84
  else if (job.name === 'generate-feedback') {
85
+ const { userId, text, trackId, exercisePrompt, lessonText, exerciseCriteria, totalDays, language, userActivity, userRegion, previousResponses, isDeepDive, iterationCount, imageUrl, isButtonChoice, isTimeTravelMode, realCurrentDay, organizationId } = job.data;
86
  const currentDay = Number(job.data.currentDay || job.data.dayNumber || 0);
87
  const enrollmentId = job.data.enrollmentId;
88
 
89
  const user = await prisma.user.findUnique({
90
  where: { id: userId },
91
+ include: {
92
+ businessProfile: true,
93
+ organization: {
94
+ include: { phoneNumbers: true }
95
+ }
96
+ }
97
  }) as any;
98
  if (!user?.phone) return;
99
 
 
118
  let AI_API_BASE_URL = '';
119
  let apiKey = '';
120
 
121
+ const tenantConfig = {
122
+ accessToken: user.organization?.systemUserToken || undefined,
123
+ phoneNumberId: user.organization?.phoneNumbers?.[0]?.id || undefined
124
+ };
125
+
126
  try {
127
  trackDay = await prisma.trackDay.findFirst({
128
  where: { trackId, dayNumber: currentDay }
 
152
  isDeepDive: isDeepDive || false,
153
  iterationCount: iterationCount || 0,
154
  imageUrl: imageUrl,
155
+ isButtonChoice: isButtonChoice || false,
156
+ tenantPrompt: user.organization?.customPrompt,
157
+ tenantBranding: user.organization?.brandingData
158
  })
159
  });
160
 
 
182
  const fallbackMsg = language === 'WOLOF'
183
  ? "Jërëjëf ci sa tontu ! (Analyse IA temporairement indisponible)"
184
  : "Merci pour ta réponse ! (Analyse IA de la réponse temporairement indisponible suite à une surcharge, mais ta progression est sauvegardée).";
185
+ await sendTextMessage(user.phone, fallbackMsg, tenantConfig);
186
  return;
187
  } else {
188
  const errText = await feedbackRes.text();
 
260
  await prisma.businessProfile.upsert({
261
  where: { userId },
262
  update: updatePayload,
263
+ create: { userId, ...updatePayload, organizationId }
264
  });
265
  } catch (bpErr: unknown) {
266
  logger.error('[WORKER] BusinessProfile upsert failed (non-fatal, REMEDIATION path):', (bpErr as Error).message);
 
334
  await prisma.businessProfile.upsert({
335
  where: { userId },
336
  update: updatePayload,
337
+ create: { userId, ...updatePayload, organizationId }
338
  });
339
  } catch (bpErr: unknown) {
340
  logger.error('[WORKER] BusinessProfile upsert failed (non-fatal, SUCCESS path):', (bpErr as Error).message);
 
357
  userId: user.id,
358
  dayNumber: currentDay,
359
  content: feedbackMsg,
360
+ aiSource: feedbackData?.aiSource || 'OPENAI',
361
+ organizationId
362
  }
363
  });
364
 
365
  // THEN send the WhatsApp message
366
+ await sendTextMessage(user.phone, feedbackMsg, tenantConfig);
367
 
368
  if (isFeatureEnabled('FEATURE_BUSINESS_PROFILE') && currentDay) {
369
  try {
 
407
  await prisma.businessProfile.upsert({
408
  where: { userId },
409
  update: updatePayload,
410
+ create: { userId, ...updatePayload, organizationId }
411
  });
412
 
413
  // 🌟 Visuals WOW: Generate Pitch Card for Day 1 🌟
 
463
  });
464
 
465
  if (currentDay >= totalDays && feedbackData?.isQualified !== false) {
466
+ const tenantConfig = await getTenantConfig(organizationId);
467
  await sendTextMessage(user.phone, language === 'WOLOF'
468
  ? "🎉 Baraka Allahu fik ! Jeex nga module bi. Dokumaan yi dinañu leen yónnee ci kanam !"
469
+ : "🎉 Félicitations ! Vous avez terminé ce module. Vos documents intelligents arrivent bientôt !",
470
+ tenantConfig
471
  );
472
 
473
  const q = new Queue('whatsapp-queue', { connection: connection as any });
474
+ await q.add('send-content', { userId, trackId, dayNumber: currentDay + 1, organizationId });
475
  } else if (feedbackData?.isQualified === false) {
476
+ const tenantConfig = await getTenantConfig(organizationId);
477
  await sendTextMessage(user.phone, language === 'WOLOF'
478
  ? "🚨 Am na lo xamni leerul bu baax. Xoolal missal yi ma la yónnee te tàmbaleet ko ndànk."
479
+ : "🚨 Certains points sont encore à renforcer pour valider. Regarde mes conseils et réessaie.",
480
+ tenantConfig
481
  );
482
 
483
  // Si on a un jour de remédiation (ex 1.5), on l'envoie automatiquement
484
  if (nextDay !== currentDay && nextDay % 1 !== 0) {
485
  const q = new Queue('whatsapp-queue', { connection: connection as any });
486
+ await q.add('send-content', { userId, trackId, dayNumber: nextDay, organizationId }, { delay: 2000 });
487
  }
488
  } else {
489
  // 🕰️ TIME-TRAVEL MODE: Custom CTA — SUITE would be blocked, so give clear guidance
490
+ const tenantConfig = await getTenantConfig(organizationId);
491
  if (isTimeTravelMode) {
492
  await sendTextMessage(user.phone, language === 'WOLOF'
493
  ? `✅ 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.`
494
+ : `✅ Enregistré ! Ta réponse du Jour ${currentDay} a été sauvegardée. Tu es toujours au Jour ${realCurrentDay || currentDay} — réponds à son exercice pour continuer 📅`,
495
+ tenantConfig
496
  );
497
  } else {
498
  await sendTextMessage(user.phone, language === 'WOLOF'
499
  ? "Baax na ! Yónnee *SUITE* ngir dem ci kanam."
500
+ : "Bravo ! Envoyez *SUITE* pour passer à la leçon suivante.",
501
+ tenantConfig
502
  );
503
  }
504
  }
505
  }
506
  }
507
  else if (job.name === 'send-nudge') {
508
+ const { userId, type, organizationId } = job.data;
509
  const user = await prisma.user.findUnique({ where: { id: userId } });
510
+ const tenantConfig = await getTenantConfig(organizationId);
511
  if (!user?.phone) return;
512
 
513
  const isWolof = user.language === 'WOLOF';
 
522
  };
523
 
524
  const text = (messages as any)[type] || messages.ENCOURAGEMENT;
525
+ await sendTextMessage(user.phone, text, tenantConfig);
526
  logger.info(`[WORKER] Nudge ${type} sent to ${user.phone}`);
527
  }
528
  else if (job.name === 'send-interactive-buttons') {
529
+ const { userId, bodyText, buttons, organizationId } = job.data;
530
  const user = await prisma.user.findUnique({ where: { id: userId } });
531
+ const tenantConfig = await getTenantConfig(organizationId);
532
  if (user?.phone) {
533
+ await sendInteractiveButtonMessage(user.phone, bodyText, buttons, undefined, tenantConfig);
534
  }
535
  }
536
  else if (job.name === 'send-interactive-list') {
537
+ const { userId, headerText, bodyText, buttonLabel, sections, organizationId } = job.data;
538
  const user = await prisma.user.findUnique({ where: { id: userId } });
539
+ const tenantConfig = await getTenantConfig(organizationId);
540
  if (user?.phone) {
541
+ await sendInteractiveListMessage(user.phone, headerText, bodyText, buttonLabel, sections, undefined, tenantConfig);
542
  }
543
  }
544
  else if (job.name === 'enroll-user') {
545
+ const { userId, trackId, organizationId } = job.data;
546
 
547
  const track = await prisma.track.findUnique({ where: { id: trackId } });
548
  if (!track) {
 
568
  if (checkoutRes.ok && checkoutData.url) {
569
  const user = await prisma.user.findUnique({ where: { id: userId } });
570
  if (user?.phone) {
571
+ const tenantConfig = await getTenantConfig(organizationId);
572
  await sendTextMessage(
573
  user.phone,
574
+ `💳 Cette formation est Premium. Complétez votre paiement ici :\n${checkoutData.url}`,
575
+ tenantConfig
576
  );
577
  }
578
  } else {
 
586
  const existing = await prisma.enrollment.findFirst({ where: { userId, trackId } });
587
  if (!existing) {
588
  await prisma.enrollment.create({
589
+ data: { userId, trackId, status: 'ACTIVE', currentDay: 1, organizationId }
590
  });
591
  const user = await prisma.user.findUnique({ where: { id: userId } });
592
  if (user?.phone) {
593
+ const tenantConfig = await getTenantConfig(organizationId);
594
  await sendTextMessage(
595
  user.phone,
596
+ `🎉 Bienvenue dans *${track.title}* ! La génération de votre cours personnalisé (Jour 1) a commencé. Cela prendra environ 30 secondes...`,
597
+ tenantConfig
598
  );
599
 
600
  // Immediately trigger Day 1 content generation
 
602
  await whatsappQueue.add('send-content', {
603
  userId,
604
  trackId,
605
+ dayNumber: 1,
606
+ organizationId
607
  });
608
  }
609
  }
 
611
  }
612
  else if (job.name === 'download-media') {
613
  // ─── Audio download from Meta Graph API — Railway only ─────────────
614
+ const { mediaId, mimeType, phone, organizationId } = job.data;
615
+ const tenantConfig = await getTenantConfig(organizationId);
616
  const traceId = `[STT-FLOW-${phone.slice(-4)}]`;
617
  // Always prioritize the live environment variable over stale job data from Redis
618
  const accessToken = process.env.WHATSAPP_ACCESS_TOKEN || job.data.accessToken;
 
660
  direction: 'INBOUND',
661
  channel: 'WHATSAPP',
662
  mediaUrl: audioUrl || null,
663
+ payload: job.data, // Raw Meta payload from job
664
+ organizationId
665
  }
666
  });
667
  logger.info(`[DB] Recorded inbound audio message for ${phone}`);
 
701
  logger.info(`[STT] Normalized: "${originalText}" -> "${transcribedText}"`);
702
 
703
  // Soft Feedback UI
704
+ await sendTextMessage(phone, `Ma dégg na: "${transcribedText}" ✅\n(Confiance STT: ${confidence}%)`, tenantConfig);
705
  if (normResult.changes.length > 0) {
706
  const limitedChanges = normResult.changes.slice(0, 2).join(", ");
707
+ await sendTextMessage(phone, `Nataal bu gën: ${limitedChanges}`, tenantConfig);
708
  }
709
 
710
  // 🚨 Wolof Auto-Validation Logic (Target: > 40%) 🚨
 
730
  update: { exerciseStatus: 'PENDING_REVIEW' as any, adminTranscription: transcribedText, confidenceScore: confidence },
731
  create: { userId: user.id, trackId: activeEnrollment.trackId, exerciseStatus: 'PENDING_REVIEW' as any, adminTranscription: transcribedText, confidenceScore: confidence }
732
  });
733
+ const tenantConfig = await getTenantConfig(organizationId);
734
+ await sendTextMessage(phone, "🎙️ Nyangi jaxas sa kàddu. Xamle dina la tontu ci kanam ! (En cours d'analyse par l'équipe)", tenantConfig);
735
 
736
  // Still save the audio URL to the message for the admin to read!
737
  await prisma.message.updateMany({
 
743
  }
744
  } else {
745
  // Edge case: not enrolled but sent audio...
746
+ const tenantConfig = await getTenantConfig(organizationId);
747
+ await sendTextMessage(phone, "Dama jaxaso ci li nga wax... Mën nga ko waxaat ndànk ?", tenantConfig);
748
  return;
749
  }
750
  }
 
778
  logger.info(`[STT] transcribe result="${transcribedText.substring(0, 80)}" (suspect=${isSuspect}, confidence=${confidence}%)`);
779
 
780
  if (isSuspect) {
781
+ await sendTextMessage(phone, "Je n'ai pas bien compris ton vocal. Pourrais-tu le renvoyer en parlant bien distinctement ? (10-15s)", tenantConfig);
782
  return;
783
  }
784
 
785
+ await sendTextMessage(phone, `🎙️ J'ai compris : "${transcribedText}"`, tenantConfig);
786
  await prisma.message.updateMany({
787
  where: { userId: user!.id, direction: 'INBOUND', mediaUrl: audioUrl },
788
  data: { content: transcribedText }
 
794
  if (transcribedText) {
795
  const traceId = `[STT-FLOW-${phone.slice(-4)}]`;
796
  logger.info(`${traceId} Processing transcribed text via WhatsAppLogic...`);
797
+ await WhatsAppLogic.handleIncomingMessage(phone, transcribedText, audioUrl, undefined, organizationId);
798
  logger.info(`${traceId} Inbound audio processing complete.`);
799
  }
800
  } else if (transcribeRes.status === 429) {
 
804
  if (user) {
805
  await sendTextMessage(phone, user.language === 'WOLOF'
806
  ? "⚠️ Mënuma dégg sa kàddu léegi (réseau bi dafa fees). Yónnee sa tontu ci bind (texte)."
807
+ : "⚠️ Le service audio est temporairement saturé. Envoie ta réponse en texte.",
808
+ tenantConfig
809
  );
810
  }
811
  return; // Stop processing
 
837
  logger.info(`[IMAGE-FLOW] 📸 Image detected for ${phone}. Routing to WhatsAppLogic (imageUrl: ${imageUrl || audioUrl || 'none'})...`);
838
  // Fallback: si imageUrl (upload #2) échoue, utiliser audioUrl (upload #1, même buffer, déjà réussi)
839
  const finalImageUrl = imageUrl || audioUrl || undefined;
840
+ await WhatsAppLogic.handleIncomingMessage(phone, job.data.caption || 'Image reçue', undefined, finalImageUrl, organizationId);
841
  logger.info(`[IMAGE-FLOW] ✅ Inbound image processing complete.`);
842
  }
843
  } catch (err: unknown) {
 
854
  }
855
  }
856
  else if (job.name === 'send-content') {
857
+ const { userId, trackId, dayNumber, organizationId } = job.data;
858
 
859
 
860
  const user = await prisma.user.findUnique({ where: { id: userId } });
 
863
  });
864
 
865
  if (trackDay) {
866
+ await sendLessonDay(userId, trackId, dayNumber, organizationId, {
867
  skipProgressUpdate: job.data.skipProgressUpdate === true
868
  });
869
 
 
918
  : `🎉 Félicitations ! Vous avez validé le Niveau ${currentLevel}. Je vous inscris immédiatement au Niveau ${nextLevel} : *${nextTrack.title}*...`;
919
 
920
  if (user?.phone) {
921
+ const tenantConfig = await getTenantConfig(user.organizationId);
922
+ await sendTextMessage(user.phone, congratsMsg, tenantConfig);
923
 
924
  if (!nextTrack.isPremium) {
925
  await prisma.enrollment.create({
 
932
  const payMsg = isWolof
933
  ? `💳 Niveau ${nextLevel} bi dafa laaj pass. Yónnee ma "PAYER" ngir tàmbaleeti.`
934
  : `💳 Le Niveau ${nextLevel} est un module Premium. Envoyez "PAYER" pour le débloquer et continuer votre ascension !`;
935
+ const tenantConfig = await getTenantConfig(user.organizationId);
936
+ await sendTextMessage(user.phone, payMsg, tenantConfig);
937
  }
938
  }
939
  }
 
990
 
991
  // Send documents to user via WhatsApp
992
  if (user?.phone) {
993
+ const tenantConfig = await getTenantConfig(userWithProfile?.organizationId);
994
  if (pdfData.url) {
995
  await sendDocumentMessage(
996
  user.phone,
997
  pdfData.url,
998
  isWolof ? 'one-pager-xamle.pdf' : 'mon-business-plan.pdf',
999
+ isWolof ? '📄 Sa One-Pager mu ngi nii !' : '📄 Votre One-Pager est prêt !',
1000
+ tenantConfig
1001
  );
1002
  }
1003
  if (pptxData.url) {
 
1005
  user.phone,
1006
  pptxData.url,
1007
  isWolof ? 'pitch-deck-xamle.pptx' : 'mon-pitch-deck.pptx',
1008
+ isWolof ? '📊 Sa Pitch Deck mu ngi nii !' : '📊 Votre Pitch Deck est prêt !',
1009
+ tenantConfig
1010
  );
1011
  }
1012
 
 
1028
  }
1029
  }
1030
  else if (job.name === 'send-admin-audio-override') {
1031
+ const { userId, trackId, overrideAudioUrl, adminId, organizationId } = job.data;
1032
  const user = await prisma.user.findUnique({ where: { id: userId } });
1033
+ const tenantConfig = await getTenantConfig(organizationId);
1034
 
1035
  if (user?.phone) {
1036
  // 1. Send the Admin's Voice Message
1037
  const { sendAudioMessage } = await import('./whatsapp-cloud');
1038
+ await sendAudioMessage(user.phone, overrideAudioUrl, tenantConfig);
1039
 
1040
  // 2. Send transition prompt
1041
  await sendTextMessage(user.phone,
1042
  user.language === 'WOLOF'
1043
  ? "Baax na ! Yónnee *SUITE* ngir dem ci kanam."
1044
+ : "Bravo ! Envoyez *SUITE* pour passer à la leçon suivante.",
1045
+ tenantConfig
1046
  );
1047
 
1048
  logger.info(`[WORKER] Admin ${adminId} Audio Overdrive sent to User ${userId}.`);
apps/whatsapp-worker/src/pedagogy.ts CHANGED
@@ -29,6 +29,7 @@ export async function sendLessonDay(
29
  userId: string,
30
  trackId: string,
31
  dayNumber: number,
 
32
  options?: { skipProgressUpdate?: boolean }
33
  ) {
34
  logger.info(`[PEDAGOGY] Preparing Lesson Day ${dayNumber} for User ${userId}`);
@@ -37,7 +38,8 @@ export async function sendLessonDay(
37
  where: { id: userId },
38
  include: {
39
  enrollments: { where: { trackId, status: 'ACTIVE' }, include: { track: true } },
40
- businessProfile: true
 
41
  }
42
  });
43
  if (!user || !user.phone) {
@@ -47,6 +49,11 @@ export async function sendLessonDay(
47
 
48
  const isWolof = user.language === 'WOLOF';
49
  const activeEnrollment = user.enrollments[0];
 
 
 
 
 
50
  const trackTitle = activeEnrollment?.track?.title || (isWolof ? 'XAMLÉ' : 'XAMLÉ (FR)');
51
 
52
  // 🚨 COHÉRENCE CHECK: Prevent jumps > 1 point
@@ -55,7 +62,8 @@ export async function sendLessonDay(
55
  logger.error(`[CRITICAL] Cohérence Error: User ${userId} attempting to jump from ${currentDay} to ${dayNumber} sans remédiation.`);
56
  await sendTextMessage(user.phone, isWolof
57
  ? "❌ Am na luy doxul ci sa njàng mi. Lëj-lëj la, dinañu ko lijjanti."
58
- : "❌ Une erreur de synchronisation a été détectée sur ton parcours (Saut > 1). L'équipe technique a été notifiée."
 
59
  );
60
  return;
61
  }
@@ -89,7 +97,7 @@ export async function sendLessonDay(
89
 
90
  // Fetch previous responses to inform the lesson examples
91
  const previousResponsesData = await prisma.response.findMany({
92
- where: { userId: user.id, enrollmentId: activeEnrollment.id },
93
  orderBy: { dayNumber: 'asc' },
94
  take: 5
95
  });
@@ -108,7 +116,9 @@ export async function sendLessonDay(
108
  userActivity: user.activity,
109
  userLanguage: user.language, // Pass language to AI
110
  businessProfile: user.businessProfile, // Pass business context
111
- previousResponses // Pass history to adapt examples
 
 
112
  })
113
  });
114
 
@@ -173,7 +183,7 @@ export async function sendLessonDay(
173
  logger.info(`[VIDEO] Sending video day=${dayNumber} track=${trackId} url=${vUrl}`);
174
 
175
  try {
176
- await sendVideoMessage(user.phone, vUrl, vCaption);
177
  logger.info(`[VIDEO_OK] WhatsApp accepted video for ${user.phone}`);
178
  } catch (vErr: unknown) {
179
  logger.warn(`[VIDEO_FALLBACK] reason=${(vErr instanceof Error ? vErr.message : String(vErr))}. Sending image fallback for ${user.phone}`);
@@ -183,14 +193,14 @@ export async function sendLessonDay(
183
  ? `⚠️ Vidéo bi mënul neex léegi. Klikal fii ngir seeti ko :\n${vUrl}\n(Xoolal nataal bi ci suuf)`
184
  : `⚠️ La vidéo ne peut pas être affichée directement. Clique ici pour la voir :\n${vUrl}\n(Regarde l'image ci-dessous)`;
185
 
186
- await sendTextMessage(user.phone, fallbackText);
187
 
188
  if (trackDay.imageUrl) {
189
- await sendImageMessage(user.phone, trackDay.imageUrl);
190
  imageAlreadySent = true;
191
  } else {
192
  // Secondary fallback image if no specific day image exists
193
- await sendImageMessage(user.phone, 'https://r2.xamle.sn/branding/bes1_welcome.png');
194
  }
195
  }
196
  }
@@ -198,14 +208,14 @@ export async function sendLessonDay(
198
  // 🌟 Visuals WoW: Send day image as an infographic to keep (even if video was sent) 🌟
199
  if (trackDay.imageUrl && !imageAlreadySent) {
200
  logger.info(`[PEDAGOGY] Sending daily image infographic: ${trackDay.imageUrl}`);
201
- await sendImageMessage(user.phone, trackDay.imageUrl);
202
  } else if (!imageAlreadySent) {
203
  // FALLBACK: Inject missing image using the user sector
204
  const sectorStr = user.activity ? user.activity.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/[^a-z0-9]/g, "_") : "general";
205
  const fallbackImageUrl = `https://r2.xamle.sn/branding/${sectorStr}_generic.png`;
206
  logger.info(`[PEDAGOGY] Missing imageUrl on Day ${dayNumber}! Using fallback: ${fallbackImageUrl}`);
207
  try {
208
- await sendImageMessage(user.phone, fallbackImageUrl);
209
  } catch (e: unknown) {
210
  logger.warn(`[PEDAGOGY] Fallback image also failed: ${(e instanceof Error ? e.message : String(e))}`);
211
  }
@@ -237,7 +247,7 @@ export async function sendLessonDay(
237
  if (finalAudioUrl) {
238
  try {
239
  logger.info(`[TTS] audioUrl=${finalAudioUrl} sending to WhatsApp...`);
240
- await sendAudioMessage(user.phone, finalAudioUrl);
241
  logger.info(`[WhatsApp] ✅ Audio message sent to ${user.phone}`);
242
 
243
  // ─── Hardening: Record Outbound Audio in DB ──────────
@@ -248,7 +258,8 @@ export async function sendLessonDay(
248
  direction: 'OUTBOUND',
249
  channel: 'WHATSAPP',
250
  content: lessonText || null,
251
- mediaUrl: finalAudioUrl
 
252
  }
253
  });
254
  } catch (dbErr: unknown) {
@@ -265,21 +276,21 @@ export async function sendLessonDay(
265
 
266
  if (dayNumber === 1 || dayNumber === 1.0) {
267
  // Heuristic: Send a branding or sector image on Day 1
268
- await sendImageMessage(user.phone, 'https://r2.xamle.sn/branding/bes1_welcome.png', `Bés 1 - Dalal jàmm!`);
269
  logger.info(`[WhatsApp] ✅ Day 1 intro image sent to ${user.phone}`);
270
  }
271
 
272
  const lessonMsg = textFR ? `${lessonText}\n(FR) ${textFR}` : lessonText;
273
  const formattedMessages = shortenForWhatsApp(lessonMsg);
274
  for (const msg of formattedMessages) {
275
- await sendTextMessage(user.phone, msg);
276
  }
277
  } catch (err) {
278
  logger.error({ err }, `[PEDAGOGY] Failed to send native audio, falling back to text`);
279
  // Fallback: Send at least the text if audio fails entirely
280
  if (lessonText) {
281
  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 :";
282
- await sendTextMessage(user.phone, alertMsg);
283
  let textFR = '';
284
  if (!isWolof && (trackDay as any).buttonsJson?.content?.FR) {
285
  textFR = (trackDay as any).buttonsJson.content.FR.lessonText;
@@ -287,14 +298,14 @@ export async function sendLessonDay(
287
  const lessonMsg = textFR ? `${lessonText}\n(FR) ${textFR}` : lessonText;
288
  const formattedMessages = shortenForWhatsApp(lessonMsg);
289
  for (const msg of formattedMessages) {
290
- await sendTextMessage(user.phone, msg);
291
  }
292
  }
293
  }
294
  } else if (lessonText) {
295
  // Fallback: Alert discreetly if no audio URL could be produced
296
  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 :";
297
- await sendTextMessage(user.phone, alertMsg);
298
 
299
  let textFR = '';
300
  if (!isWolof && (trackDay as any).buttonsJson?.content?.FR) {
@@ -304,7 +315,7 @@ export async function sendLessonDay(
304
  const lessonMsg = textFR ? `${lessonText}\n(FR) ${textFR}` : lessonText;
305
  const formattedMessages = shortenForWhatsApp(lessonMsg);
306
  for (const msg of formattedMessages) {
307
- await sendTextMessage(user.phone, msg);
308
  }
309
  }
310
 
@@ -312,9 +323,9 @@ export async function sendLessonDay(
312
  if (exercisePrompt) {
313
  if (trackDay.exerciseType === 'BUTTON' && trackDay.buttonsJson) {
314
  const buttons = trackDay.buttonsJson as Array<{ id: string; title: string }>;
315
- await sendInteractiveButtonMessage(user.phone, exercisePrompt, buttons);
316
  } else {
317
- await sendTextMessage(user.phone, exercisePrompt);
318
  }
319
  }
320
 
@@ -328,7 +339,8 @@ export async function sendLessonDay(
328
  user.phone,
329
  isWolof
330
  ? "🎙️ Lëjj bi: Tontul kàddu gi ci dëbb (vocal) walla mbind (texte)."
331
- : "🎙️ À toi de jouer ! Réponds à l'exercice ci-dessus par message vocal ou texte."
 
332
  );
333
  } else {
334
  const sections = [];
@@ -369,7 +381,9 @@ export async function sendLessonDay(
369
  ? "Seetee ci suuf ban jëf ngay def :"
370
  : "Que veux-tu faire maintenant ?",
371
  isWolof ? "Tànnal" : "Choisir",
372
- sections
 
 
373
  );
374
  }
375
 
@@ -386,7 +400,8 @@ export async function sendLessonDay(
386
  create: {
387
  userId,
388
  trackId,
389
- exerciseStatus: 'PENDING'
 
390
  }
391
  });
392
 
 
29
  userId: string,
30
  trackId: string,
31
  dayNumber: number,
32
+ organizationId: string,
33
  options?: { skipProgressUpdate?: boolean }
34
  ) {
35
  logger.info(`[PEDAGOGY] Preparing Lesson Day ${dayNumber} for User ${userId}`);
 
38
  where: { id: userId },
39
  include: {
40
  enrollments: { where: { trackId, status: 'ACTIVE' }, include: { track: true } },
41
+ businessProfile: true,
42
+ organization: { include: { phoneNumbers: true } }
43
  }
44
  });
45
  if (!user || !user.phone) {
 
49
 
50
  const isWolof = user.language === 'WOLOF';
51
  const activeEnrollment = user.enrollments[0];
52
+
53
+ const tenantConfig = {
54
+ accessToken: (user as any).organization?.systemUserToken || undefined,
55
+ phoneNumberId: (user as any).organization?.phoneNumbers?.[0]?.id || undefined
56
+ };
57
  const trackTitle = activeEnrollment?.track?.title || (isWolof ? 'XAMLÉ' : 'XAMLÉ (FR)');
58
 
59
  // 🚨 COHÉRENCE CHECK: Prevent jumps > 1 point
 
62
  logger.error(`[CRITICAL] Cohérence Error: User ${userId} attempting to jump from ${currentDay} to ${dayNumber} sans remédiation.`);
63
  await sendTextMessage(user.phone, isWolof
64
  ? "❌ Am na luy doxul ci sa njàng mi. Lëj-lëj la, dinañu ko lijjanti."
65
+ : "❌ Une erreur de synchronisation a été détectée sur ton parcours (Saut > 1). L'équipe technique a été notifiée.",
66
+ tenantConfig
67
  );
68
  return;
69
  }
 
97
 
98
  // Fetch previous responses to inform the lesson examples
99
  const previousResponsesData = await prisma.response.findMany({
100
+ where: { userId: user.id, enrollmentId: activeEnrollment.id, organizationId },
101
  orderBy: { dayNumber: 'asc' },
102
  take: 5
103
  });
 
116
  userActivity: user.activity,
117
  userLanguage: user.language, // Pass language to AI
118
  businessProfile: user.businessProfile, // Pass business context
119
+ previousResponses, // Pass history to adapt examples
120
+ tenantPrompt: (user as any).organization?.customPrompt,
121
+ tenantBranding: (user as any).organization?.brandingData
122
  })
123
  });
124
 
 
183
  logger.info(`[VIDEO] Sending video day=${dayNumber} track=${trackId} url=${vUrl}`);
184
 
185
  try {
186
+ await sendVideoMessage(user.phone, vUrl, vCaption, tenantConfig);
187
  logger.info(`[VIDEO_OK] WhatsApp accepted video for ${user.phone}`);
188
  } catch (vErr: unknown) {
189
  logger.warn(`[VIDEO_FALLBACK] reason=${(vErr instanceof Error ? vErr.message : String(vErr))}. Sending image fallback for ${user.phone}`);
 
193
  ? `⚠️ Vidéo bi mënul neex léegi. Klikal fii ngir seeti ko :\n${vUrl}\n(Xoolal nataal bi ci suuf)`
194
  : `⚠️ La vidéo ne peut pas être affichée directement. Clique ici pour la voir :\n${vUrl}\n(Regarde l'image ci-dessous)`;
195
 
196
+ await sendTextMessage(user.phone, fallbackText, tenantConfig);
197
 
198
  if (trackDay.imageUrl) {
199
+ await sendImageMessage(user.phone, trackDay.imageUrl, undefined, tenantConfig);
200
  imageAlreadySent = true;
201
  } else {
202
  // Secondary fallback image if no specific day image exists
203
+ await sendImageMessage(user.phone, 'https://r2.xamle.sn/branding/bes1_welcome.png', undefined, tenantConfig);
204
  }
205
  }
206
  }
 
208
  // 🌟 Visuals WoW: Send day image as an infographic to keep (even if video was sent) 🌟
209
  if (trackDay.imageUrl && !imageAlreadySent) {
210
  logger.info(`[PEDAGOGY] Sending daily image infographic: ${trackDay.imageUrl}`);
211
+ await sendImageMessage(user.phone, trackDay.imageUrl, undefined, tenantConfig);
212
  } else if (!imageAlreadySent) {
213
  // FALLBACK: Inject missing image using the user sector
214
  const sectorStr = user.activity ? user.activity.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/[^a-z0-9]/g, "_") : "general";
215
  const fallbackImageUrl = `https://r2.xamle.sn/branding/${sectorStr}_generic.png`;
216
  logger.info(`[PEDAGOGY] Missing imageUrl on Day ${dayNumber}! Using fallback: ${fallbackImageUrl}`);
217
  try {
218
+ await sendImageMessage(user.phone, fallbackImageUrl, undefined, tenantConfig);
219
  } catch (e: unknown) {
220
  logger.warn(`[PEDAGOGY] Fallback image also failed: ${(e instanceof Error ? e.message : String(e))}`);
221
  }
 
247
  if (finalAudioUrl) {
248
  try {
249
  logger.info(`[TTS] audioUrl=${finalAudioUrl} sending to WhatsApp...`);
250
+ await sendAudioMessage(user.phone, finalAudioUrl, tenantConfig);
251
  logger.info(`[WhatsApp] ✅ Audio message sent to ${user.phone}`);
252
 
253
  // ─── Hardening: Record Outbound Audio in DB ──────────
 
258
  direction: 'OUTBOUND',
259
  channel: 'WHATSAPP',
260
  content: lessonText || null,
261
+ mediaUrl: finalAudioUrl,
262
+ organizationId
263
  }
264
  });
265
  } catch (dbErr: unknown) {
 
276
 
277
  if (dayNumber === 1 || dayNumber === 1.0) {
278
  // Heuristic: Send a branding or sector image on Day 1
279
+ await sendImageMessage(user.phone, 'https://r2.xamle.sn/branding/bes1_welcome.png', `Bés 1 - Dalal jàmm!`, tenantConfig);
280
  logger.info(`[WhatsApp] ✅ Day 1 intro image sent to ${user.phone}`);
281
  }
282
 
283
  const lessonMsg = textFR ? `${lessonText}\n(FR) ${textFR}` : lessonText;
284
  const formattedMessages = shortenForWhatsApp(lessonMsg);
285
  for (const msg of formattedMessages) {
286
+ await sendTextMessage(user.phone, msg, tenantConfig);
287
  }
288
  } catch (err) {
289
  logger.error({ err }, `[PEDAGOGY] Failed to send native audio, falling back to text`);
290
  // Fallback: Send at least the text if audio fails entirely
291
  if (lessonText) {
292
  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 :";
293
+ await sendTextMessage(user.phone, alertMsg, tenantConfig);
294
  let textFR = '';
295
  if (!isWolof && (trackDay as any).buttonsJson?.content?.FR) {
296
  textFR = (trackDay as any).buttonsJson.content.FR.lessonText;
 
298
  const lessonMsg = textFR ? `${lessonText}\n(FR) ${textFR}` : lessonText;
299
  const formattedMessages = shortenForWhatsApp(lessonMsg);
300
  for (const msg of formattedMessages) {
301
+ await sendTextMessage(user.phone, msg, tenantConfig);
302
  }
303
  }
304
  }
305
  } else if (lessonText) {
306
  // Fallback: Alert discreetly if no audio URL could be produced
307
  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 :";
308
+ await sendTextMessage(user.phone, alertMsg, tenantConfig);
309
 
310
  let textFR = '';
311
  if (!isWolof && (trackDay as any).buttonsJson?.content?.FR) {
 
315
  const lessonMsg = textFR ? `${lessonText}\n(FR) ${textFR}` : lessonText;
316
  const formattedMessages = shortenForWhatsApp(lessonMsg);
317
  for (const msg of formattedMessages) {
318
+ await sendTextMessage(user.phone, msg, tenantConfig);
319
  }
320
  }
321
 
 
323
  if (exercisePrompt) {
324
  if (trackDay.exerciseType === 'BUTTON' && trackDay.buttonsJson) {
325
  const buttons = trackDay.buttonsJson as Array<{ id: string; title: string }>;
326
+ await sendInteractiveButtonMessage(user.phone, exercisePrompt, buttons, undefined, tenantConfig);
327
  } else {
328
+ await sendTextMessage(user.phone, exercisePrompt, tenantConfig);
329
  }
330
  }
331
 
 
339
  user.phone,
340
  isWolof
341
  ? "🎙️ Lëjj bi: Tontul kàddu gi ci dëbb (vocal) walla mbind (texte)."
342
+ : "🎙️ À toi de jouer ! Réponds à l'exercice ci-dessus par message vocal ou texte.",
343
+ tenantConfig
344
  );
345
  } else {
346
  const sections = [];
 
381
  ? "Seetee ci suuf ban jëf ngay def :"
382
  : "Que veux-tu faire maintenant ?",
383
  isWolof ? "Tànnal" : "Choisir",
384
+ sections,
385
+ undefined,
386
+ tenantConfig
387
  );
388
  }
389
 
 
400
  create: {
401
  userId,
402
  trackId,
403
+ exerciseStatus: 'PENDING',
404
+ organizationId
405
  }
406
  });
407
 
apps/whatsapp-worker/src/services/whatsapp-logic.ts CHANGED
@@ -37,7 +37,7 @@ export class WhatsAppLogic {
37
  .toUpperCase();
38
  }
39
 
40
- static async handleIncomingMessage(phone: string, text: string, audioUrl?: string, imageUrl?: string) {
41
  const traceId = audioUrl ? `[STT-FLOW-${phone.slice(-4)}]` : imageUrl ? `[IMG-FLOW-${phone.slice(-4)}]` : `[TXT-FLOW-${phone.slice(-4)}]`;
42
  const normalizedText = this.normalizeCommand(text);
43
  logger.info(`${traceId} Orchestrating Inbound: ${normalizedText}`);
@@ -56,7 +56,8 @@ export class WhatsAppLogic {
56
  content: text,
57
  mediaUrl: audioUrl || imageUrl,
58
  direction: 'INBOUND',
59
- userId: user.id
 
60
  }
61
  }).catch(() => {});
62
  }
@@ -71,7 +72,8 @@ export class WhatsAppLogic {
71
  user: user || undefined,
72
  activeEnrollment: activeEnrollment || undefined,
73
  redis: connection as any,
74
- whatsappQueue
 
75
  };
76
 
77
  // 3. Short Message Guard (Safety)
 
37
  .toUpperCase();
38
  }
39
 
40
+ static async handleIncomingMessage(phone: string, text: string, audioUrl?: string, imageUrl?: string, organizationId: string = 'default-org-id') {
41
  const traceId = audioUrl ? `[STT-FLOW-${phone.slice(-4)}]` : imageUrl ? `[IMG-FLOW-${phone.slice(-4)}]` : `[TXT-FLOW-${phone.slice(-4)}]`;
42
  const normalizedText = this.normalizeCommand(text);
43
  logger.info(`${traceId} Orchestrating Inbound: ${normalizedText}`);
 
56
  content: text,
57
  mediaUrl: audioUrl || imageUrl,
58
  direction: 'INBOUND',
59
+ userId: user.id,
60
+ organizationId
61
  }
62
  }).catch(() => {});
63
  }
 
72
  user: user || undefined,
73
  activeEnrollment: activeEnrollment || undefined,
74
  redis: connection as any,
75
+ whatsappQueue,
76
+ organizationId
77
  };
78
 
79
  // 3. Short Message Guard (Safety)
apps/whatsapp-worker/src/whatsapp-cloud.ts CHANGED
@@ -14,14 +14,14 @@ import axios from 'axios';
14
 
15
  const GRAPH_API_VERSION = 'v18.0';
16
 
17
- function getBaseUrl(): string {
18
- const phoneNumberId = process.env.WHATSAPP_PHONE_NUMBER_ID;
19
  if (!phoneNumberId) throw new Error('[WhatsApp] WHATSAPP_PHONE_NUMBER_ID is not set');
20
  return `https://graph.facebook.com/${GRAPH_API_VERSION}/${phoneNumberId}/messages`;
21
  }
22
 
23
- function getHeaders(): Record<string, string> {
24
- const token = process.env.WHATSAPP_ACCESS_TOKEN;
25
  if (!token) throw new Error('[WhatsApp] WHATSAPP_ACCESS_TOKEN is not set');
26
  return {
27
  'Content-Type': 'application/json',
@@ -34,7 +34,7 @@ function getHeaders(): Record<string, string> {
34
  * @param to - Recipient phone number in international format (e.g. "221771234567")
35
  * @param text - Message body (supports basic WhatsApp markdown: *bold*, _italic_)
36
  */
37
- export async function sendTextMessage(to: string, text: string): Promise<void> {
38
  // Safety guard: HF is inbound-only. Only Railway worker should call this.
39
  if (process.env.DISABLE_WHATSAPP_SEND === 'true') {
40
  logger.warn(`[WhatsApp] DISABLE_WHATSAPP_SEND=true — Skipping send to ${to}. Message: "${text.substring(0, 60)}..."`);
@@ -50,7 +50,7 @@ export async function sendTextMessage(to: string, text: string): Promise<void> {
50
  };
51
 
52
  try {
53
- await axios.post(getBaseUrl(), body, { headers: getHeaders() });
54
  } catch (err: unknown) {
55
  throw new Error(`[WhatsApp] sendTextMessage failed: ${(err as any)?.response?.data?.error?.message || (err instanceof Error ? err.message : String(err))}`);
56
  }
@@ -64,7 +64,7 @@ export async function sendTextMessage(to: string, text: string): Promise<void> {
64
  * @param imageUrl - Public URL of the image
65
  * @param caption - Optional caption shown under the image
66
  */
67
- export async function sendImageMessage(to: string, imageUrl: string, caption?: string): Promise<void> {
68
  if (process.env.DISABLE_WHATSAPP_SEND === 'true') {
69
  logger.warn(`[WhatsApp] DISABLE_WHATSAPP_SEND=true — Skipping image send to ${to}. URL: ${imageUrl}`);
70
  return;
@@ -82,7 +82,7 @@ export async function sendImageMessage(to: string, imageUrl: string, caption?: s
82
  };
83
 
84
  try {
85
- await axios.post(getBaseUrl(), body, { headers: getHeaders() });
86
  } catch (err: unknown) {
87
  throw new Error(`[WhatsApp] sendImageMessage failed for URL [${imageUrl}]: ${(err as any)?.response?.data?.error?.message || (err instanceof Error ? err.message : String(err))}`);
88
  }
@@ -97,7 +97,7 @@ export async function sendImageMessage(to: string, imageUrl: string, caption?: s
97
  * @param filename - Display filename (e.g. "pitch-deck.pptx")
98
  * @param caption - Optional caption shown under the document
99
  */
100
- export async function sendDocumentMessage(to: string, fileUrl: string, filename: string, caption?: string): Promise<void> {
101
  if (process.env.DISABLE_WHATSAPP_SEND === 'true') {
102
  logger.warn(`[WhatsApp] DISABLE_WHATSAPP_SEND=true — Skipping document send to ${to}.`);
103
  return;
@@ -115,7 +115,7 @@ export async function sendDocumentMessage(to: string, fileUrl: string, filename:
115
  };
116
 
117
  try {
118
- await axios.post(getBaseUrl(), body, { headers: getHeaders() });
119
  } catch (err: unknown) {
120
  throw new Error(`[WhatsApp] sendDocumentMessage failed: ${(err as any)?.response?.data?.error?.message || (err instanceof Error ? err.message : String(err))}`);
121
  }
@@ -128,7 +128,7 @@ export async function sendDocumentMessage(to: string, fileUrl: string, filename:
128
  * @param to - Recipient phone number
129
  * @param audioUrl - Public URL of the audio file (MP3/OGG)
130
  */
131
- export async function sendAudioMessage(to: string, audioUrl: string): Promise<void> {
132
  if (process.env.DISABLE_WHATSAPP_SEND === 'true') {
133
  logger.warn(`[WhatsApp] DISABLE_WHATSAPP_SEND=true — Skipping audio send to ${to}.`);
134
  return;
@@ -142,7 +142,7 @@ export async function sendAudioMessage(to: string, audioUrl: string): Promise<vo
142
  };
143
 
144
  try {
145
- await axios.post(getBaseUrl(), body, { headers: getHeaders() });
146
  } catch (err: unknown) {
147
  throw new Error(`[WhatsApp] sendAudioMessage failed for URL [${audioUrl}]: ${(err as any)?.response?.data?.error?.message || (err instanceof Error ? err.message : String(err))}`);
148
  }
@@ -156,7 +156,7 @@ export async function sendAudioMessage(to: string, audioUrl: string): Promise<vo
156
  * @param videoUrl - Public URL of the video file (MP4)
157
  * @param caption - Optional caption shown under the video
158
  */
159
- export async function sendVideoMessage(to: string, videoUrl: string, caption?: string): Promise<void> {
160
  if (process.env.DISABLE_WHATSAPP_SEND === 'true') {
161
  logger.warn(`[WhatsApp] DISABLE_WHATSAPP_SEND=true — Skipping video send to ${to}.`);
162
  return;
@@ -170,7 +170,7 @@ export async function sendVideoMessage(to: string, videoUrl: string, caption?: s
170
  };
171
 
172
  try {
173
- await axios.post(getBaseUrl(), body, { headers: getHeaders() });
174
  } catch (err: unknown) {
175
  throw new Error(`[WhatsApp] sendVideoMessage failed for URL [${videoUrl}]: ${(err as any)?.response?.data?.error?.message || (err instanceof Error ? err.message : String(err))}`);
176
  }
@@ -188,7 +188,8 @@ export async function sendInteractiveButtonMessage(
188
  to: string,
189
  bodyText: string,
190
  buttons: Array<{ id: string; title: string }>,
191
- imageUrl?: string
 
192
  ): Promise<void> {
193
  if (process.env.DISABLE_WHATSAPP_SEND === 'true') {
194
  logger.warn(`[WhatsApp] DISABLE_WHATSAPP_SEND=true — Skipping interactive send to ${to}.`);
@@ -216,7 +217,7 @@ export async function sendInteractiveButtonMessage(
216
  };
217
 
218
  try {
219
- await axios.post(getBaseUrl(), body, { headers: getHeaders() });
220
  } catch (err: unknown) {
221
  throw new Error(`[WhatsApp] sendInteractiveButtonMessage failed: ${(err as any)?.response?.data?.error?.message || (err instanceof Error ? err.message : String(err))}`);
222
  }
@@ -237,7 +238,8 @@ export async function sendInteractiveListMessage(
237
  title: string;
238
  rows: Array<{ id: string; title: string; description?: string }>;
239
  }>,
240
- imageUrl?: string
 
241
  ): Promise<void> {
242
  if (process.env.DISABLE_WHATSAPP_SEND === 'true') {
243
  logger.warn(`[WhatsApp] DISABLE_WHATSAPP_SEND=true — Skipping list message to ${to}.`);
@@ -270,13 +272,13 @@ export async function sendInteractiveListMessage(
270
  };
271
 
272
  try {
273
- await axios.post(getBaseUrl(), body, { headers: getHeaders() });
274
  logger.info(`[WhatsApp] ✅ List message sent to ${to}`);
275
  } catch (err: unknown) {
276
  // Fallback to text if interactive list fails (e.g., WhatsApp doesn't support it)
277
  logger.warn(`[WhatsApp] List message failed, falling back to text: ${(err as any)?.response?.data?.error?.message || (err instanceof Error ? err.message : String(err))}`);
278
  const fallback = sections.flatMap(s => s.rows.map((r, i) => `${i + 1}. ${r.title}`)).join('\n');
279
- await sendTextMessage(to, `${bodyText}\n\n${fallback}`);
280
  }
281
  }
282
 
 
14
 
15
  const GRAPH_API_VERSION = 'v18.0';
16
 
17
+ function getBaseUrl(explicitId?: string): string {
18
+ const phoneNumberId = explicitId || process.env.WHATSAPP_PHONE_NUMBER_ID;
19
  if (!phoneNumberId) throw new Error('[WhatsApp] WHATSAPP_PHONE_NUMBER_ID is not set');
20
  return `https://graph.facebook.com/${GRAPH_API_VERSION}/${phoneNumberId}/messages`;
21
  }
22
 
23
+ function getHeaders(explicitToken?: string): Record<string, string> {
24
+ const token = explicitToken || process.env.WHATSAPP_ACCESS_TOKEN;
25
  if (!token) throw new Error('[WhatsApp] WHATSAPP_ACCESS_TOKEN is not set');
26
  return {
27
  'Content-Type': 'application/json',
 
34
  * @param to - Recipient phone number in international format (e.g. "221771234567")
35
  * @param text - Message body (supports basic WhatsApp markdown: *bold*, _italic_)
36
  */
37
+ export async function sendTextMessage(to: string, text: string, config?: { accessToken?: string, phoneNumberId?: string }): Promise<void> {
38
  // Safety guard: HF is inbound-only. Only Railway worker should call this.
39
  if (process.env.DISABLE_WHATSAPP_SEND === 'true') {
40
  logger.warn(`[WhatsApp] DISABLE_WHATSAPP_SEND=true — Skipping send to ${to}. Message: "${text.substring(0, 60)}..."`);
 
50
  };
51
 
52
  try {
53
+ await axios.post(getBaseUrl(config?.phoneNumberId), body, { headers: getHeaders(config?.accessToken) });
54
  } catch (err: unknown) {
55
  throw new Error(`[WhatsApp] sendTextMessage failed: ${(err as any)?.response?.data?.error?.message || (err instanceof Error ? err.message : String(err))}`);
56
  }
 
64
  * @param imageUrl - Public URL of the image
65
  * @param caption - Optional caption shown under the image
66
  */
67
+ export async function sendImageMessage(to: string, imageUrl: string, caption?: string, config?: { accessToken?: string, phoneNumberId?: string }): Promise<void> {
68
  if (process.env.DISABLE_WHATSAPP_SEND === 'true') {
69
  logger.warn(`[WhatsApp] DISABLE_WHATSAPP_SEND=true — Skipping image send to ${to}. URL: ${imageUrl}`);
70
  return;
 
82
  };
83
 
84
  try {
85
+ await axios.post(getBaseUrl(config?.phoneNumberId), body, { headers: getHeaders(config?.accessToken) });
86
  } catch (err: unknown) {
87
  throw new Error(`[WhatsApp] sendImageMessage failed for URL [${imageUrl}]: ${(err as any)?.response?.data?.error?.message || (err instanceof Error ? err.message : String(err))}`);
88
  }
 
97
  * @param filename - Display filename (e.g. "pitch-deck.pptx")
98
  * @param caption - Optional caption shown under the document
99
  */
100
+ export async function sendDocumentMessage(to: string, fileUrl: string, filename: string, caption?: string, config?: { accessToken?: string, phoneNumberId?: string }): Promise<void> {
101
  if (process.env.DISABLE_WHATSAPP_SEND === 'true') {
102
  logger.warn(`[WhatsApp] DISABLE_WHATSAPP_SEND=true — Skipping document send to ${to}.`);
103
  return;
 
115
  };
116
 
117
  try {
118
+ await axios.post(getBaseUrl(config?.phoneNumberId), body, { headers: getHeaders(config?.accessToken) });
119
  } catch (err: unknown) {
120
  throw new Error(`[WhatsApp] sendDocumentMessage failed: ${(err as any)?.response?.data?.error?.message || (err instanceof Error ? err.message : String(err))}`);
121
  }
 
128
  * @param to - Recipient phone number
129
  * @param audioUrl - Public URL of the audio file (MP3/OGG)
130
  */
131
+ export async function sendAudioMessage(to: string, audioUrl: string, config?: { accessToken?: string, phoneNumberId?: string }): Promise<void> {
132
  if (process.env.DISABLE_WHATSAPP_SEND === 'true') {
133
  logger.warn(`[WhatsApp] DISABLE_WHATSAPP_SEND=true — Skipping audio send to ${to}.`);
134
  return;
 
142
  };
143
 
144
  try {
145
+ await axios.post(getBaseUrl(config?.phoneNumberId), body, { headers: getHeaders(config?.accessToken) });
146
  } catch (err: unknown) {
147
  throw new Error(`[WhatsApp] sendAudioMessage failed for URL [${audioUrl}]: ${(err as any)?.response?.data?.error?.message || (err instanceof Error ? err.message : String(err))}`);
148
  }
 
156
  * @param videoUrl - Public URL of the video file (MP4)
157
  * @param caption - Optional caption shown under the video
158
  */
159
+ export async function sendVideoMessage(to: string, videoUrl: string, caption?: string, config?: { accessToken?: string, phoneNumberId?: string }): Promise<void> {
160
  if (process.env.DISABLE_WHATSAPP_SEND === 'true') {
161
  logger.warn(`[WhatsApp] DISABLE_WHATSAPP_SEND=true — Skipping video send to ${to}.`);
162
  return;
 
170
  };
171
 
172
  try {
173
+ await axios.post(getBaseUrl(config?.phoneNumberId), body, { headers: getHeaders(config?.accessToken) });
174
  } catch (err: unknown) {
175
  throw new Error(`[WhatsApp] sendVideoMessage failed for URL [${videoUrl}]: ${(err as any)?.response?.data?.error?.message || (err instanceof Error ? err.message : String(err))}`);
176
  }
 
188
  to: string,
189
  bodyText: string,
190
  buttons: Array<{ id: string; title: string }>,
191
+ imageUrl?: string,
192
+ config?: { accessToken?: string, phoneNumberId?: string }
193
  ): Promise<void> {
194
  if (process.env.DISABLE_WHATSAPP_SEND === 'true') {
195
  logger.warn(`[WhatsApp] DISABLE_WHATSAPP_SEND=true — Skipping interactive send to ${to}.`);
 
217
  };
218
 
219
  try {
220
+ await axios.post(getBaseUrl(config?.phoneNumberId), body, { headers: getHeaders(config?.accessToken) });
221
  } catch (err: unknown) {
222
  throw new Error(`[WhatsApp] sendInteractiveButtonMessage failed: ${(err as any)?.response?.data?.error?.message || (err instanceof Error ? err.message : String(err))}`);
223
  }
 
238
  title: string;
239
  rows: Array<{ id: string; title: string; description?: string }>;
240
  }>,
241
+ imageUrl?: string,
242
+ config?: { accessToken?: string, phoneNumberId?: string }
243
  ): Promise<void> {
244
  if (process.env.DISABLE_WHATSAPP_SEND === 'true') {
245
  logger.warn(`[WhatsApp] DISABLE_WHATSAPP_SEND=true — Skipping list message to ${to}.`);
 
272
  };
273
 
274
  try {
275
+ await axios.post(getBaseUrl(config?.phoneNumberId), body, { headers: getHeaders(config?.accessToken) });
276
  logger.info(`[WhatsApp] ✅ List message sent to ${to}`);
277
  } catch (err: unknown) {
278
  // Fallback to text if interactive list fails (e.g., WhatsApp doesn't support it)
279
  logger.warn(`[WhatsApp] List message failed, falling back to text: ${(err as any)?.response?.data?.error?.message || (err instanceof Error ? err.message : String(err))}`);
280
  const fallback = sections.flatMap(s => s.rows.map((r, i) => `${i + 1}. ${r.title}`)).join('\n');
281
+ await sendTextMessage(to, `${bodyText}\n\n${fallback}`, config);
282
  }
283
  }
284
 
packages/database/prisma/schema.prisma CHANGED
@@ -7,6 +7,34 @@ datasource db {
7
  url = env("DATABASE_URL")
8
  }
9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  model User {
11
  id String @id @default(uuid())
12
  phone String @unique
@@ -15,12 +43,14 @@ model User {
15
  language Language @default(FR)
16
  city String?
17
  activity String?
 
18
  createdAt DateTime @default(now())
19
  updatedAt DateTime @updatedAt
20
  currentStreak Int @default(0)
21
  longestStreak Int @default(0)
22
  lastActivityAt DateTime?
23
  businessProfile BusinessProfile?
 
24
  enrollments Enrollment[]
25
  messages Message[]
26
  payments Payment[]
@@ -29,8 +59,8 @@ model User {
29
  }
30
 
31
  model BusinessProfile {
32
- id String @id @default(uuid())
33
- userId String @unique
34
  activityLabel String?
35
  activityPhrase String?
36
  activityType String?
@@ -43,8 +73,8 @@ model BusinessProfile {
43
  competitorList Json?
44
  financialProjections Json?
45
  fundingAsk String?
46
- lastUpdatedFromDay Int @default(0)
47
- createdAt DateTime @default(now())
48
  updatedAt DateTime @updatedAt
49
  teamMembers Json? @map("teamMembers")
50
  teamMembersList TeamMember[]
@@ -61,20 +91,22 @@ model TeamMember {
61
  }
62
 
63
  model Track {
64
- id String @id @default(uuid())
65
- title String
66
- description String?
67
- duration Int
68
- language Language @default(FR)
69
- isPremium Boolean @default(false)
70
- priceAmount Int?
71
- stripePriceId String?
72
- createdAt DateTime @default(now())
73
- updatedAt DateTime @updatedAt
74
- enrollments Enrollment[]
75
- payments Payment[]
76
- days TrackDay[]
77
- progress UserProgress[]
 
 
78
  }
79
 
80
  model TrackDay {
@@ -113,10 +145,12 @@ model UserProgress {
113
  adminTranscription String?
114
  overrideAudioUrl String?
115
  reviewedBy String?
 
116
  createdAt DateTime @default(now())
117
  updatedAt DateTime @updatedAt
118
  iterationCount Int @default(0)
119
  aiSource String?
 
120
  track Track @relation(fields: [trackId], references: [id])
121
  user User @relation(fields: [userId], references: [id])
122
 
@@ -132,34 +166,40 @@ model Enrollment {
132
  startedAt DateTime @default(now())
133
  completedAt DateTime?
134
  lastActivityAt DateTime @default(now())
 
 
135
  track Track @relation(fields: [trackId], references: [id])
136
  user User @relation(fields: [userId], references: [id])
137
  responses Response[]
138
  }
139
 
140
  model Response {
141
- id String @id @default(uuid())
142
- enrollmentId String
143
- userId String
144
- dayNumber Int
145
- content String?
146
- mediaUrl String?
147
- createdAt DateTime @default(now())
148
- aiSource String?
149
- enrollment Enrollment @relation(fields: [enrollmentId], references: [id], onDelete: Cascade)
150
- user User @relation(fields: [userId], references: [id])
 
 
151
  }
152
 
153
  model Message {
154
- id String @id @default(uuid())
155
- userId String
156
- direction Direction
157
- channel String @default("WHATSAPP")
158
- content String?
159
- mediaUrl String?
160
- payload Json?
161
- createdAt DateTime @default(now())
162
- user User @relation(fields: [userId], references: [id])
 
 
163
 
164
  @@index([userId, createdAt])
165
  }
@@ -172,8 +212,10 @@ model Payment {
172
  currency String @default("XOF")
173
  status PaymentStatus @default(PENDING)
174
  stripeSessionId String? @unique
 
175
  createdAt DateTime @default(now())
176
  updatedAt DateTime @updatedAt
 
177
  track Track @relation(fields: [trackId], references: [id])
178
  user User @relation(fields: [userId], references: [id])
179
  }
 
7
  url = env("DATABASE_URL")
8
  }
9
 
10
+ model Organization {
11
+ id String @id @default(uuid())
12
+ name String
13
+ wabaId String? @unique
14
+ systemUserToken String?
15
+ customPrompt String? @db.Text
16
+ brandingData Json?
17
+ createdAt DateTime @default(now())
18
+ updatedAt DateTime @updatedAt
19
+ users User[]
20
+ tracks Track[]
21
+ phoneNumbers WhatsAppPhoneNumber[]
22
+ enrollments Enrollment[]
23
+ messages Message[]
24
+ payments Payment[]
25
+ responses Response[]
26
+ progress UserProgress[]
27
+ }
28
+
29
+ model WhatsAppPhoneNumber {
30
+ id String @id // Meta phone_number_id
31
+ displayPhone String
32
+ organizationId String
33
+ organization Organization @relation(fields: [organizationId], references: [id])
34
+ createdAt DateTime @default(now())
35
+ updatedAt DateTime @updatedAt
36
+ }
37
+
38
  model User {
39
  id String @id @default(uuid())
40
  phone String @unique
 
43
  language Language @default(FR)
44
  city String?
45
  activity String?
46
+ organizationId String @default("default-org-id")
47
  createdAt DateTime @default(now())
48
  updatedAt DateTime @updatedAt
49
  currentStreak Int @default(0)
50
  longestStreak Int @default(0)
51
  lastActivityAt DateTime?
52
  businessProfile BusinessProfile?
53
+ organization Organization @relation(fields: [organizationId], references: [id])
54
  enrollments Enrollment[]
55
  messages Message[]
56
  payments Payment[]
 
59
  }
60
 
61
  model BusinessProfile {
62
+ id String @id @default(uuid())
63
+ userId String @unique
64
  activityLabel String?
65
  activityPhrase String?
66
  activityType String?
 
73
  competitorList Json?
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[]
 
91
  }
92
 
93
  model Track {
94
+ id String @id @default(uuid())
95
+ title String
96
+ description String?
97
+ duration Int
98
+ language Language @default(FR)
99
+ isPremium Boolean @default(false)
100
+ priceAmount Int?
101
+ stripePriceId String?
102
+ organizationId String @default("default-org-id")
103
+ createdAt DateTime @default(now())
104
+ updatedAt DateTime @updatedAt
105
+ organization Organization @relation(fields: [organizationId], references: [id])
106
+ enrollments Enrollment[]
107
+ payments Payment[]
108
+ days TrackDay[]
109
+ progress UserProgress[]
110
  }
111
 
112
  model TrackDay {
 
145
  adminTranscription String?
146
  overrideAudioUrl String?
147
  reviewedBy String?
148
+ organizationId String @default("default-org-id")
149
  createdAt DateTime @default(now())
150
  updatedAt DateTime @updatedAt
151
  iterationCount Int @default(0)
152
  aiSource String?
153
+ organization Organization @relation(fields: [organizationId], references: [id])
154
  track Track @relation(fields: [trackId], references: [id])
155
  user User @relation(fields: [userId], references: [id])
156
 
 
166
  startedAt DateTime @default(now())
167
  completedAt DateTime?
168
  lastActivityAt DateTime @default(now())
169
+ organizationId String @default("default-org-id")
170
+ organization Organization @relation(fields: [organizationId], references: [id])
171
  track Track @relation(fields: [trackId], references: [id])
172
  user User @relation(fields: [userId], references: [id])
173
  responses Response[]
174
  }
175
 
176
  model Response {
177
+ id String @id @default(uuid())
178
+ enrollmentId String
179
+ userId String
180
+ dayNumber Int
181
+ content String?
182
+ mediaUrl String?
183
+ organizationId String @default("default-org-id")
184
+ createdAt DateTime @default(now())
185
+ aiSource String?
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 {
192
+ id String @id @default(uuid())
193
+ userId String
194
+ direction Direction
195
+ channel String @default("WHATSAPP")
196
+ content String?
197
+ mediaUrl String?
198
+ payload Json?
199
+ organizationId String @default("default-org-id")
200
+ createdAt DateTime @default(now())
201
+ organization Organization @relation(fields: [organizationId], references: [id])
202
+ user User @relation(fields: [userId], references: [id])
203
 
204
  @@index([userId, createdAt])
205
  }
 
212
  currency String @default("XOF")
213
  status PaymentStatus @default(PENDING)
214
  stripeSessionId String? @unique
215
+ organizationId String @default("default-org-id")
216
  createdAt DateTime @default(now())
217
  updatedAt DateTime @updatedAt
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
  }
packages/database/scripts/raw-insert-org.ts ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { PrismaClient } from '@prisma/client';
2
+
3
+ async function main() {
4
+ const prisma = new PrismaClient();
5
+ try {
6
+ console.log('Inserting default organization via raw SQL...');
7
+ await prisma.$executeRaw`INSERT INTO "Organization" (id, name, "createdAt", "updatedAt") VALUES ('default-org-id', 'XAMLÉ Global', NOW(), NOW()) ON CONFLICT (id) DO NOTHING;`;
8
+ console.log('Success!');
9
+ } catch (e) {
10
+ console.error('Failed to insert via raw SQL. It might be because the table does not exist yet.');
11
+ console.error(e);
12
+ } finally {
13
+ await prisma.$disconnect();
14
+ }
15
+ }
16
+
17
+ main();
packages/database/src/seed-multi-tenant.ts ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { PrismaClient } from '@prisma/client';
2
+
3
+ const DEFAULT_ORG_ID = 'default-org-id';
4
+
5
+ async function main() {
6
+ const prisma = new PrismaClient();
7
+
8
+ console.log('🚀 Starting Multi-Tenant Migration Seed...');
9
+
10
+ // 1. Create Default Organization
11
+ const org = await prisma.organization.upsert({
12
+ where: { id: DEFAULT_ORG_ID },
13
+ update: { name: 'XAMLÉ Global' },
14
+ create: {
15
+ id: DEFAULT_ORG_ID,
16
+ name: 'XAMLÉ Global',
17
+ },
18
+ });
19
+ console.log(`✅ Default Organization created: ${org.name} (${org.id})`);
20
+
21
+ // 2. Connect existing data (Prisma handles the @default value in the DB,
22
+ // but we ensure consistency here for relations if needed).
23
+ // Actually, since I set a default value in the schema,
24
+ // newly generated migrations will set this value for existing rows.
25
+
26
+ console.log('✨ Multi-Tenant Migration Seed completed.');
27
+ }
28
+
29
+ main()
30
+ .catch((e) => {
31
+ console.error(e);
32
+ process.exit(1);
33
+ })
34
+ .finally(async () => {
35
+ const prisma = new PrismaClient();
36
+ await prisma.$disconnect();
37
+ });