mirror of
https://github.com/R0m1k3/noteflow.git
synced 2026-10-23 23:28:17 +02:00
Merge pull request #167 from R0m1k3/antigravity
feat: Add recurring tasks management (frontend, backend, automation)
This commit is contained in:
822 files changed
+133208
-787
No files matched your search
@@ -0,0 +1,213 @@
|
||||
// Routes de gestion des tâches récurrentes
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { body, validationResult } = require('express-validator');
|
||||
|
||||
const { getAll, getOne, runQuery } = require('../config/database');
|
||||
const { authenticateToken } = require('../middleware/auth');
|
||||
const logger = require('../config/logger');
|
||||
|
||||
// Toutes les routes nécessitent authentification
|
||||
router.use(authenticateToken);
|
||||
|
||||
/**
|
||||
* GET /api/recurring-todos
|
||||
* Liste toutes les tâches récurrentes de l'utilisateur
|
||||
*/
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const todos = await getAll(
|
||||
`SELECT * FROM recurring_todos WHERE user_id = $1 ORDER BY next_occurrence ASC`,
|
||||
[req.user.id]
|
||||
);
|
||||
res.json(todos);
|
||||
} catch (error) {
|
||||
logger.error('Erreur lors de la récupération des tâches récurrentes:', error);
|
||||
res.status(500).json({ error: 'Erreur serveur' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/recurring-todos
|
||||
* Créer une nouvelle tâche récurrente
|
||||
*/
|
||||
router.post('/',
|
||||
[
|
||||
body('text').trim().notEmpty().withMessage('Le texte est requis'),
|
||||
body('recurrence_type').isIn(['daily', 'weekly', 'biweekly', 'monthly', 'yearly']).withMessage('Type de récurrence invalide'),
|
||||
body('recurrence_interval').optional().isInt({ min: 1 }).withMessage('Intervalle invalide'),
|
||||
body('day_of_week').optional().isInt({ min: 0, max: 6 }),
|
||||
body('day_of_month').optional().isInt({ min: 1, max: 31 }),
|
||||
body('priority').optional().isBoolean(),
|
||||
body('next_occurrence').notEmpty().withMessage('Date de prochaine occurrence requise')
|
||||
],
|
||||
async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ error: 'Données invalides', details: errors.array() });
|
||||
}
|
||||
|
||||
const { text, recurrence_type, recurrence_interval, day_of_week, day_of_month, priority, next_occurrence } = req.body;
|
||||
|
||||
const result = await runQuery(
|
||||
`INSERT INTO recurring_todos (user_id, text, recurrence_type, recurrence_interval, day_of_week, day_of_month, priority, next_occurrence)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id`,
|
||||
[req.user.id, text, recurrence_type, recurrence_interval || 1, day_of_week, day_of_month, priority || false, next_occurrence]
|
||||
);
|
||||
|
||||
res.status(201).json({
|
||||
id: result.id,
|
||||
text,
|
||||
recurrence_type,
|
||||
recurrence_interval: recurrence_interval || 1,
|
||||
day_of_week,
|
||||
day_of_month,
|
||||
priority: priority || false,
|
||||
next_occurrence,
|
||||
enabled: true,
|
||||
created_at: new Date().toISOString()
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Erreur lors de la création de la tâche récurrente:', error);
|
||||
res.status(500).json({ error: 'Erreur serveur' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* PUT /api/recurring-todos/:id
|
||||
* Modifier une tâche récurrente
|
||||
*/
|
||||
router.put('/:id',
|
||||
[
|
||||
body('text').optional().trim().notEmpty(),
|
||||
body('recurrence_type').optional().isIn(['daily', 'weekly', 'biweekly', 'monthly', 'yearly']),
|
||||
body('recurrence_interval').optional().isInt({ min: 1 }),
|
||||
body('day_of_week').optional().isInt({ min: 0, max: 6 }),
|
||||
body('day_of_month').optional().isInt({ min: 1, max: 31 }),
|
||||
body('priority').optional().isBoolean(),
|
||||
body('enabled').optional().isBoolean(),
|
||||
body('next_occurrence').optional()
|
||||
],
|
||||
async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ error: 'Données invalides', details: errors.array() });
|
||||
}
|
||||
|
||||
// Vérifier que la tâche appartient à l'utilisateur
|
||||
const existing = await getOne(
|
||||
'SELECT id FROM recurring_todos WHERE id = $1 AND user_id = $2',
|
||||
[req.params.id, req.user.id]
|
||||
);
|
||||
|
||||
if (!existing) {
|
||||
return res.status(404).json({ error: 'Tâche récurrente non trouvée' });
|
||||
}
|
||||
|
||||
const { text, recurrence_type, recurrence_interval, day_of_week, day_of_month, priority, enabled, next_occurrence } = req.body;
|
||||
const updates = [];
|
||||
const params = [];
|
||||
let paramCount = 0;
|
||||
|
||||
if (text !== undefined) {
|
||||
paramCount++;
|
||||
updates.push(`text = $${paramCount}`);
|
||||
params.push(text);
|
||||
}
|
||||
if (recurrence_type !== undefined) {
|
||||
paramCount++;
|
||||
updates.push(`recurrence_type = $${paramCount}`);
|
||||
params.push(recurrence_type);
|
||||
}
|
||||
if (recurrence_interval !== undefined) {
|
||||
paramCount++;
|
||||
updates.push(`recurrence_interval = $${paramCount}`);
|
||||
params.push(recurrence_interval);
|
||||
}
|
||||
if (day_of_week !== undefined) {
|
||||
paramCount++;
|
||||
updates.push(`day_of_week = $${paramCount}`);
|
||||
params.push(day_of_week);
|
||||
}
|
||||
if (day_of_month !== undefined) {
|
||||
paramCount++;
|
||||
updates.push(`day_of_month = $${paramCount}`);
|
||||
params.push(day_of_month);
|
||||
}
|
||||
if (priority !== undefined) {
|
||||
paramCount++;
|
||||
updates.push(`priority = $${paramCount}`);
|
||||
params.push(priority);
|
||||
}
|
||||
if (enabled !== undefined) {
|
||||
paramCount++;
|
||||
updates.push(`enabled = $${paramCount}`);
|
||||
params.push(enabled);
|
||||
}
|
||||
if (next_occurrence !== undefined) {
|
||||
paramCount++;
|
||||
updates.push(`next_occurrence = $${paramCount}`);
|
||||
params.push(next_occurrence);
|
||||
}
|
||||
|
||||
if (updates.length > 0) {
|
||||
paramCount++;
|
||||
params.push(req.params.id);
|
||||
await runQuery(`UPDATE recurring_todos SET ${updates.join(', ')} WHERE id = $${paramCount}`, params);
|
||||
}
|
||||
|
||||
res.json({ message: 'Tâche récurrente modifiée avec succès' });
|
||||
} catch (error) {
|
||||
logger.error('Erreur lors de la modification de la tâche récurrente:', error);
|
||||
res.status(500).json({ error: 'Erreur serveur' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* DELETE /api/recurring-todos/:id
|
||||
* Supprimer une tâche récurrente
|
||||
*/
|
||||
router.delete('/:id', async (req, res) => {
|
||||
try {
|
||||
// Vérifier que la tâche appartient à l'utilisateur
|
||||
const existing = await getOne(
|
||||
'SELECT id FROM recurring_todos WHERE id = $1 AND user_id = $2',
|
||||
[req.params.id, req.user.id]
|
||||
);
|
||||
|
||||
if (!existing) {
|
||||
return res.status(404).json({ error: 'Tâche récurrente non trouvée' });
|
||||
}
|
||||
|
||||
await runQuery('DELETE FROM recurring_todos WHERE id = $1', [req.params.id]);
|
||||
|
||||
res.json({ message: 'Tâche récurrente supprimée avec succès' });
|
||||
} catch (error) {
|
||||
logger.error('Erreur lors de la suppression de la tâche récurrente:', error);
|
||||
res.status(500).json({ error: 'Erreur serveur' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/recurring-todos/generate
|
||||
* Générer les todos du jour à partir des tâches récurrentes
|
||||
*/
|
||||
router.post('/generate', async (req, res) => {
|
||||
try {
|
||||
const recurringTodosController = require('../controllers/recurringTodosController');
|
||||
const result = await recurringTodosController.generateTodos(req.user.id);
|
||||
res.json({
|
||||
message: `${result.generated} tâche(s) générée(s)`,
|
||||
generated: result.generated
|
||||
});
|
||||
} catch (error) {
|
||||
// Le logger est déjà appelé dans le controller
|
||||
res.status(500).json({ error: 'Erreur serveur' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,357 +0,0 @@
|
||||
// Routes RSS V2 - Simplifiées et robustes
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const Parser = require('rss-parser');
|
||||
const axios = require('axios');
|
||||
const { getAll, getOne, runQuery } = require('../config/database');
|
||||
const { authenticateToken, requireAdmin } = require('../middleware/auth');
|
||||
const logger = require('../config/logger');
|
||||
|
||||
const parser = new Parser();
|
||||
|
||||
router.use(authenticateToken);
|
||||
|
||||
/**
|
||||
* GET /api/rss/feeds
|
||||
* Liste tous les flux RSS
|
||||
*/
|
||||
router.get('/feeds', requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const feeds = await getAll('SELECT * FROM rss_feeds ORDER BY created_at DESC');
|
||||
res.json(feeds || []);
|
||||
} catch (error) {
|
||||
logger.error('Erreur récupération flux:', error);
|
||||
res.status(500).json({ error: 'Erreur serveur' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/rss/feeds
|
||||
* Ajouter un nouveau flux RSS
|
||||
*/
|
||||
router.post('/feeds', requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const { url } = req.body;
|
||||
|
||||
if (!url) {
|
||||
return res.status(400).json({ error: 'URL requise' });
|
||||
}
|
||||
|
||||
// Vérifier si existe déjà
|
||||
const existing = await getOne('SELECT id FROM rss_feeds WHERE url = $1', [url]);
|
||||
if (existing) {
|
||||
return res.status(409).json({ error: 'Ce flux existe déjà' });
|
||||
}
|
||||
|
||||
// Tester le flux
|
||||
try {
|
||||
const feed = await parser.parseURL(url);
|
||||
|
||||
// Ajouter le flux
|
||||
const result = await runQuery(
|
||||
'INSERT INTO rss_feeds (url, title, description, enabled) VALUES ($1, $2, $3, TRUE)',
|
||||
[url, feed.title || url, feed.description || '']
|
||||
);
|
||||
|
||||
logger.info(`✅ Flux ajouté: ${feed.title}`);
|
||||
|
||||
// Déclencher un fetch immédiat
|
||||
setTimeout(() => {
|
||||
const scheduler = require('../services/rss-scheduler-v2');
|
||||
scheduler.fetchAllFeeds().catch(err => logger.error('Erreur fetch après ajout:', err));
|
||||
}, 1000);
|
||||
|
||||
res.json({
|
||||
id: result.id,
|
||||
url,
|
||||
title: feed.title || url,
|
||||
description: feed.description || '',
|
||||
enabled: 1
|
||||
});
|
||||
} catch (parseError) {
|
||||
logger.error('Erreur parsing flux:', parseError);
|
||||
return res.status(400).json({ error: 'Impossible de parser ce flux RSS' });
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Erreur ajout flux:', error);
|
||||
res.status(500).json({ error: 'Erreur serveur' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/rss/feeds/:id
|
||||
* Activer/désactiver un flux
|
||||
*/
|
||||
router.put('/feeds/:id', requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const { enabled } = req.body;
|
||||
|
||||
await runQuery(
|
||||
'UPDATE rss_feeds SET enabled = $1 WHERE id = $2',
|
||||
[enabled, req.params.id] // BOOLEAN instead of 0/1
|
||||
);
|
||||
|
||||
logger.info(`Flux ${enabled ? 'activé' : 'désactivé'}: ${req.params.id}`);
|
||||
res.json({ message: 'Flux mis à jour' });
|
||||
} catch (error) {
|
||||
logger.error('Erreur MAJ flux:', error);
|
||||
res.status(500).json({ error: 'Erreur serveur' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/rss/feeds/:id
|
||||
* Supprimer un flux
|
||||
*/
|
||||
router.delete('/feeds/:id', requireAdmin, async (req, res) => {
|
||||
try {
|
||||
await runQuery('DELETE FROM rss_feeds WHERE id = $1', [req.params.id]);
|
||||
logger.info(`Flux supprimé: ${req.params.id}`);
|
||||
res.json({ message: 'Flux supprimé' });
|
||||
} catch (error) {
|
||||
logger.error('Erreur suppression flux:', error);
|
||||
res.status(500).json({ error: 'Erreur serveur' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/rss/articles
|
||||
* Récupérer les articles - SIMPLE, sans cache compliqué
|
||||
*/
|
||||
router.get('/articles', async (req, res) => {
|
||||
try {
|
||||
const limit = parseInt(req.query.limit) || 50;
|
||||
|
||||
const articles = await getAll(`
|
||||
SELECT
|
||||
a.id,
|
||||
a.title,
|
||||
a.link,
|
||||
a.description,
|
||||
a.pub_date,
|
||||
a.content,
|
||||
COALESCE(f.title, f.url) as feed_title,
|
||||
f.url as feed_url
|
||||
FROM rss_articles a
|
||||
LEFT JOIN rss_feeds f ON a.feed_id = f.id
|
||||
WHERE a.pub_date IS NOT NULL
|
||||
ORDER BY a.pub_date DESC
|
||||
LIMIT $1
|
||||
`, [limit]);
|
||||
|
||||
logger.debug(`Articles récupérés: ${articles?.length || 0}`);
|
||||
res.json(articles || []);
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Erreur récupération articles:', error);
|
||||
res.status(500).json({ error: 'Erreur serveur' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/rss/refresh
|
||||
* Forcer une mise à jour manuelle
|
||||
*/
|
||||
router.post('/refresh', requireAdmin, async (req, res) => {
|
||||
try {
|
||||
logger.info('🔄 Refresh manuel déclenché');
|
||||
|
||||
const scheduler = require('../services/rss-scheduler-v2');
|
||||
const result = await scheduler.manualFetch();
|
||||
|
||||
res.json({
|
||||
message: 'Mise à jour terminée',
|
||||
result
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Erreur refresh:', error);
|
||||
res.status(500).json({ error: 'Erreur serveur' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/rss/fetch
|
||||
* Alias pour refresh
|
||||
*/
|
||||
router.post('/fetch', requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const scheduler = require('../services/rss-scheduler-v2');
|
||||
const result = await scheduler.manualFetch();
|
||||
|
||||
res.json({
|
||||
message: 'Mise à jour terminée',
|
||||
result
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Erreur fetch:', error);
|
||||
res.status(500).json({ error: 'Erreur serveur' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/rss/summarize
|
||||
* Générer des résumés (conservé de l'ancien système)
|
||||
*/
|
||||
router.post('/summarize', async (req, res) => {
|
||||
try {
|
||||
const apiKey = await getOne('SELECT value FROM settings WHERE key = $1', ['openrouter_api_key']);
|
||||
const model = await getOne('SELECT value FROM settings WHERE key = $1', ['openrouter_model']);
|
||||
|
||||
if (!apiKey || !apiKey.value) {
|
||||
return res.status(400).json({ error: 'Clé API OpenRouter non configurée' });
|
||||
}
|
||||
|
||||
const articles = await getAll(`
|
||||
SELECT
|
||||
a.id, a.title, a.description, a.link, a.pub_date, a.content,
|
||||
COALESCE(f.title, f.url) as feed_title
|
||||
FROM rss_articles a
|
||||
LEFT JOIN rss_feeds f ON a.feed_id = f.id
|
||||
WHERE a.pub_date IS NOT NULL
|
||||
ORDER BY a.pub_date DESC
|
||||
LIMIT 5
|
||||
`);
|
||||
|
||||
if (articles.length === 0) {
|
||||
return res.status(400).json({ error: 'Aucun article à résumer' });
|
||||
}
|
||||
|
||||
const selectedModel = model?.value || 'openai/gpt-3.5-turbo';
|
||||
logger.info(`Génération de ${articles.length} résumés avec ${selectedModel}`);
|
||||
|
||||
const summaryPromises = articles.map(async (article) => {
|
||||
const prompt = `Résume cet article en maximum 100 mots. Sois concis et informatif.
|
||||
|
||||
Titre: ${article.title}
|
||||
Source: ${article.feed_title}
|
||||
Description: ${article.description || ''}
|
||||
${article.content ? `Contenu: ${article.content.substring(0, 1000)}` : ''}
|
||||
|
||||
Résumé (100 mots max):`;
|
||||
|
||||
try {
|
||||
const response = await axios.post(
|
||||
'https://openrouter.ai/api/v1/chat/completions',
|
||||
{
|
||||
model: selectedModel,
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
max_tokens: 200
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiKey.value}`,
|
||||
'HTTP-Referer': 'https://noteflow.app',
|
||||
'X-Title': 'NoteFlow RSS Summarizer',
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
timeout: 30000
|
||||
}
|
||||
);
|
||||
|
||||
const summary = response.data.choices[0].message.content;
|
||||
|
||||
await runQuery(
|
||||
'INSERT INTO rss_summaries (summary, model, articles_count, feed_title, created_at) VALUES (?, ?, ?, ?, datetime("now"))',
|
||||
[`**${article.title}**\n\n${summary}\n\n🔗 [Lire l'article](${article.link})`, selectedModel, 1, article.feed_title]
|
||||
);
|
||||
|
||||
return {
|
||||
article_id: article.id,
|
||||
title: article.title,
|
||||
link: article.link,
|
||||
feed_title: article.feed_title,
|
||||
summary: summary,
|
||||
pub_date: article.pub_date
|
||||
};
|
||||
} catch (err) {
|
||||
logger.error(`Erreur résumé "${article.title}":`, err.message);
|
||||
return {
|
||||
article_id: article.id,
|
||||
title: article.title,
|
||||
link: article.link,
|
||||
feed_title: article.feed_title,
|
||||
summary: article.description || 'Résumé non disponible',
|
||||
pub_date: article.pub_date,
|
||||
error: true
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const summaries = await Promise.all(summaryPromises);
|
||||
logger.info(`${summaries.length} résumés générés`);
|
||||
|
||||
res.json({
|
||||
summaries,
|
||||
model: selectedModel,
|
||||
articles_count: articles.length
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Erreur génération résumés:', error);
|
||||
res.status(500).json({ error: 'Erreur serveur' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/rss/summaries
|
||||
* Récupérer les résumés
|
||||
*/
|
||||
router.get('/summaries', async (req, res) => {
|
||||
try {
|
||||
const summaries = await getAll(`
|
||||
SELECT id, summary, model, articles_count, feed_title, created_at
|
||||
FROM rss_summaries
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 5
|
||||
`);
|
||||
res.json(summaries || []);
|
||||
} catch (error) {
|
||||
logger.error('Erreur récupération résumés:', error);
|
||||
res.status(500).json({ error: 'Erreur serveur' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/rss/models
|
||||
* Liste des modèles OpenRouter
|
||||
*/
|
||||
router.get('/models', requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const response = await axios.get('https://openrouter.ai/api/v1/models', {
|
||||
headers: {
|
||||
'HTTP-Referer': 'https://noteflow.app',
|
||||
'X-Title': 'NoteFlow'
|
||||
},
|
||||
timeout: 10000
|
||||
});
|
||||
|
||||
const models = response.data.data.map(model => ({
|
||||
id: model.id,
|
||||
name: model.name || model.id,
|
||||
provider: model.id.split('/')[0] || 'Unknown',
|
||||
context_length: model.context_length,
|
||||
pricing: model.pricing
|
||||
}));
|
||||
|
||||
models.sort((a, b) => {
|
||||
if (a.provider !== b.provider) {
|
||||
return a.provider.localeCompare(b.provider);
|
||||
}
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
res.json(models);
|
||||
} catch (error) {
|
||||
logger.error('Erreur récupération modèles:', error);
|
||||
|
||||
const fallbackModels = [
|
||||
{ id: 'openai/gpt-4-turbo', name: 'GPT-4 Turbo', provider: 'openai' },
|
||||
{ id: 'openai/gpt-3.5-turbo', name: 'GPT-3.5 Turbo', provider: 'openai' },
|
||||
{ id: 'anthropic/claude-3-opus', name: 'Claude 3 Opus', provider: 'anthropic' },
|
||||
{ id: 'anthropic/claude-3-sonnet', name: 'Claude 3 Sonnet', provider: 'anthropic' }
|
||||
];
|
||||
|
||||
res.json(fallbackModels);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,422 +0,0 @@
|
||||
// Routes de gestion des flux RSS
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const Parser = require('rss-parser');
|
||||
const axios = require('axios');
|
||||
const { getAll, getOne, runQuery } = require('../config/database');
|
||||
const { authenticateToken, requireAdmin } = require('../middleware/auth');
|
||||
const logger = require('../config/logger');
|
||||
|
||||
const parser = new Parser();
|
||||
|
||||
// Routes admin (gestion des flux)
|
||||
router.use(authenticateToken);
|
||||
|
||||
/**
|
||||
* GET /api/rss/feeds
|
||||
* Liste tous les flux RSS
|
||||
*/
|
||||
router.get('/feeds', requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const feeds = await getAll('SELECT * FROM rss_feeds ORDER BY created_at DESC');
|
||||
res.json(feeds || []);
|
||||
} catch (error) {
|
||||
logger.error('Erreur lors de la récupération des flux RSS:', error);
|
||||
res.status(500).json({ error: 'Erreur serveur' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/rss/feeds
|
||||
* Ajouter un nouveau flux RSS
|
||||
*/
|
||||
router.post('/feeds', requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const { url } = req.body;
|
||||
|
||||
if (!url) {
|
||||
return res.status(400).json({ error: 'URL requise' });
|
||||
}
|
||||
|
||||
// Vérifier si le flux existe déjà
|
||||
const existing = await getOne('SELECT id FROM rss_feeds WHERE url = ?', [url]);
|
||||
if (existing) {
|
||||
return res.status(409).json({ error: 'Ce flux existe déjà' });
|
||||
}
|
||||
|
||||
// Tester le flux
|
||||
try {
|
||||
const feed = await parser.parseURL(url);
|
||||
|
||||
// Ajouter le flux
|
||||
const result = await runQuery(
|
||||
'INSERT INTO rss_feeds (url, title, description) VALUES (?, ?, ?)',
|
||||
[url, feed.title || url, feed.description || '']
|
||||
);
|
||||
|
||||
logger.info(`Flux RSS ajouté: ${feed.title} (${url})`);
|
||||
|
||||
res.json({
|
||||
id: result.id,
|
||||
url,
|
||||
title: feed.title || url,
|
||||
description: feed.description || '',
|
||||
enabled: 1
|
||||
});
|
||||
} catch (parseError) {
|
||||
logger.error('Erreur lors du parsing du flux RSS:', parseError);
|
||||
return res.status(400).json({ error: 'Impossible de parser ce flux RSS. Vérifiez l\'URL.' });
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Erreur lors de l\'ajout du flux RSS:', error);
|
||||
res.status(500).json({ error: 'Erreur serveur' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/rss/feeds/:id
|
||||
* Mettre à jour un flux RSS (activer/désactiver)
|
||||
*/
|
||||
router.put('/feeds/:id', requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const { enabled } = req.body;
|
||||
|
||||
await runQuery(
|
||||
'UPDATE rss_feeds SET enabled = ? WHERE id = ?',
|
||||
[enabled ? 1 : 0, req.params.id]
|
||||
);
|
||||
|
||||
logger.info(`Flux RSS ${enabled ? 'activé' : 'désactivé'}: ${req.params.id}`);
|
||||
res.json({ message: 'Flux mis à jour avec succès' });
|
||||
} catch (error) {
|
||||
logger.error('Erreur lors de la mise à jour du flux RSS:', error);
|
||||
res.status(500).json({ error: 'Erreur serveur' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/rss/feeds/:id
|
||||
* Supprimer un flux RSS
|
||||
*/
|
||||
router.delete('/feeds/:id', requireAdmin, async (req, res) => {
|
||||
try {
|
||||
await runQuery('DELETE FROM rss_feeds WHERE id = ?', [req.params.id]);
|
||||
|
||||
logger.info(`Flux RSS supprimé: ${req.params.id}`);
|
||||
res.json({ message: 'Flux supprimé avec succès' });
|
||||
} catch (error) {
|
||||
logger.error('Erreur lors de la suppression du flux RSS:', error);
|
||||
res.status(500).json({ error: 'Erreur serveur' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/rss/fetch
|
||||
* Récupérer manuellement les articles de tous les flux RSS activés
|
||||
*/
|
||||
router.post('/fetch', requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const rssScheduler = require('../services/rss-scheduler');
|
||||
await rssScheduler.manualFetch();
|
||||
|
||||
// Invalider le cache
|
||||
articlesCache = null;
|
||||
articlesCacheTime = 0;
|
||||
|
||||
res.json({ message: 'Mise à jour des flux RSS terminée avec succès' });
|
||||
} catch (error) {
|
||||
logger.error('Erreur lors du fetch manuel des flux RSS:', error);
|
||||
res.status(500).json({ error: 'Erreur serveur' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/rss/refresh
|
||||
* Alias pour fetch (pour compatibilité)
|
||||
*/
|
||||
router.post('/refresh', requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const rssScheduler = require('../services/rss-scheduler');
|
||||
await rssScheduler.manualFetch();
|
||||
|
||||
// Invalider le cache
|
||||
articlesCache = null;
|
||||
articlesCacheTime = 0;
|
||||
|
||||
res.json({ message: 'Mise à jour des flux RSS terminée avec succès' });
|
||||
} catch (error) {
|
||||
logger.error('Erreur lors du refresh des flux RSS:', error);
|
||||
res.status(500).json({ error: 'Erreur serveur' });
|
||||
}
|
||||
});
|
||||
|
||||
// Cache pour les articles RSS (30 secondes)
|
||||
let articlesCache = null;
|
||||
let articlesCacheTime = 0;
|
||||
const ARTICLES_CACHE_DURATION = 30000; // 30 secondes
|
||||
|
||||
/**
|
||||
* Fonction pour invalider le cache (appelée par le scheduler)
|
||||
*/
|
||||
function invalidateCache() {
|
||||
articlesCache = null;
|
||||
articlesCacheTime = 0;
|
||||
logger.debug('Cache des articles RSS invalidé');
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/rss/articles
|
||||
* Récupérer les articles RSS (avec cache et paramètres limit et hours)
|
||||
* @query limit - Nombre d'articles à récupérer (défaut: 50)
|
||||
* @query hours - Récupérer uniquement les articles des X dernières heures (défaut: tous)
|
||||
*/
|
||||
router.get('/articles', async (req, res) => {
|
||||
try {
|
||||
const limit = parseInt(req.query.limit) || 50; // Augmenté de 5 à 50
|
||||
const hours = parseInt(req.query.hours) || null;
|
||||
|
||||
// Construire la requête SQL avec filtre temporel optionnel
|
||||
let query = `
|
||||
SELECT
|
||||
a.id, a.title, a.link, a.description, a.pub_date, a.content,
|
||||
COALESCE(f.title, f.url) as feed_title, f.url as feed_url
|
||||
FROM rss_articles a
|
||||
LEFT JOIN rss_feeds f ON a.feed_id = f.id
|
||||
WHERE a.pub_date IS NOT NULL
|
||||
`;
|
||||
|
||||
const params = [];
|
||||
|
||||
// Ajouter un filtre temporel si spécifié
|
||||
if (hours) {
|
||||
query += ` AND datetime(a.pub_date) >= datetime('now', '-${hours} hours')`;
|
||||
logger.debug(`Filtre appliqué: articles des ${hours} dernières heures`);
|
||||
}
|
||||
|
||||
query += `
|
||||
ORDER BY a.pub_date DESC
|
||||
LIMIT ?
|
||||
`;
|
||||
params.push(limit);
|
||||
|
||||
// Ne pas utiliser le cache si un filtre temporel est appliqué
|
||||
if (!hours) {
|
||||
// Utiliser le cache si disponible et récent
|
||||
const now = Date.now();
|
||||
if (articlesCache && (now - articlesCacheTime) < ARTICLES_CACHE_DURATION) {
|
||||
logger.debug('Articles RSS servis depuis le cache');
|
||||
return res.json(articlesCache.slice(0, limit));
|
||||
}
|
||||
}
|
||||
|
||||
const articles = await getAll(query, params);
|
||||
|
||||
// Mettre en cache uniquement si pas de filtre temporel
|
||||
if (!hours) {
|
||||
articlesCache = articles || [];
|
||||
articlesCacheTime = Date.now();
|
||||
}
|
||||
|
||||
logger.info(`Articles RSS récupérés: ${articles?.length || 0} (limit: ${limit}${hours ? `, ${hours}h` : ''})`);
|
||||
res.json(articles || []);
|
||||
} catch (error) {
|
||||
logger.error('Erreur lors de la récupération des articles RSS:', error);
|
||||
res.status(500).json({ error: 'Erreur serveur' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/rss/summarize
|
||||
* Générer un résumé PAR ARTICLE (100 mots max) avec lien
|
||||
*/
|
||||
router.post('/summarize', async (req, res) => {
|
||||
try {
|
||||
// Récupérer les paramètres
|
||||
const apiKey = await getOne('SELECT value FROM settings WHERE key = ?', ['openrouter_api_key']);
|
||||
const model = await getOne('SELECT value FROM settings WHERE key = ?', ['openrouter_model']);
|
||||
|
||||
if (!apiKey || !apiKey.value) {
|
||||
return res.status(400).json({ error: 'Clé API OpenRouter non configurée' });
|
||||
}
|
||||
|
||||
// Récupérer les 5 derniers articles
|
||||
const articles = await getAll(`
|
||||
SELECT
|
||||
a.id, a.title, a.description, a.link, a.pub_date, a.content,
|
||||
COALESCE(f.title, f.url) as feed_title
|
||||
FROM rss_articles a
|
||||
LEFT JOIN rss_feeds f ON a.feed_id = f.id
|
||||
WHERE a.pub_date IS NOT NULL
|
||||
ORDER BY a.pub_date DESC
|
||||
LIMIT 5
|
||||
`);
|
||||
|
||||
if (articles.length === 0) {
|
||||
return res.status(400).json({ error: 'Aucun article à résumer' });
|
||||
}
|
||||
|
||||
// Générer un résumé pour CHAQUE article (en parallèle)
|
||||
const selectedModel = model?.value || 'openai/gpt-3.5-turbo';
|
||||
logger.info(`Génération de ${articles.length} résumés avec ${selectedModel}...`);
|
||||
|
||||
const summaryPromises = articles.map(async (article) => {
|
||||
const prompt = `Résume cet article en maximum 100 mots. Sois concis et informatif.
|
||||
|
||||
Titre: ${article.title}
|
||||
Source: ${article.feed_title}
|
||||
Description: ${article.description || ''}
|
||||
${article.content ? `Contenu: ${article.content.substring(0, 1000)}` : ''}
|
||||
|
||||
Résumé (100 mots max):`;
|
||||
|
||||
try {
|
||||
const response = await axios.post(
|
||||
'https://openrouter.ai/api/v1/chat/completions',
|
||||
{
|
||||
model: selectedModel,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: prompt
|
||||
}
|
||||
],
|
||||
max_tokens: 200
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiKey.value}`,
|
||||
'HTTP-Referer': 'https://noteflow.app',
|
||||
'X-Title': 'NoteFlow RSS Summarizer',
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
timeout: 30000
|
||||
}
|
||||
);
|
||||
|
||||
const summary = response.data.choices[0].message.content;
|
||||
|
||||
// Sauvegarder le résumé individuel avec feed_title
|
||||
await runQuery(
|
||||
'INSERT INTO rss_summaries (summary, model, articles_count, feed_title, created_at) VALUES (?, ?, ?, ?, datetime("now"))',
|
||||
[`**${article.title}**\n\n${summary}\n\n🔗 [Lire l'article](${article.link})`, selectedModel, 1, article.feed_title]
|
||||
);
|
||||
|
||||
return {
|
||||
article_id: article.id,
|
||||
title: article.title,
|
||||
link: article.link,
|
||||
feed_title: article.feed_title,
|
||||
summary: summary,
|
||||
pub_date: article.pub_date
|
||||
};
|
||||
} catch (err) {
|
||||
logger.error(`Erreur résumé article "${article.title}":`, err.message);
|
||||
return {
|
||||
article_id: article.id,
|
||||
title: article.title,
|
||||
link: article.link,
|
||||
feed_title: article.feed_title,
|
||||
summary: article.description || 'Résumé non disponible',
|
||||
pub_date: article.pub_date,
|
||||
error: true
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const summaries = await Promise.all(summaryPromises);
|
||||
|
||||
logger.info(`${summaries.length} résumés générés avec succès`);
|
||||
|
||||
res.json({
|
||||
summaries,
|
||||
model: selectedModel,
|
||||
articles_count: articles.length
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Erreur lors de la génération des résumés:', error);
|
||||
|
||||
if (error.response) {
|
||||
logger.error('Réponse OpenRouter:', error.response.data);
|
||||
return res.status(error.response.status).json({
|
||||
error: 'Erreur API OpenRouter',
|
||||
details: error.response.data
|
||||
});
|
||||
}
|
||||
|
||||
res.status(500).json({ error: 'Erreur serveur' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/rss/summaries
|
||||
* Récupérer les 5 derniers résumés
|
||||
*/
|
||||
router.get('/summaries', async (req, res) => {
|
||||
try {
|
||||
const summaries = await getAll(`
|
||||
SELECT id, summary, model, articles_count, feed_title, created_at
|
||||
FROM rss_summaries
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 5
|
||||
`);
|
||||
|
||||
res.json(summaries || []);
|
||||
} catch (error) {
|
||||
logger.error('Erreur lors de la récupération des résumés:', error);
|
||||
res.status(500).json({ error: 'Erreur serveur' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/rss/models
|
||||
* Récupérer la liste des modèles OpenRouter depuis l'API
|
||||
*/
|
||||
router.get('/models', requireAdmin, async (req, res) => {
|
||||
try {
|
||||
// Récupérer les modèles depuis l'API OpenRouter
|
||||
const response = await axios.get('https://openrouter.ai/api/v1/models', {
|
||||
headers: {
|
||||
'HTTP-Referer': 'https://noteflow.app',
|
||||
'X-Title': 'NoteFlow'
|
||||
},
|
||||
timeout: 10000
|
||||
});
|
||||
|
||||
// Transformer les données en format simplifié
|
||||
const models = response.data.data.map(model => ({
|
||||
id: model.id,
|
||||
name: model.name || model.id,
|
||||
provider: model.id.split('/')[0] || 'Unknown',
|
||||
context_length: model.context_length,
|
||||
pricing: model.pricing
|
||||
}));
|
||||
|
||||
// Trier par provider puis par nom
|
||||
models.sort((a, b) => {
|
||||
if (a.provider !== b.provider) {
|
||||
return a.provider.localeCompare(b.provider);
|
||||
}
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
res.json(models);
|
||||
} catch (error) {
|
||||
logger.error('Erreur lors de la récupération des modèles:', error);
|
||||
|
||||
// Fallback sur une liste de modèles populaires si l'API échoue
|
||||
const fallbackModels = [
|
||||
{ id: 'openai/gpt-4-turbo', name: 'GPT-4 Turbo', provider: 'openai' },
|
||||
{ id: 'openai/gpt-3.5-turbo', name: 'GPT-3.5 Turbo', provider: 'openai' },
|
||||
{ id: 'anthropic/claude-3-opus', name: 'Claude 3 Opus', provider: 'anthropic' },
|
||||
{ id: 'anthropic/claude-3-sonnet', name: 'Claude 3 Sonnet', provider: 'anthropic' },
|
||||
{ id: 'google/gemini-pro', name: 'Gemini Pro', provider: 'google' },
|
||||
{ id: 'meta-llama/llama-3-70b-instruct', name: 'Llama 3 70B', provider: 'meta-llama' },
|
||||
{ id: 'mistralai/mistral-large', name: 'Mistral Large', provider: 'mistralai' }
|
||||
];
|
||||
|
||||
res.json(fallbackModels);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
module.exports.invalidateCache = invalidateCache;
|
||||
Reference in new issue
Block a user