|
|
| import { FastifyInstance } from 'fastify'; |
| import { prisma } from '../services/prisma'; |
| import { logger } from '../logger'; |
| import OpenAI from 'openai'; |
|
|
| const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY, timeout: 20_000 }); |
|
|
| export async function analyticsRoutes(fastify: FastifyInstance) { |
| |
| |
| |
| |
| fastify.get('/usage', async (req, reply) => { |
| const organizationId = req.organizationId; |
|
|
| if (!organizationId) { |
| return reply.code(400).send({ error: 'Organization ID is required' }); |
| } |
|
|
| try { |
| const [ |
| totalMessages, |
| inboundMessages, |
| outboundMessages, |
| totalUsers, |
| activeUsersLast24h, |
| usageAgg, |
| usageByFeature, |
| ] = await Promise.all([ |
| prisma.message.count({ where: { organizationId } }), |
| prisma.message.count({ where: { organizationId, direction: 'INBOUND' } }), |
| prisma.message.count({ where: { organizationId, direction: 'OUTBOUND' } }), |
| prisma.user.count({ where: { organizationId } }), |
| prisma.user.count({ |
| where: { |
| organizationId, |
| lastActivityAt: { gte: new Date(Date.now() - 24 * 60 * 60 * 1000) } |
| } |
| }), |
| |
| prisma.usageEvent.aggregate({ |
| where: { organizationId }, |
| _sum: { tokensIn: true, tokensOut: true, costUsd: true }, |
| }), |
| prisma.usageEvent.groupBy({ |
| by: ['feature'], |
| where: { organizationId }, |
| _sum: { costUsd: true, tokensIn: true, tokensOut: true }, |
| _count: { _all: true }, |
| }), |
| ]); |
|
|
| const realTokensIn = usageAgg._sum.tokensIn ?? 0; |
| const realTokensOut = usageAgg._sum.tokensOut ?? 0; |
| const realCostUsd = usageAgg._sum.costUsd ?? 0; |
|
|
| return { |
| messages: { |
| total: totalMessages, |
| inbound: inboundMessages, |
| outbound: outboundMessages |
| }, |
| users: { |
| total: totalUsers, |
| activeLast24h: activeUsersLast24h |
| }, |
| costs: { |
| tokensIn: realTokensIn, |
| tokensOut: realTokensOut, |
| totalTokens: realTokensIn + realTokensOut, |
| totalUsd: realCostUsd, |
| byFeature: usageByFeature.map((f: any) => ({ |
| feature: f.feature, |
| calls: f._count._all, |
| tokensIn: f._sum.tokensIn ?? 0, |
| tokensOut: f._sum.tokensOut ?? 0, |
| costUsd: f._sum.costUsd ?? 0, |
| })), |
| } |
| }; |
| } catch (err) { |
| logger.error({ err }, '[ANALYTICS] Usage fetch failed:'); |
| return reply.code(500).send({ error: 'Failed to fetch usage analytics' }); |
| } |
| }); |
|
|
| |
| |
| |
| |
| fastify.get('/pedagogy', async (req, reply) => { |
| const organizationId = req.organizationId; |
|
|
| if (!organizationId) { |
| return reply.code(400).send({ error: 'Organization ID is required' }); |
| } |
|
|
| try { |
| const enrollments = await prisma.enrollment.findMany({ |
| where: { organizationId }, |
| select: { status: true, currentDay: true } |
| }); |
|
|
| const total = enrollments.length; |
| const completed = enrollments.filter(e => e.status === 'COMPLETED').length; |
| const active = enrollments.filter(e => e.status === 'ACTIVE').length; |
|
|
| const averageProgress = total > 0 |
| ? enrollments.reduce((acc, curr) => acc + curr.currentDay, 0) / total |
| : 0; |
|
|
| const scores = await prisma.userProgress.aggregate({ |
| where: { organizationId }, |
| _avg: { score: true }, |
| _max: { score: true } |
| }); |
|
|
| return { |
| completion: { |
| total, |
| completed, |
| active, |
| rate: total > 0 ? (completed / total) * 100 : 0 |
| }, |
| performance: { |
| averageProgressDays: averageProgress, |
| averageScore: scores._avg.score || 0, |
| maxScore: scores._max.score || 0 |
| } |
| }; |
| } catch (err) { |
| logger.error({ err }, '[ANALYTICS] Pedagogy fetch failed:'); |
| return reply.code(500).send({ error: 'Failed to fetch pedagogical analytics' }); |
| } |
| }); |
|
|
| |
| |
| |
| |
| fastify.get('/campaigns', async (req, reply) => { |
| const organizationId = req.organizationId; |
|
|
| if (!organizationId) { |
| return reply.code(400).send({ error: 'Organization ID is required' }); |
| } |
|
|
| try { |
| const stats = await prisma.campaignHistory.groupBy({ |
| by: ['status'], |
| where: { organizationId }, |
| _count: { _all: true } |
| }); |
|
|
| const counts: Record<string, number> = { |
| SENT: 0, |
| DELIVERED: 0, |
| READ: 0, |
| FAILED: 0 |
| }; |
|
|
| stats.forEach((s: any) => { |
| counts[s.status] = s._count._all; |
| }); |
|
|
| const total = counts.SENT + counts.DELIVERED + counts.READ + counts.FAILED; |
|
|
| |
| |
|
|
| return { |
| summary: { |
| total, |
| sent: counts.SENT, |
| delivered: counts.DELIVERED, |
| read: counts.READ, |
| failed: counts.FAILED, |
| deliveryRate: total > 0 ? ((counts.DELIVERED + counts.READ) / total) * 100 : 0, |
| readRate: (counts.DELIVERED + counts.READ) > 0 ? (counts.READ / (counts.DELIVERED + counts.READ)) * 100 : 0 |
| }, |
| funnel: [ |
| { name: 'Envoyés', value: total, fill: '#6366f1' }, |
| { name: 'Livrés', value: counts.DELIVERED + counts.READ, fill: '#8b5cf6' }, |
| { name: 'Lus', value: counts.READ, fill: '#ec4899' } |
| ] |
| }; |
| } catch (err) { |
| logger.error({ err }, '[ANALYTICS] Campaigns fetch failed:'); |
| return reply.code(500).send({ error: 'Failed to fetch campaign analytics' }); |
| } |
| }); |
|
|
| |
| |
| |
| |
| |
| fastify.post('/query', async (req, reply) => { |
| const organizationId = (req as any).organizationId; |
| if (!organizationId) return reply.code(400).send({ error: 'Organization context required' }); |
|
|
| const { question, language = 'FR' } = req.body as { question?: string; language?: string }; |
| if (!question?.trim()) return reply.code(400).send({ error: 'Question is required' }); |
|
|
| |
| const SCHEMA_CONTEXT = ` |
| Tables disponibles (PostgreSQL, toutes filtrées par organizationId): |
| - "User"(id, phone, name, language, activity, currentStreak, createdAt, deletedAt) |
| - "Enrollment"(id, userId, trackId, status, currentDay, startedAt, completedAt, deletedAt) |
| - "UserProgress"(id, userId, trackId, exerciseStatus, confidenceScore, iterationCount) |
| - "Message"(id, userId, direction, content, createdAt) |
| - "Track"(id, title, duration, language) |
| - "CampaignHistory"(id, contactId, status, createdAt) |
| - "UsageEvent"(id, feature, provider, tokensIn, tokensOut, costUsd, createdAt) |
| - "WalletTransaction"(id, amount, type, balanceAfter, createdAt) |
| - "Contact"(id, phoneNumber, name, language, tags, createdAt) |
| |
| Règles IMPÉRATIVES: |
| 1. Tu dois générer UNIQUEMENT un SELECT SQL valide. |
| 2. TOUJOURS inclure WHERE "organizationId" = '<ORG_ID>' dans la clause WHERE. |
| 3. LIMIT 100 maximum. |
| 4. Utilise des alias lisibles pour les colonnes (AS). |
| 5. Ne génère PAS de CTE, sous-requêtes complexes, ou JOIN sur plus de 3 tables. |
| 6. Réponds UNIQUEMENT avec le SQL brut, sans markdown, sans explications. |
| `; |
|
|
| let sql: string; |
| try { |
| const completion = await openai.chat.completions.create({ |
| model: 'gpt-4o-mini', |
| temperature: 0, |
| messages: [ |
| { role: 'system', content: SCHEMA_CONTEXT }, |
| { role: 'user', content: `Génère le SQL pour répondre à cette question (langue interface: ${language}):\n"${question}"` }, |
| ], |
| }); |
| sql = (completion.choices[0]?.message?.content ?? '').trim(); |
| } catch (err: any) { |
| logger.error({ err }, '[TEXT-SQL] LLM generation failed'); |
| return reply.code(503).send({ error: 'AI service unavailable' }); |
| } |
|
|
| |
| if (/union/i.test(sql)) { |
| logger.warn('[TEXT-SQL] UNION detected — query rejected'); |
| return reply.code(400).send({ error: 'UNION not allowed' }); |
| } |
|
|
| |
| const normalized = sql.replace(/\s+/g, ' ').trim().toUpperCase(); |
| if (!normalized.startsWith('SELECT')) { |
| logger.warn('[TEXT-SQL] Non-SELECT query rejected'); |
| return reply.code(400).send({ error: 'Only SELECT queries are allowed' }); |
| } |
| const DANGEROUS_PATTERNS = [/\bUNION\b/, /\bINSERT\b/, /\bUPDATE\b/, /\bDELETE\b/, /\bDROP\b/, /\bEXEC\b/, /\bEXECUTE\b/, /--/, /\/\*/, /;\s*SELECT/i]; |
| if (DANGEROUS_PATTERNS.some(p => p.test(normalized))) { |
| logger.warn('[TEXT-SQL] Dangerous pattern detected — query rejected'); |
| return reply.code(400).send({ error: 'Query contains disallowed patterns' }); |
| } |
|
|
| |
| const ALLOWED_TABLES = new Set([ |
| 'Message', 'User', 'Enrollment', 'UserProgress', 'Contact', |
| 'WalletTransaction', 'AuditLog', 'BusinessProfile', 'KnowledgeBaseEntry', |
| 'TrackDay', 'Track', 'Campaign', 'CampaignHistory', 'UsageEvent', |
| ]); |
| const referencedTables = [...sql.matchAll(/(?:FROM|JOIN)\s+"?(\w+)"?/gi)].map(m => m[1]); |
| const disallowedTable = referencedTables.find(t => !ALLOWED_TABLES.has(t)); |
| if (disallowedTable) { |
| logger.warn({ disallowedTable }, '[TEXT-SQL] Disallowed table reference — query rejected'); |
| return reply.code(400).send({ error: `Table not allowed: ${disallowedTable}` }); |
| } |
|
|
| |
| const safeSql = sql |
| .replace(/'<ORG_ID>'/g, `'${organizationId}'`) |
| .replace(/"<ORG_ID>"/g, `'${organizationId}'`); |
|
|
| |
| if (!safeSql.includes(organizationId)) { |
| logger.warn({ organizationId }, '[TEXT-SQL] Query missing organizationId — rejected'); |
| return reply.code(400).send({ error: 'Generated query does not scope to the organization' }); |
| } |
|
|
| try { |
| const rows = await prisma.$queryRawUnsafe<Record<string, unknown>[]>(safeSql); |
| return { rows, count: rows.length }; |
| } catch (err: any) { |
| logger.error({ err }, '[TEXT-SQL] Query execution failed'); |
| return reply.code(422).send({ error: 'Query execution failed', detail: err.message }); |
| } |
| }); |
| } |
|
|