CognxSafeTrack commited on
Commit
fb4b8be
·
1 Parent(s): f4e46e8

Refactor: Modularize WhatsAppLogic into specialized Handlers (Command Pattern)

Browse files
apps/whatsapp-worker/src/handlers/CommandHandler.ts ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { MessageContext, MessageHandler } from './types';
2
+ import { isFuzzyMatch } from '../services/utils';
3
+ import { PrismaClient } from '@repo/database';
4
+
5
+ const prisma = new PrismaClient();
6
+
7
+ export class CommandHandler implements MessageHandler {
8
+ async canHandle(ctx: MessageContext): Promise<boolean> {
9
+ const { normalizedText } = ctx;
10
+
11
+ if (isFuzzyMatch(normalizedText, 'SEED')) return true;
12
+
13
+ const dayActionMatch = normalizedText.match(/^DAY(\d+)_(EXERCISE|REPLAY|CONTINUE|PROMPT)$/);
14
+ if (dayActionMatch) return true;
15
+
16
+ return false;
17
+ }
18
+
19
+ async handle(ctx: MessageContext): Promise<boolean> {
20
+ const { user, normalizedText, whatsappQueue } = ctx;
21
+ if (!user) return false;
22
+
23
+ // --- 1. SEED ---
24
+ if (isFuzzyMatch(normalizedText, 'SEED')) {
25
+ try {
26
+ // @ts-ignore
27
+ const { seedDatabase } = await import('@repo/database/seed');
28
+ const result = await seedDatabase(prisma);
29
+ await (prisma as any).businessProfile.deleteMany({ where: { userId: user.id } });
30
+ await prisma.user.update({ where: { id: user.id }, data: { activity: null } });
31
+
32
+ const msg = result.seeded
33
+ ? "✅ Seeding terminé ! Le Cache Cognitif a été réinitialisé.\nEnvoie INSCRIPTION pour commencer."
34
+ : "ℹ️ Les données existent déjà. Cache Cognitif purgé. Envoie INSCRIPTION.";
35
+ await whatsappQueue.add('send-message', { userId: user.id, text: msg });
36
+ } catch (err) {
37
+ await whatsappQueue.add('send-message', { userId: user.id, text: `❌ Erreur seed` });
38
+ }
39
+ return true;
40
+ }
41
+
42
+ // --- 2. DAY ACTIONS (List menus) ---
43
+ const dayActionMatch = normalizedText.match(/^DAY(\d+)_(EXERCISE|REPLAY|CONTINUE|PROMPT)$/);
44
+ if (dayActionMatch) {
45
+ const action = dayActionMatch[2];
46
+ const enrollment = await prisma.enrollment.findFirst({ where: { userId: user.id, status: 'ACTIVE' } });
47
+
48
+ if (action === 'REPLAY' && enrollment) {
49
+ await whatsappQueue.add('send-content', { userId: user.id, trackId: enrollment.trackId, dayNumber: enrollment.currentDay });
50
+ return true;
51
+ } else if (action === 'EXERCISE') {
52
+ const msg = user.language === 'WOLOF' ? "🎙️ Yónnee sa tontu :" : "🎙️ Envoie ta réponse :";
53
+ await whatsappQueue.add('send-message', { userId: user.id, text: msg });
54
+ return true;
55
+ }
56
+ }
57
+
58
+ return false;
59
+ }
60
+ }
apps/whatsapp-worker/src/handlers/ExerciseHandler.ts ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { MessageContext, MessageHandler } from './types';
2
+ import { isFuzzyMatch } from '../services/utils';
3
+ import { PrismaClient } from '@repo/database';
4
+ import { logger } from '../logger';
5
+ import { getTimeTravelContext } from '../timeTravelContext';
6
+
7
+ const prisma = new PrismaClient();
8
+
9
+ export class ExerciseHandler implements MessageHandler {
10
+ async canHandle(ctx: MessageContext): Promise<boolean> {
11
+ const { activeEnrollment } = ctx;
12
+ if (!activeEnrollment) return false;
13
+
14
+ // Skip if it's a short message and NOT a system command
15
+ // Note: The logic in whatsapp-logic.ts line 152 handles this as a block.
16
+ // We'll handle it inside ExerciseHandler or in the central router.
17
+
18
+ return true;
19
+ }
20
+
21
+ async handle(ctx: MessageContext): Promise<boolean> {
22
+ const { user, normalizedText, text, activeEnrollment, whatsappQueue, redis, traceId, imageUrl } = ctx;
23
+ if (!user || !activeEnrollment) return false;
24
+
25
+ // --- 1. Identify pedagogical state ---
26
+ const userProgress = await (prisma as any).userProgress.findUnique({
27
+ where: { userId_trackId: { userId: user.id, trackId: activeEnrollment.trackId } }
28
+ });
29
+
30
+ const timeTravelDay = await getTimeTravelContext(user.id, redis as any);
31
+ const effectiveDay = timeTravelDay ?? activeEnrollment.currentDay;
32
+ const isTimeTravelMode = timeTravelDay !== null && timeTravelDay !== activeEnrollment.currentDay;
33
+
34
+ // --- 2. Re-validation & Correction Logic ---
35
+ const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000);
36
+ const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000);
37
+ const isRecentlyCompleted = userProgress?.exerciseStatus === 'COMPLETED' && userProgress.updatedAt > fiveMinutesAgo;
38
+ const isImageForActiveExercise = !!imageUrl && (
39
+ userProgress?.exerciseStatus === 'PENDING' ||
40
+ userProgress?.exerciseStatus === 'PENDING_DEEPDIVE' ||
41
+ userProgress?.exerciseStatus === 'PENDING_REMEDIATION' ||
42
+ (userProgress?.exerciseStatus === 'COMPLETED' && userProgress.updatedAt > tenMinutesAgo)
43
+ );
44
+
45
+ const shouldForceRevalidation = isImageForActiveExercise || isRecentlyCompleted;
46
+
47
+ if (shouldForceRevalidation && userProgress?.exerciseStatus === 'COMPLETED') {
48
+ logger.info(`${traceId} 🔄 Re-validation triggered for User ${user.id} (Reason: ${imageUrl ? 'New Image' : 'Recent Correction'})`);
49
+ await (prisma as any).userProgress.update({
50
+ where: { id: userProgress.id },
51
+ data: { exerciseStatus: 'PENDING' }
52
+ });
53
+ }
54
+
55
+ // --- 3. Process Response ---
56
+ const pendingProgress = await (prisma as any).userProgress.findFirst({
57
+ where: {
58
+ userId: user.id,
59
+ exerciseStatus: { in: ['PENDING', 'PENDING_REMEDIATION', 'PENDING_DEEPDIVE'] },
60
+ trackId: activeEnrollment.trackId
61
+ },
62
+ });
63
+
64
+ if (!pendingProgress) {
65
+ // If completed and no revalidation, send navigation guidance
66
+ if (userProgress?.exerciseStatus === 'COMPLETED') {
67
+ const reminder = user.language === 'WOLOF'
68
+ ? "Mat nga bés bi ba pare ! ✨\nBindal *2* wala *SUITE* ngir dem ci bés bi ci kanam."
69
+ : "Tu as déjà validé cette étape ! ✨\nEnvoie *2* ou *SUITE* pour passer à la suite.";
70
+ await whatsappQueue.add('send-message', { userId: user.id, text: reminder });
71
+ return true;
72
+ }
73
+ return false;
74
+ }
75
+
76
+ const trackDay = await prisma.trackDay.findFirst({
77
+ where: { trackId: activeEnrollment.trackId, dayNumber: effectiveDay }
78
+ });
79
+
80
+ if (!trackDay) {
81
+ logger.warn(`${traceId} ⚠️ No TrackDay found for Day ${effectiveDay}.`);
82
+ return false;
83
+ }
84
+
85
+ const isDeepDiveAction = pendingProgress.exerciseStatus === 'PENDING_DEEPDIVE';
86
+ const wordCount = (text || '').trim().split(/\s+/).length;
87
+
88
+ // Bypasses (Button, Special, Vision)
89
+ let isButtonChoice = false;
90
+ const buttons = trackDay.buttonsJson as any[];
91
+ if (Array.isArray(buttons)) {
92
+ isButtonChoice = buttons.some(b => isFuzzyMatch(normalizedText, b.title || '') || isFuzzyMatch(normalizedText, b.id || ''));
93
+ }
94
+
95
+ const isDay7Special = activeEnrollment.currentDay === 7 && (
96
+ ['whatsapp', 'boutique', 'digital', 'physique', 'tel', 'e-commerce'].includes(normalizedText)
97
+ );
98
+
99
+ const isVisionDay = !!imageUrl;
100
+ const shouldBypassGuardrail = isButtonChoice || isDay7Special || isVisionDay;
101
+ const minWordCount = shouldBypassGuardrail ? 1 : 3;
102
+
103
+ if (wordCount < minWordCount) {
104
+ const msg = user.language === 'WOLOF' ? "Tontu bi gatt na..." : "Ta réponse est un peu courte.";
105
+ await whatsappQueue.add('send-message', { userId: user.id, text: msg });
106
+ return true;
107
+ }
108
+
109
+ await whatsappQueue.add('send-message', { userId: user.id, text: user.language === 'WOLOF' ? "⏳ Defar ak sa tontu..." : "⏳ Analyse de votre réponse..." });
110
+
111
+ let currentIterationCount = pendingProgress.iterationCount || 0;
112
+ if (isDeepDiveAction) {
113
+ currentIterationCount += 1;
114
+ await (prisma as any).userProgress.update({ where: { id: pendingProgress.id }, data: { iterationCount: currentIterationCount } });
115
+ }
116
+
117
+ await prisma.response.create({ data: { enrollmentId: activeEnrollment.id, userId: user.id, dayNumber: 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 }));
121
+
122
+ logger.info(`${traceId} 🚀 Enqueuing generate-feedback (Day: ${effectiveDay}, TT: ${isTimeTravelMode})`);
123
+
124
+ await whatsappQueue.add('generate-feedback', {
125
+ userId: user.id, text, trackId: activeEnrollment.trackId, trackDayId: trackDay.id,
126
+ enrollmentId: activeEnrollment.id,
127
+ exercisePrompt: trackDay.exercisePrompt || '', lessonText: trackDay.lessonText || '',
128
+ exerciseCriteria: trackDay.exerciseCriteria, pendingProgressId: pendingProgress.id,
129
+ currentDay: effectiveDay,
130
+ totalDays: activeEnrollment.track.duration, language: user.language,
131
+ userActivity: user.activity, userRegion: user.city, previousResponses,
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;
139
+ }
140
+ }
apps/whatsapp-worker/src/handlers/NavigationHandler.ts ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { MessageContext, MessageHandler } from './types';
2
+ import { isFuzzyMatch } from '../services/utils';
3
+ import { PrismaClient } from '@repo/database';
4
+ import { logger } from '../logger';
5
+ import { clearTimeTravelContext, getTimeTravelContext } from '../timeTravelContext';
6
+
7
+ const prisma = new PrismaClient();
8
+
9
+ export class NavigationHandler implements MessageHandler {
10
+ async canHandle(ctx: MessageContext): Promise<boolean> {
11
+ const { normalizedText, activeEnrollment } = ctx;
12
+ if (!activeEnrollment) return false;
13
+
14
+ const isSuite = isFuzzyMatch(normalizedText, 'SUITE') || normalizedText === '2';
15
+ const isApprofondir = isFuzzyMatch(normalizedText, 'APPROFONDIR') || normalizedText === '1';
16
+
17
+ return isSuite || isApprofondir;
18
+ }
19
+
20
+ async handle(ctx: MessageContext): Promise<boolean> {
21
+ const { user, normalizedText, activeEnrollment, whatsappQueue, redis, traceId } = ctx;
22
+ if (!user || !activeEnrollment) return false;
23
+
24
+ const isSuite = isFuzzyMatch(normalizedText, 'SUITE') || normalizedText === '2';
25
+ const isApprofondir = isFuzzyMatch(normalizedText, 'APPROFONDIR') || normalizedText === '1';
26
+
27
+ // --- 1. SUITE (Next Day Navigation) ---
28
+ if (isSuite) {
29
+ logger.info(`${traceId} User ${user.id} requested SUITE. Clearing Time-Travel.`);
30
+ await clearTimeTravelContext(user.id, redis as any);
31
+
32
+ const userProgress = await (prisma as any).userProgress.findUnique({
33
+ where: { userId_trackId: { userId: user.id, trackId: activeEnrollment.trackId } }
34
+ });
35
+ const lastResponse = await prisma.response.findFirst({
36
+ where: { userId: user.id, dayNumber: activeEnrollment.currentDay },
37
+ orderBy: { createdAt: 'desc' }
38
+ });
39
+
40
+ if (userProgress?.exerciseStatus !== 'COMPLETED' && userProgress?.exerciseStatus !== 'PENDING_DEEPDIVE' && !lastResponse) {
41
+ const msg = user.language === 'WOLOF' ? "Dafa laaj nga tontu !" : "Tu dois d'abord répondre !";
42
+ await whatsappQueue.add('send-message', { userId: user.id, text: msg });
43
+ return true;
44
+ }
45
+
46
+ const nextDay = activeEnrollment.currentDay % 1 !== 0 ? Math.floor(activeEnrollment.currentDay) + 1 : activeEnrollment.currentDay + 1;
47
+ await prisma.enrollment.update({ where: { id: activeEnrollment.id }, data: { currentDay: nextDay } });
48
+ await (prisma as any).userProgress.update({
49
+ where: { userId_trackId: { userId: user.id, trackId: activeEnrollment.trackId } },
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
+
57
+ // --- 2. APPROFONDIR (Deep Dive Transition) ---
58
+ if (isApprofondir) {
59
+ const timeTravelDay = await getTimeTravelContext(user.id, redis as any);
60
+ const effectiveDay = timeTravelDay ?? activeEnrollment.currentDay;
61
+
62
+ const userProgress = await (prisma as any).userProgress.findUnique({
63
+ where: { userId_trackId: { userId: user.id, trackId: activeEnrollment.trackId } }
64
+ });
65
+ const lastResponse = await prisma.response.findFirst({
66
+ where: { userId: user.id, dayNumber: effectiveDay },
67
+ orderBy: { createdAt: 'desc' }
68
+ });
69
+
70
+ if (userProgress?.exerciseStatus === 'COMPLETED' || (userProgress?.exerciseStatus === 'PENDING' && lastResponse)) {
71
+ await (prisma as any).userProgress.update({
72
+ where: { id: userProgress!.id },
73
+ data: { exerciseStatus: 'PENDING_DEEPDIVE' }
74
+ });
75
+ const msg = user.language === 'WOLOF' ? "Wax ma ndox mi..." : "Très bien ! Quelle info ?";
76
+ await whatsappQueue.add('send-message', { userId: user.id, text: msg });
77
+ return true;
78
+ } else {
79
+ const msg = user.language === 'WOLOF' ? "Tontul exercice bi ba pare !" : "Réponds d'abord à l'exercice principal !";
80
+ await whatsappQueue.add('send-message', { userId: user.id, text: msg });
81
+ return true;
82
+ }
83
+ }
84
+
85
+ return false;
86
+ }
87
+ }
apps/whatsapp-worker/src/handlers/OnboardingHandler.ts ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { MessageContext, MessageHandler } from './types';
2
+ import { isFuzzyMatch } from '../services/utils';
3
+ import { PrismaClient } from '@repo/database';
4
+ import { logger } from '../logger';
5
+ import { clearTimeTravelContext } from '../timeTravelContext';
6
+
7
+ const prisma = new PrismaClient();
8
+
9
+ const SECTOR_LABELS: Record<string, { fr: string; wo: string }> = {
10
+ SEC_COMMERCE: { fr: 'Commerce / Vente', wo: 'Njaay' },
11
+ SEC_AGRI: { fr: 'Agriculture / Élevage', wo: 'Mbay' },
12
+ SEC_FOOD: { fr: 'Alimentation / Restauration', wo: 'Lekk / Restauration' },
13
+ SEC_TECH: { fr: 'Tech / Digital', wo: 'Tech / Digital' },
14
+ SEC_BEAUTE: { fr: 'Beauté / Bien-être', wo: 'Rafet' },
15
+ SEC_COUTURE: { fr: 'Couture / Mode', wo: 'Couture' },
16
+ SEC_TRANSPORT: { fr: 'Transport / Livraison', wo: 'Transport / Yëgël' },
17
+ };
18
+
19
+ export class OnboardingHandler implements MessageHandler {
20
+ async canHandle(ctx: MessageContext): Promise<boolean> {
21
+ const { normalizedText, user, activeEnrollment } = ctx;
22
+
23
+ // 1. INSCRIPTION
24
+ if (isFuzzyMatch(normalizedText, 'INSCRIPTION') || normalizedText.includes('INSCRI')) return true;
25
+
26
+ // 2. Language Selection
27
+ if (normalizedText === 'LANG_FR' || normalizedText === 'LANG_WO') return true;
28
+
29
+ // 3. Sector Selection (only if not active enrollment)
30
+ const sectorLabel = SECTOR_LABELS[normalizedText];
31
+ if (!activeEnrollment && (sectorLabel || normalizedText.startsWith('SEC_'))) return true;
32
+
33
+ // 4. Activity detection (only if user exists but has no activity and isn't enrolled)
34
+ if (user && !user.activity && !activeEnrollment && ctx.text.length > 2) return true;
35
+
36
+ return false;
37
+ }
38
+
39
+ async handle(ctx: MessageContext): Promise<boolean> {
40
+ const { phone, normalizedText, text, whatsappQueue, redis, traceId } = ctx;
41
+ let user = ctx.user;
42
+
43
+ // --- 1. INSCRIPTION (Reset or First Time) ---
44
+ if (isFuzzyMatch(normalizedText, 'INSCRIPTION') || normalizedText.includes('INSCRI')) {
45
+ if (user) {
46
+ logger.info(`${traceId} Hard Reset for User ${user.id}`);
47
+ await clearTimeTravelContext(user.id, redis as any);
48
+ await (prisma as any).userBadge.deleteMany({ where: { userProgress: { userId: user.id } } });
49
+ await (prisma as any).teamMember.deleteMany({ where: { businessProfile: { userId: user.id } } });
50
+ await prisma.enrollment.deleteMany({ where: { userId: user.id } });
51
+ await prisma.userProgress.deleteMany({ where: { userId: user.id } });
52
+ await prisma.response.deleteMany({ where: { userId: user.id } });
53
+ await prisma.message.deleteMany({ where: { userId: user.id } });
54
+ await (prisma as any).businessProfile.deleteMany({ where: { userId: user.id } });
55
+ user = await prisma.user.update({
56
+ where: { id: user.id },
57
+ data: { city: null, activity: null }
58
+ });
59
+ } else {
60
+ user = await prisma.user.create({ data: { phone } });
61
+ }
62
+
63
+ await whatsappQueue.add('send-interactive-buttons', {
64
+ userId: user.id,
65
+ bodyText: user.language === 'WOLOF'
66
+ ? "Réinitialisation réussie – tànnal sa làkk :"
67
+ : "Réinitialisation réussie – choisissez votre langue :",
68
+ buttons: [
69
+ { id: 'LANG_FR', title: 'Français 🇫🇷' },
70
+ { id: 'LANG_WO', title: 'Wolof 🇸🇳' }
71
+ ]
72
+ });
73
+ return true;
74
+ }
75
+
76
+ if (!user) return false;
77
+
78
+ // --- 2. Language selection ---
79
+ if (normalizedText === 'LANG_FR' || normalizedText === 'LANG_WO') {
80
+ const newLang = normalizedText === 'LANG_FR' ? 'FR' : 'WOLOF';
81
+ user = await prisma.user.update({ where: { id: user.id }, data: { language: newLang } });
82
+ const promptText = newLang === 'FR' ? "Parfait ! Dans quel domaine te trouves-tu ?" : "Baax na ! Ci ban mbir ngay yëngu ?";
83
+
84
+ await whatsappQueue.add('send-interactive-list', {
85
+ userId: user.id,
86
+ headerText: newLang === 'FR' ? "Ton secteur" : "Sa Mbir",
87
+ bodyText: promptText,
88
+ buttonLabel: newLang === 'FR' ? "Secteurs" : "Tànn",
89
+ sections: [{
90
+ title: newLang === 'FR' ? 'Liste' : 'Mbir',
91
+ rows: [
92
+ { id: 'SEC_COMMERCE', title: newLang === 'FR' ? 'Commerce / Vente' : 'Njaay' },
93
+ { id: 'SEC_AGRI', title: newLang === 'FR' ? 'Agri / Élevage' : 'Mbay / Samm' },
94
+ { id: 'SEC_FOOD', title: newLang === 'FR' ? 'Alimentation / Rest.' : 'Lekk / Restauration' },
95
+ { id: 'SEC_COUTURE', title: newLang === 'FR' ? 'Couture / Mode' : 'Couture' },
96
+ { id: 'SEC_BEAUTE', title: newLang === 'FR' ? 'Beauté / Bien-être' : 'Rafet' },
97
+ { id: 'SEC_TRANSPORT', title: newLang === 'FR' ? 'Transport / Livr.' : 'Transport / Yëgël' },
98
+ { id: 'SEC_TECH', title: newLang === 'FR' ? 'Tech / Digital' : 'Tech / Digital' },
99
+ { id: 'SEC_AUTRE', title: newLang === 'FR' ? 'Autre secteur' : 'Beneen mbir' }
100
+ ]
101
+ }]
102
+ });
103
+ return true;
104
+ }
105
+
106
+ // --- 3. Sector Selection ---
107
+ const sectorLabel = SECTOR_LABELS[normalizedText];
108
+ if (sectorLabel || normalizedText.startsWith('SEC_')) {
109
+ const activity = sectorLabel ? (user.language === 'WOLOF' ? sectorLabel.wo : sectorLabel.fr) : text.trim();
110
+ user = await prisma.user.update({ where: { id: user.id }, data: { activity } });
111
+ const msg = user.language === 'FR' ? `Secteur noté : *${activity}*` : `Bind nanu la ci: *${activity}*`;
112
+ await whatsappQueue.add('send-message', { userId: user.id, text: msg });
113
+
114
+ const trackId = user.language === 'FR' ? "T1-FR" : "T1-WO";
115
+ await whatsappQueue.add('enroll-user', { userId: user.id, trackId });
116
+ return true;
117
+ }
118
+
119
+ // --- 4. Free-text Activity detection ---
120
+ if (!user.activity && text.length > 2) {
121
+ user = await prisma.user.update({ where: { id: user.id }, data: { activity: text.trim() } });
122
+ const msg = user.language === 'FR' ? `Secteur noté : *${text.trim()}*` : `Bind nanu la ci: *${text.trim()}*`;
123
+ await whatsappQueue.add('send-message', { userId: user.id, text: msg });
124
+
125
+ const trackId = user.language === 'FR' ? "T1-FR" : "T1-WO";
126
+ await whatsappQueue.add('enroll-user', { userId: user.id, trackId });
127
+ return true;
128
+ }
129
+
130
+ return false;
131
+ }
132
+ }
apps/whatsapp-worker/src/handlers/types.ts ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { User, Enrollment, Track } from '@repo/database';
2
+ import Redis from 'ioredis';
3
+ import { Queue } from 'bullmq';
4
+
5
+ export interface MessageContext {
6
+ phone: string;
7
+ text: string;
8
+ normalizedText: string;
9
+ audioUrl?: string;
10
+ imageUrl?: string;
11
+ traceId: string;
12
+ user?: User;
13
+ activeEnrollment?: Enrollment & { track: Track };
14
+ redis: Redis;
15
+ whatsappQueue: Queue;
16
+ }
17
+
18
+ export interface MessageHandler {
19
+ canHandle(ctx: MessageContext): boolean | Promise<boolean>;
20
+ handle(ctx: MessageContext): Promise<boolean>; // Returns true if it completely handled the message
21
+ }
apps/whatsapp-worker/src/services/utils.ts ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export function levenshteinDistance(a: string, b: string): number {
2
+ const matrix: number[][] = [];
3
+ for (let i = 0; i <= b.length; i++) matrix[i] = [i];
4
+ for (let j = 0; j <= a.length; j++) matrix[0][j] = j;
5
+
6
+ for (let i = 1; i <= b.length; i++) {
7
+ for (let j = 1; j <= a.length; j++) {
8
+ if (b.charAt(i - 1) === a.charAt(j - 1)) {
9
+ matrix[i][j] = matrix[i - 1][j - 1];
10
+ } else {
11
+ matrix[i][j] = Math.min(
12
+ matrix[i - 1][j - 1] + 1,
13
+ matrix[i][j - 1] + 1,
14
+ matrix[i - 1][j] + 1
15
+ );
16
+ }
17
+ }
18
+ }
19
+ return matrix[b.length][a.length];
20
+ }
21
+
22
+ export function isFuzzyMatch(text: string, target: string, threshold = 0.8): boolean {
23
+ const normalized = text.trim().toUpperCase();
24
+ const tar = target.toUpperCase();
25
+ if (normalized === tar) return true;
26
+ if (normalized.includes(tar) || tar.includes(normalized)) return true;
27
+
28
+ const distance = levenshteinDistance(normalized, tar);
29
+ const maxLength = Math.max(normalized.length, tar.length);
30
+ const similarity = 1 - distance / maxLength;
31
+ return similarity >= threshold;
32
+ }
apps/whatsapp-worker/src/services/whatsapp-logic.ts CHANGED
@@ -2,11 +2,15 @@ import { logger } from '../logger';
2
  import { PrismaClient } from '@repo/database';
3
  import { Queue } from 'bullmq';
4
  import Redis from 'ioredis';
5
- import { getTimeTravelContext, clearTimeTravelContext } from '../timeTravelContext';
 
 
 
 
 
6
 
7
  const prisma = new PrismaClient();
8
 
9
- // Setup local queue access for the worker-side service
10
  const connection = process.env.REDIS_URL
11
  ? new Redis(process.env.REDIS_URL, { maxRetriesPerRequest: null })
12
  : new Redis({
@@ -17,13 +21,12 @@ const connection = process.env.REDIS_URL
17
 
18
  const whatsappQueue = new Queue('whatsapp-queue', { connection: connection as any });
19
 
20
- async function scheduleMessage(userId: string, text: string, delayMs: number = 0) {
21
- await whatsappQueue.add('send-message', { userId, text }, { delay: delayMs });
22
- }
23
-
24
- async function enrollUser(userId: string, trackId: string) {
25
- await whatsappQueue.add('enroll-user', { userId, trackId });
26
- }
27
 
28
  export class WhatsAppLogic {
29
  private static normalizeCommand(text: string): string {
@@ -34,390 +37,84 @@ export class WhatsAppLogic {
34
  .toUpperCase();
35
  }
36
 
37
- private static detectIntent(text: string): 'YES' | 'NO' | 'UNKNOWN' {
38
- const normalized = text.trim().toLowerCase().replace(/[.,!?;:]+$/, "");
39
- const yesWords = ['oui', 'ouais', 'wi', 'waaw', 'yes', 'yep', 'ok', 'd’accord', 'daccord', 'da’accord'];
40
- const noWords = ['non', 'déet', 'deet', 'no', 'nah', 'nein'];
41
-
42
- if (yesWords.some(w => normalized.includes(w))) return 'YES';
43
- if (noWords.some(w => normalized.includes(w))) return 'NO';
44
- return 'UNKNOWN';
45
- }
46
-
47
- private static levenshteinDistance(a: string, b: string): number {
48
- const matrix: number[][] = [];
49
- for (let i = 0; i <= b.length; i++) matrix[i] = [i];
50
- for (let j = 0; j <= a.length; j++) matrix[0][j] = j;
51
-
52
- for (let i = 1; i <= b.length; i++) {
53
- for (let j = 1; j <= a.length; j++) {
54
- if (b.charAt(i - 1) === a.charAt(j - 1)) {
55
- matrix[i][j] = matrix[i - 1][j - 1];
56
- } else {
57
- matrix[i][j] = Math.min(
58
- matrix[i - 1][j - 1] + 1,
59
- matrix[i][j - 1] + 1,
60
- matrix[i - 1][j] + 1
61
- );
62
- }
63
- }
64
- }
65
- return matrix[b.length][a.length];
66
- }
67
-
68
- private static isFuzzyMatch(text: string, target: string, threshold = 0.8): boolean {
69
- const normalized = text.trim().toUpperCase();
70
- const tar = target.toUpperCase();
71
- if (normalized === tar) return true;
72
- if (normalized.includes(tar) || tar.includes(normalized)) return true;
73
-
74
- const distance = this.levenshteinDistance(normalized, tar);
75
- const maxLength = Math.max(normalized.length, tar.length);
76
- const similarity = 1 - distance / maxLength;
77
- return similarity >= threshold;
78
- }
79
-
80
  static async handleIncomingMessage(phone: string, text: string, audioUrl?: string, imageUrl?: string) {
81
  const traceId = audioUrl ? `[STT-FLOW-${phone.slice(-4)}]` : imageUrl ? `[IMG-FLOW-${phone.slice(-4)}]` : `[TXT-FLOW-${phone.slice(-4)}]`;
82
  const normalizedText = this.normalizeCommand(text);
83
- logger.info(`${traceId} Processing Inbound (Async): ${normalizedText} (Audio: ${audioUrl || 'N/A'})`);
84
-
85
- // 1. Find or Create User
86
- let user = await prisma.user.findUnique({ where: { phone } });
87
 
88
- if (!user) {
89
- const isInscription = this.isFuzzyMatch(normalizedText, 'INSCRIPTION') || normalizedText.includes('INSCRI');
90
-
91
- if (isInscription) {
92
- user = await prisma.user.create({ data: { phone } });
93
- await whatsappQueue.add('send-interactive-buttons', {
94
- userId: user.id,
95
- bodyText: "Dalal jàmm! Xamle ngay tàmbali. ⏳ 30s.\n(FR) Ton cours se prépare (30s).",
96
- buttons: [
97
- { id: 'LANG_FR', title: 'Français 🇫🇷' },
98
- { id: 'LANG_WO', title: 'Wolof 🇸🇳' }
99
- ]
100
- });
101
- return;
102
- } else {
103
- await whatsappQueue.add('send-message-direct', {
104
- phone,
105
- 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*"
106
- });
107
- return;
108
- }
109
- }
110
 
111
- // 1.2 Log message
112
- try {
113
- await prisma.message.create({
114
  data: {
115
  content: text,
116
  mediaUrl: audioUrl || imageUrl,
117
  direction: 'INBOUND',
118
  userId: user.id
119
  }
120
- });
121
- } catch (err) {}
122
-
123
- // 1.5. Testing / Cheat Codes
124
- if (this.isFuzzyMatch(normalizedText, 'INSCRIPTION')) {
125
- // 🚨 COUPE-CIRCUIT #1: Kill Time-Travel context BEFORE any DB reset
126
- await clearTimeTravelContext(user.id, connection as any);
127
- await (prisma as any).userBadge.deleteMany({ where: { userProgress: { userId: user.id } } });
128
- await (prisma as any).teamMember.deleteMany({ where: { businessProfile: { userId: user.id } } });
129
- await prisma.enrollment.deleteMany({ where: { userId: user.id } });
130
- await prisma.userProgress.deleteMany({ where: { userId: user.id } });
131
- await prisma.response.deleteMany({ where: { userId: user.id } });
132
- await prisma.message.deleteMany({ where: { userId: user.id } });
133
- await (prisma as any).businessProfile.deleteMany({ where: { userId: user.id } });
134
- user = await prisma.user.update({
135
- where: { id: user.id },
136
- data: { city: null, activity: null }
137
- });
138
- await whatsappQueue.add('send-interactive-buttons', {
139
- userId: user.id,
140
- bodyText: "Réinitialisation réussie – choisissez votre langue / Tànnal sa làkk :",
141
- buttons: [
142
- { id: 'LANG_FR', title: 'Français 🇫🇷' },
143
- { id: 'LANG_WO', title: 'Wolof 🇸🇳' }
144
- ]
145
- });
146
- return;
147
  }
148
 
149
- const systemCommands = ['1', '2', 'SUITE', 'APPROFONDIR', 'INSCRIPTION', 'SEED'];
150
- const isSystemCommand = systemCommands.some(cmd => this.isFuzzyMatch(normalizedText, cmd)) || normalizedText.includes('INSCRI');
 
 
 
 
 
 
 
 
 
 
151
 
 
 
 
 
152
  if (text.length < 2 && !isSystemCommand) {
153
- await scheduleMessage(user.id, user.language === 'WOLOF'
 
154
  ? "Dama lay xaar nga wax ma lu gën a yaatu ci sa mbir. Waxtaanal ak man !"
155
- : "Je n'ai pas bien compris. Peux-tu me réexpliquer en quelques mots ?");
 
 
 
 
 
 
156
  return;
157
  }
158
 
159
- if (this.isFuzzyMatch(normalizedText, 'SEED')) {
 
160
  try {
161
- // @ts-ignore
162
- const { seedDatabase } = await import('@repo/database/seed');
163
- const result = await seedDatabase(prisma);
164
- await (prisma as any).businessProfile.deleteMany({ where: { userId: user!.id } });
165
- await prisma.user.update({ where: { id: user!.id }, data: { activity: null } });
166
-
167
- await scheduleMessage(user.id, result.seeded
168
- ? "✅ Seeding terminé ! Le Cache Cognitif a été réinitialisé.\nEnvoie INSCRIPTION pour commencer."
169
- : "ℹ️ Les données existent déjà. Cache Cognitif purgé. Envoie INSCRIPTION."
170
- );
171
- } catch (err) {
172
- await scheduleMessage(user.id, `❌ Erreur seed`);
173
- }
174
- return;
175
- }
176
-
177
- // Handle Interactive LIST menu (REPLAY, EXERCISE, etc.)
178
- const dayActionMatch = normalizedText.match(/^DAY(\d+)_(EXERCISE|REPLAY|CONTINUE|PROMPT)$/);
179
- if (dayActionMatch) {
180
- const action = dayActionMatch[2];
181
- const enrollment = await prisma.enrollment.findFirst({ where: { userId: user.id, status: 'ACTIVE' } });
182
-
183
- if (action === 'REPLAY' && enrollment) {
184
- await whatsappQueue.add('send-content', { userId: user.id, trackId: enrollment.trackId, dayNumber: enrollment.currentDay });
185
- return;
186
- } else if (action === 'EXERCISE') {
187
- await scheduleMessage(user.id, user.language === 'WOLOF' ? "🎙️ Yónnee sa tontu :" : "🎙️ Envoie ta réponse :");
188
- return;
189
- }
190
- }
191
-
192
- // Language selection
193
- if (normalizedText === 'LANG_FR' || normalizedText === 'LANG_WO') {
194
- const newLang = normalizedText === 'LANG_FR' ? 'FR' : 'WOLOF';
195
- user = await prisma.user.update({ where: { id: user.id }, data: { language: newLang } });
196
- const promptText = newLang === 'FR' ? "Parfait ! Dans quel domaine te trouves-tu ?" : "Baax na ! Ci ban mbir ngay yëngu ?";
197
-
198
- await whatsappQueue.add('send-interactive-list', {
199
- userId: user.id,
200
- headerText: newLang === 'FR' ? "Ton secteur" : "Sa Mbir",
201
- bodyText: promptText,
202
- buttonLabel: newLang === 'FR' ? "Secteurs" : "Tànn",
203
- sections: [{
204
- title: newLang === 'FR' ? 'Liste' : 'Mbir',
205
- rows: [
206
- { id: 'SEC_COMMERCE', title: newLang === 'FR' ? 'Commerce / Vente' : 'Njaay' },
207
- { id: 'SEC_AGRI', title: newLang === 'FR' ? 'Agri / Élevage' : 'Mbay / Samm' },
208
- { id: 'SEC_FOOD', title: newLang === 'FR' ? 'Alimentation / Rest.' : 'Lekk / Restauration' },
209
- { id: 'SEC_COUTURE', title: newLang === 'FR' ? 'Couture / Mode' : 'Couture' },
210
- { id: 'SEC_BEAUTE', title: newLang === 'FR' ? 'Beauté / Bien-être' : 'Rafet' },
211
- { id: 'SEC_TRANSPORT', title: newLang === 'FR' ? 'Transport / Livr.' : 'Transport / Yëgël' },
212
- { id: 'SEC_TECH', title: newLang === 'FR' ? 'Tech / Digital' : 'Tech / Digital' },
213
- { id: 'SEC_AUTRE', title: newLang === 'FR' ? 'Autre secteur' : 'Beneen mbir' }
214
- ]
215
- }]
216
- });
217
- return;
218
- }
219
-
220
- const SECTOR_LABELS: Record<string, { fr: string; wo: string }> = {
221
- SEC_COMMERCE: { fr: 'Commerce / Vente', wo: 'Njaay' },
222
- SEC_AGRI: { fr: 'Agriculture / Élevage', wo: 'Mbay' },
223
- SEC_FOOD: { fr: 'Alimentation / Restauration', wo: 'Lekk / Restauration' },
224
- SEC_TECH: { fr: 'Tech / Digital', wo: 'Tech / Digital' },
225
- SEC_BEAUTE: { fr: 'Beauté / Bien-être', wo: 'Rafet' },
226
- SEC_COUTURE: { fr: 'Couture / Mode', wo: 'Couture' },
227
- SEC_TRANSPORT: { fr: 'Transport / Livraison', wo: 'Transport / Yëgël' },
228
- };
229
-
230
- const sectorLabel = SECTOR_LABELS[normalizedText];
231
- const activeEnrollment = await prisma.enrollment.findFirst({
232
- where: { userId: user.id, status: 'ACTIVE' },
233
- include: { track: true }
234
- });
235
-
236
- if (activeEnrollment && (sectorLabel || normalizedText.startsWith('SEC_'))) return;
237
-
238
- if (!activeEnrollment && (sectorLabel || (!user.activity && text.length > 2))) {
239
- const activity = sectorLabel ? (user.language === 'WOLOF' ? sectorLabel.wo : sectorLabel.fr) : text.trim();
240
- user = await prisma.user.update({ where: { id: user.id }, data: { activity } });
241
- await scheduleMessage(user.id, user.language === 'FR' ? `Secteur noté : *${activity}*` : `Bind nanu la ci: *${activity}*`);
242
- const trackId = user.language === 'FR' ? "T1-FR" : "T1-WO";
243
- await enrollUser(user.id, trackId);
244
- return;
245
- }
246
-
247
- if (activeEnrollment) {
248
- this.detectIntent(text); // Scan for intent (log purposes or future use)
249
-
250
- // 🕰️ TIME-TRAVEL: Compute effectiveDay — single source of truth for actions & flow
251
- const timeTravelDay = await getTimeTravelContext(user.id, connection as any);
252
- const effectiveDay = timeTravelDay ?? activeEnrollment.currentDay;
253
- const isTimeTravelMode = timeTravelDay !== null && timeTravelDay !== activeEnrollment.currentDay;
254
-
255
- const isSuite = this.isFuzzyMatch(normalizedText, 'SUITE') || normalizedText === '2';
256
- const isApprofondir = this.isFuzzyMatch(normalizedText, 'APPROFONDIR') || normalizedText === '1';
257
-
258
- if (isSuite) {
259
- // 🚨 COUPE-CIRCUIT #2: Kill Time-Travel context BEFORE any progression logic
260
- await clearTimeTravelContext(user.id, connection as any);
261
-
262
- const userProgress = await prisma.userProgress.findUnique({ where: { userId_trackId: { userId: user.id, trackId: activeEnrollment.trackId } } });
263
- const lastResponse = await prisma.response.findFirst({ where: { userId: user.id, dayNumber: activeEnrollment.currentDay }, orderBy: { createdAt: 'desc' } });
264
-
265
- if (userProgress?.exerciseStatus !== 'COMPLETED' && userProgress?.exerciseStatus !== 'PENDING_DEEPDIVE' && !lastResponse) {
266
- await scheduleMessage(user.id, user.language === 'WOLOF' ? "Dafa laaj nga tontu !" : "Tu dois d'abord répondre !");
267
- return;
268
- }
269
- const nextDay = activeEnrollment.currentDay % 1 !== 0 ? Math.floor(activeEnrollment.currentDay) + 1 : activeEnrollment.currentDay + 1;
270
- await prisma.enrollment.update({ where: { id: activeEnrollment.id }, data: { currentDay: nextDay } });
271
- await prisma.userProgress.update({ where: { userId_trackId: { userId: user.id, trackId: activeEnrollment.trackId } }, data: { exerciseStatus: 'PENDING', iterationCount: 0 } });
272
- await whatsappQueue.add('send-content', { userId: user.id, trackId: activeEnrollment.trackId, dayNumber: nextDay });
273
- return;
274
- }
275
-
276
- if (isApprofondir) {
277
- const userProgress = await prisma.userProgress.findUnique({ where: { userId_trackId: { userId: user.id, trackId: activeEnrollment.trackId } } });
278
- // 🕰️ TIME-TRAVEL: check for response on the historical day
279
- const lastResponse = await prisma.response.findFirst({ where: { userId: user.id, dayNumber: effectiveDay }, orderBy: { createdAt: 'desc' } });
280
-
281
- if (userProgress?.exerciseStatus === 'COMPLETED' || (userProgress?.exerciseStatus === 'PENDING' && lastResponse)) {
282
- await prisma.userProgress.update({ where: { id: userProgress!.id }, data: { exerciseStatus: 'PENDING_DEEPDIVE' } });
283
- await scheduleMessage(user.id, user.language === 'WOLOF' ? "Wax ma ndox mi..." : "Très bien ! Quelle info ?");
284
- return;
285
- }
286
- }
287
-
288
- // 🚨 FLOW-SYNC: Identify current pedagogical state
289
- const userProgress = await prisma.userProgress.findUnique({
290
- where: { userId_trackId: { userId: user.id, trackId: activeEnrollment.trackId } }
291
- });
292
-
293
- logger.info(`[FLOW-SYNC] User ${user.id} at Day ${activeEnrollment.currentDay}, status: ${userProgress?.exerciseStatus}`);
294
-
295
- const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000);
296
- const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000);
297
- const isRecentlyCompleted = userProgress?.exerciseStatus === 'COMPLETED' && userProgress.updatedAt > fiveMinutesAgo;
298
-
299
- // 🚨 Anti-Fantôme: Only revalidate with an image if the exercise is actively awaiting input
300
- // OR the image arrives shortly after completion (immediate retry). Orphan images (delayed
301
- // retries from Meta) arriving > 10 min after COMPLETED must NOT reset progression.
302
- const isImageForActiveExercise = !!imageUrl && (
303
- userProgress?.exerciseStatus === 'PENDING' ||
304
- userProgress?.exerciseStatus === 'PENDING_DEEPDIVE' ||
305
- userProgress?.exerciseStatus === 'PENDING_REMEDIATION' ||
306
- (userProgress?.exerciseStatus === 'COMPLETED' && userProgress.updatedAt > tenMinutesAgo)
307
- );
308
- const shouldForceRevalidation = isImageForActiveExercise || isRecentlyCompleted;
309
-
310
- if (shouldForceRevalidation && userProgress?.exerciseStatus === 'COMPLETED') {
311
- logger.info(`[FLOW-SYNC] 🔄 Re-validation triggered for User ${user.id} (Reason: ${imageUrl ? 'New Image' : 'Recent Correction'})`);
312
- // Briefly reset to PENDING to allow the analysis block below to pick it up
313
- await prisma.userProgress.update({
314
- where: { id: userProgress.id },
315
- data: { exerciseStatus: 'PENDING' }
316
- });
317
- }
318
-
319
- const pendingProgress = await prisma.userProgress.findFirst({
320
- where: {
321
- userId: user.id,
322
- exerciseStatus: { in: ['PENDING', 'PENDING_REMEDIATION', 'PENDING_DEEPDIVE'] },
323
- trackId: activeEnrollment.trackId
324
- },
325
- });
326
-
327
- if (pendingProgress) {
328
- // 🕰️ TIME-TRAVEL: Use pre-calculated effectiveDay
329
- if (isTimeTravelMode) {
330
- logger.info(`[TIME-TRAVEL] 🕰️ Worker: User ${user.id} replying to Day ${effectiveDay} (real currentDay: ${activeEnrollment.currentDay})`);
331
- }
332
-
333
- const trackDay = await prisma.trackDay.findFirst({ where: { trackId: activeEnrollment.trackId, dayNumber: effectiveDay } });
334
- if (trackDay) {
335
- logger.info(`[FLOW-SYNC] 🧠 User ${user.id} is at Day ${activeEnrollment.currentDay}, processing response for Day ${activeEnrollment.currentDay}.`);
336
- const isDeepDiveAction = pendingProgress.exerciseStatus === 'PENDING_DEEPDIVE';
337
- const wordCount = (text || '').trim().split(/\s+/).length;
338
-
339
- // 🚨 Card/Button Bypass Logic (Lead Product Designer Requirement)
340
- let isButtonChoice = false;
341
- const buttons = trackDay.buttonsJson as any[];
342
- if (Array.isArray(buttons)) {
343
- isButtonChoice = buttons.some(b =>
344
- this.isFuzzyMatch(normalizedText, b.title || '') ||
345
- this.isFuzzyMatch(normalizedText, b.id || '')
346
- );
347
  }
348
-
349
- // Special legacy keywords for Day 7
350
- const isDay7Special = activeEnrollment.currentDay === 7 && (
351
- ['whatsapp', 'boutique', 'digital', 'physique', 'tel', 'e-commerce'].includes(normalizedText)
352
- );
353
-
354
- const isVisionDay = !!imageUrl; // Any image should bypass wordcount to be analyzed
355
- const shouldBypassGuardrail = isButtonChoice || isDay7Special || isVisionDay;
356
-
357
- if (isVisionDay) {
358
- logger.info(`[IMAGE-FLOW] 📸 Bypassing wordcount for image response on Day ${activeEnrollment.currentDay} for User ${user.id}`);
359
- }
360
-
361
- const minWordCount = shouldBypassGuardrail ? 1 : 3;
362
-
363
- if (wordCount < minWordCount) {
364
- await scheduleMessage(user.id, user.language === 'WOLOF' ? "Tontu bi gatt na..." : "Ta réponse est un peu courte.");
365
- return;
366
- }
367
-
368
- await scheduleMessage(user.id, user.language === 'WOLOF' ? "⏳ Defar ak sa tontu..." : "⏳ Analyse de votre réponse...");
369
-
370
- let currentIterationCount = pendingProgress.iterationCount || 0;
371
- if (isDeepDiveAction) {
372
- currentIterationCount += 1;
373
- await prisma.userProgress.update({ where: { id: pendingProgress.id }, data: { iterationCount: currentIterationCount } });
374
- }
375
-
376
- await prisma.response.create({ data: { enrollmentId: activeEnrollment.id, userId: user.id, dayNumber: effectiveDay, content: text } });
377
-
378
- const previousResponsesData = await prisma.response.findMany({ where: { userId: user.id, enrollmentId: activeEnrollment.id }, orderBy: { dayNumber: 'asc' }, take: 5 });
379
- const previousResponses = previousResponsesData.map(r => ({ day: r.dayNumber, response: r.content }));
380
-
381
- logger.info(`[LOGIC] 🚀 Enqueuing generate-feedback for User ${user.id} (effectiveDay: ${effectiveDay}, TT: ${isTimeTravelMode}, Button: ${isButtonChoice})`);
382
- await whatsappQueue.add('generate-feedback', {
383
- userId: user.id, text, trackId: activeEnrollment.trackId, trackDayId: trackDay.id,
384
- enrollmentId: activeEnrollment.id,
385
- exercisePrompt: trackDay.exercisePrompt || '', lessonText: trackDay.lessonText || '',
386
- exerciseCriteria: trackDay.exerciseCriteria, pendingProgressId: pendingProgress.id,
387
- currentDay: effectiveDay, // ← effectiveDay: single source of truth
388
- totalDays: activeEnrollment.track.duration, language: user.language,
389
- userActivity: user.activity, userRegion: user.city, previousResponses,
390
- isDeepDive: isDeepDiveAction, iterationCount: currentIterationCount, imageUrl: imageUrl,
391
- isButtonChoice: isButtonChoice,
392
- isTimeTravelMode, // ← Worker uses this to skip COMPLETED update
393
- realCurrentDay: activeEnrollment.currentDay // ← For logging only
394
- }, { attempts: 3, backoff: { type: 'exponential', delay: 2000 } });
395
- return;
396
- } else {
397
- logger.info(`[LOGIC] ⚠️ Fall-through: User ${user.id} in enrollment but no matching pendingProgress (Status likely not PENDING).`);
398
  }
399
- } else {
400
- // Enrollment active but no trackDay found for currentDay?
401
- logger.warn(`[LOGIC] ⚠️ Active Enrollment for User ${user.id} but TrackDay ${activeEnrollment.currentDay} not found.`);
402
  }
403
- } else {
404
- logger.info(`[LOGIC] ℹ️ User ${user.id} has no active enrollment. Fall-through.`);
405
  }
406
 
407
- // 🌟 UX Guidance Fall-through 🌟
408
- // If we reach here, it means we found a user with an active enrollment but no pending exercise (likely COMPLETED).
409
- if (activeEnrollment) {
410
- const userProgress = await prisma.userProgress.findUnique({
411
- where: { userId_trackId: { userId: user.id, trackId: activeEnrollment.trackId } }
412
  });
413
-
414
- if (userProgress?.exerciseStatus === 'COMPLETED') {
415
- logger.info(`[LOGIC] 💡 User ${user.id} is COMPLETED. Sending navigation reminder.`);
416
- const reminder = user.language === 'WOLOF'
417
- ? "Mat nga bés bi ba pare ! ✨\nBindal *2* wala *SUITE* ngir dem ci bés bi ci kanam.\n(Bindal *REPLAY* ngir dégtu mbind mi)."
418
- : "Tu as déjà validé cette étape ! ✨\nEnvoie *2* ou *SUITE* pour passer à la suite.\n(Envoie *REPLAY* pour réécouter la leçon).";
419
- await scheduleMessage(user.id, reminder);
420
- }
421
  }
422
  }
423
  }
 
2
  import { PrismaClient } from '@repo/database';
3
  import { Queue } from 'bullmq';
4
  import Redis from 'ioredis';
5
+ import { isFuzzyMatch } from './utils';
6
+ import { MessageContext, MessageHandler } from '../handlers/types';
7
+ import { OnboardingHandler } from '../handlers/OnboardingHandler';
8
+ import { CommandHandler } from '../handlers/CommandHandler';
9
+ import { NavigationHandler } from '../handlers/NavigationHandler';
10
+ import { ExerciseHandler } from '../handlers/ExerciseHandler';
11
 
12
  const prisma = new PrismaClient();
13
 
 
14
  const connection = process.env.REDIS_URL
15
  ? new Redis(process.env.REDIS_URL, { maxRetriesPerRequest: null })
16
  : new Redis({
 
21
 
22
  const whatsappQueue = new Queue('whatsapp-queue', { connection: connection as any });
23
 
24
+ const handlers: MessageHandler[] = [
25
+ new OnboardingHandler(),
26
+ new CommandHandler(),
27
+ new NavigationHandler(),
28
+ new ExerciseHandler(),
29
+ ];
 
30
 
31
  export class WhatsAppLogic {
32
  private static normalizeCommand(text: string): string {
 
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}`);
 
 
 
44
 
45
+ // 1. Find User & Enrollment
46
+ const user = await prisma.user.findUnique({ where: { phone } });
47
+ const activeEnrollment = user ? await prisma.enrollment.findFirst({
48
+ where: { userId: user.id, status: 'ACTIVE' },
49
+ include: { track: true }
50
+ }) as any : null;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
 
52
+ // 2. Log Message (Non-blocking)
53
+ if (user) {
54
+ prisma.message.create({
55
  data: {
56
  content: text,
57
  mediaUrl: audioUrl || imageUrl,
58
  direction: 'INBOUND',
59
  userId: user.id
60
  }
61
+ }).catch(() => {});
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
  }
63
 
64
+ const ctx: MessageContext = {
65
+ phone,
66
+ text,
67
+ normalizedText,
68
+ audioUrl,
69
+ imageUrl,
70
+ traceId,
71
+ user: user || undefined,
72
+ activeEnrollment: activeEnrollment || undefined,
73
+ redis: connection as any,
74
+ whatsappQueue
75
+ };
76
 
77
+ // 3. Short Message Guard (Safety)
78
+ const systemCommands = ['1', '2', 'SUITE', 'APPROFONDIR', 'INSCRIPTION', 'SEED'];
79
+ const isSystemCommand = systemCommands.some(cmd => isFuzzyMatch(normalizedText, cmd)) || normalizedText.includes('INSCRI');
80
+
81
  if (text.length < 2 && !isSystemCommand) {
82
+ const lang = user?.language || 'FR';
83
+ const msg = lang === 'WOLOF'
84
  ? "Dama lay xaar nga wax ma lu gën a yaatu ci sa mbir. Waxtaanal ak man !"
85
+ : "Je n'ai pas bien compris. Peux-tu me réexpliquer en quelques mots ?";
86
+
87
+ if (user) {
88
+ await whatsappQueue.add('send-message', { userId: user.id, text: msg });
89
+ } else {
90
+ await whatsappQueue.add('send-message-direct', { phone, text: msg });
91
+ }
92
  return;
93
  }
94
 
95
+ // 4. Delegate to Handlers
96
+ for (const handler of handlers) {
97
  try {
98
+ if (await handler.canHandle(ctx)) {
99
+ const handled = await handler.handle(ctx);
100
+ if (handled) {
101
+ logger.info(`${traceId} Handled by ${handler.constructor.name}`);
102
+ return;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
103
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
  }
105
+ } catch (err: unknown) {
106
+ logger.error(`${traceId} Error in ${handler.constructor.name}:`, (err instanceof Error ? err.message : String(err)));
 
107
  }
 
 
108
  }
109
 
110
+ // 5. Fallback Management
111
+ if (!user) {
112
+ await whatsappQueue.add('send-message-direct', {
113
+ phone,
114
+ text: "🎓 Bienvenue chez XAMLÉ !\nPour commencer ta formation gratuite, envoie le mot : *INSCRIPTION*"
115
  });
116
+ } else {
117
+ logger.info(`${traceId} No handler matched for user ${user.id}`);
 
 
 
 
 
 
118
  }
119
  }
120
  }