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 +4 -1
- apps/admin/src/pages/ClientsManagementView.tsx +123 -0
- apps/api/src/routes/internal.ts +12 -5
- apps/api/src/routes/whatsapp.ts +16 -5
- apps/api/src/services/organization.ts +36 -0
- apps/api/src/services/queue.ts +13 -11
- apps/api/src/services/whatsapp.ts +78 -59
- apps/whatsapp-worker/src/handlers/CommandHandler.ts +2 -1
- apps/whatsapp-worker/src/handlers/ExerciseHandler.ts +3 -2
- apps/whatsapp-worker/src/handlers/NavigationHandler.ts +1 -1
- apps/whatsapp-worker/src/handlers/OnboardingHandler.ts +3 -3
- apps/whatsapp-worker/src/handlers/types.ts +1 -0
- apps/whatsapp-worker/src/index.ts +107 -52
- apps/whatsapp-worker/src/pedagogy.ts +38 -23
- apps/whatsapp-worker/src/services/whatsapp-logic.ts +5 -3
- apps/whatsapp-worker/src/whatsapp-cloud.ts +21 -19
- packages/database/prisma/schema.prisma +79 -37
- packages/database/scripts/raw-insert-org.ts +17 -0
- packages/database/src/seed-multi-tenant.ts +37 -0
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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 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
|
| 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 (
|
| 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
|
|
|
|
| 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: {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 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
|
|
|
|
| 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
|
|
|
|
| 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
|
| 33 |
-
userId String
|
| 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
|
| 47 |
-
createdAt DateTime
|
| 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
|
| 65 |
-
title
|
| 66 |
-
description
|
| 67 |
-
duration
|
| 68 |
-
language
|
| 69 |
-
isPremium
|
| 70 |
-
priceAmount
|
| 71 |
-
stripePriceId
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
|
|
|
|
|
|
| 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
|
| 142 |
-
enrollmentId
|
| 143 |
-
userId
|
| 144 |
-
dayNumber
|
| 145 |
-
content
|
| 146 |
-
mediaUrl
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
|
|
|
|
|
|
| 151 |
}
|
| 152 |
|
| 153 |
model Message {
|
| 154 |
-
id
|
| 155 |
-
userId
|
| 156 |
-
direction
|
| 157 |
-
channel
|
| 158 |
-
content
|
| 159 |
-
mediaUrl
|
| 160 |
-
payload
|
| 161 |
-
|
| 162 |
-
|
|
|
|
|
|
|
| 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 |
+
});
|