CognxSafeTrack
feat: Time-Travel complet via Redis — effectiveDay override, coupe-circuits SUITE/INSCRIPTION, skip COMPLETED en mode replay
d978795 | import { prisma } from './prisma'; | |
| import { scheduleMessage, enrollUser, whatsappQueue, scheduleInteractiveButtons, scheduleInteractiveList, setTimeTravelContext, getTimeTravelContext, clearTimeTravelContext } from './queue'; | |
| export class WhatsAppService { | |
| private static normalizeCommand(text: string): string { | |
| return text | |
| .trim() | |
| .toLowerCase() | |
| .replace(/[.,!?;:]+$/, "") // Remove trailing punctuation | |
| .toUpperCase(); | |
| } | |
| private static detectIntent(text: string): 'YES' | 'NO' | 'UNKNOWN' { | |
| const normalized = text.trim().toLowerCase().replace(/[.,!?;:]+$/, ""); | |
| const yesWords = ['oui', 'ouais', 'wi', 'waaw', 'yes', 'yep', 'ok', 'd’accord', 'daccord', 'da’accord']; | |
| const noWords = ['non', 'déet', 'deet', 'no', 'nah', 'nein']; | |
| if (yesWords.some(w => normalized.includes(w))) return 'YES'; | |
| if (noWords.some(w => normalized.includes(w))) return 'NO'; | |
| return 'UNKNOWN'; | |
| } | |
| private static levenshteinDistance(a: string, b: string): number { | |
| const matrix: number[][] = []; | |
| for (let i = 0; i <= b.length; i++) matrix[i] = [i]; | |
| for (let j = 0; j <= a.length; j++) matrix[0][j] = j; | |
| for (let i = 1; i <= b.length; i++) { | |
| for (let j = 1; j <= a.length; j++) { | |
| if (b.charAt(i - 1) === a.charAt(j - 1)) { | |
| matrix[i][j] = matrix[i - 1][j - 1]; | |
| } else { | |
| matrix[i][j] = Math.min( | |
| matrix[i - 1][j - 1] + 1, // substitution | |
| matrix[i][j - 1] + 1, // insertion | |
| matrix[i - 1][j] + 1 // deletion | |
| ); | |
| } | |
| } | |
| } | |
| return matrix[b.length][a.length]; | |
| } | |
| private static isFuzzyMatch(text: string, target: string, threshold = 0.8): boolean { | |
| const normalized = text.trim().toUpperCase(); | |
| const tar = target.toUpperCase(); | |
| if (normalized === tar) return true; | |
| if (normalized.includes(tar) || tar.includes(normalized)) return true; | |
| const distance = this.levenshteinDistance(normalized, tar); | |
| const maxLength = Math.max(normalized.length, tar.length); | |
| const similarity = 1 - distance / maxLength; | |
| return similarity >= threshold; | |
| } | |
| static async handleIncomingMessage(phone: string, text: string, audioUrl?: string, imageUrl?: string) { | |
| const traceId = audioUrl ? `[STT-FLOW-${phone.slice(-4)}]` : imageUrl ? `[IMG-FLOW-${phone.slice(-4)}]` : `[TXT-FLOW-${phone.slice(-4)}]`; | |
| const normalizedText = this.normalizeCommand(text); | |
| console.log(`${traceId} Received: ${normalizedText} (Audio: ${audioUrl || 'N/A'}, Image: ${imageUrl || 'N/A'})`); | |
| // 1. Find or Create User | |
| let user = await prisma.user.findUnique({ where: { phone } }); | |
| if (!user) { | |
| const isInscription = this.isFuzzyMatch(normalizedText, 'INSCRIPTION') || normalizedText.includes('INSCRI') || normalizedText.includes('INSCRI'); | |
| if (isInscription) { | |
| console.log(`${traceId} New user registration triggered for ${phone}`); | |
| user = await prisma.user.create({ data: { phone } }); | |
| await scheduleInteractiveButtons(user.id, | |
| "Dalal jàmm! Xamle ngay tàmbali. ⏳ 30s.\n(FR) Ton cours se prépare (30s).", | |
| [ | |
| { id: 'LANG_FR', title: 'Français 🇫🇷' }, | |
| { id: 'LANG_WO', title: 'Wolof 🇸🇳' } | |
| ] | |
| ); | |
| return; | |
| } else { | |
| console.log(`${traceId} Unregistered user ${phone} sent: "${normalizedText}". Sending instructions.`); | |
| // Anti-silence: Nudge them to register | |
| const { whatsappQueue } = await import('./queue'); | |
| await whatsappQueue.add('send-message-direct', { | |
| phone, | |
| text: "🎓 Bienvenue chez XAMLÉ !\nPour commencer ta formation gratuite, envoie le mot : *INSCRIPTION*\n\n(WO) Dalal jàmm ! Ngir tàmbali sa njàng mburu, bindal : *INSCRIPTION*" | |
| }); | |
| return; | |
| } | |
| } | |
| // 1.2 Log the incoming message in the DB | |
| try { | |
| await prisma.message.create({ | |
| data: { | |
| content: text, | |
| mediaUrl: audioUrl || imageUrl, | |
| direction: 'INBOUND', | |
| userId: user.id | |
| } | |
| }); | |
| } catch (err: unknown) { | |
| console.error('[WhatsAppService] Failed to log incoming message:', (err instanceof Error ? (err instanceof Error ? err.message : String(err)) : String(err))); | |
| } | |
| // 1.5. Testing / Cheat Codes (Only for registered users) | |
| if (this.isFuzzyMatch(normalizedText, 'INSCRIPTION')) { | |
| // 🚨 COUPE-CIRCUIT #1: Kill any active Time-Travel context BEFORE resetting progression | |
| await clearTimeTravelContext(user.id); | |
| await prisma.enrollment.deleteMany({ where: { userId: user.id } }); | |
| await prisma.userProgress.deleteMany({ where: { userId: user.id } }); | |
| await prisma.response.deleteMany({ where: { userId: user.id } }); | |
| await prisma.message.deleteMany({ where: { userId: user.id } }); // Purge totale historique des messages | |
| // Also explicitly clear business AI profile to prevent context leak on restart | |
| await (prisma as any).businessProfile.deleteMany({ where: { userId: user.id } }); | |
| user = await prisma.user.update({ | |
| where: { id: user.id }, | |
| data: { city: null, activity: null } | |
| }); | |
| await scheduleInteractiveButtons(user.id, | |
| "Réinitialisation réussie – choisissez votre langue / Tànnal sa làkk :", | |
| [ | |
| { id: 'LANG_FR', title: 'Français 🇫🇷' }, | |
| { id: 'LANG_WO', title: 'Wolof 🇸🇳' } | |
| ] | |
| ); | |
| return; | |
| } | |
| if (normalizedText === 'TEST_IMAGE') { | |
| await whatsappQueue.add('send-image', { | |
| to: user.phone, | |
| imageUrl: 'https://r2.xamle.sn/branding/branding_xamle.png', | |
| caption: 'Branding XAMLÉ - Industrialisation 2026' | |
| }); | |
| return; | |
| } | |
| if (normalizedText.startsWith('TEST_VIDEO')) { | |
| const parts = normalizedText.split(' '); | |
| if (parts.length < 3) { | |
| await scheduleMessage(user.id, "Usage: TEST_VIDEO <TrackId> <DayNumber>"); | |
| return; | |
| } | |
| const trackId = parts[1]; | |
| const dayNumber = parseFloat(parts[2]); | |
| await scheduleMessage(user.id, `🧪 Test Video pour ${trackId} J${dayNumber}...`); | |
| await whatsappQueue.add('send-content', { | |
| userId: user.id, | |
| trackId, | |
| dayNumber | |
| }); | |
| return; | |
| } | |
| const systemCommands = ['1', '2', 'SUITE', 'APPROFONDIR', 'INSCRIPTION', 'SEED']; | |
| const isSystemCommand = systemCommands.some(cmd => this.isFuzzyMatch(normalizedText, cmd)) || normalizedText.includes('INSCRI'); | |
| // 🚨 Guardrail "Gibberish" Lite (Global) | |
| if (text.length < 2 && !isSystemCommand) { | |
| await scheduleMessage(user.id, user.language === 'WOLOF' | |
| ? "Dama lay xaar nga wax ma lu gën a yaatu ci sa mbir. Waxtaanal ak man !" | |
| : "Je n'ai pas bien compris. Peux-tu me réexpliquer en quelques mots ?"); | |
| return; | |
| } | |
| if (this.isFuzzyMatch(normalizedText, 'SEED')) { | |
| // Reply immediately so the webhook doesn't time out | |
| console.log(`[SEED] Triggered by user ${user.id}`); | |
| try { | |
| // @ts-ignore - dynamic import of sub-module | |
| const { seedDatabase } = await import('@repo/database/seed'); | |
| const result = await seedDatabase(prisma); | |
| console.log('[SEED] Result:', result.message); | |
| // 🚨 COGNITIVE CACHE CLEAR: Delete old BusinessProfile contexts to prevent agricultural hallucinations | |
| try { | |
| await (prisma as any).businessProfile.deleteMany({ where: { userId: user.id } }); | |
| await prisma.user.update({ where: { id: user.id }, data: { activity: null } }); | |
| console.log(`[SEED] Cleared cognitive cache for User ${user.id}`); | |
| } catch (cacheErr: unknown) { | |
| console.error('[SEED] Failed to clear cognitive cache:', (cacheErr as Error).message); | |
| } | |
| await scheduleMessage(user.id, result.seeded | |
| ? "✅ Seeding terminé ! Le Cache Cognitif a été réinitialisé.\nEnvoie INSCRIPTION pour commencer." | |
| : "ℹ️ Les données existent déjà. Cache Cognitif purgé. Envoie INSCRIPTION." | |
| ); | |
| } catch (err: unknown) { | |
| console.error('[SEED] Error:', (err instanceof Error ? (err instanceof Error ? err.message : String(err)) : String(err))); | |
| await scheduleMessage(user.id, `❌ Erreur seed : ${(err instanceof Error ? (err instanceof Error ? err.message : String(err)) : String(err))?.substring(0, 200)}`); | |
| } | |
| return; | |
| } | |
| // ─── Interactive LIST action IDs ────────────────────────────────────── | |
| // Format: DAY{N}_EXERCISE | DAY{N}_REPLAY | DAY{N}_CONTINUE | DAY{N}_PROMPT | |
| const dayActionMatch = normalizedText.match(/^DAY(\d+)_(EXERCISE|REPLAY|CONTINUE|PROMPT)$/); | |
| if (dayActionMatch) { | |
| const action = dayActionMatch[2]; | |
| if (action === 'REPLAY') { | |
| const replayDay = parseFloat(dayActionMatch[1]); // DAY11_REPLAY → 11 | |
| const enrollment = await prisma.enrollment.findFirst({ | |
| where: { userId: user.id, status: 'ACTIVE' } | |
| }); | |
| if (enrollment) { | |
| // 🕰️ TIME-TRAVEL: Persist the replay context in Redis (30 min TTL) | |
| await setTimeTravelContext(user.id, replayDay); | |
| // ✅ UX: Confirmation FIRST, content delayed — message order guaranteed | |
| await scheduleMessage(user.id, user.language === 'WOLOF' | |
| ? `🔁 Dinanu la yëgël lexon Bés ${Math.floor(replayDay)} ci kanam...` | |
| : `🔁 Je te renvoie la Leçon ${Math.floor(replayDay)} dans quelques secondes...` | |
| ); | |
| await whatsappQueue.add('send-content', { | |
| userId: user.id, | |
| trackId: enrollment.trackId, | |
| dayNumber: replayDay, | |
| skipProgressUpdate: true | |
| }, { delay: 2000 }); | |
| } | |
| return; | |
| } else if (action === 'EXERCISE') { | |
| await scheduleMessage(user.id, user.language === 'WOLOF' | |
| ? "🎙️ Yónnee sa tontu (audio walla bind) :" | |
| : "🎙️ Envoie ta réponse (audio ou texte) :" | |
| ); | |
| return; | |
| } else if (action === 'PROMPT') { | |
| const enrollment = await prisma.enrollment.findFirst({ | |
| where: { userId: user.id, status: 'ACTIVE' } | |
| }); | |
| if (enrollment) { | |
| const trackDay = await prisma.trackDay.findFirst({ | |
| where: { trackId: enrollment.trackId, dayNumber: enrollment.currentDay } | |
| }); | |
| if (trackDay?.exercisePrompt) { | |
| await scheduleMessage(user.id, trackDay.exercisePrompt); | |
| } else { | |
| await scheduleMessage(user.id, user.language === 'WOLOF' ? "Amul lëjj" : "Pas d'exercice pour ce jour"); | |
| } | |
| } | |
| return; | |
| } else if (action === 'CONTINUE') { | |
| // Determine if there is a pending exercise before advancing | |
| const pendingProgress = await prisma.userProgress.findFirst({ | |
| where: { userId: user.id, exerciseStatus: { in: ['PENDING', 'PENDING_REMEDIATION'] } } | |
| }); | |
| if (pendingProgress) { | |
| await scheduleMessage(user.id, user.language === 'WOLOF' | |
| ? "Danga wara tontu lëjj bi balaa nga dem ci kanam. Tànnal 'Yónni tontu'." | |
| : "Tu dois d'abord répondre à l'exercice avant de continuer. Choisis 'Faire l'exercice' ou 'Répondre'." | |
| ); | |
| } else { | |
| // Safe to advance (either completed or dropped or already handled) | |
| await scheduleMessage(user.id, user.language === 'WOLOF' | |
| ? "Waaw, ñuy dem ci kanam !" | |
| : "C'est noté, on avance !" | |
| ); | |
| // To do: if advance needs to trigger scheduleTrackDay directly, it could be done here instead of tracking. | |
| // However, normally `SUITE` moves the day forward. | |
| } | |
| return; | |
| } | |
| } | |
| // 1.7. Language Selection (Interactive Buttons) | |
| if (normalizedText === 'LANG_FR' || normalizedText === 'LANG_WO') { | |
| const newLang = normalizedText === 'LANG_FR' ? 'FR' : 'WOLOF'; | |
| user = await prisma.user.update({ | |
| where: { id: user.id }, | |
| data: { language: newLang } | |
| }); | |
| const promptText = newLang === 'FR' | |
| ? "Parfait, nous allons continuer en Français ! 🇫🇷\nDans quel domaine d'activité te trouves-tu ?" | |
| : "Baax na, dinanu wéy ci Wolof ! 🇸🇳\nCi ban mbir ngay yëngu ?"; | |
| await scheduleInteractiveList( | |
| user.id, | |
| newLang === 'FR' ? "Ton secteur" : "Sa Mbir", | |
| promptText, | |
| newLang === 'FR' ? "Secteurs" : "Tànn", | |
| [{ | |
| title: newLang === 'FR' ? 'Liste' : 'Mbir', | |
| rows: [ | |
| { id: 'SEC_COMMERCE', title: newLang === 'FR' ? 'Commerce / Vente' : 'Njaay' }, | |
| { id: 'SEC_AGRI', title: newLang === 'FR' ? 'Agri / Élevage' : 'Mbay / Samm' }, | |
| { id: 'SEC_FOOD', title: newLang === 'FR' ? 'Alimentation / Rest.' : 'Lekk / Restauration' }, | |
| { id: 'SEC_COUTURE', title: newLang === 'FR' ? 'Couture / Mode' : 'Couture' }, | |
| { id: 'SEC_BEAUTE', title: newLang === 'FR' ? 'Beauté / Bien-être' : 'Rafet' }, | |
| { id: 'SEC_TRANSPORT', title: newLang === 'FR' ? 'Transport / Livr.' : 'Transport / Yëgël' }, | |
| { id: 'SEC_TECH', title: newLang === 'FR' ? 'Tech / Digital' : 'Tech / Digital' }, | |
| { id: 'SEC_AUTRE', title: newLang === 'FR' ? 'Autre secteur' : 'Beneen mbir' } | |
| ] | |
| }] | |
| ); | |
| return; | |
| } | |
| // 2. Check Pending Exercise (User Progress) | |
| // 2. Resolve sector LIST reply IDs → human-readable label | |
| const SECTOR_LABELS: Record<string, { fr: string; wo: string }> = { | |
| SEC_COMMERCE: { fr: 'Commerce / Vente', wo: 'Njaay' }, | |
| SEC_AGRI: { fr: 'Agriculture / Élevage', wo: 'Mbay' }, | |
| SEC_FOOD: { fr: 'Alimentation / Restauration', wo: 'Lekk / Restauration' }, | |
| SEC_TECH: { fr: 'Tech / Digital', wo: 'Tech / Digital' }, | |
| SEC_BEAUTE: { fr: 'Beauté / Bien-être', wo: 'Rafet' }, | |
| SEC_COUTURE: { fr: 'Couture / Mode', wo: 'Couture' }, | |
| SEC_TRANSPORT: { fr: 'Transport / Livraison', wo: 'Transport / Yëgël' }, | |
| }; | |
| if (normalizedText === 'SEC_AUTRE') { | |
| await scheduleMessage(user.id, user.language === 'WOLOF' | |
| ? 'Waaw ! Wax ma ban mbir ngay def ci ab kàddu gatt :' | |
| : 'Parfait ! Décris ton activité en quelques mots :' | |
| ); | |
| return; | |
| } | |
| const sectorLabel = SECTOR_LABELS[normalizedText]; | |
| // 🚨 Brique 1 (Immuabilité) : Vérifier si l'utilisateur est déjà inscrit. | |
| const existingEnrollment = await prisma.enrollment.findFirst({ | |
| where: { userId: user.id, status: 'ACTIVE' } | |
| }); | |
| if (existingEnrollment && (sectorLabel || normalizedText.startsWith('SEC_'))) { | |
| console.log(`[IMMUTABILITY] User ${user.id} tried to change sector but is already enrolled.`); | |
| return; // Ignore and do not allow re-routing here | |
| } | |
| if (!existingEnrollment && (sectorLabel || (!user.activity && text.length > 2))) { | |
| const activity = sectorLabel | |
| ? (user.language === 'WOLOF' ? sectorLabel.wo : sectorLabel.fr) | |
| : text.trim(); | |
| user = await prisma.user.update({ | |
| where: { id: user.id }, | |
| data: { activity } | |
| }); | |
| const welcomeMsg = user.language === 'FR' | |
| ? `Parfait ! Secteur noté : *${activity}*.\nJe t'inscris à ta formation personnalisée !` | |
| : `Baax na ! Bind nanu la ci: *${activity}*.\nLéegi dinanu la dugal ci njàng mi !`; | |
| await scheduleMessage(user.id, welcomeMsg); | |
| const trackId = user.language === 'FR' ? "T1-FR" : "T1-WO"; | |
| const defaultTrack = await prisma.track.findUnique({ where: { id: trackId } }); | |
| if (defaultTrack) await enrollUser(user.id, defaultTrack.id); | |
| return; | |
| } | |
| // 3. Check Active Enrollment (Commands Priority) | |
| const activeEnrollment = await prisma.enrollment.findFirst({ | |
| where: { userId: user.id, status: 'ACTIVE' }, | |
| include: { track: true } | |
| }); | |
| if (activeEnrollment) { | |
| const intent = this.detectIntent(text); | |
| const isSuite = this.isFuzzyMatch(normalizedText, 'SUITE') || normalizedText === '2'; | |
| const isApprofondir = this.isFuzzyMatch(normalizedText, 'APPROFONDIR') || normalizedText === '1'; | |
| // Handle SUITE Priority | |
| if (isSuite) { | |
| // 🚨 COUPE-CIRCUIT #2: Kill Time-Travel context BEFORE any progression logic | |
| await clearTimeTravelContext(user.id); | |
| const userProgress = await prisma.userProgress.findUnique({ | |
| where: { userId_trackId: { userId: user.id, trackId: activeEnrollment.trackId } } | |
| }); | |
| // 🚨 UNBLOCKING GUARD: Allow SUITE if a response has been recorded for the current day OR if status is valid. | |
| const lastResponse = await prisma.response.findFirst({ | |
| where: { userId: user.id, dayNumber: activeEnrollment.currentDay }, | |
| orderBy: { createdAt: 'desc' } | |
| }); | |
| if (userProgress?.exerciseStatus !== 'COMPLETED' && userProgress?.exerciseStatus !== 'PENDING_DEEPDIVE' && !lastResponse) { | |
| console.log(`[SUITE-BLOCKED] User ${user.id} tried SUITE but status is ${userProgress?.exerciseStatus || 'null'} and no response found.`); | |
| await scheduleMessage(user.id, user.language === 'WOLOF' | |
| ? "Dafa laaj nga tontu laaj bi ci kaw dëbb (audio walla texte) balaa nga dem ci kanam ! 🎙️" | |
| : "Tu dois d'abord répondre à l'exercice ci-dessus pour continuer ! 🎙️" | |
| ); | |
| return; | |
| } | |
| console.log(`[SUITE-ALLOWED] User ${user.id} advancing from day ${activeEnrollment.currentDay}`); | |
| const nextDay = activeEnrollment.currentDay % 1 !== 0 | |
| ? Math.floor(activeEnrollment.currentDay) + 1 | |
| : activeEnrollment.currentDay + 1; | |
| await prisma.enrollment.update({ where: { id: activeEnrollment.id }, data: { currentDay: nextDay } }); | |
| await prisma.userProgress.update({ | |
| where: { userId_trackId: { userId: user.id, trackId: activeEnrollment.trackId } }, | |
| data: { | |
| exerciseStatus: 'PENDING', | |
| iterationCount: 0 // Reset iteration count for the new day | |
| } | |
| }); | |
| await whatsappQueue.add('send-content', { userId: user.id, trackId: activeEnrollment.trackId, dayNumber: nextDay }); | |
| return; | |
| } | |
| // Handle APPROFONDIR (Deep Dive Initiation) | |
| if (isApprofondir) { | |
| const userProgress = await prisma.userProgress.findUnique({ | |
| where: { userId_trackId: { userId: user.id, trackId: activeEnrollment.trackId } } | |
| }); | |
| // 🚨 UNBLOCKING GUARD: Allow 1/APPROFONDIR if a response exists. | |
| const lastResponse = await prisma.response.findFirst({ | |
| where: { userId: user.id, dayNumber: activeEnrollment.currentDay }, | |
| orderBy: { createdAt: 'desc' } | |
| }); | |
| if (userProgress?.exerciseStatus === 'COMPLETED' || (userProgress?.exerciseStatus === 'PENDING' && lastResponse)) { | |
| // Force state transition if it was stuck | |
| if (userProgress?.exerciseStatus === 'PENDING') { | |
| await prisma.userProgress.update({ | |
| where: { id: userProgress.id }, | |
| data: { exerciseStatus: 'PENDING_DEEPDIVE' } | |
| }); | |
| } else { | |
| await prisma.userProgress.update({ | |
| where: { id: userProgress.id }, | |
| data: { exerciseStatus: 'PENDING_DEEPDIVE' } | |
| }); | |
| } | |
| await scheduleMessage(user.id, user.language === 'WOLOF' | |
| ? "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 :" | |
| : "Très bien ! Quelle information précise issue de ton terrain veux-tu ajouter ? (ex: un prix précis, un obstacle, un fournisseur, etc.) :" | |
| ); | |
| return; | |
| } else { | |
| await scheduleMessage(user.id, user.language === 'WOLOF' | |
| ? "Dafa laaj nga tontu laaj bi ci kaw dëbb balaa nga natt nga tontu !" | |
| : "Réponds d'abord à l'exercice principal avant d'approfondir !" | |
| ); | |
| return; | |
| } | |
| } | |
| // Handle YES/NO Intents | |
| if (intent === 'YES' && normalizedText.length < 15) { | |
| const userProgress = await prisma.userProgress.findUnique({ | |
| where: { userId_trackId: { userId: user.id, trackId: activeEnrollment.trackId } } | |
| }); | |
| if (userProgress?.exerciseStatus === 'COMPLETED') { | |
| await scheduleMessage(user.id, user.language === 'WOLOF' ? "Waaw ! Lexon bi mu ngi ñëw..." : "C'est parti ! Voici la suite..."); | |
| await whatsappQueue.add('send-content', { userId: user.id, trackId: activeEnrollment.trackId, dayNumber: activeEnrollment.currentDay + 1 }); | |
| return; | |
| } | |
| } | |
| if (intent === 'NO' && normalizedText.length < 15) { | |
| 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."); | |
| return; | |
| } | |
| // Fallback to Exercise Response if nothing else matched | |
| // 🚨 COACHING GUARDRAIL: AI Coach only activated if profile (sector + language) is complete | |
| if (!user.activity) { | |
| await scheduleMessage(user.id, user.language === 'WOLOF' | |
| ? "Danga wara tànn sa mbiru liggeey balaa ñuy tàmbali coaching bi." | |
| : "Tu dois d'abord définir ton activité avant que le coach AI ne puisse t'aider."); | |
| return; | |
| } | |
| // 🕰️ TIME-TRAVEL: Compute effectiveDay — single source of truth for AI prompt + Prisma saves | |
| const timeTravelDay = await getTimeTravelContext(user.id); | |
| const effectiveDay = timeTravelDay ?? activeEnrollment.currentDay; | |
| const isTimeTravelMode = timeTravelDay !== null && timeTravelDay !== activeEnrollment.currentDay; | |
| if (isTimeTravelMode) { | |
| console.log(`[TIME-TRAVEL] 🕰️ User ${user.id} responding to replay Day ${effectiveDay} (real currentDay: ${activeEnrollment.currentDay})`); | |
| } | |
| // 🚨 TEXT RE-VALIDATION: Mirror the Worker-side `shouldForceRevalidation` logic. | |
| // Image/Audio flows reset PENDING via WhatsAppLogic. For text, we do it here. | |
| const userProgressState = await prisma.userProgress.findUnique({ | |
| where: { userId_trackId: { userId: user.id, trackId: activeEnrollment.trackId } } | |
| }); | |
| const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000); | |
| const isRecentlyCompleted = userProgressState?.exerciseStatus === 'COMPLETED' | |
| && userProgressState.updatedAt > tenMinutesAgo; | |
| if (isRecentlyCompleted && !audioUrl && !imageUrl && text.length > 5 && !isSystemCommand) { | |
| console.log(`[TXT-FLOW] 🔄 Re-validation User ${user.id} Day ${activeEnrollment.currentDay} (COMPLETED → PENDING)`); | |
| await prisma.userProgress.update({ | |
| where: { id: userProgressState!.id }, | |
| data: { exerciseStatus: 'PENDING' } | |
| }); | |
| } | |
| const pendingProgress = await prisma.userProgress.findFirst({ | |
| where: { userId: user.id, exerciseStatus: { in: ['PENDING', 'PENDING_REMEDIATION', 'PENDING_DEEPDIVE'] }, trackId: activeEnrollment.trackId }, | |
| }); | |
| if (pendingProgress) { | |
| const isDeepDiveAction = pendingProgress.exerciseStatus === 'PENDING_DEEPDIVE'; | |
| const trackDay = await prisma.trackDay.findFirst({ | |
| where: { trackId: activeEnrollment.trackId, dayNumber: effectiveDay } | |
| }); | |
| if (trackDay) { | |
| // 🚨 Flexible Guardrail (Day 7 Fix) | |
| const wordCount = (text || '').trim().split(/\s+/).length; | |
| const validationKeyword = trackDay.validationKeyword || ''; | |
| const isOptionMatch = validationKeyword && this.isFuzzyMatch(normalizedText, validationKeyword); | |
| // Specific bypass for known short answers in modules (WhatsApp, Boutique, etc.) | |
| const commonOptions = ['WHATSAPP', 'BOUTIQUE', 'APPEL', 'VENTE', 'SERVICE', 'PRODUCTION']; | |
| const isCommonOption = commonOptions.some(opt => this.isFuzzyMatch(normalizedText, opt)); | |
| if (wordCount < 3 && !isOptionMatch && !isCommonOption) { | |
| await scheduleMessage(user.id, user.language === 'WOLOF' | |
| ? "Dama lay xaar nga wax ma lu gën a yaatu ci sa mbir (mbebetu 3 baat). Waxtaanal ak man !" | |
| : "Ta réponse est un peu courte. Peux-tu m'en dire plus ? (Minimum 3 mots)"); | |
| return; | |
| } | |
| await scheduleMessage(user.id, user.language === 'WOLOF' ? "⏳ Defar ak sa tontu..." : "⏳ Analyse de votre réponse..."); | |
| // Update iteration count if it's a deep dive | |
| let currentIterationCount = pendingProgress.iterationCount || 0; | |
| if (isDeepDiveAction) { | |
| currentIterationCount += 1; | |
| await prisma.userProgress.update({ | |
| where: { id: pendingProgress.id }, | |
| data: { iterationCount: currentIterationCount } // Save the increment | |
| }); | |
| } | |
| // 🚨 Store response under effectiveDay (real day OR Time-Travel override day) | |
| await prisma.response.create({ | |
| data: { | |
| enrollmentId: activeEnrollment.id, | |
| userId: user.id, | |
| dayNumber: effectiveDay, | |
| content: text | |
| } | |
| }); | |
| // Fetch previous responses to provide context to the AI Coach | |
| const previousResponsesData = await prisma.response.findMany({ | |
| where: { userId: user.id, enrollmentId: activeEnrollment.id }, | |
| orderBy: { dayNumber: 'asc' }, | |
| take: 5 // Keep context size reasonable | |
| }); | |
| const previousResponses = previousResponsesData.map(r => ({ day: r.dayNumber, response: r.content })); | |
| await whatsappQueue.add('generate-feedback', { | |
| userId: user.id, text, trackId: activeEnrollment.trackId, trackDayId: trackDay.id, | |
| exercisePrompt: trackDay.exercisePrompt || '', lessonText: trackDay.lessonText || '', | |
| exerciseCriteria: trackDay.exerciseCriteria, pendingProgressId: pendingProgress.id, | |
| dayNumber: effectiveDay, // ← effectiveDay: single source of truth | |
| currentDay: effectiveDay, // ← worker reads job.data.currentDay | |
| totalDays: activeEnrollment.track.duration, language: user.language, | |
| userActivity: user.activity, | |
| userRegion: user.city, | |
| previousResponses, | |
| isDeepDive: isDeepDiveAction, | |
| iterationCount: currentIterationCount, | |
| imageUrl: imageUrl, | |
| isTimeTravelMode, // ← Worker uses this to skip COMPLETED update | |
| realCurrentDay: activeEnrollment.currentDay // ← For logging only | |
| }, { attempts: 3, backoff: { type: 'exponential', delay: 2000 } }); | |
| return; | |
| } | |
| } | |
| // Handle daily response (Fallback if no PENDING found earlier) | |
| console.log(`${traceId} User ${user.id} fallback daily response to effectiveDay ${effectiveDay}`); | |
| await prisma.response.create({ | |
| data: { | |
| enrollmentId: activeEnrollment.id, | |
| userId: user.id, | |
| dayNumber: effectiveDay, | |
| content: text | |
| } | |
| }); | |
| const trackDayFallback = await prisma.trackDay.findFirst({ | |
| where: { trackId: activeEnrollment.trackId, dayNumber: effectiveDay } | |
| }); | |
| if (trackDayFallback) { | |
| // 🚨 Guardrail: Contenu Vide / Gibberish 🚨 | |
| const wordCount = text.trim().split(/\s+/).length; | |
| if (wordCount < 3 || text.length < 5) { | |
| console.log(`${traceId} Guardrail: Input too short or potential gibberish: "${text}"`); | |
| await scheduleMessage(user.id, user.language === 'WOLOF' | |
| ? "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) ?" | |
| : "Je n'ai pas bien compris ton activité. Peux-tu me réexpliquer en quelques mots ce que tu vends et à qui ?"); | |
| return; | |
| } | |
| // 🚨 Guardrail: Enrollment Priority 🚨 | |
| if (!user.activity || !user.language) { | |
| console.log(`${traceId} Blocking AI feedback: Enrollment incomplete for User ${user.id}`); | |
| await scheduleMessage(user.id, user.language === 'WOLOF' | |
| ? "Baax na, waaye laaj bi des na... Bindal 'INSCRIPTION' ngir tàmbali." | |
| : "C'est noté, mais il faut d'abord terminer ton inscription. Envoie 'INSCRIPTION' pour commencer."); | |
| return; | |
| } | |
| // Fetch previous responses to provide context to the AI Coach | |
| const previousResponsesData = await prisma.response.findMany({ | |
| where: { userId: user.id, enrollmentId: activeEnrollment.id }, | |
| orderBy: { dayNumber: 'asc' }, | |
| take: 5 | |
| }); | |
| const previousResponses = previousResponsesData.map(r => ({ day: r.dayNumber, response: r.content })); | |
| await whatsappQueue.add('generate-feedback', { | |
| userId: user.id, text, trackId: activeEnrollment.trackId, trackDayId: trackDayFallback.id, | |
| exercisePrompt: trackDayFallback.exercisePrompt || '', lessonText: trackDayFallback.lessonText || '', | |
| exerciseCriteria: trackDayFallback.exerciseCriteria, | |
| enrollmentId: activeEnrollment.id, | |
| currentDay: effectiveDay, // ← effectiveDay: single source of truth | |
| dayNumber: effectiveDay, | |
| totalDays: activeEnrollment.track.duration, language: user.language, | |
| userActivity: user.activity, | |
| userRegion: user.city, | |
| previousResponses, | |
| imageUrl: imageUrl, | |
| isTimeTravelMode, | |
| realCurrentDay: activeEnrollment.currentDay | |
| }); | |
| return; | |
| } | |
| await scheduleMessage(user.id, user.language === 'WOLOF' | |
| ? "Baax na ! Yónnee *SUITE* ngir dem ci kanam walla tontul laaj bi ci kaw." | |
| : "✅ Message reçu ! Envoie *SUITE* pour avancer ou réponds à l'exercice ci-dessus." | |
| ); | |
| return; | |
| } | |
| // 4. Default: fallback for generic unknown messages (not in onboarding, not in active enrollment) | |
| console.log(`${traceId} Unknown command from user ${user.id}: "${normalizedText}"`); | |
| await scheduleMessage(user.id, user.language === 'WOLOF' | |
| ? "Bañ ma dégg. Yónnee *INSCRIPTION* ngir tàmbalee ci kanam walla bind *SUITE*." | |
| : "Je n'ai pas compris. Envoie *INSCRIPTION* pour recommencer ou *SUITE* pour avancer." | |
| ); | |
| } | |
| } | |