All files / src/routes annotations.js

0% Statements 0/97
0% Branches 0/74
0% Functions 0/6
0% Lines 0/89

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                                                                                                                                                                                                                                                                                                                                                                                                                                                                               
/**
 * Routes Annotations — CRUD d'annotations sur la timeline des metriques.
 *
 * Permet de marquer des evenements sur les graphiques ("deploiement v2.3",
 * "pic RAM cause par X") avec timestamp, texte et categorie.
 *
 * Endpoints :
 *   GET    /servers/:serverId/annotations?range=24h
 *   POST   /servers/:serverId/annotations
 *   PATCH  /servers/:serverId/annotations/:id
 *   DELETE /servers/:serverId/annotations/:id
 *
 * @module routes/annotations
 */
 
const express = require('express');
const { pool: db } = require('../db');
const { requireAuth } = require('../middleware/auth');
const { requireGroupContext, requireGroupPermission, requireGroupServer } = require('../middleware/groupContext');
 
const router = express.Router({ mergeParams: true });
 
router.use(requireAuth);
router.use(requireGroupContext());
 
// Categories autorisees
const CATEGORIES = ['deployment', 'incident', 'maintenance', 'config', 'other'];
 
/**
 * Valide les champs d'un PATCH annotation.
 * @returns {{ error: string }|null} — null si valide, { error } sinon
 */
function validateAnnotationPatch({ title, description, category, timestamp }) {
  if (title !== undefined) {
    if (typeof title !== 'string' || title.trim().length === 0) {
      return { error: 'Le titre ne peut pas etre vide.' };
    }
    if (title.trim().length > 200) {
      return { error: 'Le titre ne peut pas depasser 200 caracteres.' };
    }
  }
  if (description !== undefined && description !== null && description.length > 2000) {
    return { error: 'La description ne peut pas depasser 2000 caracteres.' };
  }
  if (category !== undefined && !CATEGORIES.includes(category)) {
    return { error: `Categorie invalide. Valeurs acceptees : ${CATEGORIES.join(', ')}` };
  }
  if (timestamp !== undefined) {
    const ts = new Date(timestamp);
    if (Number.isNaN(ts.getTime())) {
      return { error: 'Timestamp invalide.' };
    }
  }
  return null;
}
 
// Mapping range -> intervalle SQL (identique aux metriques)
const RANGES = {
  '1h':  '1 hour',
  '6h':  '6 hours',
  '24h': '24 hours',
  '7d':  '7 days',
  '30d': '30 days',
  '90d': '90 days',
  '1y':  '1 year',
};
 
/**
 * Verifie que le serveur appartient a l'utilisateur.
 * @param {string} serverId
 * @param {string} userId
 * @returns {Promise<boolean>}
 */
async function checkServerOwnership(serverId, userId, access) {
  if (access?.serverIds) {
    return access.serverIds.includes(serverId);
  }
  const result = await db.query(
    'SELECT id FROM servers WHERE id = $1 AND user_id = $2',
    [serverId, userId],
  );
  return result.rows.length > 0;
}
 
// ─── GET /servers/:serverId/annotations ──────────────────────────────────────
 
router.get('/', requireGroupServer(), requireGroupPermission('annotations', 'read'), async (req, res, next) => {
  try {
    const { serverId } = req.params;
    const range = req.query.range || '24h';
 
    if (!RANGES[range]) {
      return res.status(400).json({
        error: `Range invalide. Valeurs acceptees : ${Object.keys(RANGES).join(', ')}`,
      });
    }
 
    if (!(await checkServerOwnership(serverId, req.user.id, req.access))) {
      return res.status(404).json({ error: 'Serveur introuvable.' });
    }
 
    const result = await db.query(
      `SELECT id, title, description, category, timestamp, created_at, updated_at
       FROM annotations
       WHERE server_id = $1 AND user_id = $2
         AND timestamp > NOW() - INTERVAL '${RANGES[range]}'
       ORDER BY timestamp ASC`,
      [serverId, req.access?.ownerUserId || req.user.id],
    );
 
    res.json({ annotations: result.rows });
  } catch (err) {
    next(err);
  }
});
 
