Plan d'implémentation : Intégration S3 complète pour rapports

Référence ADR : ADR-007-s3-reports-integration.md
Version : 1.0
Date : 2026-02-01


Vue d'ensemble

Ce document détaille les étapes précises pour implémenter l'intégration complète S3 dans le système de rapports ObeeSmart, avec un focus sur l'inclusion des photos dans les PDFs.


Phase 1 : Photos dans les rapports PDF

Étape 1.1 : Créer le service ReportPhotoService

Fichier : services/ReportPhotoService.js

Objectif : Centraliser la logique de récupération et préparation des photos pour les rapports.

Actions :

// Créer services/ReportPhotoService.js
class ReportPhotoService {
  constructor(photoService, db) {
    this.photoService = photoService;
    this.db = db;
  }

  // Récupérer les photos pour une période donnée
  async getPhotosForReport(userId, options) {
    // options: { periodStart, periodEnd, ruchierId, entityTypes, limit }
  }

  // Télécharger les buffers des miniatures depuis S3
  async downloadPhotoBuffers(photos, size = 'thumbnail') {
    // Retourne: [{ photo, buffer }]
  }

  // Préparer les données photos pour le PDF
  async preparePhotosForPdf(userId, options) {
    // Combine getPhotosForReport + downloadPhotoBuffers
    // Retourne données prêtes pour Python
  }
}

Fichiers à modifier : Aucun (nouveau fichier)

Tests :

  • Récupération photos par période
  • Filtrage par entity_type
  • Téléchargement buffers S3
  • Gestion limite max photos

Étape 1.2 : Modifier la collecte de données dans reports-export-premium.js

Fichier : helpers/reports-export-premium.js

Objectif : Ajouter la collecte des photos lors de la génération de rapport.

Actions :

  1. Importer ReportPhotoService (ligne ~20)
const ReportPhotoService = require('../services/ReportPhotoService');
  1. Collecter les photos (après ligne ~900, après collecte treatments)
// Collecter les photos de la période
const reportPhotoService = new ReportPhotoService(photoService, db);
const photosData = await reportPhotoService.preparePhotosForPdf(userId, {
  periodStart,
  periodEnd,
  ruchierId: ruchierId || null,
  entityTypes: ['visit', 'treatment', 'harvest', 'ruche'],
  limit: 20
});
  1. Ajouter au reportData (ligne ~950)
const reportData = {
  // ... données existantes ...
  photos: photosData.map(p => ({
    id: p.photo.id,
    entity_type: p.photo.entity_type,
    entity_name: p.entityName, // Nom ruche/visite associée
    caption: p.photo.caption,
    taken_at: p.photo.taken_at,
    buffer_base64: p.buffer.toString('base64')
  }))
};
  1. Passer les photos au script Python (déjà via JSON)

Tests :

  • Photos incluses dans reportData
  • Limite 20 photos respectée
  • Filtrage période correct
  • Buffers base64 valides

Étape 1.3 : Modifier le script Python pour afficher les photos

Fichier : helpers/generate_report_premium.py

Objectif : Ajouter une section "Galerie Photos" dans le PDF généré.

Actions :

  1. Importer modules nécessaires (ligne ~10)
import base64
from io import BytesIO
from reportlab.lib.utils import ImageReader
  1. Créer fonction draw_photo_gallery (après ligne ~1200)
