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 :
- Importer ReportPhotoService (ligne ~20)
const ReportPhotoService = require('../services/ReportPhotoService');
- 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
});
- 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')
}))
};
- 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 :
- Importer modules nécessaires (ligne ~10)
import base64
from io import BytesIO
from reportlab.lib.utils import ImageReader
- 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
- 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 :
- 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);
}
- 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 :
- 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));
}
}
}
- 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 });
}
- 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 :
- 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 };
}
- 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 };
}
}
- 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 :
- 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;
}
- 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 :
- 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 });
}
});
- 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 :
- 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;")
- 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
- 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 :
- Installer PDF.js (CDN dans layout.pug)
script(src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js")
- 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 :
- 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'
- Ajouter indicateur taille fichier (ligne ~150)
if report.file_size
small.text-muted
= (report.file_size / 1024 / 1024).toFixed(1) + ' Mo'
- 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 :
- 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 });
}
});
- 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 :
- 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
- 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.jscréé (CLI avec --dry-run et --days options)- Service
services/SchedulerService.jscréé 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 :
- 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;
- 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 :
- 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;
}
}
- 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
Migration BDD : Exécuter la migration
20260201000002-add-report-error-fields.jsavant de déployer.Configuration S3 : S'assurer que le bucket a les permissions CORS appropriées pour le preview.
Ressources serveur : La génération de PDFs avec photos consomme plus de RAM. Prévoir 512 Mo minimum par worker.
Backup : Avant le déploiement, s'assurer que les rapports existants sont backupés.
Feature flag : Envisager un flag
ENABLE_PHOTOS_IN_REPORTSpour activer progressivement.