// ─── POST /servers/:serverId/annotations ─────────────────────────────────────
 
router.post('/', requireGroupServer(), requireGroupPermission('annotations', 'write'), async (req, res, next) => {
  try {
    const { serverId } = req.params;
    const { title, description, category, timestamp } = req.body;
 
    // Validation
    if (!title || typeof title !== 'string' || title.trim().length === 0) {
      return res.status(400).json({ error: 'Le titre est requis.' });
    }
    if (title.trim().length > 200) {
      return res.status(400).json({ error: 'Le titre ne peut pas depasser 200 caracteres.' });
    }
    if (description && description.length > 2000) {
      return res.status(400).json({ error: 'La description ne peut pas depasser 2000 caracteres.' });
    }
    if (category && !CATEGORIES.includes(category)) {
      return res.status(400).json({
        error: `Categorie invalide. Valeurs acceptees : ${CATEGORIES.join(', ')}`,
      });
    }
 
    // Valider le timestamp si fourni
    const ts = timestamp ? new Date(timestamp) : new Date();
    if (Number.isNaN(ts.getTime())) {
      return res.status(400).json({ error: 'Timestamp invalide.' });
    }
 
    if (!(await checkServerOwnership(serverId, req.user.id, req.access))) {
      return res.status(404).json({ error: 'Serveur introuvable.' });
    }
 
    const result = await db.query(
      `INSERT INTO annotations (server_id, user_id, title, description, category, timestamp)
       VALUES ($1, $2, $3, $4, $5, $6)
       RETURNING id, title, description, category, timestamp, created_at`,
      [serverId, req.user.id, title.trim(), description?.trim() || null, category || 'other', ts],
    );
 
    res.status(201).json(result.rows[0]);
  } catch (err) {
    next(err);
  }
});
 
// ─── PATCH /servers/:serverId/annotations/:id ────────────────────────────────
 
router.patch('/:id', requireGroupServer(), requireGroupPermission('annotations', 'write'), async (req, res, next) => {
  try {
    const { serverId, id } = req.params;
    const { title, description, category, timestamp } = req.body;
 
    const validationError = validateAnnotationPatch({ title, description, category, timestamp });
    if (validationError) {
      return res.status(400).json(validationError);
    }
 
    // Construire dynamiquement le SET
    const fields = [];
    const values = [];
    let idx = 1;
 
    if (title !== undefined) { fields.push(`title = $${idx++}`); values.push(title.trim()); }
    if (description !== undefined) { fields.push(`description = $${idx++}`); values.push(description?.trim() || null); }
    if (category !== undefined) { fields.push(`category = $${idx++}`); values.push(category); }
    if (timestamp !== undefined) { fields.push(`timestamp = $${idx++}`); values.push(new Date(timestamp)); }
 
    if (fields.length === 0) {
      return res.status(400).json({ error: 'Aucun champ a mettre a jour.' });
    }
 
    fields.push(`updated_at = NOW()`);
 
    const result = await db.query(
      `UPDATE annotations SET ${fields.join(', ')}
       WHERE id = $${idx} AND server_id = $${idx + 1} AND user_id = $${idx + 2}
       RETURNING id, title, description, category, timestamp, created_at, updated_at`,
      [...values, id, serverId, req.user.id],
    );
 
    if (result.rows.length === 0) {
      return res.status(404).json({ error: 'Annotation introuvable.' });
    }
 
    res.json(result.rows[0]);
  } catch (err) {
    next(err);
  }
});
 
// ─── DELETE /servers/:serverId/annotations/:id ───────────────────────────────
 
router.delete('/:id', requireGroupServer(), requireGroupPermission('annotations', 'write'), async (req, res, next) => {
  try {
    const { serverId, id } = req.params;
 
    const result = await db.query(
      `DELETE FROM annotations
       WHERE id = $1 AND server_id = $2 AND user_id = $3
       RETURNING id`,
      [id, serverId, req.user.id],
    );
 
    if (result.rows.length === 0) {
      return res.status(404).json({ error: 'Annotation introuvable.' });
    }
 
    res.json({ message: 'Annotation supprimee.' });
  } catch (err) {
    next(err);
  }
});
 
module.exports = router;