CognxSafeTrack commited on
Commit Β·
3c6fc2a
1
Parent(s): 29d83ee
feat: sync all recent changes (AI, whatsapp, redis passwords, script fixes)
Browse files- apps/api/src/routes/ai.ts +28 -0
- apps/api/src/routes/whatsapp.ts +37 -2
- apps/api/src/services/ai/index.ts +32 -1
- apps/api/src/services/ai/mock-provider.ts +18 -1
- apps/api/src/services/ai/openai-provider.ts +27 -0
- apps/api/src/services/ai/types.ts +9 -0
- apps/api/src/services/queue.ts +9 -0
- apps/api/src/services/renderers/pdf-renderer.ts +1 -0
- apps/api/src/services/whatsapp.ts +56 -42
- apps/whatsapp-worker/src/index.ts +53 -5
- scripts/start-backend.sh +18 -6
apps/api/src/routes/ai.ts
CHANGED
|
@@ -52,4 +52,32 @@ export async function aiRoutes(fastify: FastifyInstance) {
|
|
| 52 |
|
| 53 |
return { success: true, url: downloadUrl, data: deckData };
|
| 54 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
}
|
|
|
|
| 52 |
|
| 53 |
return { success: true, url: downloadUrl, data: deckData };
|
| 54 |
});
|
| 55 |
+
|
| 56 |
+
// 3. Personalize Lesson Content (Dynamic Rewriting)
|
| 57 |
+
fastify.post('/personalize-lesson', async (request) => {
|
| 58 |
+
const bodySchema = z.object({
|
| 59 |
+
lessonText: z.string(),
|
| 60 |
+
userActivity: z.string()
|
| 61 |
+
});
|
| 62 |
+
const { lessonText, userActivity } = bodySchema.parse(request.body);
|
| 63 |
+
|
| 64 |
+
console.log(`Personalizing lesson for activity: ${userActivity}`);
|
| 65 |
+
|
| 66 |
+
const personalizedText = await aiService.generatePersonalizedLesson(lessonText, userActivity);
|
| 67 |
+
|
| 68 |
+
return { success: true, text: personalizedText };
|
| 69 |
+
});
|
| 70 |
+
|
| 71 |
+
// 4. Generate TTS Audio for a lesson
|
| 72 |
+
fastify.post('/tts', async (request) => {
|
| 73 |
+
const bodySchema = z.object({ text: z.string() });
|
| 74 |
+
const { text } = bodySchema.parse(request.body);
|
| 75 |
+
|
| 76 |
+
console.log(`Generating TTS audio...`);
|
| 77 |
+
|
| 78 |
+
const audioBuffer = await aiService.generateSpeech(text);
|
| 79 |
+
const downloadUrl = await mockS3Upload(audioBuffer, `lesson-audio-${Date.now()}.mp3`);
|
| 80 |
+
|
| 81 |
+
return { success: true, url: downloadUrl };
|
| 82 |
+
});
|
| 83 |
}
|
apps/api/src/routes/whatsapp.ts
CHANGED
|
@@ -27,9 +27,44 @@ export async function whatsappRoutes(fastify: FastifyInstance) {
|
|
| 27 |
const value = changes?.value;
|
| 28 |
const message = value?.messages?.[0];
|
| 29 |
|
| 30 |
-
if (message
|
| 31 |
const phone = message.from; // WhatsApp ID
|
| 32 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
|
| 34 |
if (phone && text) {
|
| 35 |
await WhatsAppService.handleIncomingMessage(phone, text);
|
|
|
|
| 27 |
const value = changes?.value;
|
| 28 |
const message = value?.messages?.[0];
|
| 29 |
|
| 30 |
+
if (message) {
|
| 31 |
const phone = message.from; // WhatsApp ID
|
| 32 |
+
let text = '';
|
| 33 |
+
|
| 34 |
+
if (message.type === 'text') {
|
| 35 |
+
text = message.text?.body;
|
| 36 |
+
} else if (message.type === 'audio') {
|
| 37 |
+
// π Support Multi-language Audio (STT) π
|
| 38 |
+
const audioId = message.audio?.id;
|
| 39 |
+
if (audioId) {
|
| 40 |
+
try {
|
| 41 |
+
console.log(`[WEBHOOK] Downloading audio message ${audioId}...`);
|
| 42 |
+
const token = process.env.WHATSAPP_ACCESS_TOKEN; // Ensure this env is accessible
|
| 43 |
+
|
| 44 |
+
// 1. Get media URL
|
| 45 |
+
const metaRes = await fetch(`https://graph.facebook.com/v18.0/${audioId}`, {
|
| 46 |
+
headers: { 'Authorization': `Bearer ${token}` }
|
| 47 |
+
});
|
| 48 |
+
const meta = await metaRes.json();
|
| 49 |
+
|
| 50 |
+
// 2. Download binary media
|
| 51 |
+
if (meta.url) {
|
| 52 |
+
const mediaRes = await fetch(meta.url, {
|
| 53 |
+
headers: { 'Authorization': `Bearer ${token}` }
|
| 54 |
+
});
|
| 55 |
+
const arrayBuffer = await mediaRes.arrayBuffer();
|
| 56 |
+
const buffer = Buffer.from(arrayBuffer);
|
| 57 |
+
|
| 58 |
+
// 3. Transcribe with Whisper (via AIService)
|
| 59 |
+
const { aiService } = await import('../services/ai');
|
| 60 |
+
text = await aiService.transcribeAudio(buffer, 'message.ogg');
|
| 61 |
+
console.log(`[STT] Transcribed audio: "${text}"`);
|
| 62 |
+
}
|
| 63 |
+
} catch (err) {
|
| 64 |
+
console.error('[WEBHOOK] Failed to process audio message:', err);
|
| 65 |
+
}
|
| 66 |
+
}
|
| 67 |
+
}
|
| 68 |
|
| 69 |
if (phone && text) {
|
| 70 |
await WhatsAppService.handleIncomingMessage(phone, text);
|
apps/api/src/services/ai/index.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
import { LLMProvider, OnePagerData, OnePagerSchema, PitchDeckData, PitchDeckSchema } from './types';
|
| 2 |
import { MockLLMProvider } from './mock-provider';
|
| 3 |
import { OpenAIProvider } from './openai-provider';
|
| 4 |
|
|
@@ -45,6 +45,37 @@ class AIService {
|
|
| 45 |
|
| 46 |
return this.provider.generateStructuredData(prompt, PitchDeckSchema);
|
| 47 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
}
|
| 49 |
|
| 50 |
// Export a singleton instance
|
|
|
|
| 1 |
+
import { LLMProvider, OnePagerData, OnePagerSchema, PitchDeckData, PitchDeckSchema, PersonalizedLessonSchema } from './types';
|
| 2 |
import { MockLLMProvider } from './mock-provider';
|
| 3 |
import { OpenAIProvider } from './openai-provider';
|
| 4 |
|
|
|
|
| 45 |
|
| 46 |
return this.provider.generateStructuredData(prompt, PitchDeckSchema);
|
| 47 |
}
|
| 48 |
+
|
| 49 |
+
/**
|
| 50 |
+
* Rewrites a daily lesson to use analogies relevant to the user's business sector.
|
| 51 |
+
*/
|
| 52 |
+
async generatePersonalizedLesson(lessonText: string, userActivity: string): Promise<string> {
|
| 53 |
+
const prompt = `
|
| 54 |
+
You are an expert business coach. Rewrite the following daily lesson to specifically target an entrepreneur in the following business sector/activity: "${userActivity}".
|
| 55 |
+
Keep the core educational value exactly the same, but use analogies, examples, and a tone that resonates directly with their specific industry.
|
| 56 |
+
Do NOT make the lesson significantly longer than the original. Maintain a friendly, direct WhatsApp formatting (use *bold* and emojis appropriately).
|
| 57 |
+
|
| 58 |
+
ORIGINAL LESSON:
|
| 59 |
+
${lessonText}
|
| 60 |
+
`;
|
| 61 |
+
|
| 62 |
+
const result = await this.provider.generateStructuredData(prompt, PersonalizedLessonSchema);
|
| 63 |
+
return result.lessonText;
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
/**
|
| 67 |
+
* Transcribes an audio buffer to text (useful for Wolof/FR voice messages).
|
| 68 |
+
*/
|
| 69 |
+
async transcribeAudio(audioBuffer: Buffer, filename: string): Promise<string> {
|
| 70 |
+
return this.provider.transcribeAudio(audioBuffer, filename);
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
/**
|
| 74 |
+
* Converts text into an audio MP3 buffer (TTS).
|
| 75 |
+
*/
|
| 76 |
+
async generateSpeech(text: string): Promise<Buffer> {
|
| 77 |
+
return this.provider.generateSpeech(text);
|
| 78 |
+
}
|
| 79 |
}
|
| 80 |
|
| 81 |
// Export a singleton instance
|
apps/api/src/services/ai/mock-provider.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
import { LLMProvider, OnePagerSchema, PitchDeckSchema } from './types';
|
| 2 |
|
| 3 |
/**
|
| 4 |
* A Provider for local development that doesn't require an API Key.
|
|
@@ -40,6 +40,23 @@ export class MockLLMProvider implements LLMProvider {
|
|
| 40 |
return schema.parse(mockDeck) as any;
|
| 41 |
}
|
| 42 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
throw new Error("MockLLMProvider does not support this schema.");
|
| 44 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
}
|
|
|
|
| 1 |
+
import { LLMProvider, OnePagerSchema, PitchDeckSchema, PersonalizedLessonSchema } from './types';
|
| 2 |
|
| 3 |
/**
|
| 4 |
* A Provider for local development that doesn't require an API Key.
|
|
|
|
| 40 |
return schema.parse(mockDeck) as any;
|
| 41 |
}
|
| 42 |
|
| 43 |
+
if (schema === PersonalizedLessonSchema) {
|
| 44 |
+
const mockPersonalized = {
|
| 45 |
+
lessonText: "Voici une leΓ§on adaptΓ©e: Pensez Γ votre ferme comme..."
|
| 46 |
+
};
|
| 47 |
+
return schema.parse(mockPersonalized) as any;
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
throw new Error("MockLLMProvider does not support this schema.");
|
| 51 |
}
|
| 52 |
+
|
| 53 |
+
async transcribeAudio(_audioBuffer: Buffer, filename: string): Promise<string> {
|
| 54 |
+
console.log(`[MOCK LLM] Transcribing audio from ${filename}...`);
|
| 55 |
+
return "INSCRIPTION";
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
async generateSpeech(text: string): Promise<Buffer> {
|
| 59 |
+
console.log(`[MOCK LLM] Generating speech for text: ${text.substring(0, 30)}...`);
|
| 60 |
+
return Buffer.from("mock_audio_data");
|
| 61 |
+
}
|
| 62 |
}
|
apps/api/src/services/ai/openai-provider.ts
CHANGED
|
@@ -36,4 +36,31 @@ export class OpenAIProvider implements LLMProvider {
|
|
| 36 |
|
| 37 |
return result as T;
|
| 38 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
}
|
|
|
|
| 36 |
|
| 37 |
return result as T;
|
| 38 |
}
|
| 39 |
+
|
| 40 |
+
async transcribeAudio(audioBuffer: Buffer, filename: string): Promise<string> {
|
| 41 |
+
console.log(`[OPENAI] Transcribing audio file ${filename}...`);
|
| 42 |
+
|
| 43 |
+
// Convert Buffer to File for OpenAI SDK
|
| 44 |
+
const { toFile } = await import('openai');
|
| 45 |
+
const file = await toFile(audioBuffer, filename);
|
| 46 |
+
|
| 47 |
+
const response = await this.openai.audio.transcriptions.create({
|
| 48 |
+
file: file,
|
| 49 |
+
model: 'whisper-1',
|
| 50 |
+
});
|
| 51 |
+
|
| 52 |
+
return response.text;
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
async generateSpeech(text: string): Promise<Buffer> {
|
| 56 |
+
console.log('[OPENAI] Generating speech TTS...');
|
| 57 |
+
|
| 58 |
+
const mp3 = await this.openai.audio.speech.create({
|
| 59 |
+
model: 'tts-1',
|
| 60 |
+
voice: 'alloy',
|
| 61 |
+
input: text,
|
| 62 |
+
});
|
| 63 |
+
|
| 64 |
+
return Buffer.from(await mp3.arrayBuffer());
|
| 65 |
+
}
|
| 66 |
}
|
apps/api/src/services/ai/types.ts
CHANGED
|
@@ -3,6 +3,8 @@ import { z } from 'zod';
|
|
| 3 |
// Base interface for all LLM Providers
|
| 4 |
export interface LLMProvider {
|
| 5 |
generateStructuredData<T>(prompt: string, schema: z.ZodSchema<T>): Promise<T>;
|
|
|
|
|
|
|
| 6 |
}
|
| 7 |
|
| 8 |
// -----------------------------------------------------
|
|
@@ -29,6 +31,7 @@ export const SlideSchema = z.object({
|
|
| 29 |
});
|
| 30 |
export type SlideData = z.infer<typeof SlideSchema>;
|
| 31 |
|
|
|
|
| 32 |
// Schema for the V1 Pitch Deck (PPTX)
|
| 33 |
export const PitchDeckSchema = z.object({
|
| 34 |
title: z.string(),
|
|
@@ -36,3 +39,9 @@ export const PitchDeckSchema = z.object({
|
|
| 36 |
slides: z.array(SlideSchema).min(5).max(12).describe("The sequence of slides for the pitch deck")
|
| 37 |
});
|
| 38 |
export type PitchDeckData = z.infer<typeof PitchDeckSchema>;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
// Base interface for all LLM Providers
|
| 4 |
export interface LLMProvider {
|
| 5 |
generateStructuredData<T>(prompt: string, schema: z.ZodSchema<T>): Promise<T>;
|
| 6 |
+
transcribeAudio(audioBuffer: Buffer, filename: string): Promise<string>;
|
| 7 |
+
generateSpeech(text: string): Promise<Buffer>;
|
| 8 |
}
|
| 9 |
|
| 10 |
// -----------------------------------------------------
|
|
|
|
| 31 |
});
|
| 32 |
export type SlideData = z.infer<typeof SlideSchema>;
|
| 33 |
|
| 34 |
+
|
| 35 |
// Schema for the V1 Pitch Deck (PPTX)
|
| 36 |
export const PitchDeckSchema = z.object({
|
| 37 |
title: z.string(),
|
|
|
|
| 39 |
slides: z.array(SlideSchema).min(5).max(12).describe("The sequence of slides for the pitch deck")
|
| 40 |
});
|
| 41 |
export type PitchDeckData = z.infer<typeof PitchDeckSchema>;
|
| 42 |
+
|
| 43 |
+
// Schema for personalized WhatsApp lesson
|
| 44 |
+
export const PersonalizedLessonSchema = z.object({
|
| 45 |
+
lessonText: z.string().describe("The rewritten lesson text adapted to the user's business sector.")
|
| 46 |
+
});
|
| 47 |
+
export type PersonalizedLessonData = z.infer<typeof PersonalizedLessonSchema>;
|
apps/api/src/services/queue.ts
CHANGED
|
@@ -3,6 +3,8 @@ import { Queue } from 'bullmq';
|
|
| 3 |
const connection = {
|
| 4 |
host: process.env.REDIS_HOST || 'localhost',
|
| 5 |
port: parseInt(process.env.REDIS_PORT || '6379'),
|
|
|
|
|
|
|
| 6 |
};
|
| 7 |
|
| 8 |
export const whatsappQueue = new Queue('whatsapp-queue', { connection });
|
|
@@ -25,3 +27,10 @@ export async function scheduleTrackDay(userId: string, trackId: string, dayNumbe
|
|
| 25 |
delay: delayMs
|
| 26 |
});
|
| 27 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
const connection = {
|
| 4 |
host: process.env.REDIS_HOST || 'localhost',
|
| 5 |
port: parseInt(process.env.REDIS_PORT || '6379'),
|
| 6 |
+
password: process.env.REDIS_PASSWORD || undefined,
|
| 7 |
+
tls: process.env.REDIS_TLS === 'true' ? {} : undefined,
|
| 8 |
};
|
| 9 |
|
| 10 |
export const whatsappQueue = new Queue('whatsapp-queue', { connection });
|
|
|
|
| 27 |
delay: delayMs
|
| 28 |
});
|
| 29 |
}
|
| 30 |
+
|
| 31 |
+
export async function enrollUser(userId: string, trackId: string) {
|
| 32 |
+
await whatsappQueue.add('enroll-user', {
|
| 33 |
+
userId,
|
| 34 |
+
trackId
|
| 35 |
+
});
|
| 36 |
+
}
|
apps/api/src/services/renderers/pdf-renderer.ts
CHANGED
|
@@ -116,6 +116,7 @@ export class PdfOnePagerRenderer implements DocumentRenderer<OnePagerData> {
|
|
| 116 |
`;
|
| 117 |
|
| 118 |
const browser = await puppeteer.launch({
|
|
|
|
| 119 |
headless: true,
|
| 120 |
args: ['--no-sandbox', '--disable-setuid-sandbox'], // Required for running as root in Docker/HF
|
| 121 |
});
|
|
|
|
| 116 |
`;
|
| 117 |
|
| 118 |
const browser = await puppeteer.launch({
|
| 119 |
+
executablePath: process.env.PUPPETEER_EXECUTABLE_PATH || undefined,
|
| 120 |
headless: true,
|
| 121 |
args: ['--no-sandbox', '--disable-setuid-sandbox'], // Required for running as root in Docker/HF
|
| 122 |
});
|
apps/api/src/services/whatsapp.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
| 1 |
import { prisma } from './prisma';
|
|
|
|
| 2 |
|
| 3 |
export class WhatsAppService {
|
| 4 |
static async handleIncomingMessage(phone: string, text: string) {
|
|
@@ -13,17 +14,63 @@ export class WhatsAppService {
|
|
| 13 |
user = await prisma.user.create({
|
| 14 |
data: { phone }
|
| 15 |
});
|
| 16 |
-
|
| 17 |
-
console.log('New user created,
|
| 18 |
return;
|
| 19 |
} else {
|
| 20 |
-
//
|
| 21 |
-
console.log(
|
| 22 |
return;
|
| 23 |
}
|
| 24 |
}
|
| 25 |
|
| 26 |
-
// 2. Check
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
const activeEnrollment = await prisma.enrollment.findFirst({
|
| 28 |
where: { userId: user.id, status: 'ACTIVE' },
|
| 29 |
include: { track: true }
|
|
@@ -43,48 +90,15 @@ export class WhatsAppService {
|
|
| 43 |
}
|
| 44 |
});
|
| 45 |
|
| 46 |
-
|
| 47 |
-
// For MVP, just acknowledge reception
|
| 48 |
-
// await sendMessage(phone, "RΓ©ponse enregistrΓ©e ! Γ demain pour la suite.");
|
| 49 |
-
|
| 50 |
return;
|
| 51 |
}
|
| 52 |
|
| 53 |
-
//
|
| 54 |
if (normalizedText === 'INSCRIPTION') {
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
// Find default track (Business Model Express)
|
| 58 |
-
const defaultTrack = await prisma.track.findFirst({
|
| 59 |
-
where: { title: "Business Model Express" }
|
| 60 |
-
});
|
| 61 |
-
|
| 62 |
-
if (!defaultTrack) {
|
| 63 |
-
console.error('Default track not found!');
|
| 64 |
-
return;
|
| 65 |
-
}
|
| 66 |
-
|
| 67 |
-
// Create enrollment
|
| 68 |
-
await prisma.enrollment.create({
|
| 69 |
-
data: {
|
| 70 |
-
userId: user.id,
|
| 71 |
-
trackId: defaultTrack.id,
|
| 72 |
-
startedAt: new Date(),
|
| 73 |
-
status: 'ACTIVE',
|
| 74 |
-
currentDay: 1
|
| 75 |
-
}
|
| 76 |
-
});
|
| 77 |
-
|
| 78 |
-
console.log(`User ${user.id} enrolled in ${defaultTrack.title}`);
|
| 79 |
-
|
| 80 |
-
// Schedule Day 1 Content immediately (or short delay)
|
| 81 |
-
// Import dynamically to avoid circular deps if necessary, or just import at top
|
| 82 |
-
const { scheduleTrackDay } = await import('./queue');
|
| 83 |
-
await scheduleTrackDay(user.id, defaultTrack.id, 1, 0);
|
| 84 |
-
|
| 85 |
} else {
|
| 86 |
-
console.log('Unknown command
|
| 87 |
-
// TODO: Send "Envoyez INSCRIPTION pour commencer"
|
| 88 |
}
|
| 89 |
}
|
| 90 |
}
|
|
|
|
| 1 |
import { prisma } from './prisma';
|
| 2 |
+
import { scheduleMessage, enrollUser } from './queue';
|
| 3 |
|
| 4 |
export class WhatsAppService {
|
| 5 |
static async handleIncomingMessage(phone: string, text: string) {
|
|
|
|
| 14 |
user = await prisma.user.create({
|
| 15 |
data: { phone }
|
| 16 |
});
|
| 17 |
+
await scheduleMessage(user.id, "Bienvenue sur SafeTrack Edu ! π\nChoisissez votre langue / TΓ nnal sa lΓ kk:\n1. FranΓ§ais π«π·\n2. Wolof πΈπ³");
|
| 18 |
+
console.log('New user created, asked for language.');
|
| 19 |
return;
|
| 20 |
} else {
|
| 21 |
+
// Not registered yet, hasn't typed INSCRIPTION. We ignore or prompt:
|
| 22 |
+
console.log(`Unregistered user ${phone} sent a message. Need INSCRIPTION.`);
|
| 23 |
return;
|
| 24 |
}
|
| 25 |
}
|
| 26 |
|
| 27 |
+
// 2. Check Onboarding State (Missing Language -> Missing Activity)
|
| 28 |
+
if (!user.city) {
|
| 29 |
+
// First time after INSCRIPTION they should answer 1 or 2
|
| 30 |
+
if (normalizedText === '1' || normalizedText === '2') {
|
| 31 |
+
const lang = normalizedText === '1' ? 'FR' : 'WOLOF';
|
| 32 |
+
user = await prisma.user.update({
|
| 33 |
+
where: { id: user.id },
|
| 34 |
+
data: { language: lang, city: 'SET' } // Using city as a step flag for MVP
|
| 35 |
+
});
|
| 36 |
+
|
| 37 |
+
const prompt = lang === 'FR'
|
| 38 |
+
? "Parfait ! Pour personnaliser votre expΓ©rience, quel est votre secteur d'activitΓ© ou projet professionnel ? (ex: Agriculture, Commerce, Tech...)"
|
| 39 |
+
: "Baax na ! Ngir gΓ«n a waajal sa njΓ ng, ban mbir ngay def ? (Mbay, Njaay, Tech...)";
|
| 40 |
+
await scheduleMessage(user.id, prompt);
|
| 41 |
+
return;
|
| 42 |
+
} else {
|
| 43 |
+
await scheduleMessage(user.id, "Veuillez choisir votre langue / TΓ nnal sa lΓ kk:\n1. FranΓ§ais π«π·\n2. Wolof πΈπ³");
|
| 44 |
+
return;
|
| 45 |
+
}
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
if (!user.activity) {
|
| 49 |
+
// Whatever they type now (or dictated via audio) is their activity
|
| 50 |
+
user = await prisma.user.update({
|
| 51 |
+
where: { id: user.id },
|
| 52 |
+
data: { activity: text.trim() }
|
| 53 |
+
});
|
| 54 |
+
|
| 55 |
+
const welcomeMsg = user.language === 'FR'
|
| 56 |
+
? `Merci ! Nous avons notΓ© votre secteur : *${user.activity}*.\nJe vous inscris maintenant Γ notre formation d'introduction !`
|
| 57 |
+
: `JΓ«rΓ«jΓ«f ! Bind nanu la ci: *${user.activity}*.\nLΓ©egi dinanu la dugal ci njΓ ng mi !`;
|
| 58 |
+
|
| 59 |
+
await scheduleMessage(user.id, welcomeMsg);
|
| 60 |
+
|
| 61 |
+
// Find default track
|
| 62 |
+
const defaultTrack = await prisma.track.findFirst({
|
| 63 |
+
where: { title: "Business Model Express" } // Assuming this exists from seed
|
| 64 |
+
});
|
| 65 |
+
|
| 66 |
+
if (defaultTrack) {
|
| 67 |
+
// Delegate to worker which handles premium vs free checks!
|
| 68 |
+
await enrollUser(user.id, defaultTrack.id);
|
| 69 |
+
}
|
| 70 |
+
return;
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
// 3. Check Active Enrollment
|
| 74 |
const activeEnrollment = await prisma.enrollment.findFirst({
|
| 75 |
where: { userId: user.id, status: 'ACTIVE' },
|
| 76 |
include: { track: true }
|
|
|
|
| 90 |
}
|
| 91 |
});
|
| 92 |
|
| 93 |
+
await scheduleMessage(user.id, "β
RΓ©ponse enregistrΓ©e ! Γ demain pour la suite.");
|
|
|
|
|
|
|
|
|
|
| 94 |
return;
|
| 95 |
}
|
| 96 |
|
| 97 |
+
// 4. Default Handle Commands
|
| 98 |
if (normalizedText === 'INSCRIPTION') {
|
| 99 |
+
await scheduleMessage(user.id, "Vous avez dΓ©jΓ complΓ©tΓ© l'inscription. Consultez le portail ou attendez nos prochaines formations !");
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
} else {
|
| 101 |
+
console.log('Unknown command from fully onboarded user without active enrollment.');
|
|
|
|
| 102 |
}
|
| 103 |
}
|
| 104 |
}
|
apps/whatsapp-worker/src/index.ts
CHANGED
|
@@ -9,6 +9,8 @@ const prisma = new PrismaClient();
|
|
| 9 |
const connection = {
|
| 10 |
host: process.env.REDIS_HOST || 'localhost',
|
| 11 |
port: parseInt(process.env.REDIS_PORT || '6379'),
|
|
|
|
|
|
|
| 12 |
};
|
| 13 |
|
| 14 |
const worker = new Worker('whatsapp-queue', async (job: Job) => {
|
|
@@ -67,13 +69,60 @@ const worker = new Worker('whatsapp-queue', async (job: Job) => {
|
|
| 67 |
else if (job.name === 'send-content') {
|
| 68 |
const { userId, trackId, dayNumber } = job.data;
|
| 69 |
|
|
|
|
| 70 |
const trackDay = await prisma.trackDay.findFirst({
|
| 71 |
where: { trackId, dayNumber }
|
| 72 |
});
|
| 73 |
|
| 74 |
if (trackDay && trackDay.textContent) {
|
| 75 |
-
|
| 76 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
|
| 78 |
// Update enrollment progress
|
| 79 |
await prisma.enrollment.updateMany({
|
|
@@ -96,9 +145,8 @@ const worker = new Worker('whatsapp-queue', async (job: Job) => {
|
|
| 96 |
// π Trigger AI Document Generation π
|
| 97 |
console.log(`[WORKER] Triggering AI Document Generation for User ${userId}...`);
|
| 98 |
try {
|
| 99 |
-
//
|
| 100 |
-
/
|
| 101 |
-
const userContext = `User ${userId} completed the Business Pitch track. They want to build an AgTech SaaS.`;
|
| 102 |
|
| 103 |
const API_URL = process.env.VITE_API_URL || 'http://localhost:3001';
|
| 104 |
|
|
|
|
| 9 |
const connection = {
|
| 10 |
host: process.env.REDIS_HOST || 'localhost',
|
| 11 |
port: parseInt(process.env.REDIS_PORT || '6379'),
|
| 12 |
+
password: process.env.REDIS_PASSWORD || undefined,
|
| 13 |
+
tls: process.env.REDIS_TLS === 'true' ? {} : undefined,
|
| 14 |
};
|
| 15 |
|
| 16 |
const worker = new Worker('whatsapp-queue', async (job: Job) => {
|
|
|
|
| 69 |
else if (job.name === 'send-content') {
|
| 70 |
const { userId, trackId, dayNumber } = job.data;
|
| 71 |
|
| 72 |
+
const user = await prisma.user.findUnique({ where: { id: userId } });
|
| 73 |
const trackDay = await prisma.trackDay.findFirst({
|
| 74 |
where: { trackId, dayNumber }
|
| 75 |
});
|
| 76 |
|
| 77 |
if (trackDay && trackDay.textContent) {
|
| 78 |
+
let finalContent = trackDay.textContent;
|
| 79 |
+
|
| 80 |
+
// π Personalize Lesson Content π
|
| 81 |
+
if (user && user.activity) {
|
| 82 |
+
try {
|
| 83 |
+
console.log(`[WORKER] Personalizing lesson for User ${userId}'s activity: ${user.activity}`);
|
| 84 |
+
const API_URL = process.env.VITE_API_URL || 'http://localhost:3001';
|
| 85 |
+
const personalizeRes = await fetch(`${API_URL}/v1/ai/personalize-lesson`, {
|
| 86 |
+
method: 'POST',
|
| 87 |
+
headers: { 'Content-Type': 'application/json' },
|
| 88 |
+
body: JSON.stringify({ lessonText: trackDay.textContent, userActivity: user.activity })
|
| 89 |
+
});
|
| 90 |
+
|
| 91 |
+
if (personalizeRes.ok) {
|
| 92 |
+
const personalizeData = await personalizeRes.json();
|
| 93 |
+
if (personalizeData.text) {
|
| 94 |
+
finalContent = personalizeData.text;
|
| 95 |
+
}
|
| 96 |
+
}
|
| 97 |
+
} catch (err) {
|
| 98 |
+
console.error('[WORKER] Failed to personalize lesson:', err);
|
| 99 |
+
}
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
// π Generate TTS Audio (Multi-language Support) π
|
| 103 |
+
let audioUrl = null;
|
| 104 |
+
try {
|
| 105 |
+
console.log(`[WORKER] Generating TTS Audio for User ${userId}...`);
|
| 106 |
+
const API_URL = process.env.VITE_API_URL || 'http://localhost:3001';
|
| 107 |
+
const ttsRes = await fetch(`${API_URL}/v1/ai/tts`, {
|
| 108 |
+
method: 'POST',
|
| 109 |
+
headers: { 'Content-Type': 'application/json' },
|
| 110 |
+
body: JSON.stringify({ text: finalContent })
|
| 111 |
+
});
|
| 112 |
+
|
| 113 |
+
if (ttsRes.ok) {
|
| 114 |
+
const ttsData = await ttsRes.json();
|
| 115 |
+
audioUrl = ttsData.url;
|
| 116 |
+
}
|
| 117 |
+
} catch (err) {
|
| 118 |
+
console.error('[WORKER] Failed to generate TTS:', err);
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
// TODO: Call WhatsApp Cloud API with finalContent and audioUrl
|
| 122 |
+
console.log(`[MOCK SEND CONTENT] To User ${userId}: "${finalContent}"`);
|
| 123 |
+
if (audioUrl) {
|
| 124 |
+
console.log(`[MOCK SEND AUDIO] To User ${userId}: π΅ ${audioUrl}`);
|
| 125 |
+
}
|
| 126 |
|
| 127 |
// Update enrollment progress
|
| 128 |
await prisma.enrollment.updateMany({
|
|
|
|
| 145 |
// π Trigger AI Document Generation π
|
| 146 |
console.log(`[WORKER] Triggering AI Document Generation for User ${userId}...`);
|
| 147 |
try {
|
| 148 |
+
// Update userContext to explicitly reference the user's sector/activity
|
| 149 |
+
const userContext = `User ${userId} completed the Business Pitch track. Their business activity/sector is: ${user?.activity || 'Unknown'}. They want to build a business in this sector using the concepts learned in the track.`;
|
|
|
|
| 150 |
|
| 151 |
const API_URL = process.env.VITE_API_URL || 'http://localhost:3001';
|
| 152 |
|
scripts/start-backend.sh
CHANGED
|
@@ -1,13 +1,25 @@
|
|
| 1 |
-
#!/bin/
|
|
|
|
| 2 |
|
| 3 |
-
# Migrate database
|
| 4 |
echo "Running database migrations..."
|
| 5 |
pnpm --filter @repo/database db:push
|
| 6 |
|
| 7 |
-
# Start Worker in background
|
| 8 |
echo "Starting WhatsApp Worker..."
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
-
# Start API in foreground
|
| 12 |
echo "Starting API..."
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
set -ex
|
| 3 |
|
|
|
|
| 4 |
echo "Running database migrations..."
|
| 5 |
pnpm --filter @repo/database db:push
|
| 6 |
|
|
|
|
| 7 |
echo "Starting WhatsApp Worker..."
|
| 8 |
+
# The TypeScript build might output to dist/src/index.js or dist/index.js depending on rootDir
|
| 9 |
+
if [ -f "apps/whatsapp-worker/dist/src/index.js" ]; then
|
| 10 |
+
node apps/whatsapp-worker/dist/src/index.js &
|
| 11 |
+
elif [ -f "apps/whatsapp-worker/dist/index.js" ]; then
|
| 12 |
+
node apps/whatsapp-worker/dist/index.js &
|
| 13 |
+
else
|
| 14 |
+
echo "ERROR: WhatsApp Worker build output missing (dist/index.js or dist/src/index.js)"
|
| 15 |
+
fi
|
| 16 |
|
|
|
|
| 17 |
echo "Starting API..."
|
| 18 |
+
if [ -f "apps/api/dist/src/index.js" ]; then
|
| 19 |
+
exec node apps/api/dist/src/index.js
|
| 20 |
+
elif [ -f "apps/api/dist/index.js" ]; then
|
| 21 |
+
exec node apps/api/dist/index.js
|
| 22 |
+
else
|
| 23 |
+
echo "ERROR: API build output missing (dist/index.js or dist/src/index.js)!"
|
| 24 |
+
exit 1
|
| 25 |
+
fi
|