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 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 JOUR 1 (REMEDIATION) :
78
- Vérifie si la réponse contient : QUI (client), QUOI (produit), COMMENT (méthode de vente).
79
- Si un de ces éléments manque, marque isQualified comme false. Sinon, true.
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 (QUI+QUOI+COMMENT pour J1)"),
92
- missingElements: z.array(z.string()).describe("Liste des éléments manquants (ex: ['WHO', 'HOW']). Vide si tout est là.")
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 Module 1 curriculum (FR + WOLOF).
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 existingFR = await prisma.track.findFirst({
9
- where: { title: "Comprendre Son Business (FR)" }
10
- });
11
 
12
- if (existingFR) {
13
- return { seeded: false, message: "Data already exists. No seeding needed." };
14
  }
15
 
16
- // ── MODULE 1 FR ──────────────────────────────────────────────────────────
17
- await prisma.track.create({
18
- data: {
19
- title: "Comprendre Son Business (FR)",
20
- description: "Apprenez à définir, tester et vendre votre projet en 12 leçons.",
21
- duration: 12,
22
- language: "FR",
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é ?" },
30
- { dayNumber: 5, exerciseType: "BUTTON", lessonText: "À quel moment ton client a ce problème ?", exercisePrompt: "Choisis le moment d'apparition du problème :", buttonsJson: [{ id: "matin_midi", title: "Matin ou Midi" }, { id: "soir", title: "Le Soir" }, { id: "tout_le_temps", title: "Tout le temps" }] },
31
- { dayNumber: 6, exerciseType: "TEXT", lessonText: "Avant toi, il faisait comment ?", exercisePrompt: "Donne-moi 2 solutions que ton client utilisait avant de te connaître :" },
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
- // ── MODULE 1 – WOLOF ───────────────────────────────────────────────────────
44
- await prisma.track.create({
45
- data: {
46
- title: "Comprendre Son Business (WOLOF)",
47
- description: "Apprenez à définir, tester et vendre votre projet en 12 leçons.",
48
- duration: 12,
49
- language: "WOLOF",
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 ?" },
57
- { dayNumber: 5, exerciseType: "BUTTON", lessonText: "Kañ la jafe jafe bi di ñëw ?", exercisePrompt: "Tànnal jamono ji jafe jafe bi di faral di am :", buttonsJson: [{ id: "suba_bëccëg", title: "Suba walla Bëccëg" }, { id: "ngoon", title: "Ngoon / Guddi" }, { id: "saa_su_ne", title: "Saa su nekk" }] },
58
- { dayNumber: 6, exerciseType: "TEXT", lessonText: "Balaa yaw, naka la daan def ?", exercisePrompt: "Wax ma ñaari pexe yi kiliifa bi daan jëfandikoo balaa xam sa produit :" },
59
- { dayNumber: 7, exerciseType: "TEXT", lessonText: "Wax sa solution ci wax yu yomb.", exercisePrompt: "Tënkal sa solution ci benn phrase bu yomb :" },
60
- { dayNumber: 8, exerciseType: "BUTTON", lessonText: "Bul promettre lépp. Tànnal benn doole.", exercisePrompt: "Lan mooy sa dige bu mag ?", buttonsJson: [{ id: "gaaw", title: "Dafa gaaw" }, { id: "yomb", title: "Dafa yomb / Prix" }, { id: "woor", title: "Dafa woor" }] },
61
- { dayNumber: 9, exerciseType: "TEXT", lessonText: "Dem waxtaan ak 5 nit. Ñaata ñu wax WAAN ?", exercisePrompt: "Ñaata nit ñoo wax WAAN ? (Bind ma chiffre bi rek)" },
62
- { dayNumber: 10, exerciseType: "TEXT", lessonText: "Sa priix war na japp sa dépense. Bind ñaari dépense.", exercisePrompt: "Bind ma ñaari dépense yu gën a rëy ci sa mbir :" },
63
- { dayNumber: 11, exerciseType: "BUTTON", lessonText: "Lu tax yaw te du keneen ?", exercisePrompt: "Lan nga gën a mën ci ñeneen ñi ?", buttonsJson: [{ id: "baax", title: "Dafa baax" }, { id: "gaaw", title: "Dafa gaaw" }, { id: "koolute", title: "Kooluté / Confiance" }] },
64
- { dayNumber: 12, exerciseType: "AUDIO", lessonText: "Léegi nga hazır. Wax ci 30 seconde : Man ma... Damaa jàppalé... Ndax... Damaa jaay... Ci...", exercisePrompt: "Yónnee ma sa Pitch bu gatt ci 30 seconde :" },
65
- ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66
  }
 
 
67
  }
68
- });
69
 
70
- return { seeded: true, message: "Module 1 FR + WOLOF seeded successfully (24 lessons)." };
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': {}