def draw_photo_gallery(c, photos, start_y, page_width, page_height):
    """
    Affiche une galerie de photos en grille 3 colonnes.

    Args:
        c: Canvas ReportLab
        photos: Liste de dicts {buffer_base64, caption, entity_type, taken_at}
        start_y: Position Y de départ
        page_width, page_height: Dimensions page

    Returns:
        y_position après la galerie
    """
    if not photos:
        return start_y

    # Configuration grille
    cols = 3
    photo_width = (page_width - 100) / cols  # ~160px
    photo_height = photo_width * 0.75  # Ratio 4:3
    spacing = 10
    caption_height = 30

    y = start_y
    x_start = 50

    # Titre section
    c.setFont("Helvetica-Bold", 14)
    c.setFillColor(colors.HexColor("#b45309"))
    c.drawString(x_start, y, "Galerie Photos")
    y -= 25

    # Afficher photos en grille
    for i, photo in enumerate(photos):
        col = i % cols
        row = i // cols

        x = x_start + col * (photo_width + spacing)
        current_y = y - row * (photo_height + caption_height + spacing)

        # Nouvelle page si nécessaire
        if current_y < 100:
            c.showPage()
            y = page_height - 50
            current_y = y - (row % 3) * (photo_height + caption_height + spacing)

        # Décoder et afficher image
        try:
            img_data = base64.b64decode(photo['buffer_base64'])
            img = ImageReader(BytesIO(img_data))
            c.drawImage(img, x, current_y - photo_height,
                       width=photo_width, height=photo_height,
                       preserveAspectRatio=True)

            # Légende
            c.setFont("Helvetica", 8)
            c.setFillColor(colors.black)
            caption = photo.get('caption', '')[:30] or photo.get('entity_type', '')
            date_str = photo.get('taken_at', '')[:10]
            c.drawString(x, current_y - photo_height - 12, f"{caption}")
            c.drawString(x, current_y - photo_height - 22, f"{date_str}")
        except Exception as e:
            print(f"Erreur photo {i}: {e}")
            continue

    # Calculer position finale
    total_rows = (len(photos) + cols - 1) // cols
    final_y = y - total_rows * (photo_height + caption_height + spacing)

    return final_y
  1. Appeler la fonction dans generate_report (ligne ~1350, après recommandations)
# Section Galerie Photos
if data.get('photos'):
    y = draw_photo_gallery(c, data['photos'], y - 30, width, height)

Tests :

  • Photos affichées en grille 3 colonnes
  • Légendes visibles
  • Pagination automatique si > 9 photos
  • Gestion erreurs images corrompues

Étape 1.4 : Activer drawPhotoGrid dans export-service.js (fallback PDFKit)

Fichier : helpers/export-service.js

Objectif : S'assurer que le fallback PDFKit inclut aussi les photos.

Actions :

  1. Modifier generatePDFReport (ligne ~800)
// Après les sections existantes, ajouter :
if (data.photos && data.photos.length > 0) {
  doc.addPage();
  await this.drawPhotoSection(doc, data.photos, photoService);
}
  1. Modifier drawPhotoGrid pour accepter buffers (ligne ~462)
async drawPhotoGrid(doc, photos, photoService, options = {}) {
  const { columns = 3, maxPhotos = 9, photoWidth = 150 } = options;

  for (const photoData of photos.slice(0, maxPhotos)) {
    let buffer;

    // Si buffer déjà fourni (base64)
    if (photoData.buffer_base64) {
      buffer = Buffer.from(photoData.buffer_base64, 'base64');
    }
    // Sinon télécharger depuis S3
    else if (photoData.photo && photoService) {
      buffer = await photoService.getPhotoBuffer(photoData.photo.s3_key);
    }

    if (buffer) {
      doc.image(buffer, x, y, { width: photoWidth });
      // ... reste du code existant ...
    }
  }
}

Tests :

  • Fallback PDFKit fonctionne avec photos
  • Buffers base64 et S3 supportés

Phase 2 : Robustesse S3

Étape 2.1 : Ajouter retry logic dans ReportStorageService

Fichier : services/ReportStorageService.js

Objectif : Implémenter un mécanisme de retry avec backoff exponentiel.

Actions :

  1. Créer fonction utilitaire retry (ligne ~50)
async retry(fn, options = {}) {
  const { maxAttempts = 3, baseDelay = 1000, maxDelay = 10000 } = options;

  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    try {
      return await fn();
    } catch (error) {
      if (attempt === maxAttempts) {
        throw error;
      }

      const delay = Math.min(baseDelay * Math.pow(2, attempt - 1), maxDelay);
      console.warn(`S3 attempt ${attempt} failed, retrying in ${delay}ms:`, error.message);
      await new Promise(resolve => setTimeout(resolve, delay));
    }
  }
}
  1. Modifier uploadReport (ligne ~150)
async uploadReport(filePath, userId, reportId, filename) {
  return this.retry(async () => {
    // Code upload existant
    const command = new PutObjectCommand({...});
    await this.s3Client.send(command);
    return { s3Key, bucket: this.bucket };
  }, { maxAttempts: 3 });
}
  1. Ajouter logging détaillé
