CognxSafeTrack commited on
Commit
8f17184
·
1 Parent(s): f20ac25

chore: setup whatsapp-worker for pure JS compilation and add Dockerfile fallback for Railway

Browse files
apps/whatsapp-worker/Dockerfile ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ FROM node:20
2
+ WORKDIR /app
3
+ COPY . .
4
+ RUN npm install -g pnpm
5
+ RUN pnpm install
6
+ RUN pnpm --filter @repo/database build
7
+ RUN pnpm --filter whatsapp-worker build
8
+ CMD ["pnpm", "--filter", "whatsapp-worker", "start"]
apps/whatsapp-worker/src/index.ts CHANGED
@@ -59,7 +59,7 @@ const worker = new Worker('whatsapp-queue', async (job: Job) => {
59
  body: JSON.stringify({ userId, trackId })
60
  });
61
 
62
- const checkoutData = await checkoutRes.json();
63
  if (checkoutRes.ok && checkoutData.url) {
64
  const user = await prisma.user.findUnique({ where: { id: userId } });
65
  if (user?.phone) {
@@ -118,7 +118,7 @@ const worker = new Worker('whatsapp-queue', async (job: Job) => {
118
  });
119
 
120
  if (personalizeRes.ok) {
121
- const personalizeData = await personalizeRes.json();
122
  if (personalizeData.text) {
123
  finalContent = personalizeData.text;
124
  }
@@ -144,7 +144,7 @@ const worker = new Worker('whatsapp-queue', async (job: Job) => {
144
  });
145
 
146
  if (ttsRes.ok) {
147
- const ttsData = await ttsRes.json();
148
  audioUrl = ttsData.url;
149
  }
150
  } catch (err) {
@@ -196,7 +196,7 @@ const worker = new Worker('whatsapp-queue', async (job: Job) => {
196
  headers: authHeaders,
197
  body: JSON.stringify({ userContext })
198
  });
199
- const pdfData = await pdfRes.json();
200
 
201
  // Trigger Pitch Deck (PPTX)
202
  const pptxRes = await fetch(`${API_URL}/v1/ai/deck`, {
@@ -204,7 +204,7 @@ const worker = new Worker('whatsapp-queue', async (job: Job) => {
204
  headers: authHeaders,
205
  body: JSON.stringify({ userContext })
206
  });
207
- const pptxData = await pptxRes.json();
208
 
209
  console.log(`[AI DOCS READY] 📄 PDF: ${pdfData.url}`);
210
  console.log(`[AI DOCS READY] 📊 PPTX: ${pptxData.url}`);
 
59
  body: JSON.stringify({ userId, trackId })
60
  });
61
 
62
+ const checkoutData = await checkoutRes.json() as any;
63
  if (checkoutRes.ok && checkoutData.url) {
64
  const user = await prisma.user.findUnique({ where: { id: userId } });
65
  if (user?.phone) {
 
118
  });
119
 
120
  if (personalizeRes.ok) {
121
+ const personalizeData = await personalizeRes.json() as any;
122
  if (personalizeData.text) {
123
  finalContent = personalizeData.text;
124
  }
 
144
  });
145
 
146
  if (ttsRes.ok) {
147
+ const ttsData = await ttsRes.json() as any;
148
  audioUrl = ttsData.url;
149
  }
150
  } catch (err) {
 
196
  headers: authHeaders,
197
  body: JSON.stringify({ userContext })
198
  });
199
+ const pdfData = await pdfRes.json() as any;
200
 
201
  // Trigger Pitch Deck (PPTX)
202
  const pptxRes = await fetch(`${API_URL}/v1/ai/deck`, {
 
204
  headers: authHeaders,
205
  body: JSON.stringify({ userContext })
206
  });
207
+ const pptxData = await pptxRes.json() as any;
208
 
209
  console.log(`[AI DOCS READY] 📄 PDF: ${pdfData.url}`);
210
  console.log(`[AI DOCS READY] 📊 PPTX: ${pptxData.url}`);
apps/whatsapp-worker/tsconfig.json CHANGED
@@ -2,7 +2,17 @@
2
  "extends": "@repo/tsconfig/base.json",
3
  "compilerOptions": {
4
  "outDir": "dist",
5
- "rootDir": "src"
 
 
 
 
 
 
 
 
 
 
6
  },
