CognxSafeTrack commited on
Commit
1dec751
·
1 Parent(s): 1403ab3

feat(ux): interactive buttons for language choice + LIST menu for sector selection (8 sectors FR/Wolof)

Browse files
apps/api/src/services/queue.ts CHANGED
@@ -15,27 +15,34 @@ const connection = process.env.REDIS_URL
15
  export const whatsappQueue = new Queue('whatsapp-queue', { connection: connection as any });
16
 
17
  export async function scheduleMessage(userId: string, text: string, delayMs: number = 0) {
18
- await whatsappQueue.add('send-message', {
19
- userId,
20
- text
21
- }, {
22
- delay: delayMs
23
- });
24
  }
25
 
26
  export async function scheduleTrackDay(userId: string, trackId: string, dayNumber: number, delayMs: number = 0) {
27
- await whatsappQueue.add('send-content', {
28
- userId,
29
- trackId,
30
- dayNumber
31
- }, {
32
- delay: delayMs
33
- });
34
  }
35
 
36
  export async function enrollUser(userId: string, trackId: string) {
37
- await whatsappQueue.add('enroll-user', {
38
- userId,
39
- trackId
40
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
  }
 
 
15
  export const whatsappQueue = new Queue('whatsapp-queue', { connection: connection as any });
16
 
17
  export async function scheduleMessage(userId: string, text: string, delayMs: number = 0) {
18
+ await whatsappQueue.add('send-message', { userId, text }, { delay: delayMs });
 
 
 
 
 
19
  }
20
 
21
  export async function scheduleTrackDay(userId: string, trackId: string, dayNumber: number, delayMs: number = 0) {
22
+ await whatsappQueue.add('send-content', { userId, trackId, dayNumber }, { delay: delayMs });
 
 
 
 
 
 
23
  }
24
 
25
  export async function enrollUser(userId: string, trackId: string) {
26
+ await whatsappQueue.add('enroll-user', { userId, trackId });
27
+ }
28
+
29
+ /** Send a WhatsApp interactive BUTTON message (max 3 buttons). */
30
+ export async function scheduleInteractiveButtons(
31
+ userId: string,
32
+ bodyText: string,
33
+ buttons: Array<{ id: string; title: string }>
34
+ ) {
35
+ await whatsappQueue.add('send-interactive-buttons', { userId, bodyText, buttons });
36
+ }
37
+
38
+ /** Send a WhatsApp interactive LIST message (up to 10 rows, grouped in sections). */
39
+ export async function scheduleInteractiveList(
40
+ userId: string,
41
+ headerText: string,
42
+ bodyText: string,
43
+ buttonLabel: string,
44
+ sections: Array<{ title: string; rows: Array<{ id: string; title: string; description?: string }> }>
45
+ ) {
46
+ await whatsappQueue.add('send-interactive-list', { userId, headerText, bodyText, buttonLabel, sections });
47
  }
48
+
apps/api/src/services/whatsapp.ts CHANGED
@@ -1,5 +1,5 @@
1
  import { prisma } from './prisma';
2
- import { scheduleMessage, enrollUser, whatsappQueue, scheduleTrackDay } from './queue';
3
  import { QuotaExceededError } from './ai/openai-provider';
4
 
5
  export class WhatsAppService {
@@ -12,11 +12,14 @@ export class WhatsAppService {
12
 
13
  if (!user) {
14
  if (normalizedText === 'INSCRIPTION') {
15
- user = await prisma.user.create({
16
- data: { phone }
17
- });
18
- await scheduleMessage(user.id, "Bienvenue sur SafeTrack Edu ! 🎉\nChoisissez votre langue / Tànnal sa làkk:\n1. Français 🇫🇷\n2. Wolof 🇸🇳");
19
- console.log('New user created, asked for language.');
 
 
 
20
  return;
21
  } else {
22
  console.log(`Unregistered user ${phone} sent a message. Need INSCRIPTION.`);
@@ -32,7 +35,13 @@ export class WhatsAppService {
32
  where: { id: user.id },
33
  data: { city: null, activity: null }
34
  });
35
- await scheduleMessage(user.id, "Réinitialisation réussie. Choisissez votre langue :\n1. Français 🇫🇷\n2. Wolof 🇸🇳");
 
 
 
 
 
 
36
  return;
37
  }
38
 
@@ -169,35 +178,90 @@ export class WhatsAppService {
169
 
170
  // 2. Check Onboarding State (Missing Language -> Missing Activity)
171
  if (!user.city) {
172
- // First time after INSCRIPTION they should answer 1 or 2
173
- if (normalizedText === '1' || normalizedText === '2') {
174
- const lang = normalizedText === '1' ? 'FR' : 'WOLOF';
 
 
 
175
  user = await prisma.user.update({
176
  where: { id: user.id },
177
- data: { language: lang, city: 'SET' } // Using city as a step flag for MVP
178
  });
179
 
180
- const prompt = lang === 'FR'
181
- ? "Parfait ! Pour personnaliser votre expérience, quel est votre secteur d'activité ou projet professionnel ? (ex: Agriculture, Commerce, Tech...)"
182
- : "Baax na ! Ngir gën a waajal sa njàng, ban mbir ngay def ? (Mbay, Njaay, Tech...)";
183
- await scheduleMessage(user.id, prompt);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
184
  return;
185
  } else {
186
- await scheduleMessage(user.id, "Veuillez choisir votre langue / Tànnal sa làkk:\n1. Français 🇫🇷\n2. Wolof 🇸🇳");
 
 
 
 
 
 
 
 
 
187
  return;
188
  }
189
  }
190
 
191
  if (!user.activity) {
192
- // Whatever they type now (or dictated via audio) is their activity
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
193
  user = await prisma.user.update({
194
  where: { id: user.id },
195
- data: { activity: text.trim() }
196
  });
197
 
198
  const welcomeMsg = user.language === 'FR'
199
- ? `Merci ! Nous avons noté votre secteur : *${user.activity}*.\nJe vous inscris maintenant à notre formation d'introduction !`
200
- : `Jërëjëf ! Bind nanu la ci: *${user.activity}*.\nLéegi dinanu la dugal ci njàng mi !`;
201
 
202
  await scheduleMessage(user.id, welcomeMsg);
203
 
 
1
  import { prisma } from './prisma';
2
+ import { scheduleMessage, enrollUser, whatsappQueue, scheduleTrackDay, scheduleInteractiveButtons, scheduleInteractiveList } from './queue';
3
  import { QuotaExceededError } from './ai/openai-provider';
4
 
5
  export class WhatsAppService {
 
12
 
13
  if (!user) {
14
  if (normalizedText === 'INSCRIPTION') {
15
+ user = await prisma.user.create({ data: { phone } });
16
+ await scheduleInteractiveButtons(user.id,
17
+ "Bienvenue sur Xamle ! 🎉\nChoisissez votre langue / Tànnal sa làkk :",
18
+ [
19
+ { id: 'LANG_FR', title: 'Français 🇫🇷' },
20
+ { id: 'LANG_WO', title: 'Wolof 🇸🇳' }
21
+ ]
22
+ );
23
  return;
24
  } else {
25
  console.log(`Unregistered user ${phone} sent a message. Need INSCRIPTION.`);
 
35
  where: { id: user.id },
36
  data: { city: null, activity: null }
37
  });
38
+ await scheduleInteractiveButtons(user.id,
39
+ "Réinitialisation réussie – choisissez votre langue / Tànnal sa làkk :",
40
+ [
41
+ { id: 'LANG_FR', title: 'Français 🇫🇷' },
42
+ { id: 'LANG_WO', title: 'Wolof 🇸🇳' }
43
+ ]
44
+ );
45
  return;
46
  }
47
 
 
178
 
179
  // 2. Check Onboarding State (Missing Language -> Missing Activity)
180
  if (!user.city) {
181
+ // Accept button ID or legacy text
182
+ const isLangFR = normalizedText === 'LANG_FR' || normalizedText === '1';
183
+ const isLangWO = normalizedText === 'LANG_WO' || normalizedText === '2';
184
+
185
+ if (isLangFR || isLangWO) {
186
+ const lang = isLangFR ? 'FR' : 'WOLOF';
187
  user = await prisma.user.update({
188
  where: { id: user.id },
189
+ data: { language: lang, city: 'SET' }
190
  });
191
 
192
+ // Sector LIST 8 common business types
193
+ const isWolof = lang === 'WOLOF';
194
+ await scheduleInteractiveList(
195
+ user.id,
196
+ isWolof ? 'Ban mbir ngay def ?' : 'Ton secteur d\'activité',
197
+ isWolof
198
+ ? 'Tànnal sa mbir ci suuf ngir waajal sa njàng :'
199
+ : 'Choisis ton secteur pour personnaliser ta formation :',
200
+ isWolof ? 'Tànnal' : 'Choisir',
201
+ [{
202
+ title: isWolof ? 'Mbir yi' : 'Secteurs',
203
+ rows: [
204
+ { id: 'SEC_COMMERCE', title: isWolof ? '🛒 Njaay / Commerce' : '🛒 Commerce / Vente', description: isWolof ? 'Njaay yëgël, boutik...' : 'Boutique, marché, revente...' },
205
+ { id: 'SEC_AGRI', title: isWolof ? '🌾 Mbay / Agriculture' : '🌾 Agriculture / Élevage', description: isWolof ? 'Mbay, génaaw-génaaw...' : 'Cultures, ferme, élevage...' },
206
+ { id: 'SEC_FOOD', title: isWolof ? '🍲 Lekk / Restauration' : '🍲 Alimentation / Restauration', description: isWolof ? 'Dëkk, thiébou djeun...' : 'Cuisine, livraison, snack...' },
207
+ { id: 'SEC_TECH', title: isWolof ? '💻 Tech / Digital' : '💻 Tech / Digital', description: isWolof ? 'App, internet, design...' : 'App, web, réseaux sociaux...' },
208
+ { id: 'SEC_BEAUTE', title: isWolof ? '💅 Rafet / Beauté' : '💅 Beauté / Bien-être', description: isWolof ? 'Sèp bu rafet...' : 'Salon, cosmétique, soins...' },
209
+ { id: 'SEC_COUTURE', title: isWolof ? '🧵 Couture / Mode' : '🧵 Couture / Mode', description: isWolof ? 'Njiit, fac...' : 'Confection, stylisme...' },
210
+ { id: 'SEC_TRANSPORT', title: isWolof ? '🚗 Yëgël / Transport' : '🚗 Transport / Livraison', description: isWolof ? 'Taxi, delivery...' : 'Moto, camion, coursier...' },
211
+ { id: 'SEC_AUTRE', title: isWolof ? '💡 Yeneen' : '💡 Autre secteur', description: isWolof ? 'Wax ma ci kanam...' : 'Décris ton activité ensuite' }
212
+ ]
213
+ }]
214
+ );
215
  return;
216
  } else {
217
+ // Re-send the language buttons instead of plain text
218
+ await scheduleInteractiveButtons(user.id,
219
+ user.language === 'WOLOF'
220
+ ? 'Tànnal sa làkk / Choisissez votre langue :'
221
+ : 'Veuillez choisir votre langue / Tànnal sa làkk :',
222
+ [
223
+ { id: 'LANG_FR', title: 'Français 🇫🇷' },
224
+ { id: 'LANG_WO', title: 'Wolof 🇸🇳' }
225
+ ]
226
+ );
227
  return;
228
  }
229
  }
230
 
231
  if (!user.activity) {
232
+ // Resolve sector LIST reply IDs human-readable label
233
+ const SECTOR_LABELS: Record<string, { fr: string; wo: string }> = {
234
+ SEC_COMMERCE: { fr: 'Commerce / Vente', wo: 'Njaay' },
235
+ SEC_AGRI: { fr: 'Agriculture / Élevage', wo: 'Mbay' },
236
+ SEC_FOOD: { fr: 'Alimentation / Restauration', wo: 'Lekk / Restauration' },
237
+ SEC_TECH: { fr: 'Tech / Digital', wo: 'Tech / Digital' },
238
+ SEC_BEAUTE: { fr: 'Beauté / Bien-être', wo: 'Rafet' },
239
+ SEC_COUTURE: { fr: 'Couture / Mode', wo: 'Couture' },
240
+ SEC_TRANSPORT: { fr: 'Transport / Livraison', wo: 'Transport / Yëgël' },
241
+ };
242
+
243
+ if (normalizedText === 'SEC_AUTRE') {
244
+ // Ask them to describe in their own words
245
+ await scheduleMessage(user.id, user.language === 'WOLOF'
246
+ ? 'Waaw ! Wax ma ban mbir ngay def ci ab kàddu gatt :'
247
+ : 'Parfait ! Décris ton activité en quelques mots :'
248
+ );
249
+ return;
250
+ }
251
+
252
+ const sectorLabel = SECTOR_LABELS[normalizedText];
253
+ const activity = sectorLabel
254
+ ? (user.language === 'WOLOF' ? sectorLabel.wo : sectorLabel.fr)
255
+ : text.trim(); // free-form text fallback
256
+
257
  user = await prisma.user.update({
258
  where: { id: user.id },
259
+ data: { activity }
260
  });
261
 
262
  const welcomeMsg = user.language === 'FR'
263
+ ? `Parfait ! Secteur noté : *${activity}*.\nJe t'inscris à ta formation personnalisée !`
264
+ : `Baax na ! Bind nanu la ci: *${activity}*.\nLéegi dinanu la dugal ci njàng mi !`;
265
 
266
  await scheduleMessage(user.id, welcomeMsg);
267
 
apps/whatsapp-worker/src/index.ts CHANGED
@@ -4,7 +4,7 @@ dns.setDefaultResultOrder('ipv4first');
4
  import { Worker, Job } from 'bullmq';
5
  import dotenv from 'dotenv';
6
  import { PrismaClient } from '@prisma/client';
7
- import { sendTextMessage, sendDocumentMessage, downloadMedia } from './whatsapp-cloud';
8
 
9
  dotenv.config();
10
 
@@ -36,6 +36,20 @@ const worker = new Worker('whatsapp-queue', async (job: Job) => {
36
  console.warn(`[WORKER] User ${userId} not found or missing phone — skipping send.`);
37
  }
38
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
  else if (job.name === 'enroll-user') {
40
  const { userId, trackId } = job.data;
41
 
 
4
  import { Worker, Job } from 'bullmq';
5
  import dotenv from 'dotenv';
6
  import { PrismaClient } from '@prisma/client';
7
+ import { sendTextMessage, sendDocumentMessage, downloadMedia, sendInteractiveButtonMessage, sendInteractiveListMessage } from './whatsapp-cloud';
8
 
9
  dotenv.config();
10
 
 
36
  console.warn(`[WORKER] User ${userId} not found or missing phone — skipping send.`);
37
  }
38
  }
39
+ else if (job.name === 'send-interactive-buttons') {
40
+ const { userId, bodyText, buttons } = job.data;
41
+ const user = await prisma.user.findUnique({ where: { id: userId } });
42
+ if (user?.phone) {
43
+ await sendInteractiveButtonMessage(user.phone, bodyText, buttons);
44
+ }
45
+ }
46
+ else if (job.name === 'send-interactive-list') {
47
+ const { userId, headerText, bodyText, buttonLabel, sections } = job.data;
48
+ const user = await prisma.user.findUnique({ where: { id: userId } });
49
+ if (user?.phone) {
50
+ await sendInteractiveListMessage(user.phone, headerText, bodyText, buttonLabel, sections);
51
+ }
52
+ }
53
  else if (job.name === 'enroll-user') {
54
  const { userId, trackId } = job.data;
55