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 +6 -1
- apps/api/src/services/ai/index.ts +8 -2
- apps/whatsapp-worker/assets/templates/pitch_card.png +0 -0
- apps/whatsapp-worker/package.json +3 -2
- apps/whatsapp-worker/src/index.ts +106 -16
- apps/whatsapp-worker/src/pedagogy.ts +36 -9
- apps/whatsapp-worker/src/scheduler.ts +11 -2
- apps/whatsapp-worker/src/storage.ts +34 -0
- apps/whatsapp-worker/src/visuals.ts +77 -0
- apps/whatsapp-worker/src/whatsapp-cloud.ts +34 -0
- apps/whatsapp-worker/tsconfig.tsbuildinfo +1 -1
- packages/database/prisma/schema.prisma +7 -2
- packages/database/run-seed.ts +14 -0
- packages/database/src/seed.ts +2 -0
- pnpm-lock.yaml +283 -5
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 {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
"@
|
| 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 '@
|
| 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 |
-
|
| 76 |
-
feedbackMsg =
|
| 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;
|
| 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
|
| 140 |
-
data: {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 '@
|
| 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 ${
|
| 84 |
-
: `*${trackTitle}*\n*Jour ${
|
| 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 '@
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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
|
| 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 |
-
'@
|
| 180 |
-
specifier: ^
|
| 181 |
-
version:
|
| 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:
|