CognxSafeTrack commited on
Commit
6c294cb
Β·
1 Parent(s): 65aabf4

feat: implement adaptive pedagogy, visuals, and remediation (WOW Phase 1)

Browse files
apps/api/src/routes/ai.ts CHANGED
@@ -172,7 +172,12 @@ export async function aiRoutes(fastify: FastifyInstance) {
172
  `🌟 ${feedback.praise}\n` +
173
  `πŸ’‘ ${feedback.action}`;
174
 
175
- return { success: true, text: formattedFeedback };
 
 
 
 
 
176
  } catch (err: any) {
177
  if (err?.name === 'QuotaExceededError') {
178
  return reply.code(429).send({ error: 'quota_exceeded' });
 
172
  `🌟 ${feedback.praise}\n` +
173
  `πŸ’‘ ${feedback.action}`;
174
 
175
+ return {
176
+ success: true,
177
+ text: formattedFeedback,
178
+ isQualified: feedback.isQualified ?? true,
179
+ missingElements: feedback.missingElements || []
180
+ };
181
  } catch (err: any) {
182
  if (err?.name === 'QuotaExceededError') {
183
  return reply.code(429).send({ error: 'quota_exceeded' });
apps/api/src/services/ai/index.ts CHANGED
@@ -50,7 +50,7 @@ class AIService {
50
  /**
51
  * Generates a short pedagogical feedback for the student's answer.
52
  */
53
- async generateFeedback(userInput: string, expectedExercise: string, lessonContent: string, userLanguage: string = 'FR', businessProfile?: any): Promise<{ rephrase: string, praise: string, action: string }> {
54
  const businessContext = businessProfile ? `BUSINESS DE L'Γ‰TUDIANT : Activity: ${businessProfile.activityLabel}, Customer: ${businessProfile.mainCustomer}, Offer: ${businessProfile.offerSimple}` : '';
55
 
56
  const prompt = `
@@ -70,6 +70,10 @@ class AIService {
70
  2. PRAISE : Une validation enthousiaste (ex: "Machallah !", "Excellent !").
71
  3. ACTION : Un conseil pratique et immΓ©diat liΓ© Γ  son business${businessProfile?.activityLabel ? ` (${businessProfile.activityLabel})` : ''}.
72
 
 
 
 
 
73
  CONTRAINTES :
74
  - LANGUE : "${userLanguage === 'WOLOF' ? 'WOLOF' : 'FranΓ§ais'}". JAMAIS D'ANGLAIS.
75
  - TAILLE : Maximum 15 mots par Γ©lΓ©ment.
@@ -79,7 +83,9 @@ class AIService {
79
  const schema = z.object({
80
  rephrase: z.string().describe("Reformulation courte"),
81
  praise: z.string().describe("Encouragement enthousiaste"),
82
- action: z.string().describe("Conseil pratique immΓ©diat")
 
 
83
  });
84
  return this.provider.generateStructuredData(prompt, schema);
85
  }
 
50
  /**
51
  * Generates a short pedagogical feedback for the student's answer.
52
  */
53
+ async generateFeedback(userInput: string, expectedExercise: string, lessonContent: string, userLanguage: string = 'FR', businessProfile?: any): Promise<{ rephrase: string, praise: string, action: string, isQualified?: boolean, missingElements?: string[] }> {
54
  const businessContext = businessProfile ? `BUSINESS DE L'Γ‰TUDIANT : Activity: ${businessProfile.activityLabel}, Customer: ${businessProfile.mainCustomer}, Offer: ${businessProfile.offerSimple}` : '';
55
 
56
  const prompt = `
 
70
  2. PRAISE : Une validation enthousiaste (ex: "Machallah !", "Excellent !").
71
  3. ACTION : Un conseil pratique et immΓ©diat liΓ© Γ  son business${businessProfile?.activityLabel ? ` (${businessProfile.activityLabel})` : ''}.
72
 
73
+ POUR JOUR 1 (REMEDIATION) :
74
+ VΓ©rifie si la rΓ©ponse contient : QUI (client), QUOI (produit), COMMENT (mΓ©thode de vente).
75
+ Si un de ces Γ©lΓ©ments manque, marque la rΓ©ponse comme non qualifiΓ©e.
76
+
77
  CONTRAINTES :
78
  - LANGUE : "${userLanguage === 'WOLOF' ? 'WOLOF' : 'FranΓ§ais'}". JAMAIS D'ANGLAIS.
79
  - TAILLE : Maximum 15 mots par Γ©lΓ©ment.
 
83
  const schema = z.object({
84
  rephrase: z.string().describe("Reformulation courte"),
85
  praise: z.string().describe("Encouragement enthousiaste"),
86
+ action: z.string().describe("Conseil pratique immΓ©diat"),
87
+ isQualified: z.boolean().optional().describe("Vrai si la rΓ©ponse remplit les objectifs (QUI+QUOI+COMMENT pour J1)"),
88
+ missingElements: z.array(z.string()).optional().describe("Liste des Γ©lΓ©ments manquants (ex: ['WHO', 'HOW'])")
89
  });
90
  return this.provider.generateStructuredData(prompt, schema);
91
  }
apps/whatsapp-worker/assets/templates/pitch_card.png ADDED
apps/whatsapp-worker/package.json CHANGED
@@ -8,13 +8,14 @@
8
  "start": "npx tsx ../api/src/index.ts & sleep 7 && node dist/index.js & wait"
9
  },
10
  "dependencies": {
11
- "@prisma/client": "^5.0.0",
12
  "@repo/database": "workspace:*",
13
  "axios": "^1.13.5",
14
  "bullmq": "^4.0.0",
15
  "dotenv": "^16.0.0",
16
  "ioredis": "^5.9.3",
17
- "node-cron": "^4.2.1"
 
18
  },
19
  "devDependencies": {
20
  "@repo/tsconfig": "workspace:*",
 
8
  "start": "npx tsx ../api/src/index.ts & sleep 7 && node dist/index.js & wait"
9
  },
10
  "dependencies": {
11
+ "@aws-sdk/client-s3": "^3.995.0",
12
  "@repo/database": "workspace:*",
13
  "axios": "^1.13.5",
14
  "bullmq": "^4.0.0",
15
  "dotenv": "^16.0.0",
16
  "ioredis": "^5.9.3",
17
+ "node-cron": "^4.2.1",
18
+ "sharp": "^0.34.5"
19
  },
20
  "devDependencies": {
21
  "@repo/tsconfig": "workspace:*",
apps/whatsapp-worker/src/index.ts CHANGED
@@ -3,10 +3,10 @@ dns.setDefaultResultOrder('ipv4first');
3
 
4
  import { Worker, Job, Queue } from 'bullmq';
5
  import dotenv from 'dotenv';
6
- import { PrismaClient } from '@prisma/client';
7
- import { sendTextMessage, sendDocumentMessage, downloadMedia, sendInteractiveButtonMessage, sendInteractiveListMessage } from './whatsapp-cloud';
8
  import { sendLessonDay } from './pedagogy';
9
- import { getApiUrl, getAdminApiKey, validateEnvironment } from './config';
10
 
11
  dotenv.config();
12
 
@@ -49,8 +49,8 @@ const worker = new Worker('whatsapp-queue', async (job: Job) => {
49
  const { userId, text, trackId, exercisePrompt, lessonText, pendingProgressId, currentDay, totalDays, language } = job.data;
50
  const user = await prisma.user.findUnique({
51
  where: { id: userId },
52
- include: { businessProfile: true }
53
- });
54
  if (!user?.phone) return;
55
 
56
  console.log(`[WORKER] Generating feedback for User ${userId}`);
@@ -58,6 +58,7 @@ const worker = new Worker('whatsapp-queue', async (job: Job) => {
58
  const apiKey = getAdminApiKey();
59
 
60
  let feedbackMsg = '';
 
61
  try {
62
  const feedbackRes = await fetch(`${AI_API_BASE_URL.replace(/\/$/, "")}/v1/ai/generate-feedback`, {
63
  method: 'POST',
@@ -72,16 +73,15 @@ const worker = new Worker('whatsapp-queue', async (job: Job) => {
72
  });
73
 
74
  if (feedbackRes.ok) {
75
- const data = await feedbackRes.json() as any;
76
- feedbackMsg = data.text || 'βœ… Analyse terminΓ©e.';
77
  } else if (feedbackRes.status === 429) {
78
  console.warn(`[WORKER] 429 Error during generate-feedback`);
79
  const fallbackMsg = language === 'WOLOF'
80
  ? "JΓ«rΓ«jΓ«f ci sa tontu ! (Analyse IA temporairement indisponible)"
81
  : "Merci pour ta rΓ©ponse ! (Analyse IA de la rΓ©ponse temporairement indisponible suite Γ  une surcharge, mais ta progression est sauvegardΓ©e).";
82
  await sendTextMessage(user.phone, fallbackMsg);
83
- return; // Stop processing to not crash worker, user stays pending ?
84
- // Fallback to text handled on next user input, or we can just send the fallbackMsg.
85
  } else {
86
  const errText = await feedbackRes.text();
87
  throw new Error(`generate-feedback failed HTTP ${feedbackRes.status}: ${errText}`);
@@ -94,8 +94,6 @@ const worker = new Worker('whatsapp-queue', async (job: Job) => {
94
  if (feedbackMsg) {
95
  await sendTextMessage(user.phone, feedbackMsg);
96
 
97
- // 🌟 WORKER WOW: Extract Business Profile if enabled 🌟
98
- const { isFeatureEnabled } = await import('./config');
99
  if (isFeatureEnabled('FEATURE_BUSINESS_PROFILE') && currentDay) {
100
  try {
101
  const extractRes = await fetch(`${AI_API_BASE_URL.replace(/\/$/, "")}/v1/ai/extract-profile`, {
@@ -115,11 +113,28 @@ const worker = new Worker('whatsapp-queue', async (job: Job) => {
115
 
116
  if (Object.keys(profileData).length > 0) {
117
  console.log(`[WORKER] Updating BusinessProfile for User ${userId}:`, profileData);
118
- await prisma.businessProfile.upsert({
119
  where: { userId },
120
  update: { ...profileData, lastUpdatedFromDay: currentDay },
121
  create: { userId, ...profileData, lastUpdatedFromDay: currentDay }
122
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
123
  }
124
  }
125
  } catch (err: any) {
@@ -127,17 +142,72 @@ const worker = new Worker('whatsapp-queue', async (job: Job) => {
127
  }
128
  }
129
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
130
  await prisma.userProgress.update({
131
  where: { id: pendingProgressId },
132
  data: {
133
  exerciseStatus: 'COMPLETED',
134
- score: { increment: 1 }
135
- }
 
136
  });
137
 
 
 
 
 
 
 
 
 
 
 
 
 
 
138
  await prisma.enrollment.updateMany({
139
- where: { userId: user.id, trackId: trackId },
140
- data: { lastActivityAt: new Date() }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
141
  });
142
 
143
  if (currentDay >= totalDays) {
@@ -160,6 +230,26 @@ const worker = new Worker('whatsapp-queue', async (job: Job) => {
160
  }
161
  }
162
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
163
  else if (job.name === 'send-interactive-buttons') {
164
  const { userId, bodyText, buttons } = job.data;
165
  const user = await prisma.user.findUnique({ where: { id: userId } });
 
3
 
4
  import { Worker, Job, Queue } from 'bullmq';
5
  import dotenv from 'dotenv';
6
+ import { PrismaClient } from '@repo/database';
7
+ import { sendTextMessage, sendDocumentMessage, downloadMedia, sendInteractiveButtonMessage, sendInteractiveListMessage, sendImageMessage } from './whatsapp-cloud';
8
  import { sendLessonDay } from './pedagogy';
9
+ import { getApiUrl, getAdminApiKey, validateEnvironment, isFeatureEnabled } from './config';
10
 
11
  dotenv.config();
12
 
 
49
  const { userId, text, trackId, exercisePrompt, lessonText, pendingProgressId, currentDay, totalDays, language } = job.data;
50
  const user = await prisma.user.findUnique({
51
  where: { id: userId },
52
+ include: { businessProfile: true } as any
53
+ }) as any;
54
  if (!user?.phone) return;
55
 
56
  console.log(`[WORKER] Generating feedback for User ${userId}`);
 
58
  const apiKey = getAdminApiKey();
59
 
60
  let feedbackMsg = '';
61
+ let feedbackData: any = null;
62
  try {
63
  const feedbackRes = await fetch(`${AI_API_BASE_URL.replace(/\/$/, "")}/v1/ai/generate-feedback`, {
64
  method: 'POST',
 
73
  });
74
 
75
  if (feedbackRes.ok) {
76
+ feedbackData = await feedbackRes.json();
77
+ feedbackMsg = feedbackData.text || 'βœ… Analyse terminΓ©e.';
78
  } else if (feedbackRes.status === 429) {
79
  console.warn(`[WORKER] 429 Error during generate-feedback`);
80
  const fallbackMsg = language === 'WOLOF'
81
  ? "JΓ«rΓ«jΓ«f ci sa tontu ! (Analyse IA temporairement indisponible)"
82
  : "Merci pour ta rΓ©ponse ! (Analyse IA de la rΓ©ponse temporairement indisponible suite Γ  une surcharge, mais ta progression est sauvegardΓ©e).";
83
  await sendTextMessage(user.phone, fallbackMsg);
84
+ return;
 
85
  } else {
86
  const errText = await feedbackRes.text();
87
  throw new Error(`generate-feedback failed HTTP ${feedbackRes.status}: ${errText}`);
 
94
  if (feedbackMsg) {
95
  await sendTextMessage(user.phone, feedbackMsg);
96
 
 
 
97
  if (isFeatureEnabled('FEATURE_BUSINESS_PROFILE') && currentDay) {
98
  try {
99
  const extractRes = await fetch(`${AI_API_BASE_URL.replace(/\/$/, "")}/v1/ai/extract-profile`, {
 
113
 
114
  if (Object.keys(profileData).length > 0) {
115
  console.log(`[WORKER] Updating BusinessProfile for User ${userId}:`, profileData);
116
+ await (prisma as any).businessProfile.upsert({
117
  where: { userId },
118
  update: { ...profileData, lastUpdatedFromDay: currentDay },
119
  create: { userId, ...profileData, lastUpdatedFromDay: currentDay }
120
  });
121
+
122
+ // 🌟 Visuals WOW: Generate Pitch Card for Day 1 🌟
123
+ if (isFeatureEnabled('FEATURE_SHARE_CARD') && currentDay === 1 && (profileData as any).activity_label) {
124
+ try {
125
+ const { generatePitchCard } = await import('./visuals');
126
+ const { uploadFile } = await import('./storage');
127
+ const cardBuffer = await generatePitchCard((profileData as any).activity_label);
128
+ const cardUrl = await uploadFile(cardBuffer, 'pitch-card.png', 'image/png');
129
+
130
+ const caption = language === 'WOLOF'
131
+ ? "Sa kàrdu business mu neex ! ✨"
132
+ : "Ta carte business personnalisée ! ✨";
133
+ await sendImageMessage(user.phone, cardUrl, caption);
134
+ } catch (vErr: any) {
135
+ console.error('[WORKER] Pitch Card generation failed:', vErr.message);
136
+ }
137
+ }
138
  }
139
  }
140
  } catch (err: any) {
 
142
  }
143
  }
144
 
145
+ // πŸ… WORKER WOW: Award Badge πŸ…
146
+ const dayBadges: Record<number, string> = {
147
+ 1: "CLARTÉ",
148
+ 2: "CONFIANCE",
149
+ 3: "CLIENT",
150
+ 7: "OFFRE",
151
+ 12: "PITCH"
152
+ };
153
+ const badgeToAward = dayBadges[currentDay];
154
+
155
+ const currentProgress = await prisma.userProgress.findUnique({ where: { id: pendingProgressId } });
156
+ const currentBadges = ((currentProgress as any)?.badges as string[]) || [];
157
+ const updatedBadges = badgeToAward && !currentBadges.includes(badgeToAward)
158
+ ? [...currentBadges, badgeToAward]
159
+ : currentBadges;
160
+
161
  await prisma.userProgress.update({
162
  where: { id: pendingProgressId },
163
  data: {
164
  exerciseStatus: 'COMPLETED',
165
+ score: { increment: 1 },
166
+ badges: updatedBadges
167
+ } as any
168
  });
169
 
170
+ // 🌟 Adaptive Pedagogy: Remediation Logic 🌟
171
+ let nextDay = currentDay + 1;
172
+
173
+ // If Day 1 and not qualified -> Jour 1bis (1.5)
174
+ if (currentDay === 1 && feedbackData?.isQualified === false) {
175
+ console.log(`[WORKER] Day 1 remediation triggered for User ${userId}`);
176
+ nextDay = 1.5;
177
+ }
178
+ // If we were in 1.5 and now qualified -> move to Day 2
179
+ else if (currentDay === 1.5 && feedbackData.isQualified !== false) {
180
+ nextDay = 2;
181
+ }
182
+
183
  await prisma.enrollment.updateMany({
184
+ where: { userId, trackId, status: 'ACTIVE' },
185
+ data: {
186
+ currentDay: nextDay,
187
+ lastActivityAt: new Date()
188
+ }
189
+ });
190
+
191
+ // 🌟 Adaptive Pedagogy: Streak Management 🌟
192
+ const lastActivity = user.lastActivityAt ? new Date(user.lastActivityAt) : null;
193
+ const today = new Date();
194
+ const diffTime = lastActivity ? Math.abs(today.getTime() - lastActivity.getTime()) : Infinity;
195
+ const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
196
+
197
+ let newStreak = (user as any).currentStreak || 0;
198
+ if (diffDays <= 1) {
199
+ newStreak += 1; // Continuous streak
200
+ } else if (diffDays > 1) {
201
+ newStreak = 1; // Streak broken
202
+ }
203
+
204
+ await prisma.user.update({
205
+ where: { id: userId },
206
+ data: {
207
+ lastActivityAt: new Date(),
208
+ currentStreak: newStreak,
209
+ longestStreak: Math.max((user as any).longestStreak || 0, newStreak)
210
+ } as any
211
  });
212
 
213
  if (currentDay >= totalDays) {
 
230
  }
231
  }
232
  }
233
+ else if (job.name === 'send-nudge') {
234
+ const { userId, type } = job.data;
235
+ const user = await prisma.user.findUnique({ where: { id: userId } });
236
+ if (!user?.phone) return;
237
+
238
+ const isWolof = user.language === 'WOLOF';
239
+
240
+ const messages = {
241
+ ENCOURAGEMENT: isWolof
242
+ ? "Assalamuyalaykum ! Fatte wuΓ±u sa mbir. Tontu bu gatt ngir wΓ©y ? πŸ’ͺ"
243
+ : "Coucou ! On n'a pas oubliΓ© ton projet. Une petite rΓ©ponse pour continuer ? πŸ’ͺ",
244
+ RESURRECTION: isWolof
245
+ ? "Sa liggeey mu ngi lay xaar ! Am succΓ¨s dafa laaj lΓ«kkalΓ«. Γ‘u tΓ mbaleeti ? πŸš€"
246
+ : "Ton business t'attend ! Le succΓ¨s vient de la rΓ©gularitΓ©. On s'y remet ? πŸš€"
247
+ };
248
+
249
+ const text = (messages as any)[type] || messages.ENCOURAGEMENT;
250
+ await sendTextMessage(user.phone, text);
251
+ console.log(`[WORKER] Nudge ${type} sent to ${user.phone}`);
252
+ }
253
  else if (job.name === 'send-interactive-buttons') {
254
  const { userId, bodyText, buttons } = job.data;
255
  const user = await prisma.user.findUnique({ where: { id: userId } });
apps/whatsapp-worker/src/pedagogy.ts CHANGED
@@ -1,9 +1,17 @@
1
- import { PrismaClient } from '@prisma/client';
2
- import { sendTextMessage, sendAudioMessage, sendInteractiveButtonMessage, sendInteractiveListMessage } from './whatsapp-cloud';
3
- import { requireHttpUrl, getAdminApiKey } from './config';
4
 
5
  const prisma = new PrismaClient();
6
 
 
 
 
 
 
 
 
 
7
  function generateProgressBar(current: number, total: number): string {
8
  const size = 10;
9
  const progress = Math.min(Math.max(Math.round((current / total) * size), 0), size);
@@ -21,8 +29,8 @@ export async function sendLessonDay(userId: string, trackId: string, dayNumber:
21
  include: {
22
  enrollments: { where: { trackId, status: 'ACTIVE' }, include: { track: true } },
23
  businessProfile: true
24
- }
25
- });
26
  if (!user || !user.phone) {
27
  console.error(`[PEDAGOGY] User ${userId} not found or has no phone number.`);
28
  return;
@@ -74,17 +82,36 @@ export async function sendLessonDay(userId: string, trackId: string, dayNumber:
74
  }
75
  }
76
 
77
- // 🌟 Formatting: Add Header & Progress 🌟
78
- const { isFeatureEnabled } = await import('./config');
79
  const totalDays = activeEnrollment?.track?.duration || 12;
 
 
 
 
 
80
  const progressBar = isFeatureEnabled('FEATURE_PROGRESS_BAR') ? `\n${generateProgressBar(dayNumber, totalDays)}` : '';
81
 
 
 
 
 
 
 
 
 
 
82
  const header = isWolof
83
- ? `*${trackTitle}*\n*BΓ©s ${dayNumber}* πŸ—“οΈ${progressBar}\n\n`
84
- : `*${trackTitle}*\n*Jour ${dayNumber}* πŸ—“οΈ${progressBar}\n\n`;
85
 
86
  lessonText = header + lessonText;
87
 
 
 
 
 
 
 
88
  // 🌟 1. Send Lesson (audio or text) 🌟
89
  let finalAudioUrl = trackDay.audioUrl;
90
 
 
1
+ import { PrismaClient } from '@repo/database';
2
+ import { sendTextMessage, sendAudioMessage, sendInteractiveButtonMessage, sendInteractiveListMessage, sendImageMessage } from './whatsapp-cloud';
3
+ import { requireHttpUrl, getAdminApiKey, isFeatureEnabled } from './config';
4
 
5
  const prisma = new PrismaClient();
6
 
7
+ const BADGE_EMOJIS: Record<string, string> = {
8
+ "CLARTΓ‰": "πŸ…",
9
+ "CONFIANCE": "🌟",
10
+ "CLIENT": "πŸ‘₯",
11
+ "OFFRE": "πŸ“¦",
12
+ "PITCH": "πŸŽ™οΈ"
13
+ };
14
+
15
  function generateProgressBar(current: number, total: number): string {
16
  const size = 10;
17
  const progress = Math.min(Math.max(Math.round((current / total) * size), 0), size);
 
29
  include: {
30
  enrollments: { where: { trackId, status: 'ACTIVE' }, include: { track: true } },
31
  businessProfile: true
32
+ } as any
33
+ }) as any;
34
  if (!user || !user.phone) {
35
  console.error(`[PEDAGOGY] User ${userId} not found or has no phone number.`);
36
  return;
 
82
  }
83
  }
84
 
85
+ // 🌟 Formatting: Add Header & Progress & Badges 🌟
 
86
  const totalDays = activeEnrollment?.track?.duration || 12;
87
+
88
+ const userProgress = await prisma.userProgress.findUnique({
89
+ where: { userId_trackId: { userId, trackId } }
90
+ }) as any;
91
+
92
  const progressBar = isFeatureEnabled('FEATURE_PROGRESS_BAR') ? `\n${generateProgressBar(dayNumber, totalDays)}` : '';
93
 
94
+ let badgeText = '';
95
+ const badges = (userProgress?.badges as string[]) || [];
96
+ if (badges.length > 0) {
97
+ const lastBadge = badges[badges.length - 1];
98
+ badgeText = `\nBadge : ${lastBadge} ${BADGE_EMOJIS[lastBadge] || 'πŸ…'}`;
99
+ }
100
+
101
+ const dayDisplay = dayNumber === 1.5 ? '1bis' : Math.floor(dayNumber).toString();
102
+
103
  const header = isWolof
104
+ ? `*${trackTitle}*\n*BΓ©s ${dayDisplay}* πŸ—“οΈ${progressBar}${badgeText}\n\n`
105
+ : `*${trackTitle}*\n*Jour ${dayDisplay}* πŸ—“οΈ${progressBar}${badgeText}\n\n`;
106
 
107
  lessonText = header + lessonText;
108
 
109
+ // 🌟 Visuals WoW: Send day image if available 🌟
110
+ if ((trackDay as any).imageUrl) {
111
+ console.log(`[PEDAGOGY] Sending daily image: ${(trackDay as any).imageUrl}`);
112
+ await sendImageMessage(user.phone, (trackDay as any).imageUrl);
113
+ }
114
+
115
  // 🌟 1. Send Lesson (audio or text) 🌟
116
  let finalAudioUrl = trackDay.audioUrl;
117
 
apps/whatsapp-worker/src/scheduler.ts CHANGED
@@ -1,6 +1,6 @@
1
  import cron from 'node-cron';
2
  import { Queue } from 'bullmq';
3
- import { PrismaClient } from '@prisma/client';
4
  import Redis from 'ioredis';
5
 
6
  const prisma = new PrismaClient();
@@ -35,7 +35,16 @@ export function startDailyScheduler() {
35
  });
36
 
37
  if (progress?.exerciseStatus === 'PENDING') {
38
- console.log(`[SCHEDULER] Skip User ${enrollment.userId} β€” Day ${enrollment.currentDay} still PENDING`);
 
 
 
 
 
 
 
 
 
39
  continue;
40
  }
41
 
 
1
  import cron from 'node-cron';
2
  import { Queue } from 'bullmq';
3
+ import { PrismaClient } from '@repo/database';
4
  import Redis from 'ioredis';
5
 
6
  const prisma = new PrismaClient();
 
35
  });
36
 
37
  if (progress?.exerciseStatus === 'PENDING') {
38
+ const lastInteraction = progress.lastInteraction;
39
+ const hoursSinceLast = (Date.now() - new Date(lastInteraction).getTime()) / (1000 * 60 * 60);
40
+
41
+ if (hoursSinceLast >= 72) {
42
+ console.log(`[SCHEDULER] Queuing RESURRECTION nudge for User ${enrollment.userId}`);
43
+ await whatsappQueue.add('send-nudge', { userId: enrollment.userId, type: 'RESURRECTION' });
44
+ } else if (hoursSinceLast >= 24) {
45
+ console.log(`[SCHEDULER] Queuing ENCOURAGEMENT nudge for User ${enrollment.userId}`);
46
+ await whatsappQueue.add('send-nudge', { userId: enrollment.userId, type: 'ENCOURAGEMENT' });
47
+ }
48
  continue;
49
  }
50
 
apps/whatsapp-worker/src/storage.ts ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
2
+ import crypto from 'crypto';
3
+ import path from 'path';
4
+
5
+ export async function uploadFile(buffer: Buffer, originalFilename: string, contentType: string): Promise<string> {
6
+ const accountId = process.env.R2_ACCOUNT_ID;
7
+ const bucket = process.env.R2_BUCKET;
8
+ const accessKeyId = process.env.R2_ACCESS_KEY_ID;
9
+ const secretAccessKey = process.env.R2_SECRET_ACCESS_KEY;
10
+ const publicUrl = process.env.R2_PUBLIC_URL;
11
+
12
+ if (!accountId || !bucket || !accessKeyId || !secretAccessKey || !publicUrl) {
13
+ console.warn('[Storage] R2 not fully configured β€” returning dummy URL');
14
+ return `https://dummy-storage.com/${originalFilename}`;
15
+ }
16
+
17
+ const ext = path.extname(originalFilename);
18
+ const uniqueName = `${crypto.randomUUID()}-${Date.now()}${ext}`;
19
+
20
+ const client = new S3Client({
21
+ region: 'auto',
22
+ endpoint: `https://${accountId}.r2.cloudflarestorage.com`,
23
+ credentials: { accessKeyId, secretAccessKey },
24
+ });
25
+
26
+ await client.send(new PutObjectCommand({
27
+ Bucket: bucket,
28
+ Key: uniqueName,
29
+ Body: buffer,
30
+ ContentType: contentType,
31
+ }));
32
+
33
+ return `${publicUrl.replace(/\/$/, "")}/${uniqueName}`;
34
+ }
apps/whatsapp-worker/src/visuals.ts ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sharp from 'sharp';
2
+ import path from 'path';
3
+
4
+ /**
5
+ * Wraps text into multiple lines for SVG.
6
+ */
7
+ function wrapText(text: string, maxCharsPerLine: number): string[] {
8
+ const words = text.split(' ');
9
+ const lines: string[] = [];
10
+ let currentLine = '';
11
+
12
+ words.forEach(word => {
13
+ if ((currentLine + word).length > maxCharsPerLine) {
14
+ lines.push(currentLine.trim());
15
+ currentLine = word + ' ';
16
+ } else {
17
+ currentLine += word + ' ';
18
+ }
19
+ });
20
+ lines.push(currentLine.trim());
21
+ return lines;
22
+ }
23
+
24
+ /**
25
+ * Generates a personalized business pitch card with text overlay.
26
+ */
27
+ export async function generatePitchCard(text: string): Promise<Buffer> {
28
+ const templatePath = path.resolve(__dirname, '../assets/templates/pitch_card.png');
29
+
30
+ const lines = wrapText(text.toUpperCase(), 25);
31
+ const lineHeight = 60;
32
+ const startY = 540 - (lines.length * lineHeight) / 2;
33
+
34
+ const svgText = lines.map((line, i) =>
35
+ `<text x="540" y="${startY + (i * lineHeight)}" class="text">${line}</text>`
36
+ ).join('');
37
+
38
+ const svgOverlay = Buffer.from(`
39
+ <svg width="1080" height="1080">
40
+ <style>
41
+ .title {
42
+ fill: #d4af37;
43
+ font-size: 56px;
44
+ font-family: sans-serif;
45
+ font-weight: 900;
46
+ text-anchor: middle;
47
+ text-transform: uppercase;
48
+ letter-spacing: 3px;
49
+ }
50
+ .label {
51
+ fill: #ffffff;
52
+ font-size: 28px;
53
+ font-family: sans-serif;
54
+ font-weight: 600;
55
+ text-anchor: middle;
56
+ opacity: 0.8;
57
+ }
58
+ .text {
59
+ fill: #ffffff;
60
+ font-size: 48px;
61
+ font-family: sans-serif;
62
+ font-weight: 800;
63
+ text-anchor: middle;
64
+ letter-spacing: 1px;
65
+ }
66
+ </style>
67
+ <text x="540" y="250" class="title">MA CARTE BUSINESS</text>
68
+ <text x="540" y="440" class="label">MON PROJET :</text>
69
+ ${svgText}
70
+ <text x="540" y="850" class="label">XAMLÉ - 2026</text>
71
+ </svg>`);
72
+
73
+ return sharp(templatePath)
74
+ .composite([{ input: svgOverlay, top: 0, left: 0 }])
75
+ .png()
76
+ .toBuffer();
77
+ }
apps/whatsapp-worker/src/whatsapp-cloud.ts CHANGED
@@ -7,6 +7,8 @@
7
  * - WHATSAPP_PHONE_NUMBER_ID (Phone Number ID from Meta App dashboard)
8
  */
9
 
 
 
10
  import axios from 'axios';
11
 
12
  const GRAPH_API_VERSION = 'v18.0';
@@ -55,6 +57,38 @@ export async function sendTextMessage(to: string, text: string): Promise<void> {
55
  console.log(`[WhatsApp] βœ… Text message sent to ${to}`);
56
  }
57
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
  /**
59
  * Send a document (PDF/PPTX) via a public URL as a WhatsApp document message.
60
  * @param to - Recipient phone number
 
7
  * - WHATSAPP_PHONE_NUMBER_ID (Phone Number ID from Meta App dashboard)
8
  */
9
 
10
+ export interface WhatsAppButton { id: string; title: string }
11
+
12
  import axios from 'axios';
13
 
14
  const GRAPH_API_VERSION = 'v18.0';
 
57
  console.log(`[WhatsApp] βœ… Text message sent to ${to}`);
58
  }
59
 
60
+ /**
61
+ * Send an image via a public URL.
62
+ * @param to - Recipient phone number
63
+ * @param imageUrl - Public URL of the image
64
+ * @param caption - Optional caption shown under the image
65
+ */
66
+ export async function sendImageMessage(to: string, imageUrl: string, caption?: string): Promise<void> {
67
+ if (process.env.DISABLE_WHATSAPP_SEND === 'true') {
68
+ console.warn(`[WhatsApp] DISABLE_WHATSAPP_SEND=true β€” Skipping image send to ${to}. URL: ${imageUrl}`);
69
+ return;
70
+ }
71
+
72
+ const body = {
73
+ messaging_product: 'whatsapp',
74
+ recipient_type: 'individual',
75
+ to,
76
+ type: 'image',
77
+ image: {
78
+ link: imageUrl,
79
+ ...(caption ? { caption } : {}),
80
+ },
81
+ };
82
+
83
+ try {
84
+ await axios.post(getBaseUrl(), body, { headers: getHeaders() });
85
+ } catch (err: any) {
86
+ throw new Error(`[WhatsApp] sendImageMessage failed for URL [${imageUrl}]: ${err.response?.data?.error?.message || err.message}`);
87
+ }
88
+
89
+ console.log(`[WhatsApp] βœ… Image message sent to ${to}`);
90
+ }
91
+
92
  /**
93
  * Send a document (PDF/PPTX) via a public URL as a WhatsApp document message.
94
  * @param to - Recipient phone number
apps/whatsapp-worker/tsconfig.tsbuildinfo CHANGED
@@ -1 +1 @@
1
- {"root":["./src/index.ts","./src/pedagogy.ts","./src/scheduler.ts","./src/whatsapp-cloud.ts"],"version":"5.9.3"}
 
1
+ {"root":["./src/config.ts","./src/index.ts","./src/pedagogy.ts","./src/scheduler.ts","./src/whatsapp-cloud.ts"],"version":"5.9.3"}
packages/database/prisma/schema.prisma CHANGED
@@ -18,6 +18,10 @@ model User {
18
  createdAt DateTime @default(now())
19
  updatedAt DateTime @updatedAt
20
 
 
 
 
 
21
  enrollments Enrollment[]
22
  responses Response[]
23
  messages Message[]
@@ -68,7 +72,7 @@ model Track {
68
  model TrackDay {
69
  id String @id @default(uuid())
70
  trackId String
71
- dayNumber Int
72
  title String?
73
  audioUrl String?
74
  lessonText String?
@@ -90,6 +94,7 @@ model UserProgress {
90
  score Int @default(0)
91
  lastInteraction DateTime @default(now())
92
  exerciseStatus ExerciseStatus @default(PENDING)
 
93
  createdAt DateTime @default(now())
94
  updatedAt DateTime @updatedAt
95
 
@@ -104,7 +109,7 @@ model Enrollment {
104
  userId String
105
  trackId String
106
  status EnrollmentStatus @default(ACTIVE)
107
- currentDay Int @default(1)
108
  startedAt DateTime @default(now())
109
  completedAt DateTime?
110
  lastActivityAt DateTime @default(now())
 
18
  createdAt DateTime @default(now())
19
  updatedAt DateTime @updatedAt
20
 
21
+ currentStreak Int @default(0)
22
+ longestStreak Int @default(0)
23
+ lastActivityAt DateTime?
24
+
25
  enrollments Enrollment[]
26
  responses Response[]
27
  messages Message[]
 
72
  model TrackDay {
73
  id String @id @default(uuid())
74
  trackId String
75
+ dayNumber Float
76
  title String?
77
  audioUrl String?
78
  lessonText String?
 
94
  score Int @default(0)
95
  lastInteraction DateTime @default(now())
96
  exerciseStatus ExerciseStatus @default(PENDING)
97
+ badges Json? // Array of strings: ["CLARTE", "CONFIANCE"]
98
  createdAt DateTime @default(now())
99
  updatedAt DateTime @updatedAt
100
 
 
109
  userId String
110
  trackId String
111
  status EnrollmentStatus @default(ACTIVE)
112
+ currentDay Float @default(1)
113
  startedAt DateTime @default(now())
114
  completedAt DateTime?
115
  lastActivityAt DateTime @default(now())
packages/database/run-seed.ts ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { PrismaClient } from '@repo/database';
2
+ import { seedDatabase } from './src/seed';
3
+
4
+ const prisma = new PrismaClient();
5
+
6
+ async function main() {
7
+ console.log('πŸš€ Running seed runner...');
8
+ const result = await seedDatabase(prisma);
9
+ console.log(result.message);
10
+ }
11
+
12
+ main()
13
+ .catch(console.error)
14
+ .finally(() => prisma.$disconnect());
packages/database/src/seed.ts CHANGED
@@ -23,6 +23,7 @@ export async function seedDatabase(prisma: PrismaClient): Promise<{ seeded: bool
23
  days: {
24
  create: [
25
  { dayNumber: 1, exerciseType: "AUDIO", lessonText: "Aujourd'hui, on commence simple. Beaucoup de personnes disent : je fais le commerce. Mais Γ§a ne veut rien dire. Dis-moi clairement : Tu aides QUI, Γ  faire QUOI, et comment tu gagnes de l'argent. Exemple : Je vends du jus bissap aux Γ©tudiants devant l'universitΓ©. Maintenant, c'est Γ  toi. Dis ta phrase en 15 secondes.", exercisePrompt: "Envoie-moi un court message vocal (ou texte) avec ta phrase d'activitΓ© :" },
 
26
  { dayNumber: 2, exerciseType: "AUDIO", lessonText: "Le client n'achΓ¨te pas ton produit. Il achΓ¨te un rΓ©sultat. Il n'achΓ¨te pas du savon. Il achΓ¨te la propretΓ©. Va demander Γ  2 clients : Pourquoi tu achΓ¨tes Γ§a ? Γ‰coute bien leurs mots.", exercisePrompt: "Envoie un audio rΓ©sumant les 2 rΓ©ponses de tes clients." },
27
  { dayNumber: 3, exerciseType: "BUTTON", lessonText: "Si tu vends Γ  tout le monde, tu ne vends Γ  personne. Choisis un seul client principal. Qui est le plus intΓ©ressΓ© par ton produit ?", exercisePrompt: "SΓ©lectionne ton client principal ci-dessous :", buttonsJson: [{ id: "jeunes", title: "Jeunes" }, { id: "femmes", title: "Femmes" }, { id: "commercants", title: "CommerΓ§ants" }] },
28
  { dayNumber: 4, exerciseType: "TEXT", lessonText: "Ton client a un problΓ¨me. Quel est son plus grand problΓ¨me ? Parle Γ  3 personnes aujourd'hui. Pose cette question. Γ‰coute sans expliquer ton produit.", exercisePrompt: "Quel est le problΓ¨me NΒ°1 que tes clients t'ont partagΓ© ?" },
@@ -49,6 +50,7 @@ export async function seedDatabase(prisma: PrismaClient): Promise<{ seeded: bool
49
  days: {
50
  create: [
51
  { dayNumber: 1, exerciseType: "AUDIO", lessonText: "Tey, danuy tΓ mbalee ak lu yomb. Nit Γ±u bari daΓ±uy wax : dama def commerce. Waaye loolu amul solo. Wax ma leer : Yaay jΓ ppalΓ© KAN, mu def LAN, te naka nga amee xaalis. Misaal : Damaa jaay jus bissap ci taalibe yu universitΓ©. LΓ©egi sa waxtu la. Wax sa activitΓ© ci 15 seconde.", exercisePrompt: "YΓ³nnee ma ab kΓ ddu (audio) walla message bu gatt ngir wax sa mbir :" },
 
52
  { dayNumber: 2, exerciseType: "AUDIO", lessonText: "Kiliifa bi du jΓ«nd sa produit rek. Mu jΓ«nd ab rΓ©sultat. Du jΓ«nd savon rek. Mu jΓ«nd set. Dem laaj 2 kiliifa : Lu tax nga jΓ«nd lii ? DΓ©ggal bu baax li Γ±uy wax.", exercisePrompt: "YΓ³nnee ma audio ngir tΓ«nk Γ±aari tontu ya." },
53
  { dayNumber: 3, exerciseType: "BUTTON", lessonText: "Su nga jaay ci Γ±Γ©pp, doo jaay ci kenn. TΓ nnal benn kiliifa bu mag. Kan moo gΓ«n a soxla sa produit ?", exercisePrompt: "TΓ nnal sa kiliifa bu mag ci suuf :", buttonsJson: [{ id: "ndaw_nyi", title: "Ndaw Γ±i / Jeunes" }, { id: "jigeen_nyi", title: "Jigeen Γ±i / Femmes" }, { id: "jaaykat_yi", title: "Jaaykat yi / Comms" }] },
54
  { dayNumber: 4, exerciseType: "TEXT", lessonText: "Sa kiliifa am na jafe jafe. Lan mooy jafe jafe bu gΓ«n a rΓ«y ? Dem waxtaan ak 3 nit. Laaj leen. Bul def publicitΓ©.", exercisePrompt: "Lan mooy jafe jafe bu gΓ«n a mag bi sa kiliifa yi am ?" },
 
23
  days: {
24
  create: [
25
  { dayNumber: 1, exerciseType: "AUDIO", lessonText: "Aujourd'hui, on commence simple. Beaucoup de personnes disent : je fais le commerce. Mais Γ§a ne veut rien dire. Dis-moi clairement : Tu aides QUI, Γ  faire QUOI, et comment tu gagnes de l'argent. Exemple : Je vends du jus bissap aux Γ©tudiants devant l'universitΓ©. Maintenant, c'est Γ  toi. Dis ta phrase en 15 secondes.", exercisePrompt: "Envoie-moi un court message vocal (ou texte) avec ta phrase d'activitΓ© :" },
26
+ { dayNumber: 1.5, exerciseType: "AUDIO", lessonText: "D'accord, on va prΓ©ciser. Pour bien rΓ©ussir, ta phrase doit rΓ©pondre Γ  3 questions : QUI (ton client), QUOI (ton produit), et COMMENT (ta mΓ©thode). Exemple : 'Je vends (QUOI) des beignets aux (QUI) travailleurs du marchΓ© (COMMENT) le matin tΓ΄t.' RΓ©essaie en incluant bien ces 3 points.", exercisePrompt: "Renvoye-moi ta phrase corrigΓ©e avec QUI + QUOI + COMMENT :" },
27
  { dayNumber: 2, exerciseType: "AUDIO", lessonText: "Le client n'achΓ¨te pas ton produit. Il achΓ¨te un rΓ©sultat. Il n'achΓ¨te pas du savon. Il achΓ¨te la propretΓ©. Va demander Γ  2 clients : Pourquoi tu achΓ¨tes Γ§a ? Γ‰coute bien leurs mots.", exercisePrompt: "Envoie un audio rΓ©sumant les 2 rΓ©ponses de tes clients." },
28
  { dayNumber: 3, exerciseType: "BUTTON", lessonText: "Si tu vends Γ  tout le monde, tu ne vends Γ  personne. Choisis un seul client principal. Qui est le plus intΓ©ressΓ© par ton produit ?", exercisePrompt: "SΓ©lectionne ton client principal ci-dessous :", buttonsJson: [{ id: "jeunes", title: "Jeunes" }, { id: "femmes", title: "Femmes" }, { id: "commercants", title: "CommerΓ§ants" }] },
29
  { dayNumber: 4, exerciseType: "TEXT", lessonText: "Ton client a un problΓ¨me. Quel est son plus grand problΓ¨me ? Parle Γ  3 personnes aujourd'hui. Pose cette question. Γ‰coute sans expliquer ton produit.", exercisePrompt: "Quel est le problΓ¨me NΒ°1 que tes clients t'ont partagΓ© ?" },
 
50
  days: {
51
  create: [
52
  { dayNumber: 1, exerciseType: "AUDIO", lessonText: "Tey, danuy tΓ mbalee ak lu yomb. Nit Γ±u bari daΓ±uy wax : dama def commerce. Waaye loolu amul solo. Wax ma leer : Yaay jΓ ppalΓ© KAN, mu def LAN, te naka nga amee xaalis. Misaal : Damaa jaay jus bissap ci taalibe yu universitΓ©. LΓ©egi sa waxtu la. Wax sa activitΓ© ci 15 seconde.", exercisePrompt: "YΓ³nnee ma ab kΓ ddu (audio) walla message bu gatt ngir wax sa mbir :" },
53
+ { dayNumber: 1.5, exerciseType: "AUDIO", lessonText: "Waaw, dinaΓ±u ko gΓ«n a leerale. Ngir sa mbir neex, sa phrase war na tontu ci 3 laaj : KAN (sa kiliifa), LAN (sa produit), ak NAKA (nan ngay dΓ©fee). Misaal : 'Damaa jaay (LAN) mburu ci (KAN) ligeey kat yi (NAKA) suba tΓ«l.' Γ‘aataal ko lΓ©egi, te bul fatte benn ci Γ±etti point yii.", exercisePrompt: "RenvoyΓ© ma sa phrase bu leeral, booleel KAN + LAN + NAKA :" },
54
  { dayNumber: 2, exerciseType: "AUDIO", lessonText: "Kiliifa bi du jΓ«nd sa produit rek. Mu jΓ«nd ab rΓ©sultat. Du jΓ«nd savon rek. Mu jΓ«nd set. Dem laaj 2 kiliifa : Lu tax nga jΓ«nd lii ? DΓ©ggal bu baax li Γ±uy wax.", exercisePrompt: "YΓ³nnee ma audio ngir tΓ«nk Γ±aari tontu ya." },
55
  { dayNumber: 3, exerciseType: "BUTTON", lessonText: "Su nga jaay ci Γ±Γ©pp, doo jaay ci kenn. TΓ nnal benn kiliifa bu mag. Kan moo gΓ«n a soxla sa produit ?", exercisePrompt: "TΓ nnal sa kiliifa bu mag ci suuf :", buttonsJson: [{ id: "ndaw_nyi", title: "Ndaw Γ±i / Jeunes" }, { id: "jigeen_nyi", title: "Jigeen Γ±i / Femmes" }, { id: "jaaykat_yi", title: "Jaaykat yi / Comms" }] },
56
  { dayNumber: 4, exerciseType: "TEXT", lessonText: "Sa kiliifa am na jafe jafe. Lan mooy jafe jafe bu gΓ«n a rΓ«y ? Dem waxtaan ak 3 nit. Laaj leen. Bul def publicitΓ©.", exercisePrompt: "Lan mooy jafe jafe bu gΓ«n a mag bi sa kiliifa yi am ?" },
pnpm-lock.yaml CHANGED
@@ -176,9 +176,9 @@ importers:
176
 
177
  apps/whatsapp-worker:
178
  dependencies:
179
- '@prisma/client':
180
- specifier: ^5.0.0
181
- version: 5.22.0(prisma@5.22.0)
182
  '@repo/database':
183
  specifier: workspace:*
184
  version: link:../../packages/database
@@ -197,6 +197,9 @@ importers:
197
  node-cron:
198
  specifier: ^4.2.1
199
  version: 4.2.1
 
 
 
200
  devDependencies:
201
  '@repo/tsconfig':
202
  specifier: workspace:*
@@ -501,6 +504,9 @@ packages:
501
  resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
502
  engines: {node: '>=6.9.0'}
503
 
 
 
 
504
  '@esbuild/aix-ppc64@0.21.5':
505
  resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==}
506
  engines: {node: '>=12'}
@@ -789,6 +795,143 @@ packages:
789
  '@fastify/rate-limit@9.1.0':
790
  resolution: {integrity: sha512-h5dZWCkuZXN0PxwqaFQLxeln8/LNwQwH9popywmDCFdKfgpi4b/HoMH1lluy6P+30CG9yzzpSpwTCIPNB9T1JA==}
791
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
792
  '@ioredis/commands@1.5.0':
793
  resolution: {integrity: sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow==}
794
 
@@ -2368,6 +2511,10 @@ packages:
2368
  setimmediate@1.0.5:
2369
  resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==}
2370
 
 
 
 
 
2371
  smart-buffer@4.2.0:
2372
  resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==}
2373
  engines: {node: '>= 6.0.0', npm: '>= 3.0.0'}
@@ -3252,6 +3399,11 @@ snapshots:
3252
  '@babel/helper-string-parser': 7.27.1
3253
  '@babel/helper-validator-identifier': 7.28.5
3254
 
 
 
 
 
 
3255
  '@esbuild/aix-ppc64@0.21.5':
3256
  optional: true
3257
 
@@ -3414,6 +3566,102 @@ snapshots:
3414
  fastify-plugin: 4.5.1
3415
  toad-cache: 3.7.0
3416
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3417
  '@ioredis/commands@1.5.0': {}
3418
 
3419
  '@jridgewell/gen-mapping@0.3.13':
@@ -4266,8 +4514,7 @@ snapshots:
4266
 
4267
  denque@2.1.0: {}
4268
 
4269
- detect-libc@2.1.2:
4270
- optional: true
4271
 
4272
  devtools-protocol@0.0.1312386: {}
4273
 
@@ -5147,6 +5394,37 @@ snapshots:
5147
 
5148
  setimmediate@1.0.5: {}
5149
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5150
  smart-buffer@4.2.0: {}
5151
 
5152
  socks-proxy-agent@8.0.5:
 
176
 
177
  apps/whatsapp-worker:
178
  dependencies:
179
+ '@aws-sdk/client-s3':
180
+ specifier: ^3.995.0
181
+ version: 3.995.0
182
  '@repo/database':
183
  specifier: workspace:*
184
  version: link:../../packages/database
 
197
  node-cron:
198
  specifier: ^4.2.1
199
  version: 4.2.1
200
+ sharp:
201
+ specifier: ^0.34.5
202
+ version: 0.34.5
203
  devDependencies:
204
  '@repo/tsconfig':
205
  specifier: workspace:*
 
504
  resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
505
  engines: {node: '>=6.9.0'}
506
 
507
+ '@emnapi/runtime@1.8.1':
508
+ resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==}
509
+
510
  '@esbuild/aix-ppc64@0.21.5':
511
  resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==}
512
  engines: {node: '>=12'}
 
795
  '@fastify/rate-limit@9.1.0':
796
  resolution: {integrity: sha512-h5dZWCkuZXN0PxwqaFQLxeln8/LNwQwH9popywmDCFdKfgpi4b/HoMH1lluy6P+30CG9yzzpSpwTCIPNB9T1JA==}
797
 
798
+ '@img/colour@1.0.0':
799
+ resolution: {integrity: sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==}
800
+ engines: {node: '>=18'}
801
+
802
+ '@img/sharp-darwin-arm64@0.34.5':
803
+ resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==}
804
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
805
+ cpu: [arm64]
806
+ os: [darwin]
807
+
808
+ '@img/sharp-darwin-x64@0.34.5':
809
+ resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==}
810
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
811
+ cpu: [x64]
812
+ os: [darwin]
813
+
814
+ '@img/sharp-libvips-darwin-arm64@1.2.4':
815
+ resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==}
816
+ cpu: [arm64]
817
+ os: [darwin]
818
+
819
+ '@img/sharp-libvips-darwin-x64@1.2.4':
820
+ resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==}
821
+ cpu: [x64]
822
+ os: [darwin]
823
+
824
+ '@img/sharp-libvips-linux-arm64@1.2.4':
825
+ resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
826
+ cpu: [arm64]
827
+ os: [linux]
828
+
829
+ '@img/sharp-libvips-linux-arm@1.2.4':
830
+ resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
831
+ cpu: [arm]
832
+ os: [linux]
833
+
834
+ '@img/sharp-libvips-linux-ppc64@1.2.4':
835
+ resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
836
+ cpu: [ppc64]
837
+ os: [linux]
838
+
839
+ '@img/sharp-libvips-linux-riscv64@1.2.4':
840
+ resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
841
+ cpu: [riscv64]
842
+ os: [linux]
843
+
844
+ '@img/sharp-libvips-linux-s390x@1.2.4':
845
+ resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
846
+ cpu: [s390x]
847
+ os: [linux]
848
+
849
+ '@img/sharp-libvips-linux-x64@1.2.4':
850
+ resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
851
+ cpu: [x64]
852
+ os: [linux]
853
+
854
+ '@img/sharp-libvips-linuxmusl-arm64@1.2.4':
855
+ resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
856
+ cpu: [arm64]
857
+ os: [linux]
858
+
859
+ '@img/sharp-libvips-linuxmusl-x64@1.2.4':
860
+ resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
861
+ cpu: [x64]
862
+ os: [linux]
863
+
864
+ '@img/sharp-linux-arm64@0.34.5':
865
+ resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
866
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
867
+ cpu: [arm64]
868
+ os: [linux]
869
+
870
+ '@img/sharp-linux-arm@0.34.5':
871
+ resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
872
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
873
+ cpu: [arm]
874
+ os: [linux]
875
+
876
+ '@img/sharp-linux-ppc64@0.34.5':
877
+ resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
878
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
879
+ cpu: [ppc64]
880
+ os: [linux]
881
+
882
+ '@img/sharp-linux-riscv64@0.34.5':
883
+ resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
884
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
885
+ cpu: [riscv64]
886
+ os: [linux]
887
+
888
+ '@img/sharp-linux-s390x@0.34.5':
889
+ resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
890
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
891
+ cpu: [s390x]
892
+ os: [linux]
893
+
894
+ '@img/sharp-linux-x64@0.34.5':
895
+ resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
896
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
897
+ cpu: [x64]
898
+ os: [linux]
899
+
900
+ '@img/sharp-linuxmusl-arm64@0.34.5':
901
+ resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
902
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
903
+ cpu: [arm64]
904
+ os: [linux]
905
+
906
+ '@img/sharp-linuxmusl-x64@0.34.5':
907
+ resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
908
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
909
+ cpu: [x64]
910
+ os: [linux]
911
+
912
+ '@img/sharp-wasm32@0.34.5':
913
+ resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
914
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
915
+ cpu: [wasm32]
916
+
917
+ '@img/sharp-win32-arm64@0.34.5':
918
+ resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==}
919
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
920
+ cpu: [arm64]
921
+ os: [win32]
922
+
923
+ '@img/sharp-win32-ia32@0.34.5':
924
+ resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==}
925
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
926
+ cpu: [ia32]
927
+ os: [win32]
928
+
929
+ '@img/sharp-win32-x64@0.34.5':
930
+ resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==}
931
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
932
+ cpu: [x64]
933
+ os: [win32]
934
+
935
  '@ioredis/commands@1.5.0':
936
  resolution: {integrity: sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow==}
937
 
 
2511
  setimmediate@1.0.5:
2512
  resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==}
2513
 
2514
+ sharp@0.34.5:
2515
+ resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
2516
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
2517
+
2518
  smart-buffer@4.2.0:
2519
  resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==}
2520
  engines: {node: '>= 6.0.0', npm: '>= 3.0.0'}
 
3399
  '@babel/helper-string-parser': 7.27.1
3400
  '@babel/helper-validator-identifier': 7.28.5
3401
 
3402
+ '@emnapi/runtime@1.8.1':
3403
+ dependencies:
3404
+ tslib: 2.8.1
3405
+ optional: true
3406
+
3407
  '@esbuild/aix-ppc64@0.21.5':
3408
  optional: true
3409
 
 
3566
  fastify-plugin: 4.5.1
3567
  toad-cache: 3.7.0
3568
 
3569
+ '@img/colour@1.0.0': {}
3570
+
3571
+ '@img/sharp-darwin-arm64@0.34.5':
3572
+ optionalDependencies:
3573
+ '@img/sharp-libvips-darwin-arm64': 1.2.4
3574
+ optional: true
3575
+
3576
+ '@img/sharp-darwin-x64@0.34.5':
3577
+ optionalDependencies:
3578
+ '@img/sharp-libvips-darwin-x64': 1.2.4
3579
+ optional: true
3580
+
3581
+ '@img/sharp-libvips-darwin-arm64@1.2.4':
3582
+ optional: true
3583
+
3584
+ '@img/sharp-libvips-darwin-x64@1.2.4':
3585
+ optional: true
3586
+
3587
+ '@img/sharp-libvips-linux-arm64@1.2.4':
3588
+ optional: true
3589
+
3590
+ '@img/sharp-libvips-linux-arm@1.2.4':
3591
+ optional: true
3592
+
3593
+ '@img/sharp-libvips-linux-ppc64@1.2.4':
3594
+ optional: true
3595
+
3596
+ '@img/sharp-libvips-linux-riscv64@1.2.4':
3597
+ optional: true
3598
+
3599
+ '@img/sharp-libvips-linux-s390x@1.2.4':
3600
+ optional: true
3601
+
3602
+ '@img/sharp-libvips-linux-x64@1.2.4':
3603
+ optional: true
3604
+
3605
+ '@img/sharp-libvips-linuxmusl-arm64@1.2.4':
3606
+ optional: true
3607
+
3608
+ '@img/sharp-libvips-linuxmusl-x64@1.2.4':
3609
+ optional: true
3610
+
3611
+ '@img/sharp-linux-arm64@0.34.5':
3612
+ optionalDependencies:
3613
+ '@img/sharp-libvips-linux-arm64': 1.2.4
3614
+ optional: true
3615
+
3616
+ '@img/sharp-linux-arm@0.34.5':
3617
+ optionalDependencies:
3618
+ '@img/sharp-libvips-linux-arm': 1.2.4
3619
+ optional: true
3620
+
3621
+ '@img/sharp-linux-ppc64@0.34.5':
3622
+ optionalDependencies:
3623
+ '@img/sharp-libvips-linux-ppc64': 1.2.4
3624
+ optional: true
3625
+
3626
+ '@img/sharp-linux-riscv64@0.34.5':
3627
+ optionalDependencies:
3628
+ '@img/sharp-libvips-linux-riscv64': 1.2.4
3629
+ optional: true
3630
+
3631
+ '@img/sharp-linux-s390x@0.34.5':
3632
+ optionalDependencies:
3633
+ '@img/sharp-libvips-linux-s390x': 1.2.4
3634
+ optional: true
3635
+
3636
+ '@img/sharp-linux-x64@0.34.5':
3637
+ optionalDependencies:
3638
+ '@img/sharp-libvips-linux-x64': 1.2.4
3639
+ optional: true
3640
+
3641
+ '@img/sharp-linuxmusl-arm64@0.34.5':
3642
+ optionalDependencies:
3643
+ '@img/sharp-libvips-linuxmusl-arm64': 1.2.4
3644
+ optional: true
3645
+
3646
+ '@img/sharp-linuxmusl-x64@0.34.5':
3647
+ optionalDependencies:
3648
+ '@img/sharp-libvips-linuxmusl-x64': 1.2.4
3649
+ optional: true
3650
+
3651
+ '@img/sharp-wasm32@0.34.5':
3652
+ dependencies:
3653
+ '@emnapi/runtime': 1.8.1
3654
+ optional: true
3655
+
3656
+ '@img/sharp-win32-arm64@0.34.5':
3657
+ optional: true
3658
+
3659
+ '@img/sharp-win32-ia32@0.34.5':
3660
+ optional: true
3661
+
3662
+ '@img/sharp-win32-x64@0.34.5':
3663
+ optional: true
3664
+
3665
  '@ioredis/commands@1.5.0': {}
3666
 
3667
  '@jridgewell/gen-mapping@0.3.13':
 
4514
 
4515
  denque@2.1.0: {}
4516
 
4517
+ detect-libc@2.1.2: {}
 
4518
 
4519
  devtools-protocol@0.0.1312386: {}
4520
 
 
5394
 
5395
  setimmediate@1.0.5: {}
5396
 
5397
+ sharp@0.34.5:
5398
+ dependencies:
5399
+ '@img/colour': 1.0.0
5400
+ detect-libc: 2.1.2
5401
+ semver: 7.7.4
5402
+ optionalDependencies:
5403
+ '@img/sharp-darwin-arm64': 0.34.5
5404
+ '@img/sharp-darwin-x64': 0.34.5
5405
+ '@img/sharp-libvips-darwin-arm64': 1.2.4
5406
+ '@img/sharp-libvips-darwin-x64': 1.2.4
5407
+ '@img/sharp-libvips-linux-arm': 1.2.4
5408
+ '@img/sharp-libvips-linux-arm64': 1.2.4
5409
+ '@img/sharp-libvips-linux-ppc64': 1.2.4
5410
+ '@img/sharp-libvips-linux-riscv64': 1.2.4
5411
+ '@img/sharp-libvips-linux-s390x': 1.2.4
5412
+ '@img/sharp-libvips-linux-x64': 1.2.4
5413
+ '@img/sharp-libvips-linuxmusl-arm64': 1.2.4
5414
+ '@img/sharp-libvips-linuxmusl-x64': 1.2.4
5415
+ '@img/sharp-linux-arm': 0.34.5
5416
+ '@img/sharp-linux-arm64': 0.34.5
5417
+ '@img/sharp-linux-ppc64': 0.34.5
5418
+ '@img/sharp-linux-riscv64': 0.34.5
5419
+ '@img/sharp-linux-s390x': 0.34.5
5420
+ '@img/sharp-linux-x64': 0.34.5
5421
+ '@img/sharp-linuxmusl-arm64': 0.34.5
5422
+ '@img/sharp-linuxmusl-x64': 0.34.5
5423
+ '@img/sharp-wasm32': 0.34.5
5424
+ '@img/sharp-win32-arm64': 0.34.5
5425
+ '@img/sharp-win32-ia32': 0.34.5
5426
+ '@img/sharp-win32-x64': 0.34.5
5427
+
5428
  smart-buffer@4.2.0: {}
5429
 
5430
  socks-proxy-agent@8.0.5: