All files / src/routes chat.js

0% Statements 0/96
0% Branches 0/45
0% Functions 0/7
0% Lines 0/94

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 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 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               
/**
 * Routes Chat — Sessions de chat avec l'agent Claude.
 *
 * Endpoints :
 *   GET    /chat/sessions              — Lister les sessions
 *   POST   /chat/sessions              — Créer une session
 *   GET    /chat/sessions/:id/messages — Historique des messages
 *   DELETE /chat/sessions/:id          — Supprimer une session
 *   POST   /chat/sessions/:id/message  — Envoyer un message + obtenir la réponse
 *
 * Flux du message :
 *   1. Le client envoie un message texte
 *   2. Le backend charge l'historique + la clé API
 *   3. Claude répond (potentiellement avec des tool_use)
 *   4. Si une action dangereuse est détectée → retour 202 avec pending_action
 *   5. Le client renvoie avec confirmed_actions → exécution
 *   6. La réponse finale est persistée et retournée
 *
 * @module routes/chat
 */
 
const express = require('express');
const { pool: db } = require('../db');
const { requireAuth } = require('../middleware/auth');
const { checkQuota, checkDailyQuota } = require('../middleware/planLimits');
const { getPlanLimits } = require('../config/plans');
const { decrypt } = require('../services/crypto');
const { chat } = require('../services/ai');
const { makeDeleteHandler } = require('../helpers/routeHelpers');
const { requireGroupContext, requireGroupPermission } = require('../middleware/groupContext');
 
const router = express.Router();
 
router.use(requireAuth);
router.use(requireGroupContext());
 
// ─── GET /chat/usage — compteur de messages aujourd'hui ─────────────────────
 
router.get('/usage', requireGroupPermission('chat', 'read'), async (req, res, next) => {
  try {
    const { rows } = await db.query(
      `SELECT COUNT(*) FROM chat_messages cm
       JOIN chat_sessions cs ON cs.id = cm.session_id
       WHERE cs.user_id = $1 AND cm.role = 'user' AND cm.created_at > NOW() - INTERVAL '24 hours'`,
      [req.user.id],
    );
    const messagesToday = Number.parseInt(rows[0].count);
    const plan = req.user?.plan || 'free';
    const limits = await getPlanLimits(plan);
    const limit = limits.chat_messages_per_day;
 
    res.json({
      messages_today: messagesToday,
      limit: limit === Infinity ? -1 : limit,
    });
  } catch (err) {
    next(err);
  }
});
 
// ─── GET /chat/sessions ──────────────────────────────────────────────────────
 
router.get('/sessions', requireGroupPermission('chat', 'read'), async (req, res, next) => {
  try {
    const result = await db.query(
      `SELECT cs.id, cs.title, cs.server_id, s.name AS server_name, cs.created_at
       FROM chat_sessions cs
       LEFT JOIN servers s ON s.id = cs.server_id
       WHERE cs.user_id = $1
       ORDER BY cs.created_at DESC
       LIMIT 50`,
      [req.user.id],
    );
    res.json(result.rows);
  } catch (err) {
    next(err);
  }
});
 
// ─── POST /chat/sessions ─────────────────────────────────────────────────────
 
router.post('/sessions', requireGroupPermission('chat', 'write'), checkQuota('chat_sessions', 'chat_sessions'), async (req, res, next) => {
  try {
    const { server_id, title } = req.body;
 
    // Vérifier que le serveur est accessible (owner ou membre du groupe)
    if (server_id) {
      let hasAccess = false;
      if (req.access?.serverIds) {
        hasAccess = req.access.serverIds.includes(server_id);
      } else {
        const check = await db.query('SELECT id FROM servers WHERE id = $1 AND user_id = $2', [server_id, req.user.id]);
        hasAccess = check.rows.length > 0;
      }
      if (!hasAccess) {
        return res.status(404).json({ error: 'Serveur introuvable.' });
      }
    }
 
    const result = await db.query(
      `INSERT INTO chat_sessions (user_id, server_id, title)
       VALUES ($1, $2, $3)
       RETURNING id, title, server_id, created_at`,
      [req.user.id, server_id || null, title || 'Nouvelle session'],
    );
 
    res.status(201).json(result.rows[0]);
  } catch (err) {
    next(err);
  }
});
 
// ─── GET /chat/sessions/:id/messages ─────────────────────────────────────────
 
router.get('/sessions/:id/messages', requireGroupPermission('chat', 'read'), async (req, res, next) => {
  try {
    // Vérifier que la session appartient à l'utilisateur
    const sessionCheck = await db.query(
      'SELECT id FROM chat_sessions WHERE id = $1 AND user_id = $2',
      [req.params.id, req.user.id],
    );
    if (sessionCheck.rows.length === 0) {
      return res.status(404).json({ error: 'Session introuvable.' });
    }
 
    const result = await db.query(
      'SELECT id, role, content, created_at FROM chat_messages WHERE session_id = $1 ORDER BY created_at',
      [req.params.id],
    );
    res.json(result.rows);
  } catch (err) {
    next(err);
  }
});
 
// ─── DELETE /chat/sessions/:id ───────────────────────────────────────────────
 
router.delete('/sessions/:id', requireGroupPermission('chat', 'write'), makeDeleteHandler('chat_sessions', 'Session introuvable.', { message: 'Session supprimée.' }));
 
// ─── POST /chat/sessions/:id/message ─────────────────────────────────────────
// Envoie un message à Claude et retourne sa réponse.
// Gère la confirmation des actions dangereuses via le pattern pending_action.
 
router.post('/sessions/:id/message', requireGroupPermission('chat', 'write'), checkDailyQuota('chat_messages_per_day', 'chat_messages', { userColumn: 'cs.user_id', joinClause: 'JOIN chat_sessions cs ON cs.id = chat_messages.session_id' }), async (req, res, next) => {
  try {
    const { content, context, confirmed_actions = [], provider: requestedProvider, model: requestedModel } = req.body;
 
    if (!content || typeof content !== 'string') {
      return res.status(400).json({ error: 'content (string) est requis.' });
    }
    if (content.length > 10000) {
      return res.status(400).json({ error: 'Message trop long (max 10 000 caractères).' });
    }
 
    const VALID_PROVIDERS = ['anthropic', 'openai', 'mistral', 'google', 'deepseek', 'xai'];
    const provider = VALID_PROVIDERS.includes(requestedProvider) ? requestedProvider : 'anthropic';
 
    // Vérifier la session et son serveur associé
    const sessionResult = await db.query(
      `SELECT cs.*, s.id AS srv_id FROM chat_sessions cs
       LEFT JOIN servers s ON s.id = cs.server_id
       WHERE cs.id = $1 AND cs.user_id = $2`,
      [req.params.id, req.user.id],
    );
    if (sessionResult.rows.length === 0) {
      return res.status(404).json({ error: 'Session introuvable.' });
    }
 
    const session = sessionResult.rows[0];
    if (!session.server_id) {
      return res.status(400).json({ error: 'Cette session n\'a pas de serveur associé.' });
    }
 
    // Récupérer la clé API du provider — en mode groupe, utiliser celle du owner
    const keyOwnerId = req.access?.ownerUserId || req.user.id;
    const apiKeyResult = await db.query(
      `SELECT encrypted_key, default_model FROM api_keys
       WHERE user_id = $1 AND provider = $2
       ORDER BY created_at DESC LIMIT 1`,
      [keyOwnerId, provider],
    );
    if (apiKeyResult.rows.length === 0) {
      const providerNames = { anthropic: 'Anthropic', openai: 'OpenAI', mistral: 'Mistral', google: 'Google', deepseek: 'DeepSeek', xai: 'xAI' };
      return res.status(400).json({
        error: `Aucune clé API ${providerNames[provider]} configurée. Ajoutez-la dans Paramètres → Clés IA.`,
      });
    }
 
    const apiKey = decrypt(apiKeyResult.rows[0].encrypted_key);
    const model = requestedModel || apiKeyResult.rows[0].default_model;
 
    // Charger l'historique de conversation existant
    const historyResult = await db.query(
      'SELECT role, content FROM chat_messages WHERE session_id = $1 ORDER BY created_at',
      [req.params.id],
    );
 
    // Construire les messages au format Anthropic
    const claudeMessages = historyResult.rows.map((m) => ({
      role: m.role,
      content: m.content,
    }));
    claudeMessages.push({ role: 'user', content });
 
    // Persister le message utilisateur
    await db.query(
      'INSERT INTO chat_messages (session_id, role, content) VALUES ($1, $2, $3)',
      [req.params.id, 'user', content],
    );
 
    // Verifier si le plan autorise les tools
    const plan = req.user?.plan || 'free';
    const planLimits = await getPlanLimits(plan);
    const allowTools = !!planLimits.chat_tool_use;
 
    // Set des actions déjà confirmées par le client
    const confirmedSet = new Set(confirmed_actions);
    let pendingAction = null;
 
    // Appel au service IA avec la boucle agentic
    const result = await chat({
      provider,
      userId: req.user.id,
      serverId: session.server_id,
      apiKey,
      model,
      messages: claudeMessages,
      context,
      allowTools,
      onPendingAction: async ({ tool, input, analysis }) => {
        // Si le client a déjà confirmé cette action, l'exécuter directement
        const actionKey = JSON.stringify({ tool, input });
        if (confirmedSet.has(actionKey)) return true;
 
        // Sinon, mettre en pause et retourner au client pour confirmation
        pendingAction = { tool, input, analysis, action_key: actionKey };
        return false;
      },
    });
 
    // Si une action est en attente de confirmation → retour 202
    if (pendingAction) {
      return res.status(202).json({
        requires_confirmation: true,
        pending_action: pendingAction,
        message: 'Une action nécessite votre confirmation. Renvoyez avec confirmed_actions.',
      });
    }
 
    // Persister la réponse de Claude
    await db.query(
      'INSERT INTO chat_messages (session_id, role, content) VALUES ($1, $2, $3)',
      [req.params.id, 'assistant', result.reply],
    );
 
    // Mettre à jour le titre si c'est le premier échange
    if (historyResult.rows.length === 0) {
      const titlePreview = content.slice(0, 60) + (content.length > 60 ? '…' : '');
      await db.query(
        'UPDATE chat_sessions SET title = $1 WHERE id = $2',
        [titlePreview, req.params.id],
      );
    }
 
    res.json({ reply: result.reply });
  } catch (err) {
    next(err);
  }
});
 
module.exports = router;