CognxSafeTrack commited on
Commit ·
ca816a7
1
Parent(s): 8927585
feat(pedagogy): implementation of dynamic content infrastructure and evaluation criteria
Browse files- apps/api/package.json +2 -0
- apps/api/src/routes/ai.ts +4 -3
- apps/api/src/scripts/sync-content.ts +35 -0
- apps/api/src/services/ai/index.ts +8 -6
- apps/api/src/services/whatsapp.ts +1 -0
- apps/whatsapp-worker/src/index.ts +3 -2
- packages/database/content/tracks/test-infra.json +31 -0
- packages/database/index.ts +1 -0
- packages/database/prisma/schema.prisma +1 -0
- packages/database/src/seed.ts +75 -57
- pnpm-lock.yaml +14 -0
apps/api/package.json
CHANGED
|
@@ -16,6 +16,7 @@
|
|
| 16 |
"@repo/shared-types": "workspace:*",
|
| 17 |
"axios": "^1.13.5",
|
| 18 |
"bullmq": "^5.1.0",
|
|
|
|
| 19 |
"fastify": "^4.0.0",
|
| 20 |
"fastify-plugin": "^4.5.1",
|
| 21 |
"ioredis": "^5.9.3",
|
|
@@ -27,6 +28,7 @@
|
|
| 27 |
},
|
| 28 |
"devDependencies": {
|
| 29 |
"@repo/tsconfig": "workspace:*",
|
|
|
|
| 30 |
"@types/node": "^20.0.0",
|
| 31 |
"tsx": "^3.0.0",
|
| 32 |
"typescript": "^5.0.0"
|
|
|
|
| 16 |
"@repo/shared-types": "workspace:*",
|
| 17 |
"axios": "^1.13.5",
|
| 18 |
"bullmq": "^5.1.0",
|
| 19 |
+
"dotenv": "^16.4.7",
|
| 20 |
"fastify": "^4.0.0",
|
| 21 |
"fastify-plugin": "^4.5.1",
|
| 22 |
"ioredis": "^5.9.3",
|
|
|
|
| 28 |
},
|
| 29 |
"devDependencies": {
|
| 30 |
"@repo/tsconfig": "workspace:*",
|
| 31 |
+
"@types/dotenv": "^8.2.3",
|
| 32 |
"@types/node": "^20.0.0",
|
| 33 |
"tsx": "^3.0.0",
|
| 34 |
"typescript": "^5.0.0"
|
apps/api/src/routes/ai.ts
CHANGED
|
@@ -164,14 +164,15 @@ export async function aiRoutes(fastify: FastifyInstance) {
|
|
| 164 |
lessonText: z.string(),
|
| 165 |
exercisePrompt: z.string(),
|
| 166 |
userLanguage: z.string().optional().default('FR'),
|
| 167 |
-
businessProfile: z.any().optional()
|
|
|
|
| 168 |
});
|
| 169 |
-
const { answers, lessonText, exercisePrompt, userLanguage, businessProfile } = bodySchema.parse(request.body);
|
| 170 |
|
| 171 |
console.log(`[AI] Generating feedback for answers (Lang: ${userLanguage}).`);
|
| 172 |
|
| 173 |
try {
|
| 174 |
-
const feedback = await aiService.generateFeedback(answers, exercisePrompt, lessonText, userLanguage, businessProfile);
|
| 175 |
|
| 176 |
// Assemble the "WOW" 3-line feedback for WhatsApp
|
| 177 |
const formattedFeedback = `✨ *Coach XAMLÉ* :\n\n` +
|
|
|
|
| 164 |
lessonText: z.string(),
|
| 165 |
exercisePrompt: z.string(),
|
| 166 |
userLanguage: z.string().optional().default('FR'),
|
| 167 |
+
businessProfile: z.any().optional(),
|
| 168 |
+
exerciseCriteria: z.any().optional()
|
| 169 |
});
|
| 170 |
+
const { answers, lessonText, exercisePrompt, userLanguage, businessProfile, exerciseCriteria } = bodySchema.parse(request.body);
|
| 171 |
|
| 172 |
console.log(`[AI] Generating feedback for answers (Lang: ${userLanguage}).`);
|
| 173 |
|
| 174 |
try {
|
| 175 |
+
const feedback = await aiService.generateFeedback(answers, exercisePrompt, lessonText, userLanguage, businessProfile, exerciseCriteria);
|
| 176 |
|
| 177 |
// Assemble the "WOW" 3-line feedback for WhatsApp
|
| 178 |
const formattedFeedback = `✨ *Coach XAMLÉ* :\n\n` +
|
apps/api/src/scripts/sync-content.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import path from 'path';
|
| 2 |
+
import dotenv from 'dotenv';
|
| 3 |
+
// Load root .env
|
| 4 |
+
dotenv.config({ path: path.resolve(__dirname, '../../../../.env') });
|
| 5 |
+
|
| 6 |
+
import { PrismaClient, seedDatabase } from '@repo/database';
|
| 7 |
+
|
| 8 |
+
const prisma = new PrismaClient();
|
| 9 |
+
|
| 10 |
+
async function runSync() {
|
| 11 |
+
try {
|
| 12 |
+
console.log('🚀 Starting content synchronization...');
|
| 13 |
+
|
| 14 |
+
// Ensure Database URL is present
|
| 15 |
+
if (!process.env.DATABASE_URL) {
|
| 16 |
+
throw new Error('DATABASE_URL is not defined in .env');
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
const result = await seedDatabase(prisma);
|
| 20 |
+
|
| 21 |
+
if (result.seeded) {
|
| 22 |
+
console.log(`✅ Success: ${result.message}`);
|
| 23 |
+
} else {
|
| 24 |
+
console.warn(`⚠️ Warning: ${result.message}`);
|
| 25 |
+
}
|
| 26 |
+
} catch (err: any) {
|
| 27 |
+
console.error('❌ Sync failed:', err.message);
|
| 28 |
+
if (err.stack) console.debug(err.stack);
|
| 29 |
+
process.exit(1);
|
| 30 |
+
} finally {
|
| 31 |
+
await prisma.$disconnect();
|
| 32 |
+
}
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
runSync();
|
apps/api/src/services/ai/index.ts
CHANGED
|
@@ -54,13 +54,15 @@ class AIService {
|
|
| 54 |
/**
|
| 55 |
* Generates a short pedagogical feedback for the student's answer.
|
| 56 |
*/
|
| 57 |
-
async generateFeedback(userInput: string, expectedExercise: string, lessonContent: string, userLanguage: string = 'FR', businessProfile?: any): Promise<{ rephrase: string, praise: string, action: string, isQualified: boolean, missingElements: string[] }> {
|
| 58 |
const businessContext = businessProfile ? `BUSINESS DE L'ÉTUDIANT : Activity: ${businessProfile.activityLabel}, Customer: ${businessProfile.mainCustomer}, Offer: ${businessProfile.offerSimple}` : '';
|
|
|
|
| 59 |
|
| 60 |
const prompt = `
|
| 61 |
Tu es un coach business sénégalais, expert, bienveillant et motivant (XAMLÉ).
|
| 62 |
|
| 63 |
${businessContext}
|
|
|
|
| 64 |
|
| 65 |
CONTEXTE :
|
| 66 |
L'étudiant répond à un exercice.
|
|
@@ -74,9 +76,9 @@ class AIService {
|
|
| 74 |
2. PRAISE : Une validation enthousiaste (ex: "Machallah !", "Excellent !").
|
| 75 |
3. ACTION : Un conseil pratique et immédiat lié à son business${businessProfile?.activityLabel ? ` (${businessProfile.activityLabel})` : ''}.
|
| 76 |
|
| 77 |
-
POUR
|
| 78 |
-
Vérifie si la réponse contient : QUI (client), QUOI (produit), COMMENT (méthode de vente).
|
| 79 |
-
Si un de ces
|
| 80 |
|
| 81 |
CONTRAINTES :
|
| 82 |
- LANGUE : "${userLanguage === 'WOLOF' ? 'WOLOF' : 'Français'}". JAMAIS D'ANGLAIS.
|
|
@@ -88,8 +90,8 @@ class AIService {
|
|
| 88 |
rephrase: z.string().describe("Reformulation courte"),
|
| 89 |
praise: z.string().describe("Encouragement enthousiaste"),
|
| 90 |
action: z.string().describe("Conseil pratique immédiat"),
|
| 91 |
-
isQualified: z.boolean().describe("Vrai si la réponse remplit les objectifs (
|
| 92 |
-
missingElements: z.array(z.string()).describe("Liste des éléments manquants
|
| 93 |
});
|
| 94 |
return this.provider.generateStructuredData(prompt, schema);
|
| 95 |
}
|
|
|
|
| 54 |
/**
|
| 55 |
* Generates a short pedagogical feedback for the student's answer.
|
| 56 |
*/
|
| 57 |
+
async generateFeedback(userInput: string, expectedExercise: string, lessonContent: string, userLanguage: string = 'FR', businessProfile?: any, exerciseCriteria?: any): Promise<{ rephrase: string, praise: string, action: string, isQualified: boolean, missingElements: string[] }> {
|
| 58 |
const businessContext = businessProfile ? `BUSINESS DE L'ÉTUDIANT : Activity: ${businessProfile.activityLabel}, Customer: ${businessProfile.mainCustomer}, Offer: ${businessProfile.offerSimple}` : '';
|
| 59 |
+
const criteriaContext = exerciseCriteria ? `CRITÈRES DE RÉUSSITE SPÉCIFIQUES :\n${JSON.stringify(exerciseCriteria, null, 2)}` : '';
|
| 60 |
|
| 61 |
const prompt = `
|
| 62 |
Tu es un coach business sénégalais, expert, bienveillant et motivant (XAMLÉ).
|
| 63 |
|
| 64 |
${businessContext}
|
| 65 |
+
${criteriaContext}
|
| 66 |
|
| 67 |
CONTEXTE :
|
| 68 |
L'étudiant répond à un exercice.
|
|
|
|
| 76 |
2. PRAISE : Une validation enthousiaste (ex: "Machallah !", "Excellent !").
|
| 77 |
3. ACTION : Un conseil pratique et immédiat lié à son business${businessProfile?.activityLabel ? ` (${businessProfile.activityLabel})` : ''}.
|
| 78 |
|
| 79 |
+
POUR L'ÉVALUATION (isQualified) :
|
| 80 |
+
${exerciseCriteria ? `Utilise PRIORITAIREMENT les "CRITÈRES DE RÉUSSITE SPÉCIFIQUES" fournis ci-dessus.` : `Vérifie si la réponse contient : QUI (client), QUOI (produit), COMMENT (méthode de vente).`}
|
| 81 |
+
Si un de ces critères manque, marque isQualified comme false. Sinon, true.
|
| 82 |
|
| 83 |
CONTRAINTES :
|
| 84 |
- LANGUE : "${userLanguage === 'WOLOF' ? 'WOLOF' : 'Français'}". JAMAIS D'ANGLAIS.
|
|
|
|
| 90 |
rephrase: z.string().describe("Reformulation courte"),
|
| 91 |
praise: z.string().describe("Encouragement enthousiaste"),
|
| 92 |
action: z.string().describe("Conseil pratique immédiat"),
|
| 93 |
+
isQualified: z.boolean().describe("Vrai si la réponse remplit les objectifs (basé sur les critères spécifiques si fournis)"),
|
| 94 |
+
missingElements: z.array(z.string()).describe("Liste des éléments manquants. Vide si tout est là.")
|
| 95 |
});
|
| 96 |
return this.provider.generateStructuredData(prompt, schema);
|
| 97 |
}
|
apps/api/src/services/whatsapp.ts
CHANGED
|
@@ -187,6 +187,7 @@ export class WhatsAppService {
|
|
| 187 |
trackDayId: trackDay.id,
|
| 188 |
exercisePrompt: trackDay.exercisePrompt || '',
|
| 189 |
lessonText: trackDay.lessonText || '',
|
|
|
|
| 190 |
pendingProgressId: pendingProgress.id,
|
| 191 |
enrollmentId: enrollment.id,
|
| 192 |
currentDay: enrollment.currentDay,
|
|
|
|
| 187 |
trackDayId: trackDay.id,
|
| 188 |
exercisePrompt: trackDay.exercisePrompt || '',
|
| 189 |
lessonText: trackDay.lessonText || '',
|
| 190 |
+
exerciseCriteria: trackDay.exerciseCriteria,
|
| 191 |
pendingProgressId: pendingProgress.id,
|
| 192 |
enrollmentId: enrollment.id,
|
| 193 |
currentDay: enrollment.currentDay,
|
apps/whatsapp-worker/src/index.ts
CHANGED
|
@@ -46,7 +46,7 @@ const worker = new Worker('whatsapp-queue', async (job: Job) => {
|
|
| 46 |
await sendTextMessage(phone, text);
|
| 47 |
}
|
| 48 |
else if (job.name === 'generate-feedback') {
|
| 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
|
|
@@ -68,7 +68,8 @@ const worker = new Worker('whatsapp-queue', async (job: Job) => {
|
|
| 68 |
lessonText,
|
| 69 |
exercisePrompt,
|
| 70 |
userLanguage: language, // Pass language (FR/WOLOF) to AI
|
| 71 |
-
businessProfile: user.businessProfile // Pass business context if available
|
|
|
|
| 72 |
})
|
| 73 |
});
|
| 74 |
|
|
|
|
| 46 |
await sendTextMessage(phone, text);
|
| 47 |
}
|
| 48 |
else if (job.name === 'generate-feedback') {
|
| 49 |
+
const { userId, text, trackId, exercisePrompt, lessonText, exerciseCriteria, pendingProgressId, currentDay, totalDays, language } = job.data;
|
| 50 |
const user = await prisma.user.findUnique({
|
| 51 |
where: { id: userId },
|
| 52 |
include: { businessProfile: true } as any
|
|
|
|
| 68 |
lessonText,
|
| 69 |
exercisePrompt,
|
| 70 |
userLanguage: language, // Pass language (FR/WOLOF) to AI
|
| 71 |
+
businessProfile: user.businessProfile, // Pass business context if available
|
| 72 |
+
exerciseCriteria: exerciseCriteria
|
| 73 |
})
|
| 74 |
});
|
| 75 |
|
packages/database/content/tracks/test-infra.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"trackId": "test-infra-module",
|
| 3 |
+
"title": "Module de Test Infrastructure",
|
| 4 |
+
"language": "FR",
|
| 5 |
+
"description": "Un module pour tester l'ingestion JSON et les critères AI.",
|
| 6 |
+
"days": [
|
| 7 |
+
{
|
| 8 |
+
"dayNumber": 1,
|
| 9 |
+
"title": "Jour 1 - Infrastructure",
|
| 10 |
+
"lessonText": "Bienvenue dans le test d'infra ! Réponds avec 'OK' pour valider.",
|
| 11 |
+
"exercisePrompt": "Réponds OK.",
|
| 12 |
+
"exerciseType": "TEXT",
|
| 13 |
+
"exerciseCriteria": {
|
| 14 |
+
"mustMention": [
|
| 15 |
+
"OK"
|
| 16 |
+
],
|
| 17 |
+
"evaluationGuide": "L'élève doit impérativement écrire OK."
|
| 18 |
+
},
|
| 19 |
+
"badges": [
|
| 20 |
+
"INFRA_OK"
|
| 21 |
+
]
|
| 22 |
+
},
|
| 23 |
+
{
|
| 24 |
+
"dayNumber": 1.5,
|
| 25 |
+
"title": "Remédiation Infrastructure",
|
| 26 |
+
"lessonText": "Tu n'as pas dit OK. Réessaie !",
|
| 27 |
+
"exercisePrompt": "Écris OK en majuscules.",
|
| 28 |
+
"exerciseType": "TEXT"
|
| 29 |
+
}
|
| 30 |
+
]
|
| 31 |
+
}
|
packages/database/index.ts
CHANGED
|
@@ -1 +1,2 @@
|
|
| 1 |
export * from '@prisma/client';
|
|
|
|
|
|
| 1 |
export * from '@prisma/client';
|
| 2 |
+
export * from './src/seed';
|
packages/database/prisma/schema.prisma
CHANGED
|
@@ -80,6 +80,7 @@ model TrackDay {
|
|
| 80 |
exercisePrompt String?
|
| 81 |
validationKeyword String?
|
| 82 |
buttonsJson Json?
|
|
|
|
| 83 |
unlockCondition String?
|
| 84 |
createdAt DateTime @default(now())
|
| 85 |
updatedAt DateTime @updatedAt
|
|
|
|
| 80 |
exercisePrompt String?
|
| 81 |
validationKeyword String?
|
| 82 |
buttonsJson Json?
|
| 83 |
+
exerciseCriteria Json?
|
| 84 |
unlockCondition String?
|
| 85 |
createdAt DateTime @default(now())
|
| 86 |
updatedAt DateTime @updatedAt
|
packages/database/src/seed.ts
CHANGED
|
@@ -1,71 +1,89 @@
|
|
| 1 |
import { PrismaClient } from '@prisma/client';
|
|
|
|
|
|
|
| 2 |
|
| 3 |
/**
|
| 4 |
-
* Seeds the database with the
|
| 5 |
-
* Safe to call multiple times — checks for existing data before inserting.
|
| 6 |
*/
|
| 7 |
export async function seedDatabase(prisma: PrismaClient): Promise<{ seeded: boolean; message: string }> {
|
| 8 |
-
const
|
| 9 |
-
where: { title: "Comprendre Son Business (FR)" }
|
| 10 |
-
});
|
| 11 |
|
| 12 |
-
if (
|
| 13 |
-
return { seeded: false, message:
|
| 14 |
}
|
| 15 |
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
{ dayNumber: 7, exerciseType: "TEXT", lessonText: "Explique ta solution en mots simples. Pas compliqué.", exercisePrompt: "Décris-moi ton offre très simplement en une phrase :" },
|
| 33 |
-
{ dayNumber: 8, exerciseType: "BUTTON", lessonText: "Tu ne peux pas promettre tout. Choisis une seule force.", exercisePrompt: "Quelle est ta promesse principale ?", buttonsJson: [{ id: "rapide", title: "Rapide" }, { id: "moins_cher", title: "Moins cher" }, { id: "fiable_proche", title: "Fiable / Proche" }] },
|
| 34 |
-
{ dayNumber: 9, exerciseType: "TEXT", lessonText: "Parle à 5 personnes. Dis ta phrase. Combien disent OUI ?", exercisePrompt: "Combien t'ont dit OUI ? (Envoie juste un chiffre)" },
|
| 35 |
-
{ dayNumber: 10, exerciseType: "TEXT", lessonText: "Ton prix doit couvrir tes coûts. Note 2 dépenses importantes.", exercisePrompt: "Quelles sont tes 2 plus grosses dépenses pour ce projet ?" },
|
| 36 |
-
{ dayNumber: 11, exerciseType: "BUTTON", lessonText: "Pourquoi toi et pas un autre ?", exercisePrompt: "Quel est ton vrai avantage concurrentiel ?", buttonsJson: [{ id: "qualite", title: "Qualité" }, { id: "rapidite", title: "Rapidité" }, { id: "confiance", title: "Confiance" }] },
|
| 37 |
-
{ dayNumber: 12, exerciseType: "AUDIO", lessonText: "Maintenant tu es prêt. Dis en 30 secondes : Je suis... J'aide... Parce que... Je vends... À...", exercisePrompt: "C'est l'heure du test ! Envoie-moi un audio avec ton Mini Pitch de 30 secondes :" },
|
| 38 |
-
]
|
| 39 |
}
|
| 40 |
-
}
|
| 41 |
-
});
|
| 42 |
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
}
|
|
|
|
|
|
|
| 67 |
}
|
| 68 |
-
}
|
| 69 |
|
| 70 |
-
return { seeded: true, message:
|
| 71 |
}
|
|
|
|
| 1 |
import { PrismaClient } from '@prisma/client';
|
| 2 |
+
import fs from 'fs';
|
| 3 |
+
import path from 'path';
|
| 4 |
|
| 5 |
/**
|
| 6 |
+
* Seeds the database with the curriculum loaded from JSON files.
|
| 7 |
+
* Safe to call multiple times — checks for existing data before inserting/updating.
|
| 8 |
*/
|
| 9 |
export async function seedDatabase(prisma: PrismaClient): Promise<{ seeded: boolean; message: string }> {
|
| 10 |
+
const contentDir = path.resolve(__dirname, '../content/tracks');
|
|
|
|
|
|
|
| 11 |
|
| 12 |
+
if (!fs.existsSync(contentDir)) {
|
| 13 |
+
return { seeded: false, message: `Content directory not found: ${contentDir}` };
|
| 14 |
}
|
| 15 |
|
| 16 |
+
const files = fs.readdirSync(contentDir).filter(f => f.endsWith('.json'));
|
| 17 |
+
|
| 18 |
+
if (files.length === 0) {
|
| 19 |
+
return { seeded: false, message: "No track files found in content/tracks." };
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
console.log(`[SEED] Found ${files.length} track files.`);
|
| 23 |
+
|
| 24 |
+
for (const file of files) {
|
| 25 |
+
try {
|
| 26 |
+
const filePath = path.join(contentDir, file);
|
| 27 |
+
const content = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
| 28 |
+
|
| 29 |
+
if (!content.trackId || !Array.isArray(content.days)) {
|
| 30 |
+
console.warn(`[SEED] Skipping invalid track file: ${file} (missing trackId or days)`);
|
| 31 |
+
continue;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
}
|
|
|
|
|
|
|
| 33 |
|
| 34 |
+
console.log(`[SEED] Syncing Track: ${content.title} (${content.language})...`);
|
| 35 |
+
|
| 36 |
+
// 1. Upsert Track
|
| 37 |
+
const track = await prisma.track.upsert({
|
| 38 |
+
where: { id: content.trackId },
|
| 39 |
+
update: {
|
| 40 |
+
title: content.title,
|
| 41 |
+
description: content.description || '',
|
| 42 |
+
duration: Math.ceil(content.days.reduce((max: number, d: any) => Math.max(max, d.dayNumber), 0)),
|
| 43 |
+
language: content.language,
|
| 44 |
+
},
|
| 45 |
+
create: {
|
| 46 |
+
id: content.trackId,
|
| 47 |
+
title: content.title,
|
| 48 |
+
description: content.description || '',
|
| 49 |
+
duration: Math.ceil(content.days.reduce((max: number, d: any) => Math.max(max, d.dayNumber), 0)),
|
| 50 |
+
language: content.language,
|
| 51 |
+
}
|
| 52 |
+
});
|
| 53 |
+
|
| 54 |
+
// 2. Upsert TrackDays
|
| 55 |
+
for (const day of content.days) {
|
| 56 |
+
const existing = await prisma.trackDay.findFirst({
|
| 57 |
+
where: { trackId: track.id, dayNumber: day.dayNumber }
|
| 58 |
+
});
|
| 59 |
+
|
| 60 |
+
const dayData = {
|
| 61 |
+
trackId: track.id,
|
| 62 |
+
dayNumber: day.dayNumber,
|
| 63 |
+
title: day.title || `Jour ${day.dayNumber}`,
|
| 64 |
+
lessonText: day.lessonText,
|
| 65 |
+
exercisePrompt: day.exercisePrompt,
|
| 66 |
+
exerciseType: day.exerciseType || 'TEXT',
|
| 67 |
+
exerciseCriteria: day.exerciseCriteria || null,
|
| 68 |
+
audioUrl: day.audioUrl || null,
|
| 69 |
+
buttonsJson: day.buttonsJson || null,
|
| 70 |
+
};
|
| 71 |
+
|
| 72 |
+
if (existing) {
|
| 73 |
+
await prisma.trackDay.update({
|
| 74 |
+
where: { id: existing.id },
|
| 75 |
+
data: dayData
|
| 76 |
+
});
|
| 77 |
+
} else {
|
| 78 |
+
await prisma.trackDay.create({
|
| 79 |
+
data: dayData
|
| 80 |
+
});
|
| 81 |
+
}
|
| 82 |
}
|
| 83 |
+
} catch (fileErr: any) {
|
| 84 |
+
console.error(`[SEED] Failed to sync file ${file}:`, fileErr.message);
|
| 85 |
}
|
| 86 |
+
}
|
| 87 |
|
| 88 |
+
return { seeded: true, message: `✅ ${files.length} modules synchronized successfully.` };
|
| 89 |
}
|
pnpm-lock.yaml
CHANGED
|
@@ -90,6 +90,9 @@ importers:
|
|
| 90 |
bullmq:
|
| 91 |
specifier: ^5.1.0
|
| 92 |
version: 5.69.3
|
|
|
|
|
|
|
|
|
|
| 93 |
fastify:
|
| 94 |
specifier: ^4.0.0
|
| 95 |
version: 4.29.1
|
|
@@ -118,6 +121,9 @@ importers:
|
|
| 118 |
'@repo/tsconfig':
|
| 119 |
specifier: workspace:*
|
| 120 |
version: link:../../packages/tsconfig
|
|
|
|
|
|
|
|
|
|
| 121 |
'@types/node':
|
| 122 |
specifier: ^20.0.0
|
| 123 |
version: 20.19.33
|
|
@@ -1392,6 +1398,10 @@ packages:
|
|
| 1392 |
'@types/babel__traverse@7.28.0':
|
| 1393 |
resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
|
| 1394 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1395 |
'@types/estree@1.0.8':
|
| 1396 |
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
| 1397 |
|
|
@@ -4198,6 +4208,10 @@ snapshots:
|
|
| 4198 |
dependencies:
|
| 4199 |
'@babel/types': 7.29.0
|
| 4200 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4201 |
'@types/estree@1.0.8': {}
|
| 4202 |
|
| 4203 |
'@types/node-cron@3.0.11': {}
|
|
|
|
| 90 |
bullmq:
|
| 91 |
specifier: ^5.1.0
|
| 92 |
version: 5.69.3
|
| 93 |
+
dotenv:
|
| 94 |
+
specifier: ^16.4.7
|
| 95 |
+
version: 16.6.1
|
| 96 |
fastify:
|
| 97 |
specifier: ^4.0.0
|
| 98 |
version: 4.29.1
|
|
|
|
| 121 |
'@repo/tsconfig':
|
| 122 |
specifier: workspace:*
|
| 123 |
version: link:../../packages/tsconfig
|
| 124 |
+
'@types/dotenv':
|
| 125 |
+
specifier: ^8.2.3
|
| 126 |
+
version: 8.2.3
|
| 127 |
'@types/node':
|
| 128 |
specifier: ^20.0.0
|
| 129 |
version: 20.19.33
|
|
|
|
| 1398 |
'@types/babel__traverse@7.28.0':
|
| 1399 |
resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
|
| 1400 |
|
| 1401 |
+
'@types/dotenv@8.2.3':
|
| 1402 |
+
resolution: {integrity: sha512-g2FXjlDX/cYuc5CiQvyU/6kkbP1JtmGzh0obW50zD7OKeILVL0NSpPWLXVfqoAGQjom2/SLLx9zHq0KXvD6mbw==}
|
| 1403 |
+
deprecated: This is a stub types definition. dotenv provides its own type definitions, so you do not need this installed.
|
| 1404 |
+
|
| 1405 |
'@types/estree@1.0.8':
|
| 1406 |
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
| 1407 |
|
|
|
|
| 4208 |
dependencies:
|
| 4209 |
'@babel/types': 7.29.0
|
| 4210 |
|
| 4211 |
+
'@types/dotenv@8.2.3':
|
| 4212 |
+
dependencies:
|
| 4213 |
+
dotenv: 16.6.1
|
| 4214 |
+
|
| 4215 |
'@types/estree@1.0.8': {}
|
| 4216 |
|
| 4217 |
'@types/node-cron@3.0.11': {}
|