const winston = require('winston');

// Logger dédié S3
this.logger = winston.createLogger({
  level: 'info',
  format: winston.format.json(),
  transports: [
    new winston.transports.File({ filename: 'logs/s3-reports.log' })
  ]
});

Tests :

  • Retry fonctionne sur erreur temporaire
  • Max 3 tentatives
  • Backoff exponentiel respecté
  • Logs enregistrés

Étape 2.2 : Implémenter fallback local

Fichier : services/ReportStorageService.js

Objectif : Sauvegarder localement si S3 échoue complètement.

Actions :

  1. Ajouter méthode saveFallbackLocal (ligne ~200)
async saveFallbackLocal(filePath, userId, reportId, filename) {
  const fallbackDir = path.join(__dirname, '../uploads/reports-fallback', String(userId));
  await fs.mkdir(fallbackDir, { recursive: true });

  const fallbackPath = path.join(fallbackDir, `${reportId}_${filename}`);
  await fs.copyFile(filePath, fallbackPath);

  this.logger.warn('S3 failed, saved to fallback local', { fallbackPath, reportId });

  return { fallbackPath, isLocal: true };
}
  1. Modifier uploadReport pour fallback (ligne ~150)
async uploadReport(filePath, userId, reportId, filename) {
  try {
    return await this.retry(async () => {
      // Upload S3...
    });
  } catch (error) {
    this.logger.error('S3 upload failed after retries', { error: error.message, reportId });

    // Fallback local
    const fallback = await this.saveFallbackLocal(filePath, userId, reportId, filename);
    return { ...fallback, s3Failed: true };
  }
}
  1. Modifier accès pour supporter fallback
async getReportUrl(report, expiresIn = 3600) {
  // Si stocké en local (fallback)
  if (report.pdf_path && !report.s3_key) {
    return `/reports/download-local/${report.id}`;
  }

  // Sinon S3
  return this.getSignedDownloadUrl(report, expiresIn);
}

Tests :

  • Fallback créé si S3 échoue
  • Fichier local accessible
  • Report.pdf_path mis à jour
  • Logs d'erreur enregistrés

Étape 2.3 : Mettre à jour le statut Report correctement

Fichier : helpers/reports-export-premium.js

Objectif : Gérer tous les cas de statut (generating, ready, failed, fallback).

Actions :

  1. Ajouter gestion statut failed (ligne ~1000)
try {
  const uploadResult = await reportStorageService.uploadReport(...);

  if (uploadResult.s3Failed) {
    await report.update({
      status: 'ready_local',
      pdf_path: uploadResult.fallbackPath,
      s3_error: 'Upload S3 failed after 3 attempts'
    });
  } else {
    await report.update({
      status: 'ready',
      s3_key: uploadResult.s3Key,
      s3_bucket: uploadResult.bucket,
      file_size: uploadResult.fileSize
    });
  }
} catch (error) {
  await report.update({
    status: 'failed',
    error_message: error.message
  });
  throw error;
}
  1. Ajouter colonne error_message au modèle Report

Migration : migrations/20260201000002-add-report-error-fields.js

module.exports = {
  up: async (queryInterface, Sequelize) => {
    await queryInterface.addColumn('reports', 'error_message', {
      type: Sequelize.TEXT,
      allowNull: true
    });
    await queryInterface.addColumn('reports', 's3_error', {
      type: Sequelize.TEXT,
      allowNull: true
    });
  },
  down: async (queryInterface) => {
    await queryInterface.removeColumn('reports', 'error_message');
    await queryInterface.removeColumn('reports', 's3_error');
  }
};

Tests :

  • Status 'ready' si S3 OK
  • Status 'ready_local' si fallback
  • Status 'failed' si erreur totale
  • Messages d'erreur enregistrés

Phase 3 : Preview PDF inline ✅ COMPLÉTÉ

Implémenté le 2026-02-01 : Endpoints GET /preview/:id et /preview-local/:id ajoutés dans routes/reports.js. Bouton "Aperçu" et section collapsible avec iframe ajoutés dans report-detail.pug.

Étape 3.1 : Ajouter endpoint preview ✅