7
  "include": [
8
  "src/**/*"
 
2
  "extends": "@repo/tsconfig/base.json",
3
  "compilerOptions": {
4
  "outDir": "dist",
5
+ "rootDir": "src",
6
+ "module": "CommonJS",
7
+ "moduleResolution": "node",
8
+ "noEmit": false,
9
+ "allowImportingTsExtensions": false,
10
+ "target": "ES2020",
11
+ "lib": [
12
+ "ES2020"
13
+ ],
14
+ "esModuleInterop": true,
15
+ "allowSyntheticDefaultImports": true
16
  },
17
  "include": [
18
  "src/**/*"
apps/whatsapp-worker/tsconfig.tsbuildinfo ADDED
@@ -0,0 +1 @@
 
 
1
+ {"root":["./src/index.ts","./src/scheduler.ts","./src/whatsapp-cloud.ts"],"version":"5.9.3"}
docs/Xamle_System_Spec_Deployment_Sheet.md ADDED
@@ -0,0 +1,237 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # XAMLÉ – System Spec & Deployment Sheet (HF + Railway Worker)
2
+
3
+ Ce document centralise toutes les informations techniques, de configuration et d'architecture de l'infrastructure XAMLÉ (SafeTrack EdTech). Il sert de référence absolue (Single Source of Truth) pour déployer et maintenir le backend scindé entre Hugging Face (API) et Railway (Worker).
4
+
5
+ ---
6
+
7
+ ## 1. Résumé Architecture (Actuelle / Cible)
8
+
9
+ Le système est construit sur un monorepo Turborepo (pnpm) composé de multiples applications qui se parlent via une base de données et un cache central.
10
+
11
+ * **Hugging Face Space (Public Inbound Endpoint) :**
12
+ * Héberge l'application `apps/api`.
13
+ * Rôle : Point d'entrée public pour le Webhook Meta (WhatsApp Inbound), les requêtes Stripe, et le back-office Admin.
14
+ * C'est ici que l'IA (OpenAI) génère le contenu et que l'utilisateur est enregistré en base. Aucune requête sortante vers Meta n'est émise d'ici à cause des restrictions DNS (ENOTFOUND).
15
+ * **Railway (Outbound Background Worker) :**
16
+ * Héberge **uniquement** l'application `apps/whatsapp-worker`.
17
+ * Rôle : Consommer les jobs BullMQ enregistrés dans Redis par Hugging Face, et envoyer physiquement les messages sortants vers l'API WhatsApp Cloud (graph.facebook.com). Contient le Cron (Scheduler) quotidien.
18
+ * **Neon Postgres :**
19
+ * Rôle : Base de données relationnelle centrale (Prisma) partagée entre HF et Railway (Tables `User`, `Track`, `Enrollment`, `TrackDay`).
20
+ * **Upstash Redis :**
21
+ * Rôle : Gestion de la file d'attente asynchrone BullMQ (queue `whatsappQueue`). Il agit comme le pont de communication en temps réel entre Hugging Face et Railway.
22
+ * **Cloudflare R2 :**
23
+ * Rôle : Stockage S3 public pour héberger les fichiers générés par l'IA (PDF, Audio, PPTX) afin que Meta puisse les télécharger via des URLs publiques et les envoyer aux utilisateurs WhatsApp.
24
+ * **OpenAI :**
25
+ * Rôle : LLM utilisé dans `apps/api` (sur Hugging Face) pour personnaliser les leçons et générer l'audio (Whisper STT / TTS).
26
+
27
+ ---
28
+
29
+ ## 2. URLs & Endpoints
30
+
31
+ **URL de base Hugging Face :** `https://safetrack-edtech.hf.space`
32
+
33
+ ### Webhook WhatsApp (Configuration Meta App Dashboard)
34
+ * **GET Verify (Public) :** `GET https://safetrack-edtech.hf.space/v1/whatsapp/webhook`
35
+ * Utilisé par Meta une seule fois lors de la configuration avec `<WHATSAPP_VERIFY_TOKEN>`.
36
+ * **POST Webhook (Public mais Sécurisé HMAC) :** `POST https://safetrack-edtech.hf.space/v1/whatsapp/webhook`
37
+ * L'endpoint où Meta poste les messages des utilisateurs.
38
+ * Sécurisé via validation de signature `X-Hub-Signature-256` en utilisant `<WHATSAPP_APP_SECRET>`.
39
+
40
+ ### Utilitaires & Healthchecks (Publics)
41
+ * `GET /` : Healthcheck basique (`{"ok": true}`).
42
+ * `GET /health` : Monitoring avec timestamp.
43
+ * `GET /privacy` : Politique de confidentialité servie en HTML (requise par Meta App Review).
44
+ * `GET /debug/net` : Teste la connectivité Google (diagnostique Egress).
45
+ * `GET /debug/graph` : Teste explicitement si la résolution DNS `graph.facebook.com` fonctionne (Diagnostic HF DNS Block).
46
+
47
+ ### Routes Privées (Protégées par `X-API-Key`)
48
+ Les routes suivantes exigent le header `Authorization: Bearer <ADMIN_API_KEY>` :
49
+ * `/v1/admin/*` : Endpoints pour le frontend Admin (Stats, Enrollments).
50
+ * `/v1/ai/*` : Génération à la demande de PDF/Audio.
51
+ * `/v1/payments/*` : Création de sessions Stripe.
52
+
53
+ ---
54
+
55
+ ## 3. Variables & Secrets (Environment)
56
+
57
+ | Name | Env | Type | Example / Placeholder | Used by | Notes |
58
+ | :--- | :--- | :--- | :--- | :--- | :--- |
59
+ | `WHATSAPP_VERIFY_TOKEN` | HF | Secret | `my_secure_verify_token_123` | `apps/api` | Vérifie le webhook initial de Meta. |
60
+ | `WHATSAPP_APP_SECRET` | HF | Secret | `c2a5...` (32 chars) | `apps/api` | Clé secrète de l'App Meta pour signer (`crypto.createHmac`) les payloads. |
61
+ | `WHATSAPP_ACCESS_TOKEN` | **Railway** & HF | Secret | `EAA...` | `whatsapp-worker` & `api` | Le jeton d'accès permanent System User. Worker (envoi message) / API (téléchargement audio STT). |
62
+ | `WHATSAPP_PHONE_NUMBER_ID` | **Railway** | Public | `5249583...` | `whatsapp-worker` | L'ID Meta du numéro expéditeur. |
63
+ | `DATABASE_URL` | Both | Secret | `postgresql://user:pass@ep-neon.tech/db?sslmode=require` | `api` & `worker` | DSN Neon (Pooler ou direct). |
64
+ | `REDIS_URL` | Both | Secret | `rediss://default:pass@upstash.io:6379` | `api` & `worker` | **Important:** Si Upstash TLS, utiliser `rediss://` (avec 2 's') et non `redis://`. |
65
+ | `ADMIN_API_KEY` | HF | Secret | `sk_admin_12345` | `apps/api` | Sécurise le dashboard et les routes privées. |
66
+ | `OPENAI_API_KEY` | HF | Secret | `sk-proj...` | `apps/api` | Provider IA. |
67
+ | `R2_ACCOUNT_ID` | HF | Public | `a1b2c3d4...` | `apps/api` | Cloudflare Storage. |
68
+ | `R2_BUCKET` | HF | Public | `xamle-edtech-assets` | `apps/api` | Nom du seau Cloudflare. |
69
+ | `R2_PUBLIC_URL` | Both | Public | `https://assets.xamle.com` | `api` & `worker` | URL de diffusion pour que l'App Meta puisse télécharger l'asset. |
70
+ | `R2_ACCESS_KEY_ID` | HF | Secret | `...` | `apps/api` | AWS S3 Compat layer credential. |
71
+ | `R2_SECRET_ACCESS_KEY` | HF | Secret | `...` | `apps/api` | AWS S3 Compat layer credential. |
72
+ | `STRIPE_SECRET_KEY` | HF | Secret | `sk_test_...` | `apps/api` | Paiements et abonnements Premium. |
73
+ | `STRIPE_WEBHOOK_SECRET` | HF | Secret | `whsec_...` | `apps/api` | Signature Webhook Stripe. |
74
+
75
+ ---
76
+
77
+ ## 4. BullMQ / Worker Logic
78
+
79
+ La file d'attente garantit la résilience.
80
+ * **Nom de la Queue :** `whatsappQueue`
81
+ * **Producer (Hugging Face) :**
82
+ Quand un webhook Meta arrive, l'API appelle `scheduleMessage(userId, text)` dans `apps/api/src/services/queue.ts`.
83
+ * **Consumer (Railway) :**
84
+ Le Worker écoute la queue dans `apps/whatsapp-worker/src/index.ts`. Le handler de la file est instancié avec `new Worker('whatsappQueue', async (job) => { ... })`.
85
+ * **Job Types (Payload JSON) :**
86
+ * Type `send-message` : `{ "userId": "uuid-1234", "text": "Bienvenue !" }`
87
+ * Type `send-content` : `{ "userId": "uuid-123", "trackDayId": "day-123" }` (déclenche génération IA et envois médias).
88
+ * **Logs Attendus sur Railway :**
89
+ `Worker: Processing job: send-message 123`
90
+ `[WhatsApp] ✅ Text message sent to 22177...`
91
+
92
+ ---
93
+
94
+ ## 5. WhatsApp Cloud API
95
+
96
+ Toutes les requêtes sortantes vers Meta sont centralisées dans `apps/whatsapp-worker/src/whatsapp-cloud.ts`.
97
+
98
+ * **URL Exacte :** `https://graph.facebook.com/v18.0/<WHATSAPP_PHONE_NUMBER_ID>/messages` (Forcée via Axios avec un agent HTTP configuré explicitement sur l'IPv4 `family: 4` pour anticiper tout problème de configuration DNS Docker).
99
+ * **Headers :**
100
+ ```json
101
+ {
102
+ "Content-Type": "application/json",
103
+ "Authorization": "Bearer <WHATSAPP_ACCESS_TOKEN>"
104
+ }
105
+ ```
106
+ * **Body Type "Text" :**
107
+ ```json
108
+ {
109
+ "messaging_product": "whatsapp",
110
+ "recipient_type": "individual",
111
+ "to": "221781476249",
112
+ "type": "text",
113
+ "text": { "preview_url": false, "body": "Hello World" }
114
+ }
115
+ ```
116
+
117
+ ---
118
+
119
+ ## 6. Sécurité
120
+
121
+ 1. **Validations Webhook HMAC**
122
+ La vérification cryptographique est active dans `apps/api/src/routes/whatsapp.ts`.
123
+ ```typescript
124
+ const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
125
+ crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
126
+ ```
127
+ 2. **Rate Limiting Global (Hugging Face / Fastify)**
128
+ Configuré dans `index.ts` avec `@fastify/rate-limit`. Le seuil actuel est de `300 requêtes / minute` par adresse IP (qui protégera l'API si le Webhook fuite mais n'impactera pas l'IP de Meta qui est distribuée).
129
+ 3. **API Key (Admin routes)**
130
+ Vérification stricte de `Bearer <ADMIN_API_KEY>` sur chaque route admin/backoffice.
131
+ 4. **CORS**
132
+ CORS est ouvert globalement via `@fastify/cors` pour permettre au dashboard frontal d'intéragir depuis n'importe où.
133
+
134
+ ---
135
+
136
+ ## 7. Repo & Build
137
+
138
+ Le projet est un monorepo géré avec **pnpm** et ses workspaces.
139
+
140
+ * `apps/api/` : Application Fastify (Node).
141
+ * `apps/whatsapp-worker/` : Processus BullMQ natif (Node).
142
+ * `packages/database/` : Couche Prisma partagée avec le schéma `schema.prisma`.
143
+
144
+ **Commandes critiques (Railway Build & Start) :**
145
+ * Root command (Installation) : `pnpm install`
146
+ * Génération client : `pnpm --filter @repo/database build` (ou `prisma generate`).
147
+ * Build du Worker : `pnpm --filter whatsapp-worker build`
148
+ * Start du Worker (Prod) : `pnpm --filter whatsapp-worker start`
149
+
150
+ _Note : Le script start de whatsapp-worker utilise actuellement `tsx src/index.ts` en développement. Pour la prod (Railway), il est conseillé de compiler avec `tsc` et lancer `node dist/index.js`._
151
+
152
+ ---
153
+
154
+ ## 8. Plan de Déploiement "Railway Worker Only"
155
+
156
+ Pour déployer le Worker sur Railway, nous voulons ignorer totalement l'API et le Frontend. La configuration Railway cible le dossier du worker :
157
+
158
+ 1. **Connect GitHub** : Ajouter le repository Edtech à Railway.
159
+ 2. **Configuration du Service (Settings)** :
160
+ * **Root Directory :** Laisser à `/` (car c'est un monorepo pnpm, Railway comprendra les workspaces).
161
+ * **Build Command :** `pnpm install && pnpm --filter @repo/database build && pnpm --filter whatsapp-worker build`
162
+ * **Start Command :** `pnpm --filter whatsapp-worker start`
163
+ 3. **Injector les Variables (Variables tab)** :
164
+ * Coller en Block Import les variables requises signalées en "Railway" ou "Both" dans la section [3].
165
+ * Spécialement `DATABASE_URL`, `REDIS_URL`, `WHATSAPP_PHONE_NUMBER_ID`, et `WHATSAPP_ACCESS_TOKEN`.
166
+ 4. **Checklist de validation finale :**
167
+ * Envoyer `INSCRIPTION` au numéro WhatsApp.
168
+ * Vérifier les logs sur le container de Hugging Face => on y voit la réception `[WEBHOOK] Received webhook event...`.
169
+ * Ouvrir les logs de Railway => on y voit le worker prendre la relève `Processing job: send-message`.
170
+ * Le téléphone reçoit le message instantanément.
171
+
172
+ ---
173
+
174
+ ## 9. Extraits de code de référence
175
+
176
+ ### A. Producer (Hugging Face) -> `services/queue.ts`
177
+ ```typescript
178
+ import { Queue } from 'bullmq';
179
+ import Redis from 'ioredis';
180
+
181
+ const redisConnection = new Redis(process.env.REDIS_URL!);
182
+ export const whatsappQueue = new Queue('whatsappQueue', { connection: redisConnection });
183
+
184
+ export async function scheduleMessage(userId: string, text: string, delayMs: number = 0) {
185
+ await whatsappQueue.add('send-message', { userId, text }, { delay: delayMs });
186
+ }
187
+ ```
188
+
189
+ ### B. Consumer (Railway Worker) -> `whatsapp-worker/src/index.ts`
190
+ ```typescript
191
+ import { Worker, Job } from 'bullmq';
192
+ import { sendTextMessage } from './whatsapp-cloud';
193
+
194
+ const worker = new Worker('whatsappQueue', async (job: Job) => {
195
+ if (job.name === 'send-message') {
196
+ const { userId, text } = job.data;
197
+ const user = await prisma.user.findUnique({ where: { id: userId } });
198
+ if (user?.phone) {
199
+ await sendTextMessage(user.phone, text); // Appelle Graph API
200
+ }
201
+ }
202
+ }, { connection: redisConnection });
203
+ ```
204
+
205
+ ### C. Bypass du blocage DNS (whatsapp-cloud.ts)
206
+ ```typescript
207
+ import axios from 'axios';
208
+ import https from 'https';
209
+ import dns from 'node:dns';
210
+
211
+ // Fix global ipv4first
212
+ dns.setDefaultResultOrder('ipv4first');
213
+
214
+ // Agent de secours
215
+ const httpsAgent = new https.Agent({ family: 4 });
216
+
217
+ export async function sendTextMessage(to: string, text: string): Promise<void> {
218
+ const url = `https://graph.facebook.com/v18.0/${process.env.WHATSAPP_PHONE_NUMBER_ID}/messages`;
219
+ await axios.post(url, body, {
220
+ headers: { Authorization: `Bearer ${process.env.WHATSAPP_ACCESS_TOKEN}` },
221
+ httpsAgent // Force le DNS à utiliser une route supportée
222
+ });
223
+ }
224
+ ```
225
+
226
+ ### D. Endpoints de Diagnostique DNS & Egress Hugging Face (`api/src/index.ts`)
227
+ ```typescript
228
+ server.get('/debug/net', async (_req, reply) => {
229
+ const res = await fetch('https://www.google.com', { method: 'GET' });
230
+ return reply.send({ ok: true, status: res.status }); // ✅ SUCCESS HF
231
+ });
232
+
233
+ server.get('/debug/graph', async (_req, reply) => {
234
+ const res = await fetch('https://graph.facebook.com/v18.0', { method: 'GET' });
235
+ return reply.send({ ok: true, status: res.status }); // ❌ ENOTFOUND HF
236
+ });
237
+ ```