Fichier : routes/reports.js

Objectif : Créer un endpoint pour afficher le PDF dans le navigateur.

Actions :

  1. Ajouter route GET /reports/preview/:id (ligne ~3000)
router.get('/preview/:id', isAuthenticated, async (req, res) => {
  try {
    const report = await Models.Report.findOne({
      where: { id: req.params.id, user_id: req.user.id }
    });

    if (!report) {
      return res.status(404).json({ error: 'Rapport non trouvé' });
    }

    if (!report.isOnS3() && !report.pdf_path) {
      return res.status(404).json({ error: 'PDF non disponible' });
    }

    // Obtenir URL avec Content-Disposition: inline
    const url = await reportStorageService.getPreviewUrl(report);

    res.json({
      url,
      title: report.title,
      generated_at: report.generated_at
    });
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
});
  1. Ajouter getPreviewUrl dans ReportStorageService (ligne ~250)
async getPreviewUrl(report, expiresIn = 3600) {
  const command = new GetObjectCommand({
    Bucket: this.bucket,
    Key: report.s3_key,
    ResponseContentDisposition: 'inline',
    ResponseContentType: 'application/pdf'
  });

  return getSignedUrl(this.s3Client, command, { expiresIn });
}

Tests :

  • URL retournée avec inline disposition
  • Auth required
  • Gestion erreurs

Étape 3.2 : Ajouter composant preview dans la vue ✅

Fichier : views/beekeeper/report-detail.pug

Objectif : Afficher un viewer PDF intégré.

Actions :

  1. Ajouter section preview (ligne ~100)
//- Section Preview PDF
.card.mb-4#pdf-preview-section(style="display: none;")
  .card-header.d-flex.justify-content-between.align-items-center
    h5.mb-0 Aperçu du rapport
    button.btn.btn-sm.btn-outline-secondary#close-preview
      i.bi.bi-x-lg
  .card-body.p-0
    iframe#pdf-viewer(style="width: 100%; height: 600px; border: none;")
  1. Ajouter bouton preview (ligne ~50, dans actions)
if report.s3_key || report.pdf_path
  button.btn.btn-outline-primary.me-2#btn-preview(data-report-id=report.id)
    i.bi.bi-eye.me-1
    | Aperçu
  1. Ajouter JavaScript (fin du fichier)
block scripts
  script.
    document.getElementById('btn-preview')?.addEventListener('click', async function() {
      const reportId = this.dataset.reportId;
      try {
        const response = await fetch(`/reports/preview/${reportId}`);
        const data = await response.json();

        if (data.url) {
          document.getElementById('pdf-viewer').src = data.url;
          document.getElementById('pdf-preview-section').style.display = 'block';
          this.scrollIntoView({ behavior: 'smooth' });
        }
      } catch (error) {
        console.error('Erreur preview:', error);
        alert('Impossible de charger l\'aperçu');
      }
    });

    document.getElementById('close-preview')?.addEventListener('click', function() {
      document.getElementById('pdf-preview-section').style.display = 'none';
      document.getElementById('pdf-viewer').src = '';
    });

Tests :

  • Bouton Aperçu visible si PDF disponible
  • Iframe charge le PDF
  • Fermeture fonctionne
  • Responsive

Étape 3.3 : Alternative PDF.js pour meilleur contrôle (OPTIONNEL)

Fichier : public/js/pdf-preview.js

Objectif : Utiliser PDF.js pour un viewer plus riche (optionnel).

Actions :

  1. Installer PDF.js (CDN dans layout.pug)
script(src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js")
  1. Créer composant viewer
// public/js/pdf-preview.js
class PDFPreview {
  constructor(containerId) {
    this.container = document.getElementById(containerId);
    pdfjsLib.GlobalWorkerOptions.workerSrc =
      'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
  }

  async load(url) {
    const loadingTask = pdfjsLib.getDocument(url);
    const pdf = await loadingTask.promise;

    this.container.innerHTML = '';

    for (let i = 1; i <= pdf.numPages; i++) {
      const page = await pdf.getPage(i);
      const scale = 1.5;
      const viewport = page.getViewport({ scale });

      const canvas = document.createElement('canvas');
      canvas.className = 'pdf-page mb-3';
      this.container.appendChild(canvas);

      const context = canvas.getContext('2d');
      canvas.height = viewport.height;
      canvas.width = viewport.width;

      await page.render({ canvasContext: context, viewport }).promise;
    }
  }
}

Tests :

  • PDF rendu page par page
  • Zoom fonctionnel
  • Performance acceptable

Phase 4 : Ergonomie et UX ✅ COMPLÉTÉ

Implémenté le 2026-02-01 :

  • Status "ready_local" ajouté dans reports.pug et report-detail.pug
  • Bouton aperçu ajouté dans la liste des rapports
  • Section stats stockage ajoutée (collapsible)
  • Alertes pour rapports en erreur ou en fallback local
  • Endpoint storage-stats enrichi avec s3_count/local_count

Étape 4.1 : Améliorer la liste des rapports ✅

Fichier : views/beekeeper/reports.pug

Objectif : Ajouter indicateurs visuels et actions rapides.

Actions :

  1. Ajouter badges de statut (ligne ~120)
.card-header
  .d-flex.justify-content-between.align-items-center
    span.badge(class=report.status === 'ready' ? 'bg-success' : report.status === 'failed' ? 'bg-danger' : 'bg-warning')
      = report.status === 'ready' ? 'Prêt' : report.status === 'failed' ? 'Échec' : 'En cours'
    if report.photos_count > 0
      span.badge.bg-info.ms-2
        i.bi.bi-image.me-1
        = report.photos_count + ' photos'
  1. Ajouter indicateur taille fichier (ligne ~150)
if report.file_size
  small.text-muted
    = (report.file_size / 1024 / 1024).toFixed(1) + ' Mo'
  1. Actions rapides dans la liste (ligne ~180)
.btn-group.btn-group-sm
  if report.s3_key
    a.btn.btn-outline-primary(href=`/reports/preview/${report.id}` title="Aperçu")
      i.bi.bi-eye
    a.btn.btn-outline-success(href=`/reports/download/${report.id}` title="Télécharger")
      i.bi.bi-download
    button.btn.btn-outline-secondary.btn-share(data-id=report.id title="Partager")
      i.bi.bi-share

Tests :

  • Badges de statut corrects
  • Compteur photos visible
  • Actions fonctionnelles

Étape 4.2 : Ajouter endpoint statistiques stockage ✅

Fichier : routes/reports.js

Objectif : Permettre de visualiser l'utilisation S3.

Actions :

  1. Ajouter route GET /reports/storage-stats (ligne ~3100)
router.get('/storage-stats', isAuthenticated, async (req, res) => {
  try {
    const stats = await Models.Report.findAll({
      where: { user_id: req.user.id },
      attributes: [
        [sequelize.fn('COUNT', sequelize.col('id')), 'total_reports'],
        [sequelize.fn('SUM', sequelize.col('file_size')), 'total_size'],
        [sequelize.fn('COUNT', sequelize.literal("CASE WHEN s3_key IS NOT NULL THEN 1 END")), 's3_count'],
        [sequelize.fn('COUNT', sequelize.literal("CASE WHEN pdf_path IS NOT NULL AND s3_key IS NULL THEN 1 END")), 'local_count']
      ],
      raw: true
    });

    const photoStats = await Models.Photo.findAll({
      where: { user_id: req.user.id },
      attributes: [
        [sequelize.fn('COUNT', sequelize.col('id')), 'total_photos'],
        [sequelize.fn('SUM', sequelize.col('file_size')), 'photos_size']
      ],
      raw: true
    });

    res.json({
      reports: stats[0],
      photos: photoStats[0],
      formatted: {
        reports_size_mb: ((stats[0].total_size || 0) / 1024 / 1024).toFixed(2),
        photos_size_mb: ((photoStats[0].photos_size || 0) / 1024 / 1024).toFixed(2)
      }
    });
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
});
  1. Afficher dans le dashboard ou page rapports
//- Section statistiques stockage
.card.mb-4
  .card-header
    h6.mb-0 Utilisation stockage
  .card-body
    .row.text-center
      .col-4
        h4#stat-reports -
        small Rapports
      .col-4
        h4#stat-photos -
        small Photos
      .col-4
        h4#stat-size -
        small Espace total

Tests :

  • Stats correctes
  • Formatage tailles lisible
  • Performance acceptable

Étape 4.3 : Améliorer les messages d'erreur ✅

Fichier : views/beekeeper/report-detail.pug et reports.pug

Objectif : Afficher des messages clairs en cas de problème.

Actions :

  1. Afficher erreur si rapport failed (ligne ~200)
if report.status === 'failed'
  .alert.alert-danger
    h6.alert-heading
      i.bi.bi-exclamation-triangle.me-2
      | Erreur de génération
    p.mb-2= report.error_message || 'Une erreur est survenue lors de la génération du rapport.'
    button.btn.btn-sm.btn-outline-danger#btn-regenerate(data-id=report.id)
      i.bi.bi-arrow-clockwise.me-1
      | Régénérer
  1. Afficher avertissement si fallback local
if report.status === 'ready_local'
  .alert.alert-warning
    i.bi.bi-info-circle.me-2
    | Ce rapport est stocké localement (S3 temporairement indisponible).

Tests :

  • Message erreur visible
  • Bouton régénérer fonctionnel
  • Avertissement fallback clair

Phase 5 : Maintenance et monitoring ✅ COMPLÉTÉ

Implémenté le 2026-02-01 :

  • Script scripts/cleanup-old-reports.js créé (CLI avec --dry-run et --days options)
  • Service services/SchedulerService.js créé pour planification des tâches
  • Scheduler intégré dans app.js (démarre automatiquement avec le serveur)
  • Endpoints admin ajoutés : /api/storage/reports-metrics, /api/storage/scheduler-status, /api/storage/run-cleanup
  • Nettoyage hebdomadaire (dimanche 3h) des rapports > 1 an
  • Nettoyage quotidien (4h) des fichiers temporaires > 7 jours

Étape 5.1 : Créer job de nettoyage ✅

Fichier : scripts/cleanup-old-reports.js

Objectif : Supprimer automatiquement les rapports > 1 an.

Actions :

  1. Créer le job
// job/cleanup-old-reports.js
const { Op } = require('sequelize');
const Models = require('../models');
const ReportStorageService = require('../services/ReportStorageService');

async function cleanupOldReports() {
  const oneYearAgo = new Date();
  oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1);

  const oldReports = await Models.Report.findAll({
    where: {
      created_at: { [Op.lt]: oneYearAgo },
      s3_key: { [Op.not]: null }
    }
  });

  console.log(`Found ${oldReports.length} reports older than 1 year`);

  const reportStorage = new ReportStorageService();
  await reportStorage.init();

  for (const report of oldReports) {
    try {
      await reportStorage.deleteReport(report);
      await report.update({ s3_key: null, s3_bucket: null, file_size: null });
      console.log(`Deleted S3 for report ${report.id}`);
    } catch (error) {
      console.error(`Failed to delete report ${report.id}:`, error.message);
    }
  }

  // Cleanup fallback local aussi
  const fs = require('fs').promises;
  const path = require('path');
  const fallbackDir = path.join(__dirname, '../uploads/reports-fallback');

  try {
    const userDirs = await fs.readdir(fallbackDir);
    for (const userDir of userDirs) {
      const files = await fs.readdir(path.join(fallbackDir, userDir));
      for (const file of files) {
        const filePath = path.join(fallbackDir, userDir, file);
        const stats = await fs.stat(filePath);
        if (stats.mtime < oneYearAgo) {
          await fs.unlink(filePath);
          console.log(`Deleted local fallback: ${filePath}`);
        }
      }
    }
  } catch (error) {
    // Ignore si dossier n'existe pas
  }

  return { deleted: oldReports.length };
}

module.exports = cleanupOldReports;
  1. Ajouter au cron (dans app.js ou fichier cron dédié)
const cron = require('node-cron');
const cleanupOldReports = require('./job/cleanup-old-reports');

// Tous les dimanches à 3h du matin
cron.schedule('0 3 * * 0', async () => {
  console.log('Starting weekly reports cleanup...');
  try {
    const result = await cleanupOldReports();
    console.log('Cleanup completed:', result);
  } catch (error) {
    console.error('Cleanup failed:', error);
  }
});

Tests :

  • Job s'exécute sans erreur
  • Rapports > 1 an supprimés de S3
  • BDD mise à jour (s3_key = null)
  • Fallback local nettoyé aussi

Étape 5.2 : Ajouter monitoring S3 ✅

Fichier : services/ReportStorageService.js

Objectif : Collecter métriques pour monitoring.

Actions :

  1. Ajouter métriques
// Dans ReportStorageService

getMetrics() {
  return {
    uploads_total: this.metrics.uploads || 0,
    uploads_failed: this.metrics.uploadsFailed || 0,
    downloads_total: this.metrics.downloads || 0,
    retries_total: this.metrics.retries || 0,
    fallbacks_total: this.metrics.fallbacks || 0,
    avg_upload_time_ms: this.metrics.avgUploadTime || 0
  };
}

// Incrémenter dans les méthodes
async uploadReport(...) {
  const start = Date.now();
  try {
    // ...
    this.metrics.uploads = (this.metrics.uploads || 0) + 1;
    this.metrics.avgUploadTime = Date.now() - start;
  } catch (error) {
    this.metrics.uploadsFailed = (this.metrics.uploadsFailed || 0) + 1;
    throw error;
  }
}
  1. Endpoint métriques (dans routes/admin.js)
router.get('/api/metrics/s3', isAdmin, async (req, res) => {
  const reportStorage = ReportStorageService.getInstance();
  res.json(reportStorage.getMetrics());
});

Tests :

  • Métriques collectées
  • Endpoint accessible admin (/api/storage/reports-metrics)
  • Valeurs cohérentes

Checklist de validation finale

Tests fonctionnels

  • Générer un rapport mensuel avec photos
  • Vérifier que les photos apparaissent dans le PDF
  • Télécharger le rapport depuis S3
  • Prévisualiser le rapport inline
  • Partager le rapport (générer lien)
  • Accéder au lien partagé (sans auth)
  • Simuler échec S3 → vérifier fallback local
  • Régénérer un rapport failed
  • Vérifier statistiques stockage

Tests de performance

  • Génération PDF avec 20 photos < 30s
  • Taille PDF raisonnable (< 10 Mo)
  • Preview charge en < 3s

Tests de robustesse

  • Retry S3 fonctionne (3 tentatives)
  • Fallback local créé si S3 down
  • Statut Report toujours cohérent
  • Pas de fichiers orphelins dans /tmp

Tests de sécurité

  • Auth required pour /preview et /download
  • Partage expire correctement
  • Pas d'accès cross-user

Ordre d'exécution recommandé

Semaine 1:
├── Étape 1.1: ReportPhotoService (jour 1)
├── Étape 1.2: Collecte photos dans reports-export-premium.js (jour 1-2)
├── Étape 1.3: Galerie photos dans Python (jour 2-3)
└── Étape 1.4: Fallback PDFKit (jour 3)

Semaine 2:
├── Étape 2.1: Retry logic S3 (jour 1)
├── Étape 2.2: Fallback local (jour 1-2)
├── Étape 2.3: Gestion statuts Report (jour 2)
├── Étape 3.1: Endpoint preview (jour 2)
├── Étape 3.2: Composant preview vue (jour 3)
└── Étape 3.3: PDF.js optionnel (jour 3)

Semaine 3:
├── Étape 4.1: UX liste rapports (jour 1)
├── Étape 4.2: Stats stockage (jour 1)
├── Étape 4.3: Messages erreur (jour 2)
├── Étape 5.1: Job cleanup (jour 2)
├── Étape 5.2: Monitoring (jour 2)
└── Tests et validation (jour 3-4)

Notes importantes

  1. Migration BDD : Exécuter la migration 20260201000002-add-report-error-fields.js avant de déployer.

  2. Configuration S3 : S'assurer que le bucket a les permissions CORS appropriées pour le preview.

  3. Ressources serveur : La génération de PDFs avec photos consomme plus de RAM. Prévoir 512 Mo minimum par worker.

  4. Backup : Avant le déploiement, s'assurer que les rapports existants sont backupés.

  5. Feature flag : Envisager un flag ENABLE_PHOTOS_IN_REPORTS pour activer progressivement.