Compare commits

...

47 Commits

Author SHA1 Message Date
Manus Automation
290e3709e2 feat(ci): ajouter validation et garde-fou de déploiement
All checks were successful
Deploy Production / deploy (push) Has been skipped
Validation applicative / TypeScript, tests et build (push) Successful in 4m19s
Deploy Recette / deploy (push) Successful in 4m59s
2026-08-20 16:40:12 +00:00
Manus
fcafbf1965 fix: restore React rendering in production bundle
All checks were successful
Deploy Production / deploy (push) Has been skipped
Deploy Recette / deploy (push) Successful in 10m34s
2026-08-18 11:51:11 +00:00
Manus
1821937106 ci: isolate recette and production deployment workflows
All checks were successful
Deploy Production / deploy (push) Has been skipped
Deploy Recette / deploy (push) Successful in 1m9s
2026-08-18 11:37:10 +00:00
Manus
720d42cf71 Merge remote-tracking branch 'recette/main'
Some checks failed
Deploy Recette / deploy (push) Failing after 6s
Deploy Production / deploy (push) Successful in 1m5s
2026-08-18 07:50:50 +00:00
Manus
9acc52da7d Checkpoint: Préparation recette : ajout du journal classification_errors, exposition de la cause technique des fallbacks IA, enregistrement non bloquant depuis le moteur RSS, écran d’administration Erreurs IA, route protégée et migration 0013. Validation locale : 28 tests, TypeScript et build de production réussis. 2026-08-18 07:49:11 +00:00
Manus
6f201dd498 chore: versionner le manifeste de déploiement
All checks were successful
Deploy Production / deploy (push) Successful in 56s
Deploy Recette / deploy (push) Successful in 1m12s
2026-08-17 22:38:22 +02:00
Manus
e4a389cc67 Checkpoint: Audit et refactoring : centralisation robuste du suivi lu/non lu, suppression des captures SQL silencieuses, migration d’unicité avec déduplication des lectures existantes, correction du reclassement AAP via classifyAap, nettoyage de cinq composants template non utilisés, nettoyage des lectures lors des purges/fusions RSS et découpage dynamique du frontend. 27 tests Vitest, TypeScript et build de production validés. 2026-08-17 20:21:45 +00:00
Manus
1344d3c060 Checkpoint: Remplacement du prompt PROMPT_CLASSIFICATION_AAP : approche par bénéficiaires principaux et utilité pour un directeur d'ESMS. Ajout des actions transversales (accès aux soins, inclusion numérique, lutte contre l'isolement). Suppression de la liste noire.
All checks were successful
Deploy Production / deploy (push) Successful in 56s
Deploy Recette / deploy (push) Successful in 58s
2026-07-17 03:57:33 -04:00
Manus
727b7c6436 Checkpoint: Remplacement du prompt PROMPT_CLASSIFICATION : approche par impact métier (directeur d'ESMS) au lieu de liste noire de sujets interdits. Suppression de la liste noire, ajout du critère d'utilité professionnelle directe.
All checks were successful
Deploy Production / deploy (push) Successful in 1m5s
Deploy Recette / deploy (push) Successful in 48s
2026-07-17 03:53:23 -04:00
Manus
4b493bc67c CI/CD: test pipeline prod (has_actions=true)
All checks were successful
Deploy Production / deploy (push) Successful in 57s
Deploy Recette / deploy (push) Successful in 47s
2026-07-10 22:34:27 -04:00
Manus
fac964e0ba CI/CD: test pipeline prod (après resync hooks) 2026-07-10 22:31:51 -04:00
Manus
5203458865 CI/CD: test pipeline prod (Actions unit 16 activé) 2026-07-10 22:29:48 -04:00
Manus
cb3f36699b CI/CD: test pipeline prod final (runner #7 online) 2026-07-10 22:28:29 -04:00
Manus
ae4953cc82 CI/CD: test pipeline prod (Gitea 1.25.5 + runner #6) 2026-07-10 22:26:28 -04:00
Manus
cc00b2263a CI/CD: force Gitea Actions workflow detection 2026-07-10 22:18:36 -04:00
Manus
80e93aa194 CI/CD: test pipeline prod (runner IP interne) 2026-07-10 22:13:57 -04:00
Manus
9164ceb22f CI/CD: test pipeline prod (workflows en place) 2026-07-10 22:09:00 -04:00
Manus
0f0e40b149 CI/CD: test pipeline prod après ré-enregistrement runner 2026-07-10 21:57:59 -04:00
Manus
62751cd4ad CI/CD: test pipeline production 2026-07-10 21:55:55 -04:00
Manus
24b7dbf128 CI/CD: trigger pipeline après fix git clean recette
All checks were successful
Deploy Production / deploy (push) Successful in 1m1s
Deploy Recette / deploy (push) Successful in 1m0s
2026-07-10 21:48:45 -04:00
Manus
0a63cc6797 Fix: labels Traefik recette uniques (veille-recette-secure/svc)
Some checks failed
Deploy Production / deploy (push) Failing after 1s
Deploy Recette / deploy (push) Failing after 1s
2026-07-10 21:46:12 -04:00
Manus
9b11f0e998 CI/CD: Suppression actions/checkout (runner host sans Node.js)
Some checks failed
Deploy Production / deploy (push) Successful in 48s
Deploy Recette / deploy (push) Failing after 56s
2026-07-10 21:40:37 -04:00
Manus
f6aa445732 CI/CD: Ajout workflows Gitea Actions (deploy-recette + deploy-prod)
Some checks failed
Deploy Production / deploy (push) Failing after 17s
Deploy Recette / deploy (push) Failing after 1s
2026-07-10 21:37:47 -04:00
Manus
d283675230 Checkpoint: Purge automatique des tombstones (processed_dedup_keys) de plus de 6 mois :
- purgeOldArticles dans db.ts : suppression des entrées processedDedupKeys.processedAt <= cutoff 6 mois
- Logs cron et init mis à jour pour afficher le nombre de tombstones supprimés
- 0 erreur TypeScript
2026-07-10 21:14:55 -04:00
Manus
8782883bea Checkpoint: Persistance "marquer comme lu" entre sessions :
- Ajout de veille.getReadIds et aap.getReadIds dans routers.ts (procédures protectedProcedure)
- VeilleDashboard.tsx : chargement des IDs lus depuis la DB au montage (useEffect sur readIdsQuery), invalidation getReadIds dans markAsRead et markAllAsRead
- AAPDashboard.tsx : même pattern
- 0 erreur TypeScript
2026-07-10 21:08:26 -04:00
Manus
465e33c3b8 Checkpoint: Connexion Microsoft 365 (Azure AD OAuth2) : migration DB azureAdId, helpers backend, route callback, bouton Login, page AzureCallback, tests OK 2026-07-07 10:29:03 -04:00
Manus
2d0de2a30d feat: prompt IA dédié AAP avec classification par secteur (Handicap/PA/Enfance/Précarité/Sanitaire) 2026-07-06 10:24:40 -04:00
Manus
b3d1e9e6ed fix: supprimer les numéros de département des regex géographiques 2026-07-06 10:08:23 -04:00
Manus
7261a7d33e Checkpoint: reclassifyWithAI traite désormais NULL + rules, supprime les non pertinents et conserve les tombstones 2026-07-06 08:31:02 -04:00
Manus
55f3d5a5d4 Checkpoint: Ajout de la prochaine date d'exécution dynamique dans les Paramètres (calcul frontend selon mode/heure/jour, aligné sur le cron réel backend). Ajout de la règle de rétention configurable (Illimité/3/6/12/18/24 mois) avec purge automatique au démarrage et après chaque import. 0 erreur TypeScript, 21 tests passés. 2026-07-05 07:51:02 -04:00
Manus
f0be15d432 Checkpoint: Rétablissement des modes Quotidienne, Hebdomadaire (choix du jour), Mensuelle (choix du jour du mois) et Intervalle dans la section Planification des Paramètres. Ajout de fetch_day_of_week et fetch_day_of_month dans le schéma Zod du backend. 2026-07-05 06:51:43 -04:00
Manus
b0550f3298 Checkpoint: Correction du scheduler : ajout des modes weekly (avec fetch_day_of_week) et monthly (avec fetch_day_of_month) dans scheduleDailyImport. Planification recette configurée : hebdomadaire, lundi à 08:00. 2026-07-03 05:53:41 -04:00
Manus
326e8b7bcf Checkpoint: Ajout d'un filtre de date minimale (1er avril 2026) dans le moteur RSS : les articles publiés avant cette date sont ignorés sans passer par la classification IA. Les tombstones en recette sont peuplés avec les 490 articles existants pour éviter les doublons. 2026-07-02 03:54:39 -04:00
Manus
b6d89e1087 Checkpoint: Ajout de la table processed_dedup_keys : chaque article traité par le moteur RSS est enregistré dans cette table. Lors des passages suivants du cron, si la dedupKey est présente dans cette table, l'article est ignoré même s'il a été supprimé de veille_items ou aap_items. 2026-07-02 03:49:58 -04:00
Manus
c6b89c5b46 Checkpoint: Ajout de nouveaux patterns de détection (fin de dépôt, fin des candidatures, remise, répondre avant le, ouvert jusqu'au, candidatures jusqu'au). Amélioration de parseFrDate : support JJ.MM.AAAA, ISO AAAA-MM-JJ, "1er mois". Normalisation du texte (retours à la ligne). Fonction findDateInStr pour extraire une date même quand du texte suit. 2026-07-01 03:33:04 -04:00
Manus
53514b0540 Checkpoint: Ajout de la fonction extractDateCloture dans rssEngine.ts : détection par expressions régulières des patterns courants en français (date limite, clôture le, avant le, jusqu'au, dépôt des dossiers, délai de réponse, réception, soumission). La date extraite est insérée dans le champ dateCloture lors de la collecte RSS des AAP. 2026-06-30 10:13:30 -04:00
Manus
1d7fa332ea Checkpoint: Extension du dictionnaire des départements à 5 régions (47 départements). Ajout de la fonction detectRegion pour détecter la région par son nom. Les fonctions detectVeilleNiveauTerritoire et detectAapGeo retournent désormais la région réelle du département détecté (plus de valeur codée en dur "Auvergne-Rhône-Alpes"). buildMergeKey mis à jour pour couvrir tous les noms de départements. 2026-06-30 09:34:21 -04:00
Manus
288bcf982c true message 2026-06-30 09:15:28 -04:00
Manus
cffb3d4201 Checkpoint: Ajout des modes de fréquence quotidien, hebdomadaire (avec sélecteur de jour Lun-Dim), mensuel (avec choix du jour 1-28) et intervalle (1h à 24h). Le cron est rechargé immédiatement après sauvegarde pour tous les paramètres de planification. 2026-06-29 17:07:28 -04:00
Manus
1f1a479029 Checkpoint: Ajout du paramétrage de fréquence des mises à jour dans Paramètres → Import & Planification : choix entre mode heure fixe (cron quotidien) et mode intervalle (1h, 2h, 4h, 6h, 12h, 24h). Le cron est rechargé immédiatement après sauvegarde sans redémarrage serveur. 2026-06-28 10:10:34 -04:00
Manus
9eb5849504 Checkpoint: Suppression du cron RSS indépendant. La lecture des flux RSS est désormais intégrée dans le cron d'import quotidien (import_time dans Paramètres). Le panneau de planification RSS dans RssFeeds affiche maintenant l'heure configurée et redirige vers Paramètres pour la modifier. 0 erreur TypeScript. 2026-06-26 10:38:49 -04:00
Manus
6b8b3ce9d7 Checkpoint: AAPDashboard mis à jour avec les 4 mêmes évolutions que VeilleDashboard :
1. Mode vignette par défaut
2. Alternance de couleurs en mode liste (pair=blanc, impair=slate-50)
3. Couleur de sélection au clic sur une ligne (bg-primary/10, titre en primary)
4. Bouton segmenté Non lus / Tous / Lus — Non lus par défaut
5. Message vide adapté selon le filtre actif
0 erreur TypeScript.
2026-06-26 10:32:19 -04:00
Manus
1c425f25ad Checkpoint: 4 évolutions UI sur VeilleDashboard :
1. Mode vignette par défaut (viewMode initialisé à "grid")
2. Alternance de couleurs en mode liste : lignes paires = blanc, lignes impaires = gris clair (slate-50)
3. Couleur de sélection au clic sur une ligne : bg-primary/10 avec titre en couleur primaire
4. Bouton segmenté Lu/Non lu/Tous au niveau des onglets — Non lu par défaut
5. Message d'état vide adapté selon le filtre actif
Planification RSS vérifiée : scheduleRssFetch() lit bien rss_settings (fetchMode, fetchIntervalMinutes, scheduledTime) depuis la BDD.
2026-06-26 10:22:13 -04:00
Manus
a7cf918fa8 Checkpoint: Filtre de pertinence strict avant insertion en base : seuls les articles jugés pertinents (iaRelevant=true) sont insérés dans veille_items et aap_items. Les articles rejetés sont loggés et comptés dans skippedItems. Prompt IA mis à jour avec la formulation exacte de l'utilisateur (expert ESMS, 5 secteurs, liste noire explicite, principe "doute = false"). 21 tests passés, 0 erreur TypeScript. 2026-06-26 09:50:58 -04:00
Manus
3613e26034 Checkpoint: Mise à jour du prompt de classification IA (aiClassifier.ts) : ajout d'une liste noire explicite de 10 domaines hors périmètre (sylviculture, agriculture, transports, urbanisme, environnement, économie, défense, sport, enseignement supérieur, nominations sans lien). Principe "doute = rejet" renforcé. 0 erreur TypeScript, 21 tests passés. 2026-06-22 06:07:12 -04:00
Manus
dde3d8b6b8 Checkpoint: Ajout de server/articleFetcher.ts (fetch HTTP + extraction 500 mots, timeout 5s, fallback silencieux). Intégration dans rssEngine.ts : si le résumé RSS est court (<150 car.), le 1er paragraphe de l'article est récupéré et transmis à classifyArticle. Signature de classifyArticle mise à jour avec paramètre contenuPage optionnel. 0 erreur TypeScript, 21 tests passés. 2026-06-19 09:27:59 -04:00
Manus
165599bc83 Checkpoint: Mise à jour du prompt de classification IA (aiClassifier.ts) : remplacement du prompt générique ESMS par une définition explicite des 5 secteurs d'activité de l'association (handicap, précarité/logement, protection de l'enfance, personnes âgées, sanitaire SMR). 0 erreur TypeScript, 21 tests passés. 2026-06-19 09:24:30 -04:00
52 changed files with 7583 additions and 2848 deletions

0
.gitea/workflows/.keep Normal file
View File

View File

@@ -0,0 +1,31 @@
name: Deploy Production
on:
push:
branches:
- main
jobs:
deploy:
# Une copie de ce dépôt existe sur les deux instances Gitea : la production ne doit
# jamais exécuter le workflow de recette, ni inversement.
if: ${{ gitea.server_url == 'https://git.santinova-soft.org' }}
runs-on: ubuntu-latest
steps:
- name: Deploy on production server
run: |
cd /opt/manus-deploy/apps/veille-reglementaire
git pull origin main
docker compose -f docker-compose.prod.yml up -d --build
echo "✅ Déploiement production terminé"
- name: Health check
run: |
sleep 30
STATUS=$(curl -s -o /dev/null -w "%{http_code}" https://veille.santinova-soft.org)
echo "HTTP status: $STATUS"
if [ "$STATUS" != "200" ]; then
echo "❌ Health check failed (HTTP $STATUS)"
exit 1
fi
echo "✅ Health check OK"

View File

@@ -0,0 +1,31 @@
name: Deploy Recette
on:
push:
branches:
- main
jobs:
deploy:
# Une copie de ce dépôt existe sur les deux instances Gitea : la recette ne doit
# jamais exécuter le workflow de production, ni inversement.
if: ${{ gitea.server_url == 'https://git.recette.santinova-soft.org' }}
runs-on: ubuntu-latest
steps:
- name: Deploy on recette server
run: |
cd /opt/manus-deploy/apps/veille-reglementaire
git pull origin main
docker compose -f docker-compose.recette.yml up -d --build
echo "✅ Déploiement recette terminé"
- name: Health check
run: |
sleep 30
STATUS=$(curl -s -o /dev/null -w "%{http_code}" https://veille.recette.santinova-soft.org)
echo "HTTP status: $STATUS"
if [ "$STATUS" != "200" ]; then
echo "❌ Health check failed (HTTP $STATUS)"
exit 1
fi
echo "✅ Health check OK"

View File

@@ -0,0 +1,43 @@
name: Validation applicative
on:
push:
branches: [main]
paths-ignore:
- "**.md"
- "docs/**"
pull_request:
branches: [main]
paths-ignore:
- "**.md"
- "docs/**"
workflow_dispatch:
jobs:
verify:
name: TypeScript, tests et build
runs-on: ci-node22
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- name: Calculer la clé de cache pnpm
id: pnpm-cache-key
shell: bash
run: |
echo "store=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
echo "lock=$(sha256sum pnpm-lock.yaml | cut -d ' ' -f 1)" >> "$GITHUB_OUTPUT"
- name: Restaurer le store pnpm
uses: actions/cache@v4
with:
path: ${{ steps.pnpm-cache-key.outputs.store }}
key: pnpm-${{ runner.os }}-${{ steps.pnpm-cache-key.outputs.lock }}
restore-keys: |
pnpm-${{ runner.os }}-
- name: Installer les dépendances verrouillées
run: pnpm install --frozen-lockfile --prefer-offline
- name: Vérifier TypeScript, tests et build
run: pnpm verify

1
.gitignore vendored
View File

@@ -108,3 +108,4 @@ temp/
# Webdev artifacts (checkpoint zips, migrations, etc.)
.webdev/
.project-config.json

View File

@@ -0,0 +1,162 @@
{
"query": "SHOW COLUMNS FROM veille_items;",
"command": "mysql --batch --raw --column-names --default-character-set=utf8mb4 --host gateway02.us-east-1.prod.aws.tidbcloud.com --port 4000 --user 4CrrYuB5tme73Qo.63b125a8f9ca --database VepzDyqR8YkJNcqpZ729Bw --execute SHOW COLUMNS FROM veille_items;",
"rows": [
{
"Field": "id",
"Type": "int",
"Null": "NO",
"Key": "PRI",
"Default": "NULL",
"Extra": "auto_increment"
},
{
"Field": "dedupKey",
"Type": "varchar(64)",
"Null": "NO",
"Key": "UNI",
"Default": "NULL",
"Extra": ""
},
{
"Field": "titre",
"Type": "text",
"Null": "NO",
"Key": "",
"Default": "NULL",
"Extra": ""
},
{
"Field": "categorie",
"Type": "varchar(128)",
"Null": "YES",
"Key": "",
"Default": "NULL",
"Extra": ""
},
{
"Field": "niveau",
"Type": "varchar(128)",
"Null": "YES",
"Key": "",
"Default": "NULL",
"Extra": ""
},
{
"Field": "territoire",
"Type": "varchar(255)",
"Null": "YES",
"Key": "",
"Default": "NULL",
"Extra": ""
},
{
"Field": "resume",
"Type": "text",
"Null": "YES",
"Key": "",
"Default": "NULL",
"Extra": ""
},
{
"Field": "source",
"Type": "varchar(512)",
"Null": "YES",
"Key": "",
"Default": "NULL",
"Extra": ""
},
{
"Field": "passage",
"Type": "text",
"Null": "YES",
"Key": "",
"Default": "NULL",
"Extra": ""
},
{
"Field": "lien",
"Type": "text",
"Null": "YES",
"Key": "",
"Default": "NULL",
"Extra": ""
},
{
"Field": "typeVeille",
"Type": "enum('reglementaire','concurrentielle','technologique','informationnelle')",
"Null": "NO",
"Key": "",
"Default": "NULL",
"Extra": ""
},
{
"Field": "datePublication",
"Type": "timestamp",
"Null": "YES",
"Key": "",
"Default": "NULL",
"Extra": ""
},
{
"Field": "importedAt",
"Type": "timestamp",
"Null": "NO",
"Key": "",
"Default": "CURRENT_TIMESTAMP",
"Extra": ""
},
{
"Field": "territoires",
"Type": "text",
"Null": "YES",
"Key": "",
"Default": "NULL",
"Extra": ""
},
{
"Field": "iaRelevant",
"Type": "tinyint(1)",
"Null": "YES",
"Key": "",
"Default": "NULL",
"Extra": ""
},
{
"Field": "iaCategorie",
"Type": "varchar(128)",
"Null": "YES",
"Key": "",
"Default": "NULL",
"Extra": ""
},
{
"Field": "iaClassifiedBy",
"Type": "enum('ia','rules')",
"Null": "YES",
"Key": "",
"Default": "NULL",
"Extra": ""
},
{
"Field": "iaReason",
"Type": "text",
"Null": "YES",
"Key": "",
"Default": "NULL",
"Extra": ""
},
{
"Field": "iaResume",
"Type": "text",
"Null": "YES",
"Key": "",
"Default": "NULL",
"Extra": ""
}
],
"messages": [],
"stdout": "Field\tType\tNull\tKey\tDefault\tExtra\nid\tint\tNO\tPRI\tNULL\tauto_increment\ndedupKey\tvarchar(64)\tNO\tUNI\tNULL\t\ntitre\ttext\tNO\t\tNULL\t\ncategorie\tvarchar(128)\tYES\t\tNULL\t\nniveau\tvarchar(128)\tYES\t\tNULL\t\nterritoire\tvarchar(255)\tYES\t\tNULL\t\nresume\ttext\tYES\t\tNULL\t\nsource\tvarchar(512)\tYES\t\tNULL\t\npassage\ttext\tYES\t\tNULL\t\nlien\ttext\tYES\t\tNULL\t\ntypeVeille\tenum('reglementaire','concurrentielle','technologique','informationnelle')\tNO\t\tNULL\t\ndatePublication\ttimestamp\tYES\t\tNULL\t\nimportedAt\ttimestamp\tNO\t\tCURRENT_TIMESTAMP\t\nterritoires\ttext\tYES\t\tNULL\t\niaRelevant\ttinyint(1)\tYES\t\tNULL\t\niaCategorie\tvarchar(128)\tYES\t\tNULL\t\niaClassifiedBy\tenum('ia','rules')\tYES\t\tNULL\t\niaReason\ttext\tYES\t\tNULL\t\niaResume\ttext\tYES\t\tNULL\t\n",
"stderr": "",
"execution_time_ms": 713
}

View File

@@ -0,0 +1,40 @@
{
"query": "\nSELECT \n titre,\n LEFT(resume, 200) AS resume_rss,\n CHAR_LENGTH(resume) AS longueur,\n typeVeille,\n iaRelevant,\n iaClassifiedBy,\n LEFT(iaReason, 200) AS iaReason,\n lien\nFROM veille_items\nWHERE CHAR_LENGTH(resume) < 150 OR resume IS NULL\nORDER BY CHAR_LENGTH(resume) ASC\nLIMIT 25;\n",
"command": "mysql --batch --raw --column-names --default-character-set=utf8mb4 --host gateway02.us-east-1.prod.aws.tidbcloud.com --port 4000 --user 4CrrYuB5tme73Qo.63b125a8f9ca --database VepzDyqR8YkJNcqpZ729Bw --execute \nSELECT \n titre,\n LEFT(resume, 200) AS resume_rss,\n CHAR_LENGTH(resume) AS longueur,\n typeVeille,\n iaRelevant,\n iaClassifiedBy,\n LEFT(iaReason, 200) AS iaReason,\n lien\nFROM veille_items\nWHERE CHAR_LENGTH(resume) < 150 OR resume IS NULL\nORDER BY CHAR_LENGTH(resume) ASC\nLIMIT 25;\n",
"rows": [
{
"titre": "Décision n°2025.0056/DC/SCES du 1er avril 2025 du Président de la Haute Autorité de santé portant nomination dexperts-visiteurs pour la certification des établissements de santé",
"resume_rss": "NULL",
"longueur": "NULL",
"typeVeille": "reglementaire",
"iaRelevant": "1",
"iaClassifiedBy": "ia",
"iaReason": "Le texte concerne la certification des établissements de santé par la Haute Autorité de santé, relevant du système de soin et de l'organisation des établissements hospitaliers.",
"lien": "https://www.has-sante.fr/jcms/p_3861382/fr/decision-n2025-0056/dc/sces-du-1er-avril-2025-du-president-de-la-haute-autorite-de-sante-portant-nomination-d-experts-visiteurs-pour-la-certification-des-etablissements-de-sante"
},
{
"titre": "Avenant n°2 au PRS 20232028 - Publication de larrêté portant révision du PRS le 25 février 2026",
"resume_rss": "Le Projet Régional de Santé (PRS) 2023-2028 de lOccitanie actuellement en vigueur, vient pour la seconde fois dêtre révisé partiellement.",
"longueur": "139",
"typeVeille": "reglementaire",
"iaRelevant": "1",
"iaClassifiedBy": "ia",
"iaReason": "Le texte concerne la révision d'un Projet Régional de Santé (PRS) par arrêté, relevant de l'organisation du système de santé et de la réglementation.",
"lien": "https://www.occitanie.ars.sante.fr/avenant-ndeg2-au-prs-2023-2028-publication-de-larrete-portant-revision-du-prs-le-25-fevrier-2026-0"
},
{
"titre": "Que pensent les personnes handicapées de leur MDPH en 2025 ?",
"resume_rss": "Parmi les répondants, 7 personnes sur 10 sont très satisfaites ou satisfaites de leur maison départementale des personnes handicapées (MDPH).",
"longueur": "141",
"typeVeille": "informationnelle",
"iaRelevant": "1",
"iaClassifiedBy": "ia",
"iaReason": "Le texte aborde la satisfaction des personnes handicapées vis-à-vis de la MDPH, une structure dédiée à ce public.",
"lien": "https://www.cnsa.fr/actualites/que-pensent-les-personnes-handicapees-de-leur-mdph-en-2025"
}
],
"messages": [],
"stdout": "titre\tresume_rss\tlongueur\ttypeVeille\tiaRelevant\tiaClassifiedBy\tiaReason\tlien\nDécision n°2025.0056/DC/SCES du 1er avril 2025 du Président de la Haute Autorité de santé portant nomination dexperts-visiteurs pour la certification des établissements de santé\tNULL\tNULL\treglementaire\t1\tia\tLe texte concerne la certification des établissements de santé par la Haute Autorité de santé, relevant du système de soin et de l'organisation des établissements hospitaliers.\thttps://www.has-sante.fr/jcms/p_3861382/fr/decision-n2025-0056/dc/sces-du-1er-avril-2025-du-president-de-la-haute-autorite-de-sante-portant-nomination-d-experts-visiteurs-pour-la-certification-des-etablissements-de-sante\nAvenant n°2 au PRS 20232028 - Publication de larrêté portant révision du PRS le 25 février 2026\tLe Projet Régional de Santé (PRS) 2023-2028 de lOccitanie actuellement en vigueur, vient pour la seconde fois dêtre révisé partiellement.\t139\treglementaire\t1\tia\tLe texte concerne la révision d'un Projet Régional de Santé (PRS) par arrêté, relevant de l'organisation du système de santé et de la réglementation.\thttps://www.occitanie.ars.sante.fr/avenant-ndeg2-au-prs-2023-2028-publication-de-larrete-portant-revision-du-prs-le-25-fevrier-2026-0\nQue pensent les personnes handicapées de leur MDPH en 2025 ?\tParmi les répondants, 7 personnes sur 10 sont très satisfaites ou satisfaites de leur maison départementale des personnes handicapées (MDPH).\t141\tinformationnelle\t1\tia\tLe texte aborde la satisfaction des personnes handicapées vis-à-vis de la MDPH, une structure dédiée à ce public.\thttps://www.cnsa.fr/actualites/que-pensent-les-personnes-handicapees-de-leur-mdph-en-2025\n",
"stderr": "",
"execution_time_ms": 682
}

View File

@@ -0,0 +1,9 @@
{
"query": "\nSELECT \n titre,\n LEFT(resume, 250) AS resume_rss,\n CHAR_LENGTH(resume) AS longueur,\n typeVeille,\n iaRelevant,\n iaClassifiedBy,\n LEFT(iaReason, 250) AS iaReason\nFROM veille_items\nWHERE iaClassifiedBy = 'rules' OR iaRelevant = 0\nORDER BY importedAt DESC\nLIMIT 20;\n",
"command": "mysql --batch --raw --column-names --default-character-set=utf8mb4 --host gateway02.us-east-1.prod.aws.tidbcloud.com --port 4000 --user 4CrrYuB5tme73Qo.63b125a8f9ca --database VepzDyqR8YkJNcqpZ729Bw --execute \nSELECT \n titre,\n LEFT(resume, 250) AS resume_rss,\n CHAR_LENGTH(resume) AS longueur,\n typeVeille,\n iaRelevant,\n iaClassifiedBy,\n LEFT(iaReason, 250) AS iaReason\nFROM veille_items\nWHERE iaClassifiedBy = 'rules' OR iaRelevant = 0\nORDER BY importedAt DESC\nLIMIT 20;\n",
"rows": [],
"messages": [],
"stdout": "",
"stderr": "",
"execution_time_ms": 590
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,8 @@
{
"query": "\nSELECT \n titre,\n LEFT(resume, 200) AS resume_court,\n CHAR_LENGTH(resume) AS longueur_resume,\n typeVeille,\n iaType,\n iaResume,\n lien\nFROM veille_items\nWHERE CHAR_LENGTH(resume) < 150\nORDER BY CHAR_LENGTH(resume) ASC\nLIMIT 20;\n",
"command": "mysql --batch --raw --column-names --default-character-set=utf8mb4 --host gateway02.us-east-1.prod.aws.tidbcloud.com --port 4000 --user 4CrrYuB5tme73Qo.63b125a8f9ca --database VepzDyqR8YkJNcqpZ729Bw --execute \nSELECT \n titre,\n LEFT(resume, 200) AS resume_court,\n CHAR_LENGTH(resume) AS longueur_resume,\n typeVeille,\n iaType,\n iaResume,\n lien\nFROM veille_items\nWHERE CHAR_LENGTH(resume) < 150\nORDER BY CHAR_LENGTH(resume) ASC\nLIMIT 20;\n",
"returncode": 1,
"logs": [
"ERROR 1054 (42S22) at line 2: Unknown column 'iatype' in 'field list'"
]
}

16
app.json Normal file
View File

@@ -0,0 +1,16 @@
{
"id": "veille-reglementaire",
"name": "Veille Réglementaire",
"category": "ITINOVA",
"urls": {
"recette": "https://veille.recette.santinova-soft.org",
"prod": "https://veille.santinova-soft.org"
},
"containerName": "veille-reglementaire-recette",
"image": "images/veille-reglementaire.png",
"giteaRepo": "veille-reglementaire",
"giteaOwner": "manus-admin",
"ci": {
"required": true
}
}

View File

@@ -1,4 +1,4 @@
{
"version": "7708bf54",
"timestamp": 1781683337686
"timestamp": 1787039351851,
"version": "85944a51"
}

View File

@@ -1,21 +1,27 @@
import { Toaster } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip";
import NotFound from "@/pages/NotFound";
import { lazy, Suspense } from "react";
import { Route, Switch, Redirect } from "wouter";
import ErrorBoundary from "./components/ErrorBoundary";
import { ThemeProvider } from "./contexts/ThemeContext";
import { LocalAuthProvider, useLocalAuth } from "./contexts/LocalAuthContext";
import { AppLayout } from "./components/AppLayout";
import Login from "./pages/Login";
import VeilleDashboard from "./pages/VeilleDashboard";
import AAPDashboard from "./pages/AAPDashboard";
import Settings from "./pages/Settings";
import UsersAdmin from "./pages/UsersAdmin";
import ImportLogs from "./pages/ImportLogs";
import BoiteAIdees from "@/pages/BoiteAIdees";
import RssFeeds from "@/pages/RssFeeds";
import { Loader2 } from "lucide-react";
// Chaque page est chargée à la demande : la connexion reste rapide et les écrans
// d'administration lourds ne sont téléchargés que lorsqu'ils sont effectivement ouverts.
const NotFound = lazy(() => import("@/pages/NotFound"));
const Login = lazy(() => import("./pages/Login"));
const VeilleDashboard = lazy(() => import("./pages/VeilleDashboard"));
const AAPDashboard = lazy(() => import("./pages/AAPDashboard"));
const Settings = lazy(() => import("./pages/Settings"));
const UsersAdmin = lazy(() => import("./pages/UsersAdmin"));
const ImportLogs = lazy(() => import("./pages/ImportLogs"));
const ClassificationErrors = lazy(() => import("./pages/ClassificationErrors"));
const BoiteAIdees = lazy(() => import("@/pages/BoiteAIdees"));
const RssFeeds = lazy(() => import("@/pages/RssFeeds"));
const AzureCallback = lazy(() => import("@/pages/AzureCallback"));
// ─── Guard d'authentification ─────────────────────────────────────────────────
function AuthGuard({ children }: { children: React.ReactNode }) {
@@ -99,6 +105,16 @@ function LogsPage() {
);
}
function ClassificationErrorsPage() {
return (
<AuthGuard>
<DashboardWrapper>
<ClassificationErrors />
</DashboardWrapper>
</AuthGuard>
);
}
function BoiteAIdeesPage() {
return (
<AuthGuard>
@@ -125,6 +141,7 @@ function Router() {
return (
<Switch>
<Route path="/login" component={Login} />
<Route path="/azure-callback" component={AzureCallback} />
<Route path="/">
<Redirect to="/veille" />
</Route>
@@ -133,6 +150,7 @@ function Router() {
<Route path="/admin/settings" component={SettingsPage} />
<Route path="/admin/users" component={UsersPage} />
<Route path="/admin/logs" component={LogsPage} />
<Route path="/admin/classification-errors" component={ClassificationErrorsPage} />
<Route path="/boite-a-idees" component={BoiteAIdeesPage} />
<Route path="/admin/rss" component={RssFeedsPage} />
<Route path="/404" component={NotFound} />
@@ -148,7 +166,15 @@ function App() {
<LocalAuthProvider>
<TooltipProvider>
<Toaster richColors position="top-right" />
<Suspense
fallback={(
<div className="min-h-screen flex items-center justify-center bg-background">
<Loader2 size={32} className="animate-spin text-primary" />
</div>
)}
>
<Router />
</Suspense>
</TooltipProvider>
</LocalAuthProvider>
</ThemeProvider>

View File

@@ -1,335 +0,0 @@
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
import { Loader2, Send, User, Sparkles } from "lucide-react";
import { useState, useEffect, useRef } from "react";
import { Streamdown } from "streamdown";
/**
* Message type matching server-side LLM Message interface
*/
export type Message = {
role: "system" | "user" | "assistant";
content: string;
};
export type AIChatBoxProps = {
/**
* Messages array to display in the chat.
* Should match the format used by invokeLLM on the server.
*/
messages: Message[];
/**
* Callback when user sends a message.
* Typically you'll call a tRPC mutation here to invoke the LLM.
*/
onSendMessage: (content: string) => void;
/**
* Whether the AI is currently generating a response
*/
isLoading?: boolean;
/**
* Placeholder text for the input field
*/
placeholder?: string;
/**
* Custom className for the container
*/
className?: string;
/**
* Height of the chat box (default: 600px)
*/
height?: string | number;
/**
* Empty state message to display when no messages
*/
emptyStateMessage?: string;
/**
* Suggested prompts to display in empty state
* Click to send directly
*/
suggestedPrompts?: string[];
};
/**
* A ready-to-use AI chat box component that integrates with the LLM system.
*
* Features:
* - Matches server-side Message interface for seamless integration
* - Markdown rendering with Streamdown
* - Auto-scrolls to latest message
* - Loading states
* - Uses global theme colors from index.css
*
* @example
* ```tsx
* const ChatPage = () => {
* const [messages, setMessages] = useState<Message[]>([
* { role: "system", content: "You are a helpful assistant." }
* ]);
*
* const chatMutation = trpc.ai.chat.useMutation({
* onSuccess: (response) => {
* // Assuming your tRPC endpoint returns the AI response as a string
* setMessages(prev => [...prev, {
* role: "assistant",
* content: response
* }]);
* },
* onError: (error) => {
* console.error("Chat error:", error);
* // Optionally show error message to user
* }
* });
*
* const handleSend = (content: string) => {
* const newMessages = [...messages, { role: "user", content }];
* setMessages(newMessages);
* chatMutation.mutate({ messages: newMessages });
* };
*
* return (
* <AIChatBox
* messages={messages}
* onSendMessage={handleSend}
* isLoading={chatMutation.isPending}
* suggestedPrompts={[
* "Explain quantum computing",
* "Write a hello world in Python"
* ]}
* />
* );
* };
* ```
*/
export function AIChatBox({
messages,
onSendMessage,
isLoading = false,
placeholder = "Type your message...",
className,
height = "600px",
emptyStateMessage = "Start a conversation with AI",
suggestedPrompts,
}: AIChatBoxProps) {
const [input, setInput] = useState("");
const scrollAreaRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const inputAreaRef = useRef<HTMLFormElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Filter out system messages
const displayMessages = messages.filter((msg) => msg.role !== "system");
// Calculate min-height for last assistant message to push user message to top
const [minHeightForLastMessage, setMinHeightForLastMessage] = useState(0);
useEffect(() => {
if (containerRef.current && inputAreaRef.current) {
const containerHeight = containerRef.current.offsetHeight;
const inputHeight = inputAreaRef.current.offsetHeight;
const scrollAreaHeight = containerHeight - inputHeight;
// Reserve space for:
// - padding (p-4 = 32px top+bottom)
// - user message: 40px (item height) + 16px (margin-top from space-y-4) = 56px
// Note: margin-bottom is not counted because it naturally pushes the assistant message down
const userMessageReservedHeight = 56;
const calculatedHeight = scrollAreaHeight - 32 - userMessageReservedHeight;
setMinHeightForLastMessage(Math.max(0, calculatedHeight));
}
}, []);
// Scroll to bottom helper function with smooth animation
const scrollToBottom = () => {
const viewport = scrollAreaRef.current?.querySelector(
'[data-radix-scroll-area-viewport]'
) as HTMLDivElement;
if (viewport) {
requestAnimationFrame(() => {
viewport.scrollTo({
top: viewport.scrollHeight,
behavior: 'smooth'
});
});
}
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const trimmedInput = input.trim();
if (!trimmedInput || isLoading) return;
onSendMessage(trimmedInput);
setInput("");
// Scroll immediately after sending
scrollToBottom();
// Keep focus on input
textareaRef.current?.focus();
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit(e);
}
};
return (
<div
ref={containerRef}
className={cn(
"flex flex-col bg-card text-card-foreground rounded-lg border shadow-sm",
className
)}
style={{ height }}
>
{/* Messages Area */}
<div ref={scrollAreaRef} className="flex-1 overflow-hidden">
{displayMessages.length === 0 ? (
<div className="flex h-full flex-col p-4">
<div className="flex flex-1 flex-col items-center justify-center gap-6 text-muted-foreground">
<div className="flex flex-col items-center gap-3">
<Sparkles className="size-12 opacity-20" />
<p className="text-sm">{emptyStateMessage}</p>
</div>
{suggestedPrompts && suggestedPrompts.length > 0 && (
<div className="flex max-w-2xl flex-wrap justify-center gap-2">
{suggestedPrompts.map((prompt, index) => (
<button
key={index}
onClick={() => onSendMessage(prompt)}
disabled={isLoading}
className="rounded-lg border border-border bg-card px-4 py-2 text-sm transition-colors hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50"
>
{prompt}
</button>
))}
</div>
)}
</div>
</div>
) : (
<ScrollArea className="h-full">
<div className="flex flex-col space-y-4 p-4">
{displayMessages.map((message, index) => {
// Apply min-height to last message only if NOT loading (when loading, the loading indicator gets it)
const isLastMessage = index === displayMessages.length - 1;
const shouldApplyMinHeight =
isLastMessage && !isLoading && minHeightForLastMessage > 0;
return (
<div
key={index}
className={cn(
"flex gap-3",
message.role === "user"
? "justify-end items-start"
: "justify-start items-start"
)}
style={
shouldApplyMinHeight
? { minHeight: `${minHeightForLastMessage}px` }
: undefined
}
>
{message.role === "assistant" && (
<div className="size-8 shrink-0 mt-1 rounded-full bg-primary/10 flex items-center justify-center">
<Sparkles className="size-4 text-primary" />
</div>
)}
<div
className={cn(
"max-w-[80%] rounded-lg px-4 py-2.5",
message.role === "user"
? "bg-primary text-primary-foreground"
: "bg-muted text-foreground"
)}
>
{message.role === "assistant" ? (
<div className="prose prose-sm dark:prose-invert max-w-none">
<Streamdown>{message.content}</Streamdown>
</div>
) : (
<p className="whitespace-pre-wrap text-sm">
{message.content}
</p>
)}
</div>
{message.role === "user" && (
<div className="size-8 shrink-0 mt-1 rounded-full bg-secondary flex items-center justify-center">
<User className="size-4 text-secondary-foreground" />
</div>
)}
</div>
);
})}
{isLoading && (
<div
className="flex items-start gap-3"
style={
minHeightForLastMessage > 0
? { minHeight: `${minHeightForLastMessage}px` }
: undefined
}
>
<div className="size-8 shrink-0 mt-1 rounded-full bg-primary/10 flex items-center justify-center">
<Sparkles className="size-4 text-primary" />
</div>
<div className="rounded-lg bg-muted px-4 py-2.5">
<Loader2 className="size-4 animate-spin text-muted-foreground" />
</div>
</div>
)}
</div>
</ScrollArea>
)}
</div>
{/* Input Area */}
<form
ref={inputAreaRef}
onSubmit={handleSubmit}
className="flex gap-2 p-4 border-t bg-background/50 items-end"
>
<Textarea
ref={textareaRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
className="flex-1 max-h-32 resize-none min-h-9"
rows={1}
/>
<Button
type="submit"
size="icon"
disabled={!input.trim() || isLoading}
className="shrink-0 h-[38px] w-[38px]"
>
{isLoading ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Send className="size-4" />
)}
</Button>
</form>
</div>
);
}

View File

@@ -18,6 +18,7 @@ import {
X,
Lightbulb,
Rss,
AlertTriangle,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
@@ -64,6 +65,7 @@ const NAV_GROUPS: NavGroup[] = [
defaultOpen: false,
items: [
{ label: "Logs d'import", href: "/admin/logs", icon: <Activity size={16} />, adminOnly: true },
{ label: "Erreurs IA", href: "/admin/classification-errors", icon: <AlertTriangle size={16} />, adminOnly: true },
{ label: "Utilisateurs", href: "/admin/users", icon: <Users size={16} />, adminOnly: true },
{ label: "Flux RSS", href: "/admin/rss", icon: <Rss size={16} />, adminOnly: true },
{ label: "Paramètres", href: "/admin/settings", icon: <Settings size={16} />, adminOnly: true },

View File

@@ -1,264 +0,0 @@
import { useAuth } from "@/_core/hooks/useAuth";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarHeader,
SidebarInset,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarProvider,
SidebarTrigger,
useSidebar,
} from "@/components/ui/sidebar";
import { getLoginUrl } from "@/const";
import { useIsMobile } from "@/hooks/useMobile";
import { LayoutDashboard, LogOut, PanelLeft, Users } from "lucide-react";
import { CSSProperties, useEffect, useRef, useState } from "react";
import { useLocation } from "wouter";
import { DashboardLayoutSkeleton } from './DashboardLayoutSkeleton';
import { Button } from "./ui/button";
const menuItems = [
{ icon: LayoutDashboard, label: "Page 1", path: "/" },
{ icon: Users, label: "Page 2", path: "/some-path" },
];
const SIDEBAR_WIDTH_KEY = "sidebar-width";
const DEFAULT_WIDTH = 280;
const MIN_WIDTH = 200;
const MAX_WIDTH = 480;
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
const [sidebarWidth, setSidebarWidth] = useState(() => {
const saved = localStorage.getItem(SIDEBAR_WIDTH_KEY);
return saved ? parseInt(saved, 10) : DEFAULT_WIDTH;
});
const { loading, user } = useAuth();
useEffect(() => {
localStorage.setItem(SIDEBAR_WIDTH_KEY, sidebarWidth.toString());
}, [sidebarWidth]);
if (loading) {
return <DashboardLayoutSkeleton />
}
if (!user) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="flex flex-col items-center gap-8 p-8 max-w-md w-full">
<div className="flex flex-col items-center gap-6">
<h1 className="text-2xl font-semibold tracking-tight text-center">
Sign in to continue
</h1>
<p className="text-sm text-muted-foreground text-center max-w-sm">
Access to this dashboard requires authentication. Continue to launch the login flow.
</p>
</div>
<Button
onClick={() => {
window.location.href = getLoginUrl();
}}
size="lg"
className="w-full shadow-lg hover:shadow-xl transition-all"
>
Sign in
</Button>
</div>
</div>
);
}
return (
<SidebarProvider
style={
{
"--sidebar-width": `${sidebarWidth}px`,
} as CSSProperties
}
>
<DashboardLayoutContent setSidebarWidth={setSidebarWidth}>
{children}
</DashboardLayoutContent>
</SidebarProvider>
);
}
type DashboardLayoutContentProps = {
children: React.ReactNode;
setSidebarWidth: (width: number) => void;
};
function DashboardLayoutContent({
children,
setSidebarWidth,
}: DashboardLayoutContentProps) {
const { user, logout } = useAuth();
const [location, setLocation] = useLocation();
const { state, toggleSidebar } = useSidebar();
const isCollapsed = state === "collapsed";
const [isResizing, setIsResizing] = useState(false);
const sidebarRef = useRef<HTMLDivElement>(null);
const activeMenuItem = menuItems.find(item => item.path === location);
const isMobile = useIsMobile();
useEffect(() => {
if (isCollapsed) {
setIsResizing(false);
}
}, [isCollapsed]);
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
if (!isResizing) return;
const sidebarLeft = sidebarRef.current?.getBoundingClientRect().left ?? 0;
const newWidth = e.clientX - sidebarLeft;
if (newWidth >= MIN_WIDTH && newWidth <= MAX_WIDTH) {
setSidebarWidth(newWidth);
}
};
const handleMouseUp = () => {
setIsResizing(false);
};
if (isResizing) {
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
}
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
document.body.style.cursor = "";
document.body.style.userSelect = "";
};
}, [isResizing, setSidebarWidth]);
return (
<>
<div className="relative" ref={sidebarRef}>
<Sidebar
collapsible="icon"
className="border-r-0"
disableTransition={isResizing}
>
<SidebarHeader className="h-16 justify-center">
<div className="flex items-center gap-3 px-2 transition-all w-full">
<button
onClick={toggleSidebar}
className="h-8 w-8 flex items-center justify-center hover:bg-accent rounded-lg transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-ring shrink-0"
aria-label="Toggle navigation"
>
<PanelLeft className="h-4 w-4 text-muted-foreground" />
</button>
{!isCollapsed ? (
<div className="flex items-center gap-2 min-w-0">
<span className="font-semibold tracking-tight truncate">
Navigation
</span>
</div>
) : null}
</div>
</SidebarHeader>
<SidebarContent className="gap-0">
<SidebarMenu className="px-2 py-1">
{menuItems.map(item => {
const isActive = location === item.path;
return (
<SidebarMenuItem key={item.path}>
<SidebarMenuButton
isActive={isActive}
onClick={() => setLocation(item.path)}
tooltip={item.label}
className={`h-10 transition-all font-normal`}
>
<item.icon
className={`h-4 w-4 ${isActive ? "text-primary" : ""}`}
/>
<span>{item.label}</span>
</SidebarMenuButton>
</SidebarMenuItem>
);
})}
</SidebarMenu>
</SidebarContent>
<SidebarFooter className="p-3">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="flex items-center gap-3 rounded-lg px-1 py-1 hover:bg-accent/50 transition-colors w-full text-left group-data-[collapsible=icon]:justify-center focus:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<Avatar className="h-9 w-9 border shrink-0">
<AvatarFallback className="text-xs font-medium">
{user?.name?.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0 group-data-[collapsible=icon]:hidden">
<p className="text-sm font-medium truncate leading-none">
{user?.name || "-"}
</p>
<p className="text-xs text-muted-foreground truncate mt-1.5">
{user?.email || "-"}
</p>
</div>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem
onClick={logout}
className="cursor-pointer text-destructive focus:text-destructive"
>
<LogOut className="mr-2 h-4 w-4" />
<span>Sign out</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarFooter>
</Sidebar>
<div
className={`absolute top-0 right-0 w-1 h-full cursor-col-resize hover:bg-primary/20 transition-colors ${isCollapsed ? "hidden" : ""}`}
onMouseDown={() => {
if (isCollapsed) return;
setIsResizing(true);
}}
style={{ zIndex: 50 }}
/>
</div>
<SidebarInset>
{isMobile && (
<div className="flex border-b h-14 items-center justify-between bg-background/95 px-2 backdrop-blur supports-[backdrop-filter]:backdrop-blur sticky top-0 z-40">
<div className="flex items-center gap-2">
<SidebarTrigger className="h-9 w-9 rounded-lg bg-background" />
<div className="flex items-center gap-3">
<div className="flex flex-col gap-1">
<span className="tracking-tight text-foreground">
{activeMenuItem?.label ?? "Menu"}
</span>
</div>
</div>
</div>
</div>
)}
<main className="flex-1 p-4">{children}</main>
</SidebarInset>
</>
);
}

View File

@@ -1,46 +0,0 @@
import { Skeleton } from './ui/skeleton';
export function DashboardLayoutSkeleton() {
return (
<div className="flex min-h-screen bg-background">
{/* Sidebar skeleton */}
<div className="w-[280px] border-r border-border bg-background p-4 space-y-6">
{/* Logo area */}
<div className="flex items-center gap-3 px-2">
<Skeleton className="h-8 w-8 rounded-md" />
<Skeleton className="h-4 w-24" />
</div>
{/* Menu items */}
<div className="space-y-2 px-2">
<Skeleton className="h-10 w-full rounded-lg" />
<Skeleton className="h-10 w-full rounded-lg" />
<Skeleton className="h-10 w-full rounded-lg" />
</div>
{/* User profile area at bottom */}
<div className="absolute bottom-4 left-4 right-4">
<div className="flex items-center gap-3 px-1">
<Skeleton className="h-9 w-9 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-3 w-20" />
<Skeleton className="h-2 w-32" />
</div>
</div>
</div>
</div>
{/* Main content skeleton */}
<div className="flex-1 p-4 space-y-4">
{/* Content blocks */}
<Skeleton className="h-12 w-48 rounded-lg" />
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<Skeleton className="h-32 rounded-xl" />
<Skeleton className="h-32 rounded-xl" />
<Skeleton className="h-32 rounded-xl" />
</div>
<Skeleton className="h-64 rounded-xl" />
</div>
</div>
);
}

View File

@@ -1,155 +0,0 @@
/**
* GOOGLE MAPS FRONTEND INTEGRATION - ESSENTIAL GUIDE
*
* USAGE FROM PARENT COMPONENT:
* ======
*
* const mapRef = useRef<google.maps.Map | null>(null);
*
* <MapView
* initialCenter={{ lat: 40.7128, lng: -74.0060 }}
* initialZoom={15}
* onMapReady={(map) => {
* mapRef.current = map; // Store to control map from parent anytime, google map itself is in charge of the re-rendering, not react state.
* </MapView>
*
* ======
* Available Libraries and Core Features:
* -------------------------------
* 📍 MARKER (from `marker` library)
* - Attaches to map using { map, position }
* new google.maps.marker.AdvancedMarkerElement({
* map,
* position: { lat: 37.7749, lng: -122.4194 },
* title: "San Francisco",
* });
*
* -------------------------------
* 🏢 PLACES (from `places` library)
* - Does not attach directly to map; use data with your map manually.
* const place = new google.maps.places.Place({ id: PLACE_ID });
* await place.fetchFields({ fields: ["displayName", "location"] });
* map.setCenter(place.location);
* new google.maps.marker.AdvancedMarkerElement({ map, position: place.location });
*
* -------------------------------
* 🧭 GEOCODER (from `geocoding` library)
* - Standalone service; manually apply results to map.
* const geocoder = new google.maps.Geocoder();
* geocoder.geocode({ address: "New York" }, (results, status) => {
* if (status === "OK" && results[0]) {
* map.setCenter(results[0].geometry.location);
* new google.maps.marker.AdvancedMarkerElement({
* map,
* position: results[0].geometry.location,
* });
* }
* });
*
* -------------------------------
* 📐 GEOMETRY (from `geometry` library)
* - Pure utility functions; not attached to map.
* const dist = google.maps.geometry.spherical.computeDistanceBetween(p1, p2);
*
* -------------------------------
* 🛣️ ROUTES (from `routes` library)
* - Combines DirectionsService (standalone) + DirectionsRenderer (map-attached)
* const directionsService = new google.maps.DirectionsService();
* const directionsRenderer = new google.maps.DirectionsRenderer({ map });
* directionsService.route(
* { origin, destination, travelMode: "DRIVING" },
* (res, status) => status === "OK" && directionsRenderer.setDirections(res)
* );
*
* -------------------------------
* 🌦️ MAP LAYERS (attach directly to map)
* - new google.maps.TrafficLayer().setMap(map);
* - new google.maps.TransitLayer().setMap(map);
* - new google.maps.BicyclingLayer().setMap(map);
*
* -------------------------------
* ✅ SUMMARY
* - “map-attached” → AdvancedMarkerElement, DirectionsRenderer, Layers.
* - “standalone” → Geocoder, DirectionsService, DistanceMatrixService, ElevationService.
* - “data-only” → Place, Geometry utilities.
*/
/// <reference types="@types/google.maps" />
import { useEffect, useRef } from "react";
import { usePersistFn } from "@/hooks/usePersistFn";
import { cn } from "@/lib/utils";
declare global {
interface Window {
google?: typeof google;
}
}
const API_KEY = import.meta.env.VITE_FRONTEND_FORGE_API_KEY;
const FORGE_BASE_URL =
import.meta.env.VITE_FRONTEND_FORGE_API_URL ||
"https://forge.butterfly-effect.dev";
const MAPS_PROXY_URL = `${FORGE_BASE_URL}/v1/maps/proxy`;
function loadMapScript() {
return new Promise(resolve => {
const script = document.createElement("script");
script.src = `${MAPS_PROXY_URL}/maps/api/js?key=${API_KEY}&v=weekly&libraries=marker,places,geocoding,geometry`;
script.async = true;
script.crossOrigin = "anonymous";
script.onload = () => {
resolve(null);
script.remove(); // Clean up immediately
};
script.onerror = () => {
console.error("Failed to load Google Maps script");
};
document.head.appendChild(script);
});
}
interface MapViewProps {
className?: string;
initialCenter?: google.maps.LatLngLiteral;
initialZoom?: number;
onMapReady?: (map: google.maps.Map) => void;
}
export function MapView({
className,
initialCenter = { lat: 37.7749, lng: -122.4194 },
initialZoom = 12,
onMapReady,
}: MapViewProps) {
const mapContainer = useRef<HTMLDivElement>(null);
const map = useRef<google.maps.Map | null>(null);
const init = usePersistFn(async () => {
await loadMapScript();
if (!mapContainer.current) {
console.error("Map container not found");
return;
}
map.current = new window.google.maps.Map(mapContainer.current, {
zoom: initialZoom,
center: initialCenter,
mapTypeControl: true,
fullscreenControl: true,
zoomControl: true,
streetViewControl: true,
mapId: "DEMO_MAP_ID",
});
if (onMapReady) {
onMapReady(map.current);
}
});
useEffect(() => {
init();
}, [init]);
return (
<div ref={mapContainer} className={cn("w-full h-[500px]", className)} />
);
}

View File

@@ -14,6 +14,7 @@ interface LocalAuthContextType {
loading: boolean;
login: (identifier: string, password: string) => Promise<void>;
logout: () => Promise<void>;
hydrateUser: (user: LocalUser) => void;
isAuthenticated: boolean;
}
@@ -57,6 +58,11 @@ export function LocalAuthProvider({ children }: { children: ReactNode }) {
localStorage.removeItem(LOCAL_USER_KEY);
};
const hydrateUser = (u: LocalUser) => {
setUser(u);
localStorage.setItem(LOCAL_USER_KEY, JSON.stringify(u));
};
return (
<LocalAuthContext.Provider
value={{
@@ -64,6 +70,7 @@ export function LocalAuthProvider({ children }: { children: ReactNode }) {
loading,
login,
logout,
hydrateUser,
isAuthenticated: !!user,
}}
>

View File

@@ -1,4 +1,4 @@
import { useState, useMemo } from "react";
import { useState, useMemo, useEffect } from "react";
import { useLocalAuth } from "@/contexts/LocalAuthContext";
import { toast } from "sonner";
import { trpc } from "@/lib/trpc";
@@ -11,6 +11,7 @@ import {
LayoutGrid,
List,
Eye,
EyeOff,
ExternalLink,
Calendar,
MapPin,
@@ -39,6 +40,7 @@ import { format, isPast, differenceInDays } from "date-fns";
import { fr } from "date-fns/locale";
type AAPCategorie = "Handicap" | "PA" | "Enfance" | "Précarité" | "Sanitaire" | "Autre";
type ReadFilter = "unread" | "all" | "read";
interface AAPItem {
id: number;
@@ -115,13 +117,23 @@ export default function AAPDashboard() {
// Marquage lu/non lu
const [readIds, setReadIds] = useState<Set<number>>(new Set());
const readIdsQuery = trpc.aap.getReadIds.useQuery(undefined, { enabled: !!user });
useEffect(() => {
if (readIdsQuery.data?.ids) {
setReadIds(new Set(readIdsQuery.data.ids));
}
}, [readIdsQuery.data]);
const markAsReadMutation = trpc.aap.markAsRead.useMutation({
onSuccess: (_, vars) => {
setReadIds((prev) => { const next = new Set(prev); next.add(vars.articleId); return next; });
utils.aap.getReadIds.invalidate();
},
});
const markAllAsReadMutation = trpc.aap.markAllAsRead.useMutation({
onSuccess: () => { utils.aap.unreadCount.invalidate(); },
onSuccess: () => {
utils.aap.unreadCount.invalidate();
utils.aap.getReadIds.invalidate();
},
});
const unreadCountQuery = trpc.aap.unreadCount.useQuery();
const unreadCount = unreadCountQuery.data?.count ?? 0;
@@ -136,10 +148,17 @@ export default function AAPDashboard() {
toast.error(`Erreur lors de la purge : ${err.message}`);
},
});
const [viewMode, setViewMode] = useState<"list" | "grid">("list");
// ── État UI ─────────────────────────────────────────────────────────────────
// Mode vignette par défaut
const [viewMode, setViewMode] = useState<"list" | "grid">("grid");
const [activeTab, setActiveTab] = useState<AAPCategorie | "all">("all");
const [page, setPage] = useState(1);
const [filterValues, setFilterValues] = useState<Record<string, string>>({});
// Filtre Lu/Non lu — Non lu par défaut
const [readFilter, setReadFilter] = useState<ReadFilter>("unread");
// Ligne sélectionnée en mode liste
const [selectedRowId, setSelectedRowId] = useState<number | null>(null);
const filtersQuery = trpc.aap.filters.useQuery();
@@ -168,10 +187,17 @@ export default function AAPDashboard() {
setPage(1);
};
const items = (itemsQuery.data?.items ?? []) as AAPItem[];
const allItems = (itemsQuery.data?.items ?? []) as AAPItem[];
const total = itemsQuery.data?.total ?? 0;
const totalPages = Math.ceil(total / PAGE_SIZE);
// Filtrage Lu/Non lu côté client
const items = useMemo(() => {
if (readFilter === "unread") return allItems.filter((i) => !readIds.has(i.id));
if (readFilter === "read") return allItems.filter((i) => readIds.has(i.id));
return allItems;
}, [allItems, readIds, readFilter]);
const filterOptions = [
{ key: "region", label: "Région", options: filtersQuery.data?.regions ?? [] },
{ key: "departement", label: "Département", options: filtersQuery.data?.departements ?? [] },
@@ -183,6 +209,13 @@ export default function AAPDashboard() {
const categories: AAPCategorie[] = ["Handicap", "PA", "Enfance", "Précarité", "Sanitaire", "Autre"];
const READ_FILTER_LABELS: Record<ReadFilter, string> = {
unread: "Non lus",
all: "Tous",
read: "Lus",
};
const READ_FILTER_ORDER: ReadFilter[] = ["unread", "all", "read"];
return (
<div className="p-6 space-y-6 animate-fade-up">
{/* En-tête */}
@@ -199,12 +232,14 @@ export default function AAPDashboard() {
Handicap, Personnes Âgées, Enfance, Précarité, Sanitaire et Autre
</p>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 flex-wrap">
{unreadCount > 0 && (
<Button variant="outline" size="sm" onClick={() => markAllAsReadMutation.mutate()} disabled={markAllAsReadMutation.isPending} className="gap-2 text-muted-foreground">
<Eye size={15} />
Tout marquer comme lu
</Button>
)}
{/* Boutons mode d'affichage */}
<Button variant={viewMode === "list" ? "default" : "outline"} size="sm" onClick={() => setViewMode("list")} className="gap-2">
<List size={15} />Liste
</Button>
@@ -227,8 +262,8 @@ export default function AAPDashboard() {
</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="space-y-2">
<p>Cette action va <strong>supprimer définitivement</strong> tous les appels à projets (Handicap, PA, Enfance, Précarité, Sanitaire et Autre).</p>
<p className="text-destructive font-medium">Cette opération est irréversible. Les données ne pourront pas être récupérées.</p>
<p>Cette action va <strong>supprimer définitivement</strong> tous les appels à projets.</p>
<p className="text-destructive font-medium">Cette opération est irréversible.</p>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
@@ -248,7 +283,8 @@ export default function AAPDashboard() {
</div>
</div>
{/* Onglets */}
{/* Onglets catégories + bouton Lu/Non lu */}
<div className="flex items-center justify-between gap-4 flex-wrap">
<Tabs value={activeTab} onValueChange={(v) => { setActiveTab(v as AAPCategorie | "all"); setPage(1); }}>
<TabsList className="bg-muted/50 flex-wrap h-auto gap-1">
<TabsTrigger value="all">Tous</TabsTrigger>
@@ -258,6 +294,30 @@ export default function AAPDashboard() {
</TabsList>
</Tabs>
{/* Bouton segmenté Lu / Non lu */}
<div className="flex items-center rounded-lg border border-border overflow-hidden shadow-sm">
{READ_FILTER_ORDER.map((f) => (
<button
key={f}
onClick={() => setReadFilter(f)}
className={cn(
"px-3 py-1.5 text-xs font-medium transition-colors flex items-center gap-1.5",
readFilter === f
? "bg-primary text-primary-foreground"
: "bg-background text-muted-foreground hover:bg-muted/50"
)}
>
{f === "unread" && <EyeOff size={12} />}
{f === "read" && <Eye size={12} />}
{READ_FILTER_LABELS[f]}
{f === "unread" && unreadCount > 0 && (
<span className="ml-0.5 inline-flex items-center justify-center min-w-[16px] h-4 px-1 rounded-full bg-primary-foreground/20 text-[10px] font-bold">{unreadCount}</span>
)}
</button>
))}
</div>
</div>
{/* Filtres */}
<FilterBar
filters={filterOptions}
@@ -276,11 +336,23 @@ export default function AAPDashboard() {
) : items.length === 0 ? (
<div className="flex flex-col items-center justify-center py-24 text-center">
<Target size={48} className="text-muted-foreground/30 mb-4" />
<p className="text-muted-foreground font-medium">Aucun appel à projets trouvé</p>
<p className="text-muted-foreground/60 text-sm mt-1">Modifiez vos filtres ou importez des données</p>
<p className="text-muted-foreground font-medium">
{readFilter === "unread" ? "Aucun appel à projets non lu" : "Aucun appel à projets trouvé"}
</p>
<p className="text-muted-foreground/60 text-sm mt-1">
{readFilter === "unread"
? "Tous les appels à projets ont été lus, ou modifiez le filtre Lu/Non lu"
: "Modifiez vos filtres ou importez des données"}
</p>
</div>
) : viewMode === "list" ? (
<AAPListView items={items} readIds={readIds} onMarkRead={(id) => markAsReadMutation.mutate({ articleId: id })} />
<AAPListView
items={items}
readIds={readIds}
onMarkRead={(id) => markAsReadMutation.mutate({ articleId: id })}
selectedRowId={selectedRowId}
onSelectRow={setSelectedRowId}
/>
) : (
<AAPGridView items={items} readIds={readIds} onMarkRead={(id) => markAsReadMutation.mutate({ articleId: id })} />
)}
@@ -303,13 +375,25 @@ export default function AAPDashboard() {
// ─── Vue Liste ────────────────────────────────────────────────────────────────
function AAPListView({ items, readIds, onMarkRead }: { items: AAPItem[]; readIds: Set<number>; onMarkRead: (id: number) => void }) {
function AAPListView({
items,
readIds,
onMarkRead,
selectedRowId,
onSelectRow,
}: {
items: AAPItem[];
readIds: Set<number>;
onMarkRead: (id: number) => void;
selectedRowId: number | null;
onSelectRow: (id: number | null) => void;
}) {
return (
<div className="rounded-xl border border-border overflow-hidden shadow-sm">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="bg-muted/50 border-b border-border">
<tr className="bg-muted/60 border-b border-border">
<th className="text-left px-4 py-3 font-semibold text-muted-foreground w-8">#</th>
<th className="text-left px-4 py-3 font-semibold text-muted-foreground">Titre</th>
<th className="text-left px-4 py-3 font-semibold text-muted-foreground w-28">Catégorie</th>
@@ -320,46 +404,85 @@ function AAPListView({ items, readIds, onMarkRead }: { items: AAPItem[]; readIds
<th className="text-left px-4 py-3 font-semibold text-muted-foreground w-16">Lien</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{items.map((item, idx) => (
<tr key={item.id} className={cn("hover:bg-muted/30 transition-colors border-l-4", CAT_ACCENT[item.iaCategorie || item.categorie] || "border-l-transparent", !readIds.has(item.id) && "bg-blue-50/30")}>
<tbody>
{items.map((item, idx) => {
const isSelected = selectedRowId === item.id;
const isRead = readIds.has(item.id);
const isEven = idx % 2 === 0;
const catKey = item.iaCategorie || item.categorie;
return (
<tr
key={item.id}
onClick={() => onSelectRow(isSelected ? null : item.id)}
className={cn(
"border-b border-border/50 border-l-4 cursor-pointer transition-colors",
CAT_ACCENT[catKey] || "border-l-transparent",
isSelected
? "bg-primary/10 hover:bg-primary/15"
: isEven
? "bg-white hover:bg-primary/5"
: "bg-slate-50/80 hover:bg-primary/5"
)}
>
<td className="px-4 py-3 text-muted-foreground/50 text-xs">{idx + 1}</td>
<td className="px-4 py-3">
<div className="flex items-start gap-2 max-w-sm">
{!readIds.has(item.id) && (
{!isRead && (
<span className="mt-1.5 w-2 h-2 rounded-full bg-primary flex-shrink-0" title="Non lu" />
)}
<div>
<p className={cn("font-medium line-clamp-2 leading-snug", readIds.has(item.id) ? "text-muted-foreground" : "text-foreground")}>{item.titre}</p>
{item.iaResume && <p className="text-xs text-muted-foreground mt-1 line-clamp-2">{item.iaResume}</p>}
<p className={cn(
"font-medium line-clamp-2 leading-snug",
isRead ? "text-muted-foreground" : "text-foreground",
isSelected && "text-primary font-semibold"
)}>
{item.titre}
</p>
{item.iaResume && (
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">{item.iaResume}</p>
)}
</div>
</div>
</td>
<td className="px-4 py-3">
<Badge variant="outline" className={cn("text-xs", CAT_COLORS[item.iaCategorie || item.categorie])}>
{item.iaCategorie || item.categorie}
<Badge variant="outline" className={cn("text-xs", CAT_COLORS[catKey])}>
{catKey}
</Badge>
</td>
<td className="px-4 py-3 text-muted-foreground text-xs">{item.region || "—"}</td>
<td className="px-4 py-3 text-muted-foreground text-xs">{item.departement || "—"}</td>
<td className="px-4 py-3 text-muted-foreground text-xs whitespace-nowrap">{formatDate(item.datePublication) || "—"}</td>
<td className="px-4 py-3 text-muted-foreground text-xs whitespace-nowrap">
{formatDate(item.datePublication) || "—"}
</td>
<td className="px-4 py-3"><ClotureStatus date={item.dateCloture} /></td>
<td className="px-4 py-3">
<td className="px-4 py-3" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center gap-1.5">
{!readIds.has(item.id) && (
<button onClick={() => onMarkRead(item.id)} className="text-muted-foreground hover:text-primary transition-colors" title="Marquer comme lu">
{!isRead && (
<button
onClick={() => onMarkRead(item.id)}
className="inline-flex items-center justify-center w-7 h-7 rounded-md bg-blue-50 text-blue-600 hover:bg-blue-100 hover:text-blue-700 transition-colors border border-blue-200"
title="Marquer comme lu"
>
<Eye size={13} />
</button>
)}
{item.lien && (
<a href={item.lien} target="_blank" rel="noopener noreferrer" className="text-accent hover:text-accent/80 transition-colors">
<ExternalLink size={15} />
<a
href={item.lien}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center w-7 h-7 rounded-md bg-emerald-50 text-emerald-600 hover:bg-emerald-100 hover:text-emerald-700 transition-colors border border-emerald-200"
title="Ouvrir la source"
>
<ExternalLink size={13} />
</a>
)}
</div>
</td>
</tr>
))}
);
})}
</tbody>
</table>
</div>
@@ -369,28 +492,71 @@ function AAPListView({ items, readIds, onMarkRead }: { items: AAPItem[]; readIds
// ─── Vue Vignettes ────────────────────────────────────────────────────────────
function AAPGridView({ items, readIds, onMarkRead }: { items: AAPItem[]; readIds: Set<number>; onMarkRead: (id: number) => void }) {
function AAPGridView({
items,
readIds,
onMarkRead,
}: {
items: AAPItem[];
readIds: Set<number>;
onMarkRead: (id: number) => void;
}) {
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{items.map((item) => (
<Card key={item.id} className={cn("group hover:shadow-md transition-all duration-200 border-border overflow-hidden border-l-4", CAT_ACCENT[item.iaCategorie || item.categorie] || "", !readIds.has(item.id) && "ring-1 ring-primary/20")}>
{items.map((item) => {
const catKey = item.iaCategorie || item.categorie;
return (
<Card
key={item.id}
className={cn(
"group hover:shadow-md transition-all duration-200 border-border overflow-hidden border-l-4",
CAT_ACCENT[catKey] || "",
!readIds.has(item.id) && "ring-1 ring-primary/20"
)}
>
<CardHeader className="pb-2 pt-4 px-4">
<div className="flex items-start justify-between gap-2">
<Badge variant="outline" className={cn("text-xs flex-shrink-0", CAT_COLORS[item.iaCategorie || item.categorie])}>
{item.iaCategorie || item.categorie}
<Badge variant="outline" className={cn("text-xs flex-shrink-0", CAT_COLORS[catKey])}>
{catKey}
</Badge>
<div className="flex items-center gap-1.5 flex-shrink-0">
{!readIds.has(item.id) && (
<button
onClick={() => onMarkRead(item.id)}
className="text-muted-foreground hover:text-primary transition-colors"
title="Marquer comme lu"
>
<Eye size={14} />
</button>
)}
{item.lien && (
<a href={item.lien} target="_blank" rel="noopener noreferrer" className="text-muted-foreground hover:text-accent transition-colors flex-shrink-0">
<a
href={item.lien}
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-accent transition-colors"
>
<ExternalLink size={14} />
</a>
)}
</div>
</div>
<div className="flex items-start gap-1.5 mt-2">
{!readIds.has(item.id) && <span className="mt-1 w-2 h-2 rounded-full bg-primary flex-shrink-0" title="Non lu" />}
<h3 className={cn("font-semibold text-sm leading-snug line-clamp-3", readIds.has(item.id) ? "text-muted-foreground" : "text-foreground")}>{item.titre}</h3>
{!readIds.has(item.id) && (
<span className="mt-1 w-2 h-2 rounded-full bg-primary flex-shrink-0" title="Non lu" />
)}
<h3 className={cn(
"font-semibold text-sm leading-snug line-clamp-3",
readIds.has(item.id) ? "text-muted-foreground" : "text-foreground"
)}>
{item.titre}
</h3>
</div>
</CardHeader>
<CardContent className="px-4 pb-4 space-y-2">
{item.iaResume && (
<p className="text-xs text-muted-foreground line-clamp-3 leading-relaxed">{item.iaResume}</p>
)}
<div className="flex flex-wrap gap-1.5">
{item.region && (
<span className="inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded bg-violet-50 text-violet-700 border border-violet-200">
@@ -414,7 +580,8 @@ function AAPGridView({ items, readIds, onMarkRead }: { items: AAPItem[]; readIds
</div>
</CardContent>
</Card>
))}
);
})}
</div>
);
}

View File

@@ -0,0 +1,40 @@
import { useEffect } from "react";
import { useLocation } from "wouter";
import { useLocalAuth } from "@/contexts/LocalAuthContext";
import { Loader2 } from "lucide-react";
/**
* Page intermédiaire appelée après le callback Azure AD.
* Elle récupère les infos user depuis le query param, hydrate LocalAuthContext
* (localStorage + state), puis redirige vers /veille.
*/
export default function AzureCallback() {
const [, navigate] = useLocation();
const { hydrateUser } = useLocalAuth();
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const userParam = params.get("user");
if (userParam) {
try {
const user = JSON.parse(decodeURIComponent(userParam));
hydrateUser(user);
navigate("/veille");
} catch {
navigate("/login?error=" + encodeURIComponent("Erreur lors de la connexion Microsoft"));
}
} else {
navigate("/login?error=" + encodeURIComponent("Réponse Azure invalide"));
}
}, []);
return (
<div className="min-h-screen bg-background flex items-center justify-center">
<div className="flex flex-col items-center gap-4 text-muted-foreground">
<Loader2 className="h-8 w-8 animate-spin" />
<p className="text-sm">Connexion Microsoft en cours</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,145 @@
import { useState } from "react";
import { AlertTriangle, ChevronLeft, ChevronRight, ExternalLink, Loader2, RefreshCw, Rss } from "lucide-react";
import { trpc } from "@/lib/trpc";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { cn } from "@/lib/utils";
interface ClassificationError {
id: number;
feedName: string;
feedType: "veille" | "aap";
articleTitle: string;
articleUrl: string | null;
errorMessage: string;
occurredAt: Date;
}
const PAGE_SIZE = 25;
const typeConfig = {
veille: { label: "Veille", className: "bg-blue-100 text-blue-800 border-blue-200" },
aap: { label: "AAP", className: "bg-violet-100 text-violet-800 border-violet-200" },
};
function formatDate(value: Date) {
return new Intl.DateTimeFormat("fr-FR", {
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(value));
}
/** Rapport des erreurs LLM ayant déclenché le fallback de classification RSS. */
export default function ClassificationErrors() {
const [page, setPage] = useState(1);
const errorsQuery = trpc.rss.classificationErrors.useQuery({ page, pageSize: PAGE_SIZE });
const errors = (errorsQuery.data?.errors ?? []) as ClassificationError[];
const total = errorsQuery.data?.total ?? 0;
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
return (
<div className="p-6 space-y-6 animate-fade-up">
<div className="flex items-start justify-between gap-4">
<div>
<div className="flex items-center gap-2 mb-1">
<AlertTriangle size={22} className="text-amber-600" />
<h1 className="text-2xl font-bold text-foreground">Erreurs de classification</h1>
</div>
<p className="text-sm text-muted-foreground">
Fallbacks IA détectés pendant la lecture des flux RSS. Les articles concernés ont é importés avec les règles de repli.
</p>
</div>
<Button variant="outline" size="sm" className="gap-2" onClick={() => errorsQuery.refetch()} disabled={errorsQuery.isFetching}>
<RefreshCw size={15} className={cn(errorsQuery.isFetching && "animate-spin")} />
Actualiser
</Button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Card className="border-amber-200 bg-amber-50/50">
<CardContent className="p-4">
<div className="flex items-center gap-2 text-amber-700 mb-1">
<AlertTriangle size={16} />
<span className="text-xs font-medium">Fallbacks enregistrés</span>
</div>
<p className="text-2xl font-bold text-amber-800">{total}</p>
</CardContent>
</Card>
<Card className="border-border/50">
<CardContent className="p-4">
<div className="flex items-center gap-2 text-primary mb-1">
<Rss size={16} />
<span className="text-xs font-medium text-muted-foreground">Comportement de sécurité</span>
</div>
<p className="text-sm font-medium text-foreground">Import maintenu via règles de repli</p>
</CardContent>
</Card>
</div>
<Card>
<CardContent className="p-0">
{errorsQuery.isLoading ? (
<div className="flex items-center justify-center py-16"><Loader2 size={28} className="animate-spin text-primary" /></div>
) : errorsQuery.isError ? (
<div className="flex flex-col items-center justify-center py-16 text-center gap-2">
<AlertTriangle size={40} className="text-destructive/60" />
<p className="font-medium text-foreground">Le rapport ne peut pas être chargé</p>
<p className="text-sm text-muted-foreground">{errorsQuery.error.message}</p>
</div>
) : errors.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-center">
<AlertTriangle size={40} className="text-emerald-500/60 mb-3" />
<p className="font-medium text-foreground">Aucune erreur de classification</p>
<p className="text-sm text-muted-foreground mt-1">Les prochains fallbacks IA apparaîtront ici.</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-muted/30">
<th className="text-left px-4 py-3 font-semibold text-muted-foreground whitespace-nowrap">Date</th>
<th className="text-left px-4 py-3 font-semibold text-muted-foreground w-24">Flux</th>
<th className="text-left px-4 py-3 font-semibold text-muted-foreground">Article</th>
<th className="text-left px-4 py-3 font-semibold text-muted-foreground">Cause technique</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{errors.map((error) => {
const type = typeConfig[error.feedType];
return (
<tr key={error.id} className="hover:bg-muted/20 transition-colors align-top">
<td className="px-4 py-3 text-xs text-muted-foreground whitespace-nowrap">{formatDate(error.occurredAt)}</td>
<td className="px-4 py-3">
<div className="space-y-1">
<Badge variant="outline" className={cn("text-xs", type.className)}>{type.label}</Badge>
<p className="text-xs text-muted-foreground max-w-40 truncate" title={error.feedName}>{error.feedName}</p>
</div>
</td>
<td className="px-4 py-3 max-w-sm">
{error.articleUrl ? (
<a href={error.articleUrl} target="_blank" rel="noreferrer" className="inline-flex items-start gap-1 font-medium text-primary hover:underline">
<span>{error.articleTitle}</span><ExternalLink size={13} className="mt-0.5 shrink-0" />
</a>
) : <span className="font-medium text-foreground">{error.articleTitle}</span>}
</td>
<td className="px-4 py-3 text-xs text-destructive max-w-md break-words">{error.errorMessage}</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2">
<Button variant="outline" size="sm" onClick={() => setPage((value) => Math.max(1, value - 1))} disabled={page === 1}><ChevronLeft size={14} /></Button>
<span className="text-sm text-muted-foreground px-2">Page {page} / {totalPages}</span>
<Button variant="outline" size="sm" onClick={() => setPage((value) => Math.min(totalPages, value + 1))} disabled={page === totalPages}><ChevronRight size={14} /></Button>
</div>
)}
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
import { useState } from "react";
import { useState, useEffect } from "react";
import { useLocation } from "wouter";
import { useLocalAuth } from "@/contexts/LocalAuthContext";
import { trpc } from "@/lib/trpc";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -11,14 +12,40 @@ import { toast } from "sonner";
const ITINOVA_LOGO = "https://d2xsxph8kpxj0f.cloudfront.net/310519663070627318/VepzDyqR8YkJNcqpZ729Bw/itinova-logo_8e653b24.jpg";
const SANTINOVA_LOGO = "https://d2xsxph8kpxj0f.cloudfront.net/310519663070627318/VepzDyqR8YkJNcqpZ729Bw/santinova-logo_b8de54c4.webp";
function MicrosoftLogo() {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 21 21" width="18" height="18">
<rect x="1" y="1" width="9" height="9" fill="#f25022"/>
<rect x="11" y="1" width="9" height="9" fill="#7fba00"/>
<rect x="1" y="11" width="9" height="9" fill="#00a4ef"/>
<rect x="11" y="11" width="9" height="9" fill="#ffb900"/>
</svg>
);
}
export default function Login() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [loading, setLoading] = useState(false);
const [azureLoading, setAzureLoading] = useState(false);
const { login } = useLocalAuth();
const [, navigate] = useLocation();
const azureAvailableQuery = trpc.auth.isAzureAdAvailable.useQuery();
const azureLoginQuery = trpc.auth.getAzureLoginUrl.useQuery(undefined, { enabled: false, retry: false });
const azureAvailable = azureAvailableQuery.data?.available ?? false;
// Afficher les erreurs depuis query param (callback Azure)
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const error = params.get("error");
if (error) {
toast.error(decodeURIComponent(error));
window.history.replaceState({}, "", "/login");
}
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!email || !password) return;
@@ -34,6 +61,22 @@ export default function Login() {
}
};
const handleMicrosoftLogin = async () => {
setAzureLoading(true);
try {
const result = await azureLoginQuery.refetch();
if (result.data?.url) {
window.location.href = result.data.url;
} else {
toast.error("Impossible d'obtenir l'URL de connexion Microsoft");
setAzureLoading(false);
}
} catch {
toast.error("Erreur lors de la connexion Microsoft");
setAzureLoading(false);
}
};
return (
<div className="min-h-screen bg-background flex items-center justify-center p-4">
{/* Décoration de fond */}
@@ -67,6 +110,34 @@ export default function Login() {
</CardDescription>
</CardHeader>
<CardContent>
{/* Bouton Microsoft 365 — affiché uniquement si Azure AD est configuré */}
{azureAvailable && (
<>
<Button
type="button"
variant="outline"
className="w-full flex items-center gap-3 border-slate-300 bg-white hover:bg-slate-50 text-slate-700 font-medium mb-4"
onClick={handleMicrosoftLogin}
disabled={azureLoading}
>
{azureLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<MicrosoftLogo />
)}
Se connecter avec Microsoft 365
</Button>
<div className="relative mb-4">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t border-slate-200" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-card px-2 text-muted-foreground">ou</span>
</div>
</div>
</>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Identifiant ou e-mail</Label>

View File

@@ -539,127 +539,31 @@ function FeedCard({
// ─── Panneau de configuration ─────────────────────────────────────────────────
function SettingsPanel({ isAdmin }: { isAdmin: boolean }) {
const { data: settings, refetch } = trpc.rss.getSettings.useQuery();
const saveMutation = trpc.rss.saveSettings.useMutation({
onSuccess: () => {
toast.success("Paramètres RSS sauvegardés");
refetch();
},
onError: (e) => toast.error("Erreur", { description: e.message }),
});
const [fetchMode, setFetchMode] = useState<"interval" | "scheduled">(settings?.fetchMode ?? "scheduled");
const [fetchIntervalMinutes, setFetchIntervalMinutes] = useState(settings?.fetchIntervalMinutes ?? 360);
const [scheduledTime, setScheduledTime] = useState(settings?.scheduledTime ?? "06:00");
const [autoFetchEnabled, setAutoFetchEnabled] = useState(settings?.autoFetchEnabled ?? true);
// Sync state when settings load
if (settings && settings.fetchMode !== fetchMode && !saveMutation.isPending) {
setFetchMode(settings.fetchMode);
setFetchIntervalMinutes(settings.fetchIntervalMinutes);
setScheduledTime(settings.scheduledTime ?? "06:00");
setAutoFetchEnabled(settings.autoFetchEnabled);
}
const handleSave = () => {
saveMutation.mutate({
fetchMode,
fetchIntervalMinutes,
scheduledTime,
autoFetchEnabled,
});
};
function SettingsPanel({ isAdmin: _isAdmin }: { isAdmin: boolean }) {
const { data: settings } = trpc.rss.getSettings.useQuery();
const importTime = settings?.scheduledTime ?? "06:00";
return (
<Card className="border-2 border-dashed border-primary/20">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<Settings2 className="h-4 w-4 text-primary" />
Paramètres de lecture automatique
Planification de la lecture RSS
</CardTitle>
<CardDescription>
Configurez la fréquence et le mode de lecture des flux RSS.
La lecture des flux RSS est déclenchée automatiquement par le cron dimport quotidien.
</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
{/* Activation */}
<div className="flex items-center justify-between p-3 bg-muted/50 rounded-lg">
<CardContent>
<div className="flex items-center gap-3 p-4 rounded-lg bg-muted/50 border border-border">
<Clock className="h-5 w-5 text-primary flex-shrink-0" />
<div>
<p className="font-medium text-sm">Lecture automatique</p>
<p className="text-xs text-muted-foreground">Active la récupération périodique des flux</p>
</div>
<Switch
checked={autoFetchEnabled}
onCheckedChange={setAutoFetchEnabled}
disabled={!isAdmin}
/>
</div>
{/* Mode */}
<div className="space-y-2">
<Label className="text-sm font-medium">Mode de planification</Label>
<div className="grid grid-cols-2 gap-3">
{(["scheduled", "interval"] as const).map((mode) => (
<button
key={mode}
type="button"
disabled={!isAdmin}
onClick={() => setFetchMode(mode)}
className={cn(
"flex items-center gap-2 p-3 rounded-lg border-2 text-left transition-all text-sm",
fetchMode === mode ? "border-primary bg-primary/5" : "border-border hover:border-primary/40",
!isAdmin && "opacity-50 cursor-not-allowed"
)}
>
<Clock className={cn("h-4 w-4", fetchMode === mode ? "text-primary" : "text-muted-foreground")} />
<div>
<p className="font-medium">{mode === "scheduled" ? "Heure fixe" : "Intervalle"}</p>
<p className="text-xs text-muted-foreground">{mode === "scheduled" ? "Chaque jour à une heure précise" : "Toutes les N minutes"}</p>
</div>
</button>
))}
<p className="font-medium text-sm">Lecture automatique chaque jour à <span className="text-primary font-bold">{importTime}</span></p>
<p className="text-xs text-muted-foreground mt-0.5">
Pour modifier lheure, rendez-vous dans <strong>Paramètres → Import &amp; Planification</strong>.
</p>
</div>
</div>
{/* Paramètre selon le mode */}
{fetchMode === "scheduled" ? (
<div className="space-y-1.5">
<Label className="text-sm">Heure de lecture quotidienne</Label>
<Input
type="time"
value={scheduledTime}
onChange={(e) => setScheduledTime(e.target.value)}
disabled={!isAdmin}
className="w-36"
/>
</div>
) : (
<div className="space-y-1.5">
<Label className="text-sm">Intervalle de lecture</Label>
<Select
value={String(fetchIntervalMinutes)}
onValueChange={(v) => setFetchIntervalMinutes(Number(v))}
disabled={!isAdmin}
>
<SelectTrigger className="w-48">
<SelectValue />
</SelectTrigger>
<SelectContent>
{INTERVAL_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={String(opt.value)}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{isAdmin && (
<Button onClick={handleSave} disabled={saveMutation.isPending} size="sm" className="gap-2">
<Save className="h-3.5 w-3.5" />
{saveMutation.isPending ? "Enregistrement…" : "Sauvegarder les paramètres"}
</Button>
)}
</CardContent>
</Card>
);

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
import { trpc } from "@/lib/trpc";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -23,6 +23,8 @@ import {
Upload,
FileSpreadsheet,
AlertCircle,
CalendarClock,
Trash2,
} from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
@@ -43,7 +45,96 @@ const SOURCE_LABELS: Record<SourceType, string> = {
sharepoint: "SharePoint",
};
// ─── Composant UploadZone (réutilisé depuis ImportLogs) ───────────────────────
const DAYS_FR = ["Dimanche", "Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi"];
const DAYS_SHORT = ["Dim", "Lun", "Mar", "Mer", "Jeu", "Ven", "Sam"];
// ─── Calcul de la prochaine date d'exécution ─────────────────────────────────
function computeNextRun(
mode: string,
importTime: string,
dayOfWeek: number,
dayOfMonth: number,
intervalMinutes: number
): Date {
const now = new Date();
const [hour, minute] = importTime.split(":").map(Number);
if (mode === "interval") {
const intervalMin = Math.max(60, intervalMinutes);
const next = new Date(now);
next.setSeconds(0, 0);
if (intervalMin % 60 === 0) {
const hours = intervalMin / 60;
if (hours >= 24) {
// cron `0 0 * * *` — prochain minuit
next.setHours(0, 0);
next.setDate(next.getDate() + 1);
} else {
// cron `0 */N * * *` — prochaine heure alignée
const currentHour = now.getHours();
const nextHour = Math.ceil((currentHour * 60 + now.getMinutes() + 1) / 60 / hours) * hours;
next.setHours(nextHour, 0);
if (next <= now) next.setHours(next.getHours() + hours);
}
} else {
// cron `*/N * * * *` — prochain multiple de N minutes
const totalMin = now.getHours() * 60 + now.getMinutes();
const nextMin = Math.ceil((totalMin + 1) / intervalMin) * intervalMin;
next.setHours(Math.floor(nextMin / 60), nextMin % 60);
if (next <= now) next.setMinutes(next.getMinutes() + intervalMin);
}
return next;
}
if (mode === "weekly") {
const next = new Date(now);
next.setSeconds(0, 0);
next.setHours(hour ?? 6, minute ?? 0);
// Avancer jusqu'au prochain jour de la semaine voulu
const diff = (dayOfWeek - next.getDay() + 7) % 7;
next.setDate(next.getDate() + (diff === 0 && next <= now ? 7 : diff));
return next;
}
if (mode === "monthly") {
const next = new Date(now);
next.setSeconds(0, 0);
next.setHours(hour ?? 6, minute ?? 0);
next.setDate(dayOfMonth);
if (next <= now) {
next.setMonth(next.getMonth() + 1);
next.setDate(dayOfMonth);
}
return next;
}
// scheduled (quotidien)
const next = new Date(now);
next.setSeconds(0, 0);
next.setHours(hour ?? 6, minute ?? 0);
if (next <= now) next.setDate(next.getDate() + 1);
return next;
}
function formatNextRun(date: Date, mode: string, intervalMinutes: number): string {
if (mode === "interval") {
const mins = Math.max(60, intervalMinutes);
if (mins < 60) return `dans ${mins} minutes`;
const h = mins / 60;
return `dans ${h}h (à ${date.toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" })})`;
}
const dayName = DAYS_FR[date.getDay()];
const dayNum = date.getDate();
const monthName = date.toLocaleDateString("fr-FR", { month: "long" });
const timeStr = date.toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" });
const isToday = new Date().toDateString() === date.toDateString();
const isTomorrow = new Date(Date.now() + 86400000).toDateString() === date.toDateString();
const dayLabel = isToday ? "aujourd'hui" : isTomorrow ? "demain" : `${dayName} ${dayNum} ${monthName}`;
return `${dayLabel} à ${timeStr}`;
}
// ─── Composant UploadZone ─────────────────────────────────────────────────────
interface UploadResult {
success: boolean;
@@ -220,6 +311,11 @@ export default function SettingsPage() {
sharepoint_token: "",
auth_mode: "local",
import_time: "06:00",
fetch_mode: "scheduled",
fetch_interval_minutes: "1440",
fetch_day_of_week: "1",
fetch_day_of_month: "1",
retention_months: "0",
});
useEffect(() => {
@@ -232,6 +328,21 @@ export default function SettingsPage() {
const handleSave = () => saveMutation.mutate(form as Parameters<typeof saveMutation.mutate>[0]);
const sourceType = (form.source_type || "local") as SourceType;
// ─── Calcul de la prochaine date d'exécution ──────────────────────────────
const nextRun = useMemo(() => {
return computeNextRun(
form.fetch_mode || "scheduled",
form.import_time || "06:00",
parseInt(form.fetch_day_of_week || "1", 10),
parseInt(form.fetch_day_of_month || "1", 10),
parseInt(form.fetch_interval_minutes || "1440", 10)
);
}, [form.fetch_mode, form.import_time, form.fetch_day_of_week, form.fetch_day_of_month, form.fetch_interval_minutes]);
const nextRunLabel = useMemo(() => {
return formatNextRun(nextRun, form.fetch_mode || "scheduled", parseInt(form.fetch_interval_minutes || "1440", 10));
}, [nextRun, form.fetch_mode, form.fetch_interval_minutes]);
return (
<div className="p-6 space-y-6 max-w-4xl animate-fade-up">
{/* En-tête */}
@@ -460,20 +571,179 @@ export default function SettingsPage() {
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2"><Clock size={16} />Planification de l'import</CardTitle>
<CardDescription>L'import automatique s'exécute quotidiennement à l'heure configurée</CardDescription>
<CardDescription>Configurez la fréquence et l'heure de déclenchement des mises à jour automatiques (import Excel + lecture RSS).</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<CardContent className="space-y-5">
{/* Mode */}
<div className="space-y-2">
<Label className="text-sm font-medium">Fréquence de mise à jour</Label>
<div className="grid grid-cols-2 gap-3 max-w-xl">
{([
{ value: "scheduled", label: "Quotidienne", desc: "Chaque jour à une heure précise" },
{ value: "weekly", label: "Hebdomadaire", desc: "Un jour précis de la semaine" },
{ value: "monthly", label: "Mensuelle", desc: "Un jour précis du mois" },
{ value: "interval", label: "Intervalle", desc: "Toutes les N heures" },
] as const).map((mode) => (
<button
key={mode.value}
type="button"
onClick={() => set("fetch_mode", mode.value)}
className={cn(
"flex items-center gap-2 p-3 rounded-lg border-2 text-left transition-all text-sm",
(form.fetch_mode || "scheduled") === mode.value
? "border-primary bg-primary/5"
: "border-border hover:border-primary/40"
)}
>
<Clock className={cn("h-4 w-4", (form.fetch_mode || "scheduled") === mode.value ? "text-primary" : "text-muted-foreground")} />
<div>
<p className="font-medium">{mode.label}</p>
<p className="text-xs text-muted-foreground">{mode.desc}</p>
</div>
</button>
))}
</div>
</div>
{/* Heure (quotidien, hebdo, mensuel) */}
{["scheduled", "weekly", "monthly"].includes(form.fetch_mode || "scheduled") && (
<div className="space-y-2 max-w-xs">
<Label>Heure d'import quotidien</Label>
<Label>Heure de déclenchement</Label>
<Input
type="time"
value={form.import_time || "06:00"}
onChange={(e) => set("import_time", e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Prochain import : demain à {form.import_time || "06:00"}
</div>
)}
{/* Jour de la semaine (hebdo) */}
{(form.fetch_mode || "scheduled") === "weekly" && (
<div className="space-y-2 max-w-xs">
<Label>Jour de la semaine</Label>
<div className="grid grid-cols-7 gap-1">
{DAYS_SHORT.map((day, i) => (
<button
key={i}
type="button"
onClick={() => set("fetch_day_of_week", String(i))}
className={cn(
"py-1.5 rounded-md border-2 text-xs font-medium transition-all",
(form.fetch_day_of_week || "1") === String(i)
? "border-primary bg-primary/5 text-primary"
: "border-border hover:border-primary/40 text-foreground"
)}
>
{day}
</button>
))}
</div>
</div>
)}
{/* Jour du mois (mensuel) */}
{(form.fetch_mode || "scheduled") === "monthly" && (
<div className="space-y-2 max-w-xs">
<Label>Jour du mois (128)</Label>
<Input
type="number"
min={1}
max={28}
value={form.fetch_day_of_month || "1"}
onChange={(e) => set("fetch_day_of_month", e.target.value)}
/>
</div>
)}
{/* Intervalle */}
{(form.fetch_mode || "scheduled") === "interval" && (
<div className="space-y-2 max-w-xs">
<Label>Intervalle de mise à jour</Label>
<div className="grid grid-cols-3 gap-2">
{[
{ label: "1h", value: "60" },
{ label: "2h", value: "120" },
{ label: "4h", value: "240" },
{ label: "6h", value: "360" },
{ label: "12h", value: "720" },
{ label: "24h", value: "1440" },
].map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => set("fetch_interval_minutes", opt.value)}
className={cn(
"py-2 px-3 rounded-lg border-2 text-sm font-medium transition-all",
(form.fetch_interval_minutes || "1440") === opt.value
? "border-primary bg-primary/5 text-primary"
: "border-border hover:border-primary/40 text-foreground"
)}
>
{opt.label}
</button>
))}
</div>
</div>
)}
{/* ── Prochaine date d'exécution */}
<div className="flex items-center gap-3 p-3 rounded-lg bg-primary/5 border border-primary/20 max-w-xl">
<CalendarClock size={16} className="text-primary shrink-0" />
<div className="text-sm">
<span className="text-muted-foreground">Prochain import prévu : </span>
<span className="font-semibold text-foreground">{nextRunLabel}</span>
</div>
</div>
</CardContent>
</Card>
{/* ── Rétention des articles ──────────────────────────────────── */}
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Trash2 size={16} className="text-muted-foreground" />
Rétention des articles
</CardTitle>
<CardDescription>
Supprimez automatiquement les articles plus anciens que la durée choisie.
La purge s'exécute au démarrage du serveur et après chaque import automatique.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid grid-cols-3 sm:grid-cols-6 gap-2 max-w-xl">
{[
{ label: "Illimité", value: "0" },
{ label: "3 mois", value: "3" },
{ label: "6 mois", value: "6" },
{ label: "12 mois", value: "12" },
{ label: "18 mois", value: "18" },
{ label: "24 mois", value: "24" },
].map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => set("retention_months", opt.value)}
className={cn(
"py-2 px-3 rounded-lg border-2 text-sm font-medium transition-all",
(form.retention_months || "0") === opt.value
? opt.value === "0"
? "border-emerald-500 bg-emerald-50 text-emerald-700"
: "border-primary bg-primary/5 text-primary"
: "border-border hover:border-primary/40 text-foreground"
)}
>
{opt.label}
</button>
))}
</div>
{(form.retention_months || "0") !== "0" && (
<div className="flex items-start gap-2 p-3 rounded-lg bg-amber-50 border border-amber-200 max-w-xl">
<AlertCircle size={14} className="text-amber-600 mt-0.5 shrink-0" />
<p className="text-xs text-amber-700">
Les articles importés il y a plus de <strong>{form.retention_months} mois</strong> seront supprimés définitivement lors du prochain démarrage ou import automatique.
</p>
</div>
)}
</CardContent>
</Card>

View File

@@ -19,7 +19,6 @@ import {
ExternalLink,
Calendar,
MapPin,
Tag,
Layers,
FileSearch,
Loader2,
@@ -30,6 +29,7 @@ import {
BookOpen,
Trash2,
AlertTriangle,
EyeOff,
} from "lucide-react";
import {
AlertDialog,
@@ -47,6 +47,7 @@ import { format } from "date-fns";
import { fr } from "date-fns/locale";
type TypeVeille = "reglementaire" | "concurrentielle" | "technologique" | "informationnelle";
type ReadFilter = "unread" | "all" | "read";
interface VeilleItem {
id: number;
@@ -150,7 +151,6 @@ function VeilleDetailDialog({
{/* Métadonnées en grille */}
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
{item.niveau && (
<div className="flex items-start gap-2 p-3 rounded-lg bg-violet-50 border border-violet-200">
<Layers size={14} className="text-violet-500 mt-0.5 shrink-0" />
@@ -205,7 +205,6 @@ function VeilleDetailDialog({
</div>
)}
{/* Lien externe */}
{item.lien && (
<div className="pt-1 border-t border-border/50">
@@ -235,14 +234,22 @@ export default function VeilleDashboard() {
// Marquage lu/non lu
const [readIds, setReadIds] = useState<Set<number>>(new Set());
const readIdsQuery = trpc.veille.getReadIds.useQuery(undefined, { enabled: !!user });
useEffect(() => {
if (readIdsQuery.data?.ids) {
setReadIds(new Set(readIdsQuery.data.ids));
}
}, [readIdsQuery.data]);
const markAsReadMutation = trpc.veille.markAsRead.useMutation({
onSuccess: (_, vars) => {
setReadIds((prev) => { const next = new Set(prev); next.add(vars.articleId); return next; });
utils.veille.getReadIds.invalidate();
},
});
const markAllAsReadMutation = trpc.veille.markAllAsRead.useMutation({
onSuccess: () => {
utils.veille.unreadCount.invalidate();
utils.veille.getReadIds.invalidate();
},
});
const unreadCountQuery = trpc.veille.unreadCount.useQuery();
@@ -258,12 +265,19 @@ export default function VeilleDashboard() {
toast.error(`Erreur lors de la purge : ${err.message}`);
},
});
const [viewMode, setViewMode] = useState<"list" | "grid">("list");
// ── État UI ─────────────────────────────────────────────────────────────────
// Mode vignette par défaut
const [viewMode, setViewMode] = useState<"list" | "grid">("grid");
const [activeTab, setActiveTab] = useState<TypeVeille | "all">("all");
const [page, setPage] = useState(1);
const [filterValues, setFilterValues] = useState<Record<string, string>>({});
const [selectedItem, setSelectedItem] = useState<VeilleItem | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
// Filtre Lu/Non lu — Non lu par défaut
const [readFilter, setReadFilter] = useState<ReadFilter>("unread");
// Ligne sélectionnée en mode liste
const [selectedRowId, setSelectedRowId] = useState<number | null>(null);
const filtersQuery = trpc.veille.filters.useQuery();
@@ -294,16 +308,22 @@ export default function VeilleDashboard() {
const openDetail = (item: VeilleItem) => {
setSelectedItem(item);
setDialogOpen(true);
// Marquer comme lu automatiquement à l'ouverture du détail
if (!readIds.has(item.id)) {
markAsReadMutation.mutate({ articleId: item.id });
}
};
const items = (itemsQuery.data?.items ?? []) as VeilleItem[];
const allItems = (itemsQuery.data?.items ?? []) as VeilleItem[];
const total = itemsQuery.data?.total ?? 0;
const totalPages = Math.ceil(total / PAGE_SIZE);
// Filtrage Lu/Non lu côté client (readIds est local à la session)
const items = useMemo(() => {
if (readFilter === "unread") return allItems.filter((i) => !readIds.has(i.id));
if (readFilter === "read") return allItems.filter((i) => readIds.has(i.id));
return allItems;
}, [allItems, readIds, readFilter]);
const filterOptions = [
{ key: "niveau", label: "Niveau", options: filtersQuery.data?.niveaux ?? [] },
{ key: "territoire", label: "Territoire", options: filtersQuery.data?.territoires ?? [] },
@@ -311,6 +331,14 @@ export default function VeilleDashboard() {
{ key: "dateTo", label: "Date jusqu'à", type: "date" as const },
];
// Labels et styles du bouton Lu/Non lu
const READ_FILTER_LABELS: Record<ReadFilter, string> = {
unread: "Non lus",
all: "Tous",
read: "Lus",
};
const READ_FILTER_ORDER: ReadFilter[] = ["unread", "all", "read"];
return (
<div className="p-6 space-y-6 animate-fade-up">
{/* En-tête */}
@@ -327,13 +355,14 @@ export default function VeilleDashboard() {
Suivi réglementaire, concurrentiel, technologique et informationnel
</p>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 flex-wrap">
{unreadCount > 0 && (
<Button variant="outline" size="sm" onClick={() => markAllAsReadMutation.mutate()} disabled={markAllAsReadMutation.isPending} className="gap-2 text-muted-foreground">
<Eye size={15} />
Tout marquer comme lu
</Button>
)}
{/* Boutons mode d'affichage */}
<Button variant={viewMode === "list" ? "default" : "outline"} size="sm" onClick={() => setViewMode("list")} className="gap-2">
<List size={15} />Liste
</Button>
@@ -341,7 +370,6 @@ export default function VeilleDashboard() {
<LayoutGrid size={15} />Vignettes
</Button>
{isAdmin && (
<>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" size="sm" className="gap-2 border-destructive/50 text-destructive hover:bg-destructive hover:text-destructive-foreground ml-2">
@@ -357,8 +385,8 @@ export default function VeilleDashboard() {
</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="space-y-2">
<p>Cette action va <strong>supprimer définitivement</strong> toutes les entrées de la veille stratégique (réglementaire, concurrentielle, technologique et générale).</p>
<p className="text-destructive font-medium">Cette opération est irréversible. Les données ne pourront pas être récupérées.</p>
<p>Cette action va <strong>supprimer définitivement</strong> toutes les entrées de la veille stratégique.</p>
<p className="text-destructive font-medium">Cette opération est irréversible.</p>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
@@ -374,11 +402,12 @@ export default function VeilleDashboard() {
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)}
</div>
</div>
{/* Onglets */}
{/* Onglets type de veille + bouton Lu/Non lu */}
<div className="flex items-center justify-between gap-4 flex-wrap">
<Tabs value={activeTab} onValueChange={(v) => { setActiveTab(v as TypeVeille | "all"); setPage(1); }}>
<TabsList className="bg-muted/50">
<TabsTrigger value="all">Tous</TabsTrigger>
@@ -388,6 +417,30 @@ export default function VeilleDashboard() {
</TabsList>
</Tabs>
{/* Bouton segmenté Lu / Non lu */}
<div className="flex items-center rounded-lg border border-border overflow-hidden shadow-sm">
{READ_FILTER_ORDER.map((f) => (
<button
key={f}
onClick={() => setReadFilter(f)}
className={cn(
"px-3 py-1.5 text-xs font-medium transition-colors flex items-center gap-1.5",
readFilter === f
? "bg-primary text-primary-foreground"
: "bg-background text-muted-foreground hover:bg-muted/50"
)}
>
{f === "unread" && <EyeOff size={12} />}
{f === "read" && <Eye size={12} />}
{READ_FILTER_LABELS[f]}
{f === "unread" && unreadCount > 0 && (
<span className="ml-0.5 inline-flex items-center justify-center min-w-[16px] h-4 px-1 rounded-full bg-primary-foreground/20 text-[10px] font-bold">{unreadCount}</span>
)}
</button>
))}
</div>
</div>
{/* Filtres */}
<FilterBar
filters={filterOptions}
@@ -406,11 +459,23 @@ export default function VeilleDashboard() {
) : items.length === 0 ? (
<div className="flex flex-col items-center justify-center py-24 text-center">
<FileSearch size={48} className="text-muted-foreground/30 mb-4" />
<p className="text-muted-foreground font-medium">Aucun résultat trouvé</p>
<p className="text-muted-foreground/60 text-sm mt-1">Modifiez vos filtres ou importez des données</p>
<p className="text-muted-foreground font-medium">
{readFilter === "unread" ? "Aucun article non lu" : "Aucun résultat trouvé"}
</p>
<p className="text-muted-foreground/60 text-sm mt-1">
{readFilter === "unread"
? "Tous les articles ont é lus, ou modifiez le filtre Lu/Non lu"
: "Modifiez vos filtres ou importez des données"}
</p>
</div>
) : viewMode === "list" ? (
<VeilleListView items={items} onDetail={openDetail} readIds={readIds} />
<VeilleListView
items={items}
onDetail={openDetail}
readIds={readIds}
selectedRowId={selectedRowId}
onSelectRow={setSelectedRowId}
/>
) : (
<VeilleGridView items={items} onDetail={openDetail} readIds={readIds} />
)}
@@ -440,13 +505,25 @@ export default function VeilleDashboard() {
// ─── Vue Liste ────────────────────────────────────────────────────────────────
function VeilleListView({ items, onDetail, readIds }: { items: VeilleItem[]; onDetail: (item: VeilleItem) => void; readIds: Set<number> }) {
function VeilleListView({
items,
onDetail,
readIds,
selectedRowId,
onSelectRow,
}: {
items: VeilleItem[];
onDetail: (item: VeilleItem) => void;
readIds: Set<number>;
selectedRowId: number | null;
onSelectRow: (id: number | null) => void;
}) {
return (
<div className="rounded-xl border border-border overflow-hidden shadow-sm">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="bg-muted/50 border-b border-border">
<tr className="bg-muted/60 border-b border-border">
<th className="text-left px-4 py-3 font-semibold text-muted-foreground w-8">#</th>
<th className="text-left px-4 py-3 font-semibold text-muted-foreground">Titre</th>
<th className="text-left px-4 py-3 font-semibold text-muted-foreground w-32">Type</th>
@@ -456,18 +533,48 @@ function VeilleListView({ items, onDetail, readIds }: { items: VeilleItem[]; onD
<th className="text-left px-4 py-3 font-semibold text-muted-foreground w-20">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{items.map((item, idx) => (
<tr key={item.id} className={cn("hover:bg-muted/30 transition-colors border-l-4", TYPE_ACCENT[item.typeVeille] || "border-l-transparent", !readIds.has(item.id) && "bg-blue-50/30")}>
<tbody>
{items.map((item, idx) => {
const isSelected = selectedRowId === item.id;
const isRead = readIds.has(item.id);
// Alternance de couleurs : pair = blanc, impair = gris très léger
const isEven = idx % 2 === 0;
return (
<tr
key={item.id}
onClick={() => onSelectRow(isSelected ? null : item.id)}
className={cn(
"border-b border-border/50 border-l-4 cursor-pointer transition-colors",
TYPE_ACCENT[item.typeVeille] || "border-l-transparent",
// Ligne sélectionnée — priorité maximale
isSelected
? "bg-primary/10 hover:bg-primary/15"
// Non sélectionnée : alternance pair/impair
: isEven
? "bg-white hover:bg-primary/5"
: "bg-slate-50/80 hover:bg-primary/5"
)}
>
<td className="px-4 py-3 text-muted-foreground/50 text-xs">{idx + 1}</td>
<td className="px-4 py-3">
<div className="max-w-md flex items-start gap-2">
{!readIds.has(item.id) && (
{!isRead && (
<span className="mt-1.5 w-2 h-2 rounded-full bg-primary flex-shrink-0" title="Non lu" />
)}
<div>
<p className={cn("font-medium line-clamp-2 leading-snug", readIds.has(item.id) ? "text-muted-foreground" : "text-foreground")}>{item.titre}</p>
{(item.iaResume || item.resume) && <p className="text-xs text-muted-foreground mt-1 line-clamp-2">{item.iaResume || item.resume}</p>}
<p className={cn(
"font-medium line-clamp-2 leading-snug",
isRead ? "text-muted-foreground" : "text-foreground",
isSelected && "text-primary font-semibold"
)}>
{item.titre}
</p>
{(item.iaResume || item.resume) && (
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
{item.iaResume || item.resume}
</p>
)}
</div>
</div>
</td>
@@ -478,10 +585,11 @@ function VeilleListView({ items, onDetail, readIds }: { items: VeilleItem[]; onD
</td>
<td className="px-4 py-3 text-muted-foreground text-xs">{item.niveau || ""}</td>
<td className="px-4 py-3 text-muted-foreground text-xs">{item.territoire || ""}</td>
<td className="px-4 py-3 text-muted-foreground text-xs whitespace-nowrap">{formatDate(item.datePublication) || "—"}</td>
<td className="px-4 py-3">
<td className="px-4 py-3 text-muted-foreground text-xs whitespace-nowrap">
{formatDate(item.datePublication) || ""}
</td>
<td className="px-4 py-3" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center gap-1.5">
{/* Bouton Détail — bleu */}
<button
onClick={() => onDetail(item)}
className="inline-flex items-center justify-center w-7 h-7 rounded-md bg-blue-50 text-blue-600 hover:bg-blue-100 hover:text-blue-700 transition-colors border border-blue-200"
@@ -489,7 +597,6 @@ function VeilleListView({ items, onDetail, readIds }: { items: VeilleItem[]; onD
>
<Eye size={13} />
</button>
{/* Bouton Lien externe — vert */}
{item.lien && (
<a
href={item.lien}
@@ -504,7 +611,8 @@ function VeilleListView({ items, onDetail, readIds }: { items: VeilleItem[]; onD
</div>
</td>
</tr>
))}
);
})}
</tbody>
</table>
</div>
@@ -514,18 +622,32 @@ function VeilleListView({ items, onDetail, readIds }: { items: VeilleItem[]; onD
// ─── Vue Vignettes ────────────────────────────────────────────────────────────
function VeilleGridView({ items, onDetail, readIds }: { items: VeilleItem[]; onDetail: (item: VeilleItem) => void; readIds: Set<number> }) {
function VeilleGridView({
items,
onDetail,
readIds,
}: {
items: VeilleItem[];
onDetail: (item: VeilleItem) => void;
readIds: Set<number>;
}) {
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{items.map((item) => (
<Card key={item.id} className={cn("group hover:shadow-md transition-all duration-200 border-border overflow-hidden border-l-4", TYPE_ACCENT[item.typeVeille] || "", !readIds.has(item.id) && "ring-1 ring-primary/20")}>
<Card
key={item.id}
className={cn(
"group hover:shadow-md transition-all duration-200 border-border overflow-hidden border-l-4",
TYPE_ACCENT[item.typeVeille] || "",
!readIds.has(item.id) && "ring-1 ring-primary/20"
)}
>
<CardHeader className="pb-2 pt-4 px-4">
<div className="flex items-start justify-between gap-2">
<Badge variant="outline" className={cn("text-xs flex-shrink-0", TYPE_COLORS[item.typeVeille])}>
{TYPE_LABELS[item.typeVeille as TypeVeille] || item.typeVeille}
</Badge>
<div className="flex items-center gap-1.5 flex-shrink-0">
{/* Bouton Détail */}
<button
onClick={() => onDetail(item)}
className="text-muted-foreground hover:text-primary transition-colors"
@@ -534,19 +656,35 @@ function VeilleGridView({ items, onDetail, readIds }: { items: VeilleItem[]; onD
<Eye size={14} />
</button>
{item.lien && (
<a href={item.lien} target="_blank" rel="noopener noreferrer" className="text-muted-foreground hover:text-accent transition-colors">
<a
href={item.lien}
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-accent transition-colors"
>
<ExternalLink size={14} />
</a>
)}
</div>
</div>
<div className="flex items-start gap-1.5 mt-2">
{!readIds.has(item.id) && <span className="mt-1 w-2 h-2 rounded-full bg-primary flex-shrink-0" title="Non lu" />}
<h3 className={cn("font-semibold text-sm leading-snug line-clamp-3", readIds.has(item.id) ? "text-muted-foreground" : "text-foreground")}>{item.titre}</h3>
{!readIds.has(item.id) && (
<span className="mt-1 w-2 h-2 rounded-full bg-primary flex-shrink-0" title="Non lu" />
)}
<h3 className={cn(
"font-semibold text-sm leading-snug line-clamp-3",
readIds.has(item.id) ? "text-muted-foreground" : "text-foreground"
)}>
{item.titre}
</h3>
</div>
</CardHeader>
<CardContent className="px-4 pb-4 space-y-2">
{(item.iaResume || item.resume) && <p className="text-xs text-muted-foreground line-clamp-3 leading-relaxed">{item.iaResume || item.resume}</p>}
{(item.iaResume || item.resume) && (
<p className="text-xs text-muted-foreground line-clamp-3 leading-relaxed">
{item.iaResume || item.resume}
</p>
)}
<div className="flex flex-wrap gap-1.5 pt-1">
{item.territoire && (
<span className="inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded bg-teal-50 text-teal-700 border border-teal-200">

View File

@@ -0,0 +1,67 @@
services:
veille-reglementaire-recette:
image: veille-reglementaire-app:latest
build:
context: .
dockerfile: Dockerfile
container_name: veille-reglementaire-recette
restart: unless-stopped
environment:
- NODE_ENV=production
- PORT=3000
- DATABASE_URL=mysql://veille_user:VeilleDB2026Secure!@veille-db-recette:3306/veille_reglementaire
- JWT_SECRET=veille-jwt-secret-2026-recette
- VITE_APP_ID=veille-reglementaire-recette
- OAUTH_SERVER_URL=https://api.manus.im
- VITE_OAUTH_PORTAL_URL=https://manus.im
- OWNER_OPEN_ID=admin
- OWNER_NAME=Administrateur Itinova
- BUILT_IN_FORGE_API_URL=
- BUILT_IN_FORGE_API_KEY=
- VITE_FRONTEND_FORGE_API_KEY=
- VITE_FRONTEND_FORGE_API_URL=
- VITE_ANALYTICS_ENDPOINT=
- VITE_ANALYTICS_WEBSITE_ID=
- AZURE_AD_CLIENT_ID=f496da82-e18f-4567-bf05-8551ae6669b2
- AZURE_AD_CLIENT_SECRET=d.T8Q~RR9iobI9bin2hqecoBWDjvyD7I11U5QcPR
- AZURE_AD_TENANT_ID=487d0a81-de35-44ce-8847-03bb74ec553e
- AZURE_AD_REDIRECT_URI=https://veille.recette.santinova-soft.org/api/auth/azure/callback
depends_on:
veille-db-recette:
condition: service_healthy
networks:
- web
labels:
- "traefik.enable=true"
- "traefik.docker.network=web"
- "traefik.http.routers.veille-recette-secure.entrypoints=websecure"
- "traefik.http.routers.veille-recette-secure.rule=Host(`veille.recette.santinova-soft.org`)"
- "traefik.http.routers.veille-recette-secure.service=veille-recette-svc"
- "traefik.http.routers.veille-recette-secure.tls=true"
- "traefik.http.routers.veille-recette-secure.tls.certresolver=letsencrypt"
- "traefik.http.services.veille-recette-svc.loadbalancer.server.port=3000"
veille-db-recette:
image: mysql:8.0
container_name: veille-db-recette
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: VeilleRootDB2026!
MYSQL_DATABASE: veille_reglementaire
MYSQL_USER: veille_user
MYSQL_PASSWORD: VeilleDB2026Secure!
volumes:
- veille-db-recette-data:/var/lib/mysql
networks:
- web
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 10s
timeout: 5s
retries: 5
volumes:
veille-db-recette-data:
networks:
web:
external: true

View File

@@ -0,0 +1,8 @@
CREATE TABLE `processed_dedup_keys` (
`id` int AUTO_INCREMENT NOT NULL,
`dedupKey` varchar(64) NOT NULL,
`feedType` enum('veille','aap') NOT NULL,
`processedAt` timestamp NOT NULL DEFAULT (now()),
CONSTRAINT `processed_dedup_keys_id` PRIMARY KEY(`id`),
CONSTRAINT `processed_dedup_keys_dedupKey_unique` UNIQUE(`dedupKey`)
);

View File

@@ -0,0 +1,2 @@
ALTER TABLE `local_users` ADD `azureAdId` varchar(128);--> statement-breakpoint
ALTER TABLE `local_users` ADD CONSTRAINT `local_users_azureAdId_unique` UNIQUE(`azureAdId`);

View File

@@ -0,0 +1,10 @@
-- Conserver la première lecture de chaque article avant d'imposer l'unicité.
DELETE duplicate_read
FROM `article_reads` AS duplicate_read
INNER JOIN `article_reads` AS retained_read
ON duplicate_read.`userId` = retained_read.`userId`
AND duplicate_read.`articleType` = retained_read.`articleType`
AND duplicate_read.`articleId` = retained_read.`articleId`
AND duplicate_read.`id` > retained_read.`id`;
--> statement-breakpoint
ALTER TABLE `article_reads` ADD CONSTRAINT `article_reads_user_type_article_unique` UNIQUE(`userId`,`articleType`,`articleId`);

View File

@@ -0,0 +1,11 @@
CREATE TABLE `classification_errors` (
`id` int AUTO_INCREMENT NOT NULL,
`feedId` int,
`feedName` varchar(255) NOT NULL,
`feedType` enum('veille','aap') NOT NULL,
`articleTitle` text NOT NULL,
`articleUrl` text,
`errorMessage` text NOT NULL,
`occurredAt` timestamp NOT NULL DEFAULT (now()),
CONSTRAINT `classification_errors_id` PRIMARY KEY(`id`)
);

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -71,6 +71,34 @@
"when": 1781683138994,
"tag": "0009_fat_rocket_racer",
"breakpoints": true
},
{
"idx": 10,
"version": "5",
"when": 1782978530081,
"tag": "0010_graceful_nova",
"breakpoints": true
},
{
"idx": 11,
"version": "5",
"when": 1783432258494,
"tag": "0011_sharp_gambit",
"breakpoints": true
},
{
"idx": 12,
"version": "5",
"when": 1786997741455,
"tag": "0012_fluffy_boomer",
"breakpoints": true
},
{
"idx": 13,
"version": "5",
"when": 1787039231200,
"tag": "0013_odd_grim_reaper",
"breakpoints": true
}
]
}

View File

@@ -5,6 +5,7 @@ import {
mysqlTable,
text,
timestamp,
uniqueIndex,
varchar,
json,
} from "drizzle-orm/mysql-core";
@@ -36,6 +37,7 @@ export const localUsers = mysqlTable("local_users", {
passwordHash: varchar("passwordHash", { length: 255 }).notNull(),
role: mysqlEnum("role", ["admin", "user", "readonly"]).default("user").notNull(),
isActive: boolean("isActive").default(true).notNull(),
azureAdId: varchar("azureAdId", { length: 128 }).unique(),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
lastSignedIn: timestamp("lastSignedIn"),
@@ -135,6 +137,25 @@ export const importLogs = mysqlTable("import_logs", {
export type ImportLog = typeof importLogs.$inferSelect;
export type InsertImportLog = typeof importLogs.$inferInsert;
// ─── Erreurs de classification RSS ───────────────────────────────────────────
/**
* Journal technique destiné à l'administration. Il conserve les fallbacks IA
* article par article, sans bloquer l'import ni exposer l'erreur aux utilisateurs.
*/
export const classificationErrors = mysqlTable("classification_errors", {
id: int("id").autoincrement().primaryKey(),
feedId: int("feedId"),
feedName: varchar("feedName", { length: 255 }).notNull(),
feedType: mysqlEnum("feedType", ["veille", "aap"]).notNull(),
articleTitle: text("articleTitle").notNull(),
articleUrl: text("articleUrl"),
errorMessage: text("errorMessage").notNull(),
occurredAt: timestamp("occurredAt").defaultNow().notNull(),
});
export type ClassificationError = typeof classificationErrors.$inferSelect;
// ─── Boîte à idées ───────────────────────────────────────────────────────────
export const ideas = mysqlTable("ideas", {
@@ -203,13 +224,34 @@ export type InsertRssSettings = typeof rssSettings.$inferInsert;
// ─── Suivi de lecture des articles ──────────────────────────────────────────
export const articleReads = mysqlTable("article_reads", {
export const articleReads = mysqlTable(
"article_reads",
{
id: int("id").autoincrement().primaryKey(),
userId: int("userId").notNull(),
articleType: mysqlEnum("articleType", ["veille", "aap"]).notNull(),
articleId: int("articleId").notNull(),
readAt: timestamp("readAt").defaultNow().notNull(),
});
},
(table) => [
// Une lecture est une relation unique entre un utilisateur et un article.
uniqueIndex("article_reads_user_type_article_unique").on(table.userId, table.articleType, table.articleId),
],
);
export type ArticleRead = typeof articleReads.$inferSelect;
export type InsertArticleRead = typeof articleReads.$inferInsert;
// ─── Tombstones : articles déjà traités (empêche la réinsertion après purge) ──
export const processedDedupKeys = mysqlTable("processed_dedup_keys", {
id: int("id").autoincrement().primaryKey(),
dedupKey: varchar("dedupKey", { length: 64 }).notNull().unique(),
// Type de contenu (veille ou aap)
feedType: mysqlEnum("feedType", ["veille", "aap"]).notNull(),
// Date à laquelle l'article a été traité pour la première fois
processedAt: timestamp("processedAt").defaultNow().notNull(),
});
export type ProcessedDedupKey = typeof processedDedupKeys.$inferSelect;
export type InsertProcessedDedupKey = typeof processedDedupKeys.$inferInsert;

View File

@@ -10,11 +10,13 @@
"check": "tsc --noEmit",
"format": "prettier --write .",
"test": "vitest run",
"verify": "pnpm check && pnpm test && pnpm build",
"db:push": "drizzle-kit generate && drizzle-kit migrate"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.693.0",
"@aws-sdk/s3-request-presigner": "^3.693.0",
"@azure/msal-node": "^5.3.1",
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15",

106
pnpm-lock.yaml generated
View File

@@ -22,6 +22,9 @@ importers:
'@aws-sdk/s3-request-presigner':
specifier: ^3.693.0
version: 3.907.0
'@azure/msal-node':
specifier: ^5.3.1
version: 5.3.1
'@hookform/resolvers':
specifier: ^5.2.2
version: 5.2.2(react-hook-form@7.64.0(react@19.2.1))
@@ -477,6 +480,14 @@ packages:
resolution: {integrity: sha512-ORHRQ2tmvnBXc8t/X9Z8IcSbBA4xTLKuN873FopzklHMeqBst7YG0d+AX97inkvDX+NChYtSr+qGfcqGFaI8Zw==}
engines: {node: '>=18.0.0'}
'@azure/msal-common@16.11.0':
resolution: {integrity: sha512-UikJOtMwkFpZNzTH6Dqk8UTUPbow15zH3e0UjGYZy69lYENW/S05gMLhbxI2eonz66uALhIljvhsSMEb6+O30g==}
engines: {node: '>=0.8.0'}
'@azure/msal-node@5.3.1':
resolution: {integrity: sha512-sqqv3L1UOI4KDXonNtbxPYUgbSWVXqxvmmb6BUw9n4P/UXgG+cVur3dLWQN4Cz7qQ+UJROCCxMXlksm7gIq0Sw==}
engines: {node: '>=20'}
'@babel/code-frame@7.27.1':
resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==}
engines: {node: '>=6.9.0'}
@@ -2481,6 +2492,9 @@ packages:
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true
buffer-equal-constant-time@1.0.1:
resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
@@ -2968,6 +2982,9 @@ packages:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
ecdsa-sig-formatter@1.0.11:
resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
ee-first@1.1.1:
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
@@ -3336,6 +3353,16 @@ packages:
engines: {node: '>=6'}
hasBin: true
jsonwebtoken@9.0.3:
resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==}
engines: {node: '>=12', npm: '>=6'}
jwa@2.0.1:
resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==}
jws@4.0.1:
resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==}
katex@0.16.25:
resolution: {integrity: sha512-woHRUZ/iF23GBP1dkDQMh1QBad9dmr8/PAwNA54VrSOVYgI12MAcE14TqnDdQOdzyEonGzMepYnqBMYdsoAr8Q==}
hasBin: true
@@ -3427,6 +3454,27 @@ packages:
lodash-es@4.17.21:
resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==}
lodash.includes@4.3.0:
resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==}
lodash.isboolean@3.0.3:
resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==}
lodash.isinteger@4.0.4:
resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==}
lodash.isnumber@3.0.3:
resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==}
lodash.isplainobject@4.0.6:
resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}
lodash.isstring@4.0.1:
resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==}
lodash.once@4.1.1:
resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==}
lodash@4.17.21:
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
@@ -4001,6 +4049,11 @@ packages:
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
hasBin: true
semver@7.8.5:
resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
engines: {node: '>=10'}
hasBin: true
send@0.19.0:
resolution: {integrity: sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==}
engines: {node: '>= 0.8.0'}
@@ -4949,6 +5002,13 @@ snapshots:
'@aws/lambda-invoke-store@0.0.1': {}
'@azure/msal-common@16.11.0': {}
'@azure/msal-node@5.3.1':
dependencies:
'@azure/msal-common': 16.11.0
jsonwebtoken: 9.0.3
'@babel/code-frame@7.27.1':
dependencies:
'@babel/helper-validator-identifier': 7.27.1
@@ -6953,6 +7013,8 @@ snapshots:
node-releases: 2.0.23
update-browserslist-db: 1.1.3(browserslist@4.26.3)
buffer-equal-constant-time@1.0.1: {}
buffer-from@1.1.2: {}
busboy@1.6.0:
@@ -7347,6 +7409,10 @@ snapshots:
es-errors: 1.3.0
gopd: 1.2.0
ecdsa-sig-formatter@1.0.11:
dependencies:
safe-buffer: 5.2.1
ee-first@1.1.1: {}
electron-to-chromium@1.5.230: {}
@@ -7837,6 +7903,30 @@ snapshots:
json5@2.2.3: {}
jsonwebtoken@9.0.3:
dependencies:
jws: 4.0.1
lodash.includes: 4.3.0
lodash.isboolean: 3.0.3
lodash.isinteger: 4.0.4
lodash.isnumber: 3.0.3
lodash.isplainobject: 4.0.6
lodash.isstring: 4.0.1
lodash.once: 4.1.1
ms: 2.1.3
semver: 7.8.5
jwa@2.0.1:
dependencies:
buffer-equal-constant-time: 1.0.1
ecdsa-sig-formatter: 1.0.11
safe-buffer: 5.2.1
jws@4.0.1:
dependencies:
jwa: 2.0.1
safe-buffer: 5.2.1
katex@0.16.25:
dependencies:
commander: 8.3.0
@@ -7910,6 +8000,20 @@ snapshots:
lodash-es@4.17.21: {}
lodash.includes@4.3.0: {}
lodash.isboolean@3.0.3: {}
lodash.isinteger@4.0.4: {}
lodash.isnumber@3.0.3: {}
lodash.isplainobject@4.0.6: {}
lodash.isstring@4.0.1: {}
lodash.once@4.1.1: {}
lodash@4.17.21: {}
long@5.3.2: {}
@@ -8774,6 +8878,8 @@ snapshots:
semver@6.3.1: {}
semver@7.8.5: {}
send@0.19.0:
dependencies:
debug: 2.6.9

View File

@@ -11,8 +11,9 @@ import { serveStatic, setupVite } from "./vite";
import { runFullImport } from "../importer";
import uploadRoutes from "../uploadRoutes";
import scheduledRoutes from "../scheduledRoutes";
import { ensureAdminExists } from "../localAuth";
import { getSetting, getRssSettings } from "../db";
import { ensureAdminExists, generateLocalToken } from "../localAuth";
import { isAzureAdConfigured, getAzureAuthUrl, handleAzureCallback } from "../azureAuth";
import { getLocalUserByAzureAdId, getLocalUserByEmail, upsertLocalUserAzure, getSetting, purgeOldArticles } from "../db";
import { runRssFetch } from "../rssEngine";
function isPortAvailable(port: number): Promise<boolean> {
@@ -30,14 +31,43 @@ async function findAvailablePort(startPort: number = 3000): Promise<number> {
throw new Error(`No available port found starting from ${startPort}`);
}
// ─── Tâche d'import quotidien ─────────────────────────────────────────────────
// ─── Tâche d'import quotidien + lecture RSS ──────────────────────────────────
let cronJob: ReturnType<typeof cron.schedule> | null = null;
async function scheduleDailyImport() {
// Heure configurable, défaut 06:00
export async function scheduleDailyImport() {
const importTime = (await getSetting("import_time")) || "06:00";
const fetchMode = (await getSetting("fetch_mode")) || "scheduled";
const fetchIntervalMinutes = parseInt((await getSetting("fetch_interval_minutes")) || "1440", 10);
const fetchDayOfWeek = parseInt((await getSetting("fetch_day_of_week")) || "1", 10); // 0=dim, 1=lun, ..., 6=sam
const fetchDayOfMonth = parseInt((await getSetting("fetch_day_of_month")) || "1", 10); // 1-28
const DAYS_FR = ["dimanche", "lundi", "mardi", "mercredi", "jeudi", "vendredi", "samedi"];
let cronExpr: string;
if (fetchMode === "interval") {
const intervalMin = Math.max(60, fetchIntervalMinutes);
if (intervalMin % 60 === 0) {
const hours = intervalMin / 60;
cronExpr = hours === 24 ? `0 0 * * *` : `0 */${hours} * * *`;
} else {
cronExpr = `*/${intervalMin} * * * *`;
}
console.log(`[Cron] Mode intervalle — toutes les ${intervalMin} minutes (${cronExpr})`);
} else if (fetchMode === "weekly") {
const [hour, minute] = importTime.split(":").map(Number);
const cronExpr = `0 ${minute ?? 0} ${hour ?? 6} * * *`;
cronExpr = `0 ${minute ?? 0} ${hour ?? 6} * * ${fetchDayOfWeek}`;
console.log(`[Cron] Mode hebdomadaire — chaque ${DAYS_FR[fetchDayOfWeek] ?? "lundi"} à ${importTime} (${cronExpr})`);
} else if (fetchMode === "monthly") {
const [hour, minute] = importTime.split(":").map(Number);
cronExpr = `0 ${minute ?? 0} ${hour ?? 6} ${fetchDayOfMonth} * *`;
console.log(`[Cron] Mode mensuel — le ${fetchDayOfMonth} de chaque mois à ${importTime} (${cronExpr})`);
} else {
// scheduled (heure fixe quotidienne) ou daily
const [hour, minute] = importTime.split(":").map(Number);
cronExpr = `0 ${minute ?? 0} ${hour ?? 6} * * *`;
console.log(`[Cron] Mode quotidien — tous les jours à ${importTime} (${cronExpr})`);
}
if (cronJob) {
cronJob.stop();
cronJob = null;
@@ -45,85 +75,46 @@ async function scheduleDailyImport() {
cronJob = cron.schedule(cronExpr, async () => {
console.log(`[Cron] Import automatique démarré à ${new Date().toISOString()}`);
try {
// 1. Import des fichiers Excel (Veille + AAP)
const result = await runFullImport();
console.log(`[Cron] Import terminé — Veille: +${result.veille.newRows} | AAP: +${result.aap.newRows}`);
console.log(`[Cron] Import Excel terminé — Veille: +${result.veille.newRows} | AAP: +${result.aap.newRows}`);
} catch (e) {
console.error("[Cron] Erreur lors de l'import:", e);
console.error("[Cron] Erreur lors de l'import Excel:", e);
}
});
console.log(`[Cron] Import quotidien planifié à ${importTime} (${cronExpr})`);
}
// ─── Planificateur RSS natif ──────────────────────────────────────────────────
let rssCronJob: ReturnType<typeof cron.schedule> | null = null;
/**
* Démarre (ou redémarre) le planificateur RSS en lisant la configuration
* depuis la table rss_settings. Peut être appelé au démarrage et à chaque
* modification des paramètres RSS via l'interface d'administration.
*/
export async function scheduleRssFetch() {
// Arrêter le cron existant s'il y en a un
if (rssCronJob) {
rssCronJob.stop();
rssCronJob = null;
console.log("[RSS Cron] Planificateur RSS arrêté.");
}
let settings = null;
try {
settings = await getRssSettings();
} catch (e) {
// En cas d'erreur (ex: colonne manquante lors d'une migration),
// utiliser les valeurs par défaut et planifier un retry dans 2 minutes
console.warn("[RSS Cron] Impossible de lire les paramètres RSS, utilisation des valeurs par défaut:", (e as Error).message);
setTimeout(() => scheduleRssFetch(), 2 * 60 * 1000);
// Démarrer quand même avec les valeurs par défaut
settings = { autoFetchEnabled: true, fetchMode: "interval" as const, fetchIntervalMinutes: 60, scheduledTime: "06:00" };
}
if (!settings || !settings.autoFetchEnabled) {
console.log("[RSS Cron] Lecture automatique des flux RSS désactivée.");
return;
}
let cronExpr: string;
if (settings.fetchMode === "interval") {
// Mode intervalle : toutes les N minutes
const intervalMin = Math.max(5, settings.fetchIntervalMinutes ?? 60);
if (intervalMin < 60) {
cronExpr = `*/${intervalMin} * * * *`;
} else if (intervalMin % 60 === 0) {
const hours = intervalMin / 60;
cronExpr = `0 */${hours} * * *`;
} else {
cronExpr = `*/${intervalMin} * * * *`;
}
console.log(`[RSS Cron] Mode intervalle — toutes les ${intervalMin} minutes (${cronExpr})`);
} else {
// Mode planifié : heure fixe quotidienne
const scheduledTime = settings.scheduledTime ?? "06:00";
const [hour, minute] = scheduledTime.split(":").map(Number);
cronExpr = `0 ${minute ?? 0} ${hour ?? 6} * * *`;
console.log(`[RSS Cron] Mode planifié — tous les jours à ${scheduledTime} (${cronExpr})`);
}
rssCronJob = cron.schedule(cronExpr, async () => {
console.log(`[RSS Cron] Lecture des flux RSS démarrée à ${new Date().toISOString()}`);
try {
const summary = await runRssFetch();
// 2. Lecture des flux RSS
const rssSummary = await runRssFetch();
console.log(
`[RSS Cron] Lecture terminée — ${summary.totalFeeds} flux, ` +
`+${summary.totalNewItems} nouveaux articles, ` +
`${summary.errorFeeds} erreur(s)`
`[Cron] Lecture RSS terminée — ${rssSummary.totalFeeds} flux, ` +
`+${rssSummary.totalNewItems} nouveaux articles, ` +
`${rssSummary.errorFeeds} erreur(s)`
);
} catch (e) {
console.error("[RSS Cron] Erreur lors de la lecture des flux:", e);
console.error("[Cron] Erreur lors de la lecture RSS:", e);
}
// 3. Purge des articles selon la règle de rétention
try {
const retentionStr = await getSetting("retention_months");
const retentionMonths = retentionStr ? parseInt(retentionStr, 10) : 0;
if (retentionMonths > 0) {
const purged = await purgeOldArticles(retentionMonths);
if (purged.veille > 0 || purged.aap > 0 || purged.tombstones > 0) {
console.log(`[Cron] Purge rétention (${retentionMonths} mois) — Veille: -${purged.veille} | AAP: -${purged.aap} | Tombstones: -${purged.tombstones}`);
}
}
} catch (e) {
console.error("[Cron] Erreur lors de la purge de rétention:", e);
}
});
}
console.log("[RSS Cron] Planificateur RSS démarré.");
/**
* Stub conservé pour compatibilité avec les imports existants dans routers.ts.
* La lecture RSS est désormais intégrée dans scheduleDailyImport().
*/
export async function scheduleRssFetch() {
// No-op : la lecture RSS est pilotée par le cron d'import quotidien (import_time)
console.log("[RSS Cron] Planificateur RSS indépendant désactivé — la lecture est pilotée par le cron d'import quotidien.");
}
async function startServer() {
@@ -136,6 +127,87 @@ async function startServer() {
registerOAuthRoutes(app);
app.use(uploadRoutes);
app.use(scheduledRoutes);
// ─── Azure AD OAuth2 callback ─────────────────────────────────────────────
app.get("/api/auth/azure/callback", async (req, res) => {
const code = req.query.code as string | undefined;
const error = req.query.error as string | undefined;
if (error) {
res.redirect(`/login?error=${encodeURIComponent("Connexion Microsoft refus\u00e9e")}`);
return;
}
if (!code) {
res.redirect("/login?error=" + encodeURIComponent("Code OAuth manquant"));
return;
}
if (!isAzureAdConfigured()) {
res.redirect("/login?error=" + encodeURIComponent("Azure AD non configur\u00e9"));
return;
}
try {
const azureUser = await handleAzureCallback(code);
// Chercher par azureAdId puis par email
let user = await getLocalUserByAzureAdId(azureUser.azureAdId);
if (!user) user = await getLocalUserByEmail(azureUser.email);
if (!user) {
// Cr\u00e9er automatiquement avec r\u00f4le "user"
await upsertLocalUserAzure({
email: azureUser.email,
name: azureUser.name,
azureAdId: azureUser.azureAdId,
role: "user",
});
user = await getLocalUserByEmail(azureUser.email);
} else if (!user.azureAdId) {
// Lier le compte existant \u00e0 Azure AD
await upsertLocalUserAzure({
email: user.email ?? azureUser.email,
azureAdId: azureUser.azureAdId,
});
}
if (!user || !user.isActive) {
res.redirect("/login?error=" + encodeURIComponent("Compte inactif ou introuvable"));
return;
}
// Mettre \u00e0 jour lastSignedIn
const db = await (await import("../db")).getDb();
if (db) {
const { localUsers } = await import("../../drizzle/schema");
const { eq } = await import("drizzle-orm");
await db.update(localUsers).set({ lastSignedIn: new Date() }).where(eq(localUsers.id, user.id));
}
// G\u00e9n\u00e9rer le token JWT local et le stocker dans le cookie
const token = await generateLocalToken(user.id, user.role);
res.cookie("veille_local_auth", token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: 7 * 24 * 60 * 60 * 1000,
});
// Retourner les infos user en JSON pour que le frontend hydrate LocalAuthContext
const userPayload = JSON.stringify({
id: user.id,
name: user.name,
username: user.username ?? null,
email: user.email ?? null,
role: user.role,
});
// Rediriger vers une page de callback qui hydrate le contexte
res.redirect(`/azure-callback?user=${encodeURIComponent(userPayload)}`);
} catch (err: any) {
console.error("[Azure AD] Erreur callback:", err.message);
res.redirect("/login?error=" + encodeURIComponent("Erreur d'authentification Microsoft"));
}
});
app.use(
"/api/trpc",
createExpressMiddleware({ router: appRouter, createContext })
@@ -159,11 +231,23 @@ async function startServer() {
try {
await ensureAdminExists();
await scheduleDailyImport();
await scheduleRssFetch();
// Purge de rétention au démarrage
const retentionStr = await getSetting("retention_months");
const retentionMonths = retentionStr ? parseInt(retentionStr, 10) : 0;
if (retentionMonths > 0) {
const purged = await purgeOldArticles(retentionMonths);
if (purged.veille > 0 || purged.aap > 0 || purged.tombstones > 0) {
console.log(`[Init] Purge rétention (${retentionMonths} mois) — Veille: -${purged.veille} | AAP: -${purged.aap} | Tombstones: -${purged.tombstones}`);
}
}
} catch (e) {
console.error("[Init] Erreur d'initialisation:", e);
}
});
}
// Les routeurs importent certaines fonctions de ce module. Vitest ne doit jamais
// démarrer un serveur HTTP à cet effet, sinon les suites parallèles se disputent un port.
if (!process.env.VITEST) {
startServer().catch(console.error);
}

View File

@@ -56,6 +56,7 @@ describe("classifyArticle", () => {
expect(result.typeVeille).toBe("informationnelle");
expect(result.relevant).toBe(true); // on suppose pertinent par défaut
expect(result.reason).toContain("règles");
expect(result.technicalError).toBe("LLM timeout");
});
it("retourne le fallback (rules) si le LLM retourne un JSON malformé", async () => {

View File

@@ -19,41 +19,55 @@ export type AiTypeVeille =
| "technologique"
| "informationnelle";
export type AiCategorieAap =
| "Handicap"
| "Précarité"
| "Enfance"
| "PA"
| "Sanitaire"
| "Autre";
export interface AiClassificationResult {
/** L'article est-il pertinent pour le secteur médico-social ? */
relevant: boolean;
/** Type de veille déduit par l'IA (null si non pertinent) */
typeVeille: AiTypeVeille | null;
/** Catégorie AAP déduite par l'IA (null si non AAP ou non pertinent) */
categorieAap: AiCategorieAap | null;
/** Explication courte de la décision */
reason: string;
/** Indique si la classification a été faite par l'IA ou par les règles (fallback) */
classifiedBy: "ia" | "rules";
/** Cause technique du fallback, réservée au journal d'administration. */
technicalError: string | null;
}
// ─── Prompt unique : pertinence + type de veille ─────────────────────────────
function getTechnicalErrorMessage(error: unknown): string {
const message = error instanceof Error ? error.message : String(error);
return message.slice(0, 1000);
}
const PROMPT_CLASSIFICATION = `Tu es un expert des établissements et services sociaux et médico-sociaux (ESMS) en France, spécialisé dans les secteurs : handicap, personnes âgées, protection de l'enfance, précarité/insertion, sanitaire.
// ─── Prompt veille stratégique : pertinence + type de veille ─────────────────
Ta mission est d'analyser un article et de déterminer :
1. S'il est DIRECTEMENT pertinent pour une association gestionnaire d'ESMS
2. Si oui, à quel type de veille il appartient
const PROMPT_CLASSIFICATION = `Tu es un expert des établissements et services sociaux et médico-sociaux (ESMS) en France.
Un article est pertinent UNIQUEMENT s'il traite explicitement de l'un des sujets suivants :
- Réglementation, lois, décrets, circulaires concernant les ESMS, le secteur social ou médico-social
- Financement, tarification, dotations des structures sociales et médico-sociales (CPOM, SERAFIN-PH, etc.)
- Politiques publiques ciblant les personnes handicapées, âgées, enfants en danger, personnes précaires
- Fonctionnement, organisation, ressources humaines des ESMS
- Droits et accompagnement des usagers des ESMS
- Institutions directement liées : ARS, MDPH, ASE, conseils départementaux (action sociale), CAF, CNSA
- Santé publique avec impact direct sur les ESMS ou leurs usagers
Pose-toi cette unique question : "Un directeur d'ESMS ou un cadre de direction d'une association médico-sociale (Itinova) devrait-il lire cet article pour faire son travail ?"
Un article est NON PERTINENT si :
- Il traite de politique générale, économie, international, environnement, sport, culture, agriculture, pêche, tourisme, immobilier, technologie grand public, rénovation énergétique, etc.
- Il mentionne des personnes vulnérables de façon anecdotique sans lien avec les ESMS
- Il concerne la santé uniquement sous l'angle hospitalier ou médical sans lien avec le médico-social
- Le lien avec le secteur médico-social est vague, indirect ou nécessite plusieurs degrés de déduction
Un article est pertinent s'il concerne directement ou indirectement :
• Handicap (enfant et adulte) : services, dispositifs politiques et financement liés au handicap ; ESAT, IME, ITEP, SESSAD, MAS, FAM, SAVS, SAMSAH, MDPH, RQTH, inclusion scolaire, emploi des personnes handicapées
• Précarité / Logement : les problématiques et situations de précarité, d'exclusion, de vulnérabilité, d'accès aux droits ou d'insertion ; CHRS, SIAO, hébergement d'urgence, insertion par le logement, RSA, sans-abrisme, expulsion
• Protection de l'enfance : services de protection de l'enfance ; ASE, MECS, famille d'accueil, pupilles de l'État, prévention spécialisée, mineurs non accompagnés
• Personnes âgées : l'accompagnement des personnes âgées, les soins associés et la perte d'autonomie ; EHPAD, SSIAD, résidences autonomie, APA, dépendance, plan grand âge
• Sanitaire : la gestion sanitaire et les questions de santé ; SMR (Soins de Suite et de Réadaptation), HAD, cliniques de réadaptation, parcours de soins post-aigus
• Réglementation applicable aux ESMS (lois, décrets, circulaires, instructions)
• Financement des ESMS (dotations, tarification, appels à projets, CPOM)
• Pratiques professionnelles ou droits des usagers accompagnés (personnes handicapées, personnes âgées, enfants protégés, personnes en précarité, patients en SSR/HAD)
• Gestion, ressources humaines, numérique ou stratégie d'une association gestionnaire d'ESMS
• Environnement concurrentiel ou partenarial du secteur médico-social
Sois STRICT : en cas de doute, réponds pertinent: false.
Un article est NON PERTINENT si, même en le lisant en entier, un directeur d'ESMS n'en tirerait aucune information utile à son activité professionnelle — qu'il porte sur l'environnement, l'agriculture, les transports, la politique générale, le sport, ou tout autre sujet sans lien opérationnel avec les ESMS.
En cas de doute, réponds pertinent: false.
Si l'article est pertinent, classe-le dans l'un des types de veille suivants :
- "reglementaire" : textes de loi, décrets, circulaires, obligations légales, réformes institutionnelles, instructions ministérielles
@@ -66,6 +80,38 @@ Réponds UNIQUEMENT avec un objet JSON valide, sans texte autour, sans balises m
Si pertinent est false, typeVeille doit être null.`;
// ─── Prompt AAP : pertinence + catégorie secteur ─────────────────────────────
const PROMPT_CLASSIFICATION_AAP = `Tu es un expert des établissements et services sociaux et médico-sociaux (ESMS) en France.
Pose-toi cette unique question : "Un directeur d'ESMS ou un cadre de direction d'une association médico-sociale (Itinova) devrait-il répondre à cet appel à projets ou à cet appel à candidatures ?"
Un appel à projets est pertinent s'il vise à financer ou développer des actions concernant directement ou indirectement :
• Handicap (enfant et adulte) : services, dispositifs et financement liés au handicap ; ESAT, IME, ITEP, SESSAD, MAS, FAM, SAVS, SAMSAH, MDPH, RQTH, inclusion scolaire, emploi des personnes handicapées
• Précarité / Logement : les situations de précarité, d'exclusion, de vulnérabilité, d'accès aux droits, aux soins ou à l'insertion ; CHRS, SIAO, hébergement d'urgence, insertion par le logement, RSA, sans-abrisme, expulsion, personnes isolées ou en situation de vulnérabilité sociale
• Protection de l'enfance : ASE, MECS, famille d'accueil, pupilles de l'État, prévention spécialisée, mineurs non accompagnés
• Personnes âgées : accompagnement des personnes âgées, perte d'autonomie, soins associés ; EHPAD, SSIAD, résidences autonomie, APA, dépendance, plan grand âge
• Sanitaire : santé, soins, réadaptation ; SMR, HAD, cliniques de réadaptation, parcours de soins post-aigus
• Actions transversales bénéficiant aux usagers des ESMS : accès aux soins, inclusion numérique, lutte contre l'isolement, innovation sociale, bien-être des professionnels du secteur
Un appel à projets est NON PERTINENT si, même en le lisant en entier, un directeur d'ESMS ne pourrait pas y répondre ni en bénéficier — qu'il porte sur l'agriculture, l'environnement, les transports, la recherche fondamentale, le sport de haut niveau, ou tout autre domaine sans lien opérationnel avec les ESMS ou leurs usagers.
En cas de doute, réponds pertinent: false.
Si l'appel à projets est pertinent, identifie le secteur principal concerné parmi les 5 suivants (utilise exactement ces valeurs) :
- "Handicap" : bénéficiaires principaux = personnes en situation de handicap (enfant ou adulte)
- "Précarité" : bénéficiaires principaux = personnes en situation de précarité, d'exclusion, de vulnérabilité sociale ou d'isolement
- "Enfance" : bénéficiaires principaux = enfants et jeunes relevant de la protection de l'enfance
- "PA" : bénéficiaires principaux = personnes âgées ou en perte d'autonomie
- "Sanitaire" : bénéficiaires principaux = patients ou professionnels du sanitaire
Si l'appel à projets est pertinent mais concerne plusieurs secteurs à égalité, choisis celui qui représente le plus grand volume d'activité d'Itinova. Si aucune catégorie ne convient clairement, utilise "Autre".
Réponds UNIQUEMENT avec un objet JSON valide, sans texte autour, sans balises markdown :
{"pertinent": true/false, "categorie": "Handicap"|"Précarité"|"Enfance"|"PA"|"Sanitaire"|"Autre"|null, "raison": "explication courte en une phrase"}
Si pertinent est false, categorie doit être null.`;
// ─── Prompt résumé IA ─────────────────────────────────────────────────────────
const PROMPT_RESUME = `Tu es un expert des établissements et services sociaux et médico-sociaux (ESMS).
@@ -99,20 +145,19 @@ function parseJsonSafe<T>(text: string): T | null {
// ─── Classification principale ────────────────────────────────────────────────
/**
* Classifie un article en une seule étape via l'IA.
* Détermine simultanément la pertinence et le type de veille.
* Classifie un article de veille stratégique : pertinence + type de veille.
* Retourne toujours un résultat (fallback sur rules en cas d'erreur).
*
* @param titre Titre de l'article
* @param resume Résumé / description de l'article
* @param fallbackFn Fonction de fallback appelée si l'IA échoue
*/
export async function classifyArticle(
titre: string,
resume: string,
fallbackFn: () => { typeVeille: AiTypeVeille }
fallbackFn: () => { typeVeille: AiTypeVeille },
contenuPage?: string | null
): Promise<AiClassificationResult> {
const userContent = `Titre : ${titre}\n\nRésumé : ${resume}`;
const contenuSection = contenuPage
? `\n\nExtrait de l'article (premiers paragraphes) : ${contenuPage}`
: "";
const userContent = `Titre : ${titre}\n\nRésumé RSS : ${resume}${contenuSection}`;
try {
const response = await invokeLLM({
@@ -131,10 +176,7 @@ export async function classifyArticle(
pertinent: { type: "boolean" },
typeVeille: {
anyOf: [
{
type: "string",
enum: ["reglementaire", "concurrentielle", "technologique", "informationnelle"],
},
{ type: "string", enum: ["reglementaire", "concurrentielle", "technologique", "informationnelle"] },
{ type: "null" },
],
},
@@ -149,31 +191,102 @@ export async function classifyArticle(
const rawContent = response?.choices?.[0]?.message?.content;
if (!rawContent) throw new Error("Réponse LLM vide");
const content = typeof rawContent === "string" ? rawContent : JSON.stringify(rawContent);
const parsed = parseJsonSafe<{
pertinent: boolean;
typeVeille: AiTypeVeille | null;
raison: string;
}>(content);
const parsed = parseJsonSafe<{ pertinent: boolean; typeVeille: AiTypeVeille | null; raison: string }>(content);
if (!parsed) throw new Error("JSON LLM invalide");
return {
relevant: parsed.pertinent,
typeVeille: parsed.pertinent ? (parsed.typeVeille ?? "informationnelle") : null,
categorieAap: null,
reason: parsed.raison,
classifiedBy: "ia",
technicalError: null,
};
} catch (e) {
console.error("[AI Classifier] Erreur classification:", (e as Error).message);
// Fallback sur les règles
const technicalError = getTechnicalErrorMessage(e);
console.error("[AI Classifier] Erreur classification veille:", technicalError);
const fb = fallbackFn();
return {
relevant: true,
typeVeille: fb.typeVeille,
categorieAap: null,
reason: "Classification par règles (erreur LLM)",
classifiedBy: "rules",
technicalError,
};
}
}
/**
* Classifie un appel à projet : pertinence + catégorie secteur (Handicap/PA/Enfance/Précarité/Sanitaire).
* Retourne toujours un résultat (fallback sur rules en cas d'erreur).
*/
export async function classifyAap(
titre: string,
resume: string,
fallbackCategorie: AiCategorieAap,
contenuPage?: string | null
): Promise<AiClassificationResult> {
const contenuSection = contenuPage
? `\n\nExtrait de l'article (premiers paragraphes) : ${contenuPage}`
: "";
const userContent = `Titre : ${titre}\n\nRésumé RSS : ${resume}${contenuSection}`;
try {
const response = await invokeLLM({
messages: [
{ role: "system", content: PROMPT_CLASSIFICATION_AAP },
{ role: "user", content: userContent },
],
response_format: {
type: "json_schema",
json_schema: {
name: "classification_aap_result",
strict: true,
schema: {
type: "object",
properties: {
pertinent: { type: "boolean" },
categorie: {
anyOf: [
{ type: "string", enum: ["Handicap", "Précarité", "Enfance", "PA", "Sanitaire", "Autre"] },
{ type: "null" },
],
},
raison: { type: "string" },
},
required: ["pertinent", "categorie", "raison"],
additionalProperties: false,
},
},
},
});
const rawContent = response?.choices?.[0]?.message?.content;
if (!rawContent) throw new Error("Réponse LLM vide");
const content = typeof rawContent === "string" ? rawContent : JSON.stringify(rawContent);
const parsed = parseJsonSafe<{ pertinent: boolean; categorie: AiCategorieAap | null; raison: string }>(content);
if (!parsed) throw new Error("JSON LLM invalide");
return {
relevant: parsed.pertinent,
typeVeille: null,
categorieAap: parsed.pertinent ? (parsed.categorie ?? "Autre") : null,
reason: parsed.raison,
classifiedBy: "ia",
technicalError: null,
};
} catch (e) {
const technicalError = getTechnicalErrorMessage(e);
console.error("[AI Classifier] Erreur classification AAP:", technicalError);
return {
relevant: true,
typeVeille: null,
categorieAap: fallbackCategorie,
reason: "Classification par règles (erreur LLM)",
classifiedBy: "rules",
technicalError,
};
}
}

94
server/articleFetcher.ts Normal file
View File

@@ -0,0 +1,94 @@
/**
* articleFetcher.ts
*
* Récupère le contenu textuel des 500 premiers mots d'un article web
* pour enrichir la classification IA quand le résumé RSS est trop court.
*
* Stratégie :
* - Fetch HTTP avec timeout 5 secondes
* - Extraction des balises <p>, <h1><h3> du HTML brut (sans dépendance lourde)
* - Limite à 500 mots pour rester dans le contexte LLM
* - Retourne null en cas d'erreur (timeout, 403, paywall, JS-only…) — silencieux
*/
const FETCH_TIMEOUT_MS = 5_000;
const MAX_WORDS = 500;
/**
* Extrait le texte brut des balises de contenu d'un HTML.
* Approche légère sans parser DOM complet.
*/
function extractTextFromHtml(html: string): string {
// Supprimer les balises script, style, nav, header, footer, aside
let cleaned = html
.replace(/<script[\s\S]*?<\/script>/gi, " ")
.replace(/<style[\s\S]*?<\/style>/gi, " ")
.replace(/<nav[\s\S]*?<\/nav>/gi, " ")
.replace(/<header[\s\S]*?<\/header>/gi, " ")
.replace(/<footer[\s\S]*?<\/footer>/gi, " ")
.replace(/<aside[\s\S]*?<\/aside>/gi, " ")
.replace(/<noscript[\s\S]*?<\/noscript>/gi, " ");
// Extraire le contenu des balises de texte pertinentes
const contentTags = cleaned.match(/<(p|h[1-3]|li|blockquote)[^>]*>([\s\S]*?)<\/\1>/gi) ?? [];
const texts = contentTags.map((tag) =>
tag
.replace(/<[^>]+>/g, " ") // supprimer les balises HTML restantes
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&nbsp;/g, " ")
.replace(/&quot;/g, '"')
.replace(/&#\d+;/g, " ")
.replace(/\s+/g, " ")
.trim()
);
return texts.filter((t) => t.length > 20).join(" ");
}
/**
* Récupère les premiers ~500 mots du contenu d'un article web.
* Retourne null si l'article est inaccessible ou si le contenu est trop pauvre.
*
* @param url URL de l'article
* @returns Texte extrait (500 mots max) ou null
*/
export async function fetchArticleFirstParagraph(url: string): Promise<string | null> {
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
const response = await fetch(url, {
signal: controller.signal,
headers: {
"User-Agent":
"Mozilla/5.0 (compatible; VeilleBot/1.0; +https://itinova.fr)",
Accept: "text/html,application/xhtml+xml",
"Accept-Language": "fr-FR,fr;q=0.9",
},
});
clearTimeout(timer);
if (!response.ok) return null;
const contentType = response.headers.get("content-type") ?? "";
if (!contentType.includes("text/html")) return null;
const html = await response.text();
const text = extractTextFromHtml(html);
if (!text || text.length < 50) return null;
// Limiter à MAX_WORDS mots
const words = text.split(/\s+/);
const truncated = words.slice(0, MAX_WORDS).join(" ");
return truncated.length > 50 ? truncated : null;
} catch {
// Timeout, réseau, CORS, paywall, etc. — silencieux
return null;
}
}

View File

@@ -0,0 +1,44 @@
import { describe, expect, it, vi } from "vitest";
import { isDuplicateEntryError, persistArticleReads, readArticleIdsFromDb } from "./db";
function createReadDb(existingIds: number[] = []) {
const insertValues = vi.fn().mockResolvedValue(undefined);
const insert = vi.fn(() => ({ values: insertValues }));
const where = vi.fn().mockResolvedValue(existingIds.map((articleId) => ({ articleId })));
const from = vi.fn(() => ({ where }));
const select = vi.fn(() => ({ from }));
return { db: { select, insert }, insertValues };
}
describe("isDuplicateEntryError", () => {
it("identifie les erreurs de contrainte unique MySQL", () => {
expect(isDuplicateEntryError({ code: "ER_DUP_ENTRY" })).toBe(true);
expect(isDuplicateEntryError({ cause: { code: "ER_DUP_ENTRY" } })).toBe(true);
});
it("ne masque jamais une erreur SQL non liée à un doublon", () => {
expect(isDuplicateEntryError(new Error("Field 'readAt' doesn't have a default value"))).toBe(false);
expect(isDuplicateEntryError({ code: "ER_NO_DEFAULT_FOR_FIELD" })).toBe(false);
expect(isDuplicateEntryError(null)).toBe(false);
});
});
describe("persistance des articles lus", () => {
it("insère uniquement les articles encore non lus et déduplique la demande", async () => {
const { db, insertValues } = createReadDb([10]);
const inserted = await persistArticleReads(db, 2, "veille", [10, 12, 12, 13]);
expect(inserted).toBe(2);
expect(insertValues).toHaveBeenCalledWith([
{ userId: 2, articleType: "veille", articleId: 12 },
{ userId: 2, articleType: "veille", articleId: 13 },
]);
});
it("restaure les identifiants lus stockés pour le bon utilisateur et le bon flux", async () => {
const { db } = createReadDb([4, 9]);
await expect(readArticleIdsFromDb(db, 2, "aap")).resolves.toEqual([4, 9]);
});
});

47
server/azureAuth.test.ts Normal file
View File

@@ -0,0 +1,47 @@
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
const azureTestConfig = {
tenantId: "00000000-0000-0000-0000-000000000001",
clientId: "00000000-0000-0000-0000-000000000002",
clientSecret: "test-secret-not-used-for-url",
};
const originalAzureEnv = {
tenantId: process.env.AZURE_AD_TENANT_ID,
clientId: process.env.AZURE_AD_CLIENT_ID,
clientSecret: process.env.AZURE_AD_CLIENT_SECRET,
redirectUri: process.env.AZURE_AD_REDIRECT_URI,
};
describe("Azure AD configuration", () => {
beforeEach(() => {
// Chaque test utilise une instance MSAL neuve et ne dépend jamais des secrets CI.
vi.resetModules();
process.env.AZURE_AD_TENANT_ID = azureTestConfig.tenantId;
process.env.AZURE_AD_CLIENT_ID = azureTestConfig.clientId;
process.env.AZURE_AD_CLIENT_SECRET = azureTestConfig.clientSecret;
process.env.AZURE_AD_REDIRECT_URI = "https://example.test/api/auth/azure/callback";
});
afterAll(() => {
process.env.AZURE_AD_TENANT_ID = originalAzureEnv.tenantId;
process.env.AZURE_AD_CLIENT_ID = originalAzureEnv.clientId;
process.env.AZURE_AD_CLIENT_SECRET = originalAzureEnv.clientSecret;
process.env.AZURE_AD_REDIRECT_URI = originalAzureEnv.redirectUri;
});
it("détecte Azure AD lorsquune configuration complète est fournie", async () => {
const { isAzureAdConfigured } = await import("./azureAuth");
const configured = isAzureAdConfigured();
expect(configured).toBe(true);
});
it("génère une URL dautorisation Azure AD à partir de la configuration de test", async () => {
const { getAzureAuthUrl } = await import("./azureAuth");
const url = await getAzureAuthUrl();
expect(url).toContain("login.microsoftonline.com");
expect(url).toContain("oauth2/v2.0/authorize");
expect(url).toContain(azureTestConfig.tenantId);
expect(url).toContain(azureTestConfig.clientId);
});
});

76
server/azureAuth.ts Normal file
View File

@@ -0,0 +1,76 @@
import { ConfidentialClientApplication } from "@azure/msal-node";
// ─── Azure AD Authentication ──────────────────────────────────────────────────
let msalClient: ConfidentialClientApplication | null = null;
/**
* Vérifie que les 3 variables d'environnement Azure AD sont présentes
*/
export function isAzureAdConfigured(): boolean {
return !!(
process.env.AZURE_AD_TENANT_ID &&
process.env.AZURE_AD_CLIENT_ID &&
process.env.AZURE_AD_CLIENT_SECRET
);
}
/**
* Instancie le client MSAL (lazy, singleton)
*/
function getMsalClient(): ConfidentialClientApplication {
if (!isAzureAdConfigured()) {
throw new Error("Azure AD is not configured");
}
if (!msalClient) {
msalClient = new ConfidentialClientApplication({
auth: {
clientId: process.env.AZURE_AD_CLIENT_ID!,
authority: `https://login.microsoftonline.com/${process.env.AZURE_AD_TENANT_ID}`,
clientSecret: process.env.AZURE_AD_CLIENT_SECRET!,
},
});
}
return msalClient;
}
/**
* Retourne l'URL de redirection Azure AD pour l'utilisateur
*/
export async function getAzureAuthUrl(): Promise<string> {
const client = getMsalClient();
const redirectUri =
process.env.AZURE_AD_REDIRECT_URI ||
"http://localhost:3000/api/auth/azure/callback";
return client.getAuthCodeUrl({
scopes: ["user.read"],
redirectUri,
});
}
/**
* Échange le code OAuth contre un token et retourne les infos utilisateur.
* azureAdId = homeAccountId = "{objectId}.{tenantId}" (~73 caractères)
*/
export async function handleAzureCallback(code: string) {
const client = getMsalClient();
const redirectUri =
process.env.AZURE_AD_REDIRECT_URI ||
"http://localhost:3000/api/auth/azure/callback";
const response = await client.acquireTokenByCode({
code,
scopes: ["user.read"],
redirectUri,
});
if (!response || !response.account) {
throw new Error("Failed to acquire token from Azure AD");
}
return {
azureAdId: response.account.homeAccountId, // "{objectId}.{tenantId}"
email: response.account.username, // UPN (ex: user@domain.com)
name: response.account.name || response.account.username,
};
}

View File

@@ -1,4 +1,4 @@
import { eq, desc, and, like, gte, lte, or, sql } from "drizzle-orm";
import { count, desc, and, eq, inArray, like, gte, lte, or, sql } from "drizzle-orm";
import { drizzle } from "drizzle-orm/mysql2";
import mysql from "mysql2/promise";
import {
@@ -14,9 +14,13 @@ import {
InsertIdea,
rssFeeds,
rssSettings,
processedDedupKeys,
articleReads,
classificationErrors,
type InsertRssFeed,
type InsertRssSettings,
type ImportLog,
type ClassificationError,
type RssFeed,
type RssSettings,
} from "../drizzle/schema";
@@ -43,6 +47,114 @@ export async function getDb() {
return _db;
}
// ─── Lectures d'articles ────────────────────────────────────────────────────
/** Les deux collections suivies par le marquage lu/non lu. */
export type ArticleReadType = "veille" | "aap";
/**
* Un doublon est attendu lorsqu'un utilisateur rouvre rapidement le même article.
* Toute autre erreur SQL doit rester visible afin de ne jamais perdre une lecture
* silencieusement, comme cela s'était produit avec une colonne readAt invalide.
*/
export function isDuplicateEntryError(error: unknown): boolean {
if (!error || typeof error !== "object") return false;
const databaseError = error as { code?: unknown; cause?: unknown };
const cause = databaseError.cause as { code?: unknown } | undefined;
return databaseError.code === "ER_DUP_ENTRY" || cause?.code === "ER_DUP_ENTRY";
}
/** Supprime les marqueurs de lecture devenus orphelins après suppression d'articles. */
export async function removeArticleReadRecords(articleType: ArticleReadType, articleIds: number[]): Promise<void> {
const uniqueIds = Array.from(new Set(articleIds));
if (uniqueIds.length === 0) return;
const db = await getDb();
if (!db) throw new Error("Database not available");
await db.delete(articleReads).where(and(
eq(articleReads.articleType, articleType),
inArray(articleReads.articleId, uniqueIds),
));
}
/**
* Insère les lectures encore absentes pour un utilisateur.
* L'unicité (userId, articleType, articleId) est garantie par le schéma SQL ; le
* pré-filtrage évite néanmoins une écriture inutile sur chaque ouverture de détail.
*/
export async function markArticlesAsRead(
userId: number,
articleType: ArticleReadType,
articleIds: number[],
): Promise<number> {
const db = await getDb();
if (!db) throw new Error("Database not available");
return persistArticleReads(db, userId, articleType, articleIds);
}
/**
* Cœur testable de l'écriture des lectures. Le routeur ne lui transmet qu'une
* connexion Drizzle déjà ouverte ; aucune règle métier ne dépend de l'interface HTTP.
*/
export async function persistArticleReads(
db: any,
userId: number,
articleType: ArticleReadType,
articleIds: number[],
): Promise<number> {
const uniqueIds = Array.from(new Set(articleIds));
if (uniqueIds.length === 0) return 0;
const existingReads = await db
.select({ articleId: articleReads.articleId })
.from(articleReads)
.where(and(
eq(articleReads.userId, userId),
eq(articleReads.articleType, articleType),
inArray(articleReads.articleId, uniqueIds),
));
const existingIds = new Set(existingReads.map((read: { articleId: number }) => read.articleId));
const unreadIds = uniqueIds.filter((articleId) => !existingIds.has(articleId));
if (unreadIds.length === 0) return 0;
try {
await db.insert(articleReads).values(unreadIds.map((articleId) => ({ userId, articleType, articleId })));
return unreadIds.length;
} catch (error) {
if (isDuplicateEntryError(error)) return 0;
throw error;
}
}
/** Retourne les identifiants lus, utilisés pour restaurer l'état utilisateur après connexion. */
export async function getReadArticleIds(userId: number, articleType: ArticleReadType): Promise<number[]> {
const db = await getDb();
if (!db) throw new Error("Database not available");
return readArticleIdsFromDb(db, userId, articleType);
}
/** Cœur testable de la restitution de l'état lu/non lu après reconnexion. */
export async function readArticleIdsFromDb(db: any, userId: number, articleType: ArticleReadType): Promise<number[]> {
const rows = await db
.select({ articleId: articleReads.articleId })
.from(articleReads)
.where(and(eq(articleReads.userId, userId), eq(articleReads.articleType, articleType)));
return rows.map((read: { articleId: number }) => read.articleId);
}
/** Calcule le nombre d'articles non lus à partir d'un total métier fourni par le routeur. */
export async function getUnreadArticleCount(userId: number, articleType: ArticleReadType, totalItems: number): Promise<number> {
const db = await getDb();
if (!db) throw new Error("Database not available");
const rows = await db
.select({ total: count() })
.from(articleReads)
.where(and(eq(articleReads.userId, userId), eq(articleReads.articleType, articleType)));
return Math.max(0, totalItems - (rows[0]?.total ?? 0));
}
// ─── Users (Manus OAuth) ─────────────────────────────────────────────────────
export async function upsertUser(user: InsertUser): Promise<void> {
@@ -116,6 +228,51 @@ export async function deleteLocalUser(id: number) {
await db.delete(localUsers).where(eq(localUsers.id, id));
}
export async function getLocalUserByAzureAdId(azureAdId: string) {
const db = await getDb();
if (!db) return null;
const results = await db.select().from(localUsers).where(eq(localUsers.azureAdId, azureAdId)).limit(1);
return results[0] ?? null;
}
export async function getLocalUserByEmail(email: string) {
const db = await getDb();
if (!db) return null;
const results = await db.select().from(localUsers).where(eq(localUsers.email, email)).limit(1);
return results[0] ?? null;
}
export async function upsertLocalUserAzure(data: {
email: string;
name?: string;
azureAdId: string;
role?: "admin" | "user" | "readonly";
}) {
const db = await getDb();
if (!db) throw new Error("DB unavailable");
// Chercher si l'utilisateur existe déjà par email
const existing = await getLocalUserByEmail(data.email);
if (existing) {
// Lier le compte existant à Azure AD
await db.update(localUsers)
.set({ azureAdId: data.azureAdId, ...(data.name && { name: data.name }) })
.where(eq(localUsers.id, existing.id));
return existing.id;
} else {
// Créer un nouveau compte (sans mot de passe — connexion Azure uniquement)
const result = await db.insert(localUsers).values({
name: data.name ?? data.email,
username: data.email,
email: data.email,
passwordHash: "", // Pas de mot de passe local
role: data.role ?? "user",
isActive: true,
azureAdId: data.azureAdId,
});
return (result as any)[0]?.insertId ?? null;
}
}
// ─── Veille Items ─────────────────────────────────────────────────────────────
export interface VeilleFilters {
@@ -334,6 +491,49 @@ export async function getImportStats() {
};
}
// ─── Rapport d'erreurs de classification RSS ──────────────────────────────────
export interface ClassificationErrorInput {
feedId: number | null;
feedName: string;
feedType: "veille" | "aap";
articleTitle: string;
articleUrl: string | null;
errorMessage: string;
}
/**
* Enregistre un fallback IA sans interrompre le traitement du flux RSS.
* La collecte reste opérationnelle : l'erreur est visible dans l'administration
* tandis que l'article est classé par la règle de repli prévue.
*/
export async function recordClassificationError(input: ClassificationErrorInput): Promise<void> {
const db = await getDb();
if (!db) {
console.error("[Classification errors] Base indisponible : erreur non journalisée");
return;
}
try {
await db.insert(classificationErrors).values(input);
} catch (error) {
console.error("[Classification errors] Échec de journalisation :", error);
}
}
/** Liste paginée des fallbacks IA, réservée aux administrateurs via le routeur. */
export async function getClassificationErrors(page: number, pageSize: number): Promise<{ errors: ClassificationError[]; total: number }> {
const db = await getDb();
if (!db) return { errors: [], total: 0 };
const offset = (page - 1) * pageSize;
const [errors, totals] = await Promise.all([
db.select().from(classificationErrors).orderBy(desc(classificationErrors.occurredAt)).limit(pageSize).offset(offset),
db.select({ total: count() }).from(classificationErrors),
]);
return { errors, total: Number(totals[0]?.total ?? 0) };
}
// ─── Boîte à idées ────────────────────────────────────────────────────────────
export async function createIdea(data: InsertIdea) {
@@ -444,9 +644,35 @@ export async function saveRssSettings(data: Partial<Omit<InsertRssSettings, "id"
}
// ─── Purge ───────────────────────────────────────────────────────────────────
export async function purgeOldArticles(retentionMonths: number): Promise<{ veille: number; aap: number; tombstones: number }> {
const db = await getDb();
if (!db) throw new Error("Database not available");
const cutoff = new Date();
cutoff.setMonth(cutoff.getMonth() - retentionMonths);
// Les marqueurs de lecture ne doivent jamais survivre à l'article auquel ils se rapportent.
const oldVeilleItems = await db.select({ id: veilleItems.id }).from(veilleItems).where(lte(veilleItems.importedAt, cutoff));
const oldAapItems = await db.select({ id: aapItems.id }).from(aapItems).where(lte(aapItems.importedAt, cutoff));
await removeArticleReadRecords("veille", oldVeilleItems.map((item: { id: number }) => item.id));
await removeArticleReadRecords("aap", oldAapItems.map((item: { id: number }) => item.id));
const veilleResult = await db.delete(veilleItems).where(lte(veilleItems.importedAt, cutoff));
const aapResult = await db.delete(aapItems).where(lte(aapItems.importedAt, cutoff));
// Purge des tombstones (processed_dedup_keys) de plus de 6 mois
const tombstoneCutoff = new Date();
tombstoneCutoff.setMonth(tombstoneCutoff.getMonth() - 6);
const tombstoneResult = await db.delete(processedDedupKeys).where(lte(processedDedupKeys.processedAt, tombstoneCutoff));
return {
veille: (veilleResult as any).affectedRows ?? 0,
aap: (aapResult as any).affectedRows ?? 0,
tombstones: (tombstoneResult as any).affectedRows ?? 0,
};
}
export async function purgeVeilleItems(): Promise<number> {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db.delete(articleReads).where(eq(articleReads.articleType, "veille"));
const result = await db.delete(veilleItems);
return (result as any).affectedRows ?? 0;
}
@@ -454,6 +680,7 @@ export async function purgeVeilleItems(): Promise<number> {
export async function purgeAapItems(): Promise<number> {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db.delete(articleReads).where(eq(articleReads.articleType, "aap"));
const result = await db.delete(aapItems);
return (result as any).affectedRows ?? 0;
}

View File

@@ -15,6 +15,7 @@ import {
setSettings,
getImportLogs,
getImportStats,
getClassificationErrors,
getLocalUsers,
createLocalUser,
updateLocalUser,
@@ -31,14 +32,19 @@ import {
deleteRssFeed,
getRssSettings,
saveRssSettings,
getDb,
getReadArticleIds,
getUnreadArticleCount,
markArticlesAsRead,
removeArticleReadRecords,
} from "./db";
import { importVeille, importAAP, runFullImport, getImportConfig } from "./importer";
import { scheduleRssFetch } from "./_core/index";
import { scheduleDailyImport } from "./_core/index";
import { loginLocalUser, hashPassword, ensureAdminExists } from "./localAuth";
import { classifyArticle } from "./aiClassifier";
import { getDb } from "./db";
import { veilleItems, aapItems, articleReads } from "../drizzle/schema";
import { isNull, or, eq as eqDrizzle, and, inArray, count } from "drizzle-orm";
import { isAzureAdConfigured, getAzureAuthUrl } from "./azureAuth";
import { classifyAap, classifyArticle } from "./aiClassifier";
import { veilleItems, aapItems, processedDedupKeys } from "../drizzle/schema";
import { isNull, or, eq as eqDrizzle } from "drizzle-orm";
// ─── Middleware admin ─────────────────────────────────────────────────────────
@@ -80,6 +86,17 @@ export const appRouter = router({
ctx.res.clearCookie("veille_local_auth", { ...cookieOptions, maxAge: -1 });
return { success: true };
}),
// Azure AD
isAzureAdAvailable: publicProcedure.query(() => {
return { available: isAzureAdConfigured() };
}),
getAzureLoginUrl: publicProcedure.query(async () => {
if (!isAzureAdConfigured()) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Azure AD non configur\u00e9" });
}
const url = await getAzureAuthUrl();
return { url };
}),
}),
// ─── Veille ─────────────────────────────────────────────────────────────────
@@ -116,14 +133,15 @@ export const appRouter = router({
const db = await getDb();
if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB indisponible" });
// Récupérer les articles non encore classés par l'IA
// Récupérer les articles non encore classés par l'IA OU classés par le fallback rules
const rows = await db
.select()
.from(veilleItems)
.where(or(isNull(veilleItems.iaClassifiedBy), isNull(veilleItems.iaRelevant)))
.where(or(isNull(veilleItems.iaClassifiedBy), isNull(veilleItems.iaRelevant), eqDrizzle(veilleItems.iaClassifiedBy, "rules")))
.limit(input.limit);
let processed = 0;
let deleted = 0;
let errors = 0;
for (const row of rows) {
@@ -133,6 +151,18 @@ export const appRouter = router({
row.resume || "",
() => ({ typeVeille: (row.typeVeille || "informationnelle") as "reglementaire" | "concurrentielle" | "technologique" | "informationnelle" })
);
if (!aiResult.relevant) {
// Supprimer l'article non pertinent
await removeArticleReadRecords("veille", [row.id]);
await db.delete(veilleItems).where(eqDrizzle(veilleItems.id, row.id));
// Conserver le tombstone pour éviter la réinsertion
if (row.dedupKey) {
await db.insert(processedDedupKeys)
.values({ dedupKey: row.dedupKey, feedType: "veille" })
.onDuplicateKeyUpdate({ set: { dedupKey: row.dedupKey } });
}
deleted++;
} else {
await db
.update(veilleItems)
.set({
@@ -143,63 +173,40 @@ export const appRouter = router({
})
.where(eqDrizzle(veilleItems.id, row.id));
processed++;
}
} catch {
errors++;
}
}
return { processed, errors, total: rows.length };
return { processed, deleted, errors, total: rows.length };
}),
// ─── Marquage lu/non lu ──────────────────────────────────────────────────────────────────────
// ─── Marquage lu/non lu ─────────────────────────────────────────────────
markAsRead: protectedProcedure
.input(z.object({ articleId: z.number().int().positive() }))
.mutation(async ({ input, ctx }) => {
const db = await getDb();
if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB indisponible" });
// Insérer seulement si pas déjà lu (ignore le doublon)
try {
await db.insert(articleReads).values({
userId: ctx.user.id,
articleType: "veille",
articleId: input.articleId,
});
} catch { /* doublon = déjà lu, on ignore */ }
return { success: true };
const marked = await markArticlesAsRead(ctx.user.id, "veille", [input.articleId]);
return { success: true, marked };
}),
markAllAsRead: protectedProcedure.mutation(async ({ ctx }) => {
const db = await getDb();
if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB indisponible" });
// Récupérer tous les IDs veille
// Les IDs sont lus à l'instant de la mutation : aucun élément filtré ne peut être marqué par erreur.
const allItems = await db.select({ id: veilleItems.id }).from(veilleItems);
const allIds = allItems.map((r: { id: number }) => r.id);
// Trouver ceux déjà lus
const alreadyRead = await db
.select({ articleId: articleReads.articleId })
.from(articleReads)
.where(and(eqDrizzle(articleReads.userId, ctx.user.id), eqDrizzle(articleReads.articleType, "veille")));
const alreadyReadIds = new Set(alreadyRead.map((r: { articleId: number }) => r.articleId));
const toInsert = allIds.filter((id: number) => !alreadyReadIds.has(id)).map((id: number) => ({
userId: ctx.user.id, articleType: "veille" as const, articleId: id,
}));
if (toInsert.length > 0) {
await db.insert(articleReads).values(toInsert);
}
return { success: true, marked: toInsert.length };
const marked = await markArticlesAsRead(ctx.user.id, "veille", allItems.map((item: { id: number }) => item.id));
return { success: true, marked };
}),
unreadCount: protectedProcedure.query(async ({ ctx }) => {
const db = await getDb();
if (!db) return { count: 0 };
const totalRows = await db.select({ cnt: count() }).from(veilleItems);
const total = totalRows[0]?.cnt ?? 0;
const readRows = await db
.select({ cnt: count() })
.from(articleReads)
.where(and(eqDrizzle(articleReads.userId, ctx.user.id), eqDrizzle(articleReads.articleType, "veille")));
const read = readRows[0]?.cnt ?? 0;
return { count: Math.max(0, total - read) };
if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB indisponible" });
const totalRows = await db.select({ id: veilleItems.id }).from(veilleItems);
return { count: await getUnreadArticleCount(ctx.user.id, "veille", totalRows.length) };
}),
getReadIds: protectedProcedure.query(async ({ ctx }) => {
return { ids: await getReadArticleIds(ctx.user.id, "veille") };
}),
}),
// ─── AAPP ────────────────────────────────────────────────────────────────────
@@ -240,85 +247,77 @@ export const appRouter = router({
const rows = await db
.select()
.from(aapItems)
.where(or(isNull(aapItems.iaClassifiedBy), isNull(aapItems.iaRelevant)))
.where(or(isNull(aapItems.iaClassifiedBy), isNull(aapItems.iaRelevant), eqDrizzle(aapItems.iaClassifiedBy, "rules")))
.limit(input.limit);
let processed = 0;
let deleted = 0;
let errors = 0;
for (const row of rows) {
try {
const aiResult = await classifyArticle(
const aiResult = await classifyAap(
row.titre || "",
"",
() => ({ typeVeille: "informationnelle" as const })
row.resume || "",
row.categorie
);
if (!aiResult.relevant) {
await removeArticleReadRecords("aap", [row.id]);
await db.delete(aapItems).where(eqDrizzle(aapItems.id, row.id));
if (row.dedupKey) {
await db.insert(processedDedupKeys)
.values({ dedupKey: row.dedupKey, feedType: "aap" })
.onDuplicateKeyUpdate({ set: { dedupKey: row.dedupKey } });
}
deleted++;
} else {
await db
.update(aapItems)
.set({
categorie: aiResult.categorieAap ?? row.categorie,
iaRelevant: aiResult.relevant,
iaCategorie: aiResult.categorieAap,
iaClassifiedBy: aiResult.classifiedBy,
iaReason: aiResult.reason,
})
.where(eqDrizzle(aapItems.id, row.id));
processed++;
}
} catch {
errors++;
}
}
return { processed, errors, total: rows.length };
return { processed, deleted, errors, total: rows.length };
}),
// ─── Marquage lu/non lu AAP ──────────────────────────────────────────────────────────────────────
// ─── Marquage lu/non lu AAP ───────────────────────────────────────────────
markAsRead: protectedProcedure
.input(z.object({ articleId: z.number().int().positive() }))
.mutation(async ({ input, ctx }) => {
const db = await getDb();
if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB indisponible" });
try {
await db.insert(articleReads).values({
userId: ctx.user.id,
articleType: "aap",
articleId: input.articleId,
});
} catch { /* doublon = déjà lu, on ignore */ }
return { success: true };
const marked = await markArticlesAsRead(ctx.user.id, "aap", [input.articleId]);
return { success: true, marked };
}),
markAllAsRead: protectedProcedure.mutation(async ({ ctx }) => {
const db = await getDb();
if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB indisponible" });
const allItems = await db.select({ id: aapItems.id }).from(aapItems);
const allIds = allItems.map((r: { id: number }) => r.id);
const alreadyRead = await db
.select({ articleId: articleReads.articleId })
.from(articleReads)
.where(and(eqDrizzle(articleReads.userId, ctx.user.id), eqDrizzle(articleReads.articleType, "aap")));
const alreadyReadIds = new Set(alreadyRead.map((r: { articleId: number }) => r.articleId));
const toInsert = allIds.filter((id: number) => !alreadyReadIds.has(id)).map((id: number) => ({
userId: ctx.user.id, articleType: "aap" as const, articleId: id,
}));
if (toInsert.length > 0) {
await db.insert(articleReads).values(toInsert);
}
return { success: true, marked: toInsert.length };
const marked = await markArticlesAsRead(ctx.user.id, "aap", allItems.map((item: { id: number }) => item.id));
return { success: true, marked };
}),
unreadCount: protectedProcedure.query(async ({ ctx }) => {
const db = await getDb();
if (!db) return { count: 0 };
const totalRows = await db.select({ cnt: count() }).from(aapItems);
const total = totalRows[0]?.cnt ?? 0;
const readRows = await db
.select({ cnt: count() })
.from(articleReads)
.where(and(eqDrizzle(articleReads.userId, ctx.user.id), eqDrizzle(articleReads.articleType, "aap")));
const read = readRows[0]?.cnt ?? 0;
return { count: Math.max(0, total - read) };
if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB indisponible" });
const totalRows = await db.select({ id: aapItems.id }).from(aapItems);
return { count: await getUnreadArticleCount(ctx.user.id, "aap", totalRows.length) };
}),
getReadIds: protectedProcedure.query(async ({ ctx }) => {
return { ids: await getReadArticleIds(ctx.user.id, "aap") };
}),
}),
// ─── Importt ─────────────────────────────────────────────────────────────────
// ─── Import ─────────────────────────────────────────────────────────────────
import: router({
run: adminProcedure
.input(z.object({ type: z.enum(["veille", "aap", "all"]).default("all") }))
@@ -372,6 +371,11 @@ export const appRouter = router({
sharepoint_token: z.string().optional(),
auth_mode: z.enum(["local", "free"]).optional(),
import_time: z.string().optional(),
fetch_mode: z.enum(["scheduled", "weekly", "monthly", "interval"]).optional(),
fetch_interval_minutes: z.string().optional(),
fetch_day_of_week: z.string().optional(),
fetch_day_of_month: z.string().optional(),
retention_months: z.string().optional(),
})
)
.mutation(async ({ input }) => {
@@ -380,6 +384,10 @@ export const appRouter = router({
if (v !== undefined && v !== "••••••••") toSave[k] = v;
}
await setSettings(toSave);
// Recharger le cron si la planification a changé
if (toSave.import_time || toSave.fetch_mode || toSave.fetch_interval_minutes || toSave.fetch_day_of_week || toSave.fetch_day_of_month) {
await scheduleDailyImport();
}
return { success: true };
}),
}),
@@ -513,6 +521,11 @@ export const appRouter = router({
return getRssFeeds();
}),
// Consulter les fallbacks IA par article, sans exposer les erreurs aux utilisateurs standards.
classificationErrors: adminProcedure
.input(z.object({ page: z.number().int().min(1).default(1), pageSize: z.number().int().min(1).max(100).default(25) }))
.query(async ({ input }) => getClassificationErrors(input.page, input.pageSize)),
// Créer un flux
create: adminProcedure
.input(z.object({
@@ -592,8 +605,8 @@ export const appRouter = router({
}))
.mutation(async ({ input }) => {
await saveRssSettings(input);
// Recharger le planificateur RSS avec les nouveaux paramètres
await scheduleRssFetch();
// Recharger le planificateur (la lecture RSS est intégrée dans scheduleDailyImport)
await scheduleDailyImport();
return { success: true };
}),
}),

View File

@@ -14,15 +14,17 @@
*/
import { XMLParser } from "fast-xml-parser";
import * as crypto from "crypto";
import { getDb } from "./db";
import { getDb, recordClassificationError, removeArticleReadRecords } from "./db";
import {
rssFeeds,
veilleItems,
aapItems,
processedDedupKeys,
type RssFeed,
} from "../drizzle/schema";
import { eq, sql } from "drizzle-orm";
import { classifyArticle, generateSummary } from "./aiClassifier";
import { classifyArticle, classifyAap, generateSummary } from "./aiClassifier";
import { fetchArticleFirstParagraph } from "./articleFetcher";
// ─── Types internes ───────────────────────────────────────────────────────────
@@ -47,50 +49,106 @@ interface FetchResult {
newItems: number;
skippedItems: number;
mergedItems: number;
error?: string;
errorMessage?: string;
}
// ─── Dictionnaire des départements d'Auvergne-Rhône-Alpes ────────────────────
// IMPORTANT : les départements composés (Haute-Loire, Haute-Savoie, Puy-de-Dôme)
// doivent être AVANT leurs variantes simples (Loire, Savoie) pour éviter les faux positifs.
// ─── Dictionnaire des départements (AuRA, PACA, Nouvelle-Aquitaine, Occitanie, Bourgogne-FC) ──────
// IMPORTANT : les départements composés doivent être AVANT leurs variantes simples
// pour éviter les faux positifs (ex. Haute-Loire avant Loire).
const AURA_DEPARTMENTS: Array<{ pattern: RegExp; name: string; num: string }> = [
// Composés en premier
{ pattern: /haute-savoie|haute savoie|(?<!\d)74(?!\d)|\(74\)/i, name: "Haute-Savoie", num: "74" },
{ pattern: /haute-loire|haute loire|(?<!\d)43(?!\d)|\(43\)/i, name: "Haute-Loire", num: "43" },
{ pattern: /puy-de-d[oô]me|puy de d[oô]me|(?<!\d)63(?!\d)|\(63\)/i, name: "Puy-de-Dôme", num: "63" },
// Simples ensuite
{ pattern: /\bain\b|(?<!\d)01(?!\d)|\(01\)/i, name: "Ain", num: "01" },
{ pattern: /\ballier\b|(?<!\d)03(?!\d)|\(03\)/i, name: "Allier", num: "03" },
{ pattern: /\bard[eè]che\b|(?<!\d)07(?!\d)|\(07\)/i, name: "Ardèche", num: "07" },
{ pattern: /\bcantal\b|(?<!\d)15(?!\d)|\(15\)/i, name: "Cantal", num: "15" },
{ pattern: /\bdr[oô]me\b|(?<!\d)26(?!\d)|\(26\)/i, name: "Drôme", num: "26" },
{ pattern: /\bis[eè]re\b|(?<!\d)38(?!\d)|\(38\)/i, name: "Isère", num: "38" },
// Loire : exclure "Haute-Loire" déjà traité
{ pattern: /(?<!haute-)\bloire\b|(?<!\d)42(?!\d)|\(42\)/i, name: "Loire", num: "42" },
{ pattern: /\brhone\b|\brhône\b|(?<!\d)69(?!\d)|\(69\)|m[eé]tropole\s+de\s+lyon/i, name: "Rhône", num: "69" },
// Savoie : exclure "Haute-Savoie" déjà traité
{ pattern: /\bsavoie\b(?!.*haute)|(?<!\d)73(?!\d)|\(73\)/i, name: "Savoie", num: "73" },
const AURA_DEPARTMENTS: Array<{ pattern: RegExp; name: string; num: string; region: string }> = [
// ─── AUVERGNE-RHÔNE-ALPES ─────────────────────────────────────────────────────────────────
{ pattern: /haute-savoie|haute savoie/i, name: "Haute-Savoie", num: "74", region: "Auvergne-Rhône-Alpes" },
{ pattern: /haute-loire|haute loire/i, name: "Haute-Loire", num: "43", region: "Auvergne-Rhône-Alpes" },
{ pattern: /puy-de-d[oô]me|puy de d[oô]me/i, name: "Puy-de-Dôme", num: "63", region: "Auvergne-Rhône-Alpes" },
{ pattern: /\bain\b/i, name: "Ain", num: "01", region: "Auvergne-Rhône-Alpes" },
{ pattern: /\ballier\b/i, name: "Allier", num: "03", region: "Auvergne-Rhône-Alpes" },
{ pattern: /\bard[eè]che\b/i, name: "Ardèche", num: "07", region: "Auvergne-Rhône-Alpes" },
{ pattern: /\bcantal\b/i, name: "Cantal", num: "15", region: "Auvergne-Rhône-Alpes" },
{ pattern: /\bdr[oô]me\b/i, name: "Drôme", num: "26", region: "Auvergne-Rhône-Alpes" },
{ pattern: /\bis[eè]re\b/i, name: "Isère", num: "38", region: "Auvergne-Rhône-Alpes" },
{ pattern: /(?<!haute-)\bloire\b/i, name: "Loire", num: "42", region: "Auvergne-Rhône-Alpes" },
{ pattern: /\brhone\b|\brhône\b|m[eé]tropole\s+de\s+lyon/i, name: "Rhône", num: "69", region: "Auvergne-Rhône-Alpes" },
{ pattern: /\bsavoie\b(?!.*haute)/i, name: "Savoie", num: "73", region: "Auvergne-Rhône-Alpes" },
// ─── PROVENCE-ALPES-CÔTE D'AZUR (PACA) ────────────────────────────────────────────────────
{ pattern: /alpes-de-haute-provence|alpes de haute provence/i, name: "Alpes-de-Haute-Provence", num: "04", region: "Provence-Alpes-Côte d'Azur" },
{ pattern: /hautes-alpes|hautes alpes/i, name: "Hautes-Alpes", num: "05", region: "Provence-Alpes-Côte d'Azur" },
{ pattern: /alpes-maritimes|alpes maritimes/i, name: "Alpes-Maritimes", num: "06", region: "Provence-Alpes-Côte d'Azur" },
{ pattern: /bouches-du-rh[oô]ne|bouches du rh[oô]ne|marseille/i, name: "Bouches-du-Rhône", num: "13", region: "Provence-Alpes-Côte d'Azur" },
{ pattern: /\bvar\b/i, name: "Var", num: "83", region: "Provence-Alpes-Côte d'Azur" },
{ pattern: /\bvaucluse\b/i, name: "Vaucluse", num: "84", region: "Provence-Alpes-Côte d'Azur" },
// ─── NOUVELLE-AQUITAINE ───────────────────────────────────────────────────────────────────────────
{ pattern: /charente-maritime|charente maritime/i, name: "Charente-Maritime", num: "17", region: "Nouvelle-Aquitaine" },
{ pattern: /\bcharente\b/i, name: "Charente", num: "16", region: "Nouvelle-Aquitaine" },
{ pattern: /\bcorr[eè]ze\b/i, name: "Corrèze", num: "19", region: "Nouvelle-Aquitaine" },
{ pattern: /\bcreuse\b/i, name: "Creuse", num: "23", region: "Nouvelle-Aquitaine" },
{ pattern: /\bdordogne\b/i, name: "Dordogne", num: "24", region: "Nouvelle-Aquitaine" },
{ pattern: /\bgironde\b|bordeaux/i, name: "Gironde", num: "33", region: "Nouvelle-Aquitaine" },
{ pattern: /\blandes\b/i, name: "Landes", num: "40", region: "Nouvelle-Aquitaine" },
{ pattern: /lot-et-garonne|lot et garonne/i, name: "Lot-et-Garonne", num: "47", region: "Nouvelle-Aquitaine" },
{ pattern: /pyr[eé]n[eé]es-atlantiques|pyr[eé]n[eé]es atlantiques/i, name: "Pyrénées-Atlantiques", num: "64", region: "Nouvelle-Aquitaine" },
{ pattern: /deux-s[eè]vres|deux s[eè]vres/i, name: "Deux-Sèvres", num: "79", region: "Nouvelle-Aquitaine" },
{ pattern: /haute-vienne|haute vienne/i, name: "Haute-Vienne", num: "87", region: "Nouvelle-Aquitaine" },
{ pattern: /\bvienne\b/i, name: "Vienne", num: "86", region: "Nouvelle-Aquitaine" },
// ─── OCCITANIE ───────────────────────────────────────────────────────────────────────────────────
{ pattern: /\bari[eè]ge\b/i, name: "Ariège", num: "09", region: "Occitanie" },
{ pattern: /\baude\b/i, name: "Aude", num: "11", region: "Occitanie" },
{ pattern: /\baveyron\b/i, name: "Aveyron", num: "12", region: "Occitanie" },
{ pattern: /\bgard\b/i, name: "Gard", num: "30", region: "Occitanie" },
{ pattern: /haute-garonne|haute garonne|toulouse/i, name: "Haute-Garonne", num: "31", region: "Occitanie" },
{ pattern: /\bgers\b/i, name: "Gers", num: "32", region: "Occitanie" },
{ pattern: /\bh[eé]rault\b|montpellier/i, name: "Hérault", num: "34", region: "Occitanie" },
{ pattern: /\blot\b/i, name: "Lot", num: "46", region: "Occitanie" },
{ pattern: /\bloz[eè]re\b/i, name: "Lozère", num: "48", region: "Occitanie" },
{ pattern: /hautes-pyr[eé]n[eé]es|hautes pyr[eé]n[eé]es/i, name: "Hautes-Pyrénées", num: "65", region: "Occitanie" },
{ pattern: /pyr[eé]n[eé]es-orientales|pyr[eé]n[eé]es orientales/i, name: "Pyrénées-Orientales", num: "66", region: "Occitanie" },
{ pattern: /tarn-et-garonne|tarn et garonne/i, name: "Tarn-et-Garonne", num: "82", region: "Occitanie" },
{ pattern: /\btarn\b/i, name: "Tarn", num: "81", region: "Occitanie" },
// ─── BOURGOGNE-FRANCHE-COMTÉ ────────────────────────────────────────────────────────────────────
{ pattern: /c[oô]te-d.or|c[oô]te d.or|dijon/i, name: "Côte-d'Or", num: "21", region: "Bourgogne-Franche-Comté" },
{ pattern: /\bdoubs\b|besan[cç]on/i, name: "Doubs", num: "25", region: "Bourgogne-Franche-Comté" },
{ pattern: /\bjura\b/i, name: "Jura", num: "39", region: "Bourgogne-Franche-Comté" },
{ pattern: /\bni[eè]vre\b/i, name: "Nièvre", num: "58", region: "Bourgogne-Franche-Comté" },
{ pattern: /haute-sa[oô]ne|haute sa[oô]ne/i, name: "Haute-Saône", num: "70", region: "Bourgogne-Franche-Comté" },
{ pattern: /sa[oô]ne-et-loire|sa[oô]ne et loire/i, name: "Saône-et-Loire", num: "71", region: "Bourgogne-Franche-Comté" },
{ pattern: /\byonne\b/i, name: "Yonne", num: "89", region: "Bourgogne-Franche-Comté" },
{ pattern: /territoire-de-belfort|territoire de belfort|belfort/i, name: "Territoire de Belfort", num: "90", region: "Bourgogne-Franche-Comté" },
];
/**
* Détecte le département dans un texte.
* Retourne { name, num } ou null si non trouvé.
* Retourne { name, num, region } ou null si non trouvé.
*/
export function detectDepartment(text: string): { name: string; num: string } | null {
export function detectDepartment(text: string): { name: string; num: string; region: string } | null {
for (const dept of AURA_DEPARTMENTS) {
if (dept.pattern.test(text)) {
return { name: dept.name, num: dept.num };
return { name: dept.name, num: dept.num, region: dept.region };
}
}
return null;
}
/**
* Détecte si le texte mentionne la région Auvergne-Rhône-Alpes.
* Détecte si le texte mentionne une des régions couvertes.
* Retourne le nom de la région ou null.
*/
function detectRegion(text: string): string | null {
if (/auvergne.?rh.?ne.?alpes|\baura\b|a\.r\.a\.|ars\s+auvergne|rh.?ne.?alpes/i.test(text)) return "Auvergne-Rhône-Alpes";
if (/provence.?alpes.?c.?te.?d.?azur|\bpaca\b|ars\s+paca|ars\s+provence/i.test(text)) return "Provence-Alpes-Côte d'Azur";
if (/nouvelle.?aquitaine|ars\s+nouvelle.?aquitaine/i.test(text)) return "Nouvelle-Aquitaine";
if (/\boccitanie\b|ars\s+occitanie/i.test(text)) return "Occitanie";
if (/bourgogne.?franche.?comt[eé]|ars\s+bourgogne/i.test(text)) return "Bourgogne-Franche-Comté";
return null;
}
/** @deprecated Utiliser detectRegion à la place */
function isRegional(text: string): boolean {
return /auvergne.?rh.?ne.?alpes|aura\b|a\.r\.a\.|ars\s+auvergne/i.test(text);
return detectRegion(text) !== null;
}
// ─── Normalisation du titre pour la fusion ────────────────────────────────────
@@ -104,16 +162,14 @@ function isRegional(text: string): boolean {
function buildMergeKey(title: string): string {
let key = title.toLowerCase();
// Supprimer les noms de départements AuRA (composites d'abord)
const deptNames = [
"haute-savoie", "haute savoie", "haute-loire", "haute loire",
"puy-de-dôme", "puy-de-dome", "puy de dôme", "puy de dome",
"ain", "allier", "ardèche", "ardeche", "cantal",
"drôme", "drome", "isère", "isere", "loire",
"rhône", "rhone", "savoie",
// Supprimer les noms de départements (composites d'abord)
const deptNames = AURA_DEPARTMENTS.flatMap(d => [
d.name.toLowerCase(),
d.name.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, ""),
]).concat([
"métropole de lyon", "metropole de lyon", "lyon",
"allier",
];
"marseille", "toulouse", "bordeaux", "montpellier", "nice", "dijon", "besançon", "besancon",
]);
// Protéger "auvergne-rhône-alpes" et "rhône-alpes"
key = key.replace(/auvergne.?rh.?ne.?alpes/gi, "__AURA__");
@@ -188,25 +244,46 @@ export function detectVeilleCategorie(text: string): VeilleCategorie {
/**
* Déduit le niveau et le territoire d'un article de veille.
* Retourne le niveau le plus fin détecté : départemental > régional > national.
*/
export function detectVeilleNiveauTerritoire(text: string): { niveau: VeilleNiveau; territoire: string } {
const dept = detectDepartment(text);
if (dept) {
return { niveau: "departemental", territoire: dept.name };
}
if (isRegional(text)) {
return { niveau: "regional", territoire: "Auvergne-Rhône-Alpes" };
const region = detectRegion(text);
if (region) {
return { niveau: "regional", territoire: region };
}
return { niveau: "national", territoire: "France" };
}
// ─── Extraction automatique pour les AAP ─────────────────────────────────────
/**
* Détecte le niveau de localisation le plus fin dans le texte :
* - Département connu → region = région du département, departement = "Nom (num)"
* - Région connue → region = nom de la région, departement = null
* - Sinon → region = "National", departement = null
*/
export function detectAapGeo(text: string): { region: string; departement: string | null } {
const dept = detectDepartment(text);
if (dept) {
return {
region: "Auvergne-Rhône-Alpes",
departement: dept ? `${dept.name} (${dept.num})` : null,
region: dept.region,
departement: `${dept.name} (${dept.num})`,
};
}
const region = detectRegion(text);
if (region) {
return {
region,
departement: null,
};
}
return {
region: "National",
departement: null,
};
}
@@ -222,6 +299,105 @@ function parseDate(dateStr?: string): Date | null {
return isNaN(d.getTime()) ? null : d;
}
/**
* Tente d'extraire une date de clôture depuis le texte d'un AAP.
* Patterns reconnus (fr) :
* "date limite : 30 septembre 2026"
* "clôture le 30/09/2026"
* "avant le 30 sept. 2026"
* "jusqu'au 30-09-2026"
* "dépôt des dossiers : 30 septembre 2026"
* "délai de réponse : 30 septembre 2026"
* Retourne un objet Date ou null si non détecté.
*/
const MONTHS_FR: Record<string, number> = {
janvier: 0, février: 1, fevrier: 1, mars: 2, avril: 3, mai: 4, juin: 5,
juillet: 6, août: 7, aout: 7, septembre: 8, octobre: 9, novembre: 10, décembre: 11, decembre: 11,
janv: 0, févr: 1, fevr: 1, avr: 3, juil: 6, sept: 8, oct: 9, nov: 10, déc: 11, dec: 11,
};
function parseFrDate(dateStr: string): Date | null {
const s = dateStr.trim();
// Format JJ/MM/AAAA ou JJ-MM-AAAA ou JJ.MM.AAAA
const numMatch = s.match(/(\d{1,2})[\/.\-](\d{1,2})[\/.\-](\d{4})/);
if (numMatch) {
const d = new Date(parseInt(numMatch[3]), parseInt(numMatch[2]) - 1, parseInt(numMatch[1]));
return isNaN(d.getTime()) ? null : d;
}
// Format JJ mois AAAA ou "1er mois AAAA"
const textMatch = s.match(/(\d{1,2})(?:er|e)?\s+([a-z\u00e9\u00e8\u00ea\u00e0\u00f9\u00fb]+)\.?\s+(\d{4})/i);
if (textMatch) {
const month = MONTHS_FR[textMatch[2].toLowerCase().replace(/\.$/, "")];
if (month !== undefined) {
const d = new Date(parseInt(textMatch[3]), month, parseInt(textMatch[1]));
return isNaN(d.getTime()) ? null : d;
}
}
// Format AAAA-MM-JJ (ISO)
const isoMatch = s.match(/(\d{4})-(\d{2})-(\d{2})/);
if (isoMatch) {
const d = new Date(parseInt(isoMatch[1]), parseInt(isoMatch[2]) - 1, parseInt(isoMatch[3]));
return isNaN(d.getTime()) ? null : d;
}
return null;
}
// Extrait la première date valide trouvée dans une chaîne
function findDateInStr(s: string): Date | null {
// Essayer directement
const d = parseFrDate(s);
if (d) return d;
// Chercher un sous-motif date dans la chaîne (utile quand du texte suit la date)
const sub = s.match(/(\d{1,2}(?:er|e)?[\s.\/\-][\w\s.]+\d{4}|\d{4}-\d{2}-\d{2}|\d{1,2}[\/\.\-]\d{1,2}[\/\.\-]\d{4})/);
if (sub) return parseFrDate(sub[1]);
return null;
}
export function extractDateCloture(text: string): Date | null {
// Normaliser : supprimer les retours à la ligne et les espaces multiples
const t = text.replace(/[\r\n]+/g, " ").replace(/\s{2,}/g, " ");
const patterns: RegExp[] = [
// "date limite (de dépôt|de candidature|d'envoi|de soumission|de remise)? ..."
/date\s+limite(?:[^:\d]{0,50})?[:\s]+([\d][\w\s.\/\-]+\d{4})/i,
// "fin (de dépôt|des candidatures|de soumission|d'inscription|de réception)?"
/fin\s+(?:de\s+)?(?:d[eé]p[oô]t|des\s+candidatures|de\s+soumission|d['\u2019]inscription|de\s+r[eé]ception|des\s+dossiers)(?:[^:\d]{0,30})?[:\s]+([\d][\w\s.\/\-]+\d{4})/i,
// "clôture (des candidatures|des dossiers|de l'appel|le|au|:)?"
/cl[oô]ture(?:[^:\d]{0,50})?[:\s]+([\d][\w\s.\/\-]+\d{4})/i,
// "clôture le ..."
/cl[oô]ture\s+le\s+([\d][\w\s.\/\-]+\d{4})/i,
// "avant le ..."
/avant\s+le\s+([\d][\w\s.\/\-]+\d{4})/i,
// "jusqu'au ..."
/jusqu['\u2019]au\s+([\d][\w\s.\/\-]+\d{4})/i,
// "dépôt (des dossiers|des candidatures|des projets)? ..."
/d[eé]p[oô]t(?:[^:\d]{0,50})?[:\s]+([\d][\w\s.\/\-]+\d{4})/i,
// "délai (de réponse|de dépôt|de soumission)? ..."
/d[eé]lai(?:[^:\d]{0,50})?[:\s]+([\d][\w\s.\/\-]+\d{4})/i,
// "réception (des dossiers|des candidatures)? ..."
/r[eé]ception(?:[^:\d]{0,50})?[:\s]+([\d][\w\s.\/\-]+\d{4})/i,
// "soumission (des candidatures|des projets)? ..."
/soumission(?:[^:\d]{0,50})?[:\s]+([\d][\w\s.\/\-]+\d{4})/i,
// "remise (des offres|des dossiers|des candidatures)? ..."
/remise(?:[^:\d]{0,50})?[:\s]+([\d][\w\s.\/\-]+\d{4})/i,
// "candidatures (attendues|acceptées|ouvertes)? jusqu'au ..."
/candidatures?(?:[^\d]{0,40})?jusqu['\u2019]au\s+([\d][\w\s.\/\-]+\d{4})/i,
// "répondre avant le ..."
/r[eé]pondre\s+avant\s+le\s+([\d][\w\s.\/\-]+\d{4})/i,
// "ouvert jusqu'au ..."
/ouvert(?:e)?\s+jusqu['\u2019]au\s+([\d][\w\s.\/\-]+\d{4})/i,
];
for (const pattern of patterns) {
const match = t.match(pattern);
if (match?.[1]) {
const d = findDateInStr(match[1].trim());
if (d && d.getFullYear() >= 2020 && d.getFullYear() <= 2035) return d;
}
}
return null;
}
function stripHtml(html: string): string {
return html
.replace(/<[^>]*>/g, "")
@@ -346,14 +522,35 @@ async function processFeed(feed: RssFeed): Promise<FetchResult> {
continue;
}
// ─── Filtre de date minimale : ignorer les articles trop anciens ──────────────
const MIN_DATE = new Date('2026-04-01');
if (pubDate && pubDate < MIN_DATE) {
result.skippedItems++;
continue;
}
// ─── Clé de déduplication : basée sur le titre NORMALISÉ (sans département) ───
const normalizedTitle = buildMergeKey(title);
const dedupKey = dedupHash(normalizedTitle + "|" + (feed.feedType ?? ""));
// ─── Tombstone : ignorer les articles déjà traités (même supprimés) ─────────
const tombstone = await db.select().from(processedDedupKeys)
.where(eq(processedDedupKeys.dedupKey, dedupKey))
.limit(1);
if (tombstone.length > 0) {
result.skippedItems++;
continue;
}
if (feed.feedType === "veille") {
// ── Classification IA (avec fallback sur mots-clés) ──────────────────
// ── Classification IA (avec fallback sur mots-clés) ──────────────────────────────────────
const { niveau, territoire } = detectVeilleNiveauTerritoire(fullText);
// Enrichissement : récupérer le 1er paragraphe si le résumé RSS est court (<150 car.)
const contenuPage = description.length < 150 && link
? await fetchArticleFirstParagraph(link)
: null;
const aiResult = await classifyArticle(
title,
description,
@@ -363,16 +560,32 @@ async function processFeed(feed: RssFeed): Promise<FetchResult> {
typeVeille: (matchedRule?.typeVeille ?? feed.defaultTypeVeille ?? "informationnelle") as
"reglementaire" | "concurrentielle" | "technologique" | "informationnelle",
};
}
},
contenuPage
);
if (aiResult.technicalError) {
await recordClassificationError({
feedId: feed.id,
feedName: feed.name,
feedType: "veille",
articleTitle: title,
articleUrl: link || null,
errorMessage: aiResult.technicalError,
});
}
const typeVeille = (aiResult.typeVeille ?? feed.defaultTypeVeille ?? "informationnelle") as
"reglementaire" | "concurrentielle" | "technologique" | "informationnelle";
// ── Filtre de pertinence : ne pas insérer les articles non pertinents ──────
if (!aiResult.relevant) {
console.log(`[RSS] Article rejeté (non pertinent) : "${title.substring(0, 80)}" — ${aiResult.reason}`);
result.skippedItems++;
continue;
}
// Générer le résumé IA uniquement pour les articles pertinents
const iaResume = aiResult.relevant
? await generateSummary(title, description)
: null;
const iaResume = await generateSummary(title, description);
try {
// Essayer d'insérer
@@ -392,6 +605,8 @@ async function processFeed(feed: RssFeed): Promise<FetchResult> {
iaReason: aiResult.reason,
iaResume: iaResume || null,
});
// Enregistrer le tombstone pour éviter la réinsertion après purge
await db.insert(processedDedupKeys).values({ dedupKey, feedType: "veille" }).onDuplicateKeyUpdate({ set: { dedupKey } });
result.newItems++;
} catch (e: any) {
if (e?.code === "ER_DUP_ENTRY" || e?.cause?.code === "ER_DUP_ENTRY" || e?.message?.includes("Duplicate entry") || e?.cause?.message?.includes("Duplicate entry")) {
@@ -420,21 +635,42 @@ async function processFeed(feed: RssFeed): Promise<FetchResult> {
// ── Classification IA pour les AAP ───────────────────────────────────
const { region, departement } = detectAapGeo(fullText);
const aiResult = await classifyArticle(
// Enrichissement : récupérer le 1er paragraphe si le résumé RSS est court (<150 car.)
const contenuPageAap = description.length < 150 && link
? await fetchArticleFirstParagraph(link)
: null;
const aiResult = await classifyAap(
title,
description,
() => ({
typeVeille: "informationnelle" as const,
})
(feed.defaultCategorieAap ?? "Autre") as "Handicap" | "PA" | "Enfance" | "Précarité" | "Sanitaire" | "Autre",
contenuPageAap
);
const categorie = (feed.defaultCategorieAap ?? "Autre") as
if (aiResult.technicalError) {
await recordClassificationError({
feedId: feed.id,
feedName: feed.name,
feedType: "aap",
articleTitle: title,
articleUrl: link || null,
errorMessage: aiResult.technicalError,
});
}
const categorie = (aiResult.categorieAap ?? feed.defaultCategorieAap ?? "Autre") as
"Handicap" | "PA" | "Enfance" | "Précarité" | "Sanitaire" | "Autre";
// Générer le résumé IA uniquement pour les articles pertinents
const iaResume = aiResult.relevant
? await generateSummary(title, description)
: null;
// ── Filtre de pertinence : ne pas insérer les AAP non pertinents ─────────
if (!aiResult.relevant) {
console.log(`[RSS] AAP rejeté (non pertinent) : "${title.substring(0, 80)}" — ${aiResult.reason}`);
result.skippedItems++;
continue;
}
// Générer le résumé IA pour les AAP pertinents
const iaResume = await generateSummary(title, description);
// Extraire la date de clôture depuis le texte
const dateCloture = extractDateCloture(fullText);
try {
await db.insert(aapItems).values({
@@ -446,11 +682,14 @@ async function processFeed(feed: RssFeed): Promise<FetchResult> {
departements: JSON.stringify(departement ? [departement] : []),
lien: link || null,
datePublication: pubDate,
dateCloture: dateCloture || null,
iaRelevant: aiResult.relevant,
iaClassifiedBy: aiResult.classifiedBy,
iaReason: aiResult.reason,
iaResume: iaResume || null,
});
// Enregistrer le tombstone pour éviter la réinsertion après purge
await db.insert(processedDedupKeys).values({ dedupKey, feedType: "aap" }).onDuplicateKeyUpdate({ set: { dedupKey } });
result.newItems++;
} catch (e: any) {
if (e?.code === "ER_DUP_ENTRY" || e?.cause?.code === "ER_DUP_ENTRY" || e?.message?.includes("Duplicate entry") || e?.cause?.message?.includes("Duplicate entry")) {
@@ -483,10 +722,10 @@ async function processFeed(feed: RssFeed): Promise<FetchResult> {
} catch (e: any) {
result.status = "error";
result.error = e?.message ?? String(e);
result.errorMessage = e?.message ?? String(e);
try {
await db.update(rssFeeds)
.set({ lastFetchedAt: new Date(), lastFetchStatus: "error", lastFetchError: result.error })
.set({ lastFetchedAt: new Date(), lastFetchStatus: "error", lastFetchError: result.errorMessage })
.where(eq(rssFeeds.id, feed.id));
} catch (_) { /* ignore */ }
}
@@ -556,6 +795,7 @@ export async function migrateExistingItems(): Promise<MigrationSummary> {
} catch (e: any) {
// Si le nouveau dedupKey existe déjà → cet article est un doublon, le supprimer
if (e?.code === "ER_DUP_ENTRY" || e?.cause?.code === "ER_DUP_ENTRY" || e?.cause?.message?.includes("Duplicate entry")) {
await removeArticleReadRecords("veille", [row.id]);
await db.delete(veilleItems).where(eq(veilleItems.id, row.id));
veilleMerged++;
} else {
@@ -598,6 +838,7 @@ export async function migrateExistingItems(): Promise<MigrationSummary> {
// Supprimer les doublons
for (const dup of duplicates) {
await removeArticleReadRecords("veille", [dup.id]);
await db.delete(veilleItems).where(eq(veilleItems.id, dup.id));
veilleMerged++;
}
@@ -606,6 +847,7 @@ export async function migrateExistingItems(): Promise<MigrationSummary> {
// Si le newDedupKey existe déjà → supprimer tout le groupe
if (e?.code === "ER_DUP_ENTRY" || e?.cause?.code === "ER_DUP_ENTRY" || e?.cause?.message?.includes("Duplicate entry")) {
for (const row of sorted) {
await removeArticleReadRecords("veille", [row.id]);
await db.delete(veilleItems).where(eq(veilleItems.id, row.id));
veilleMerged++;
}
@@ -649,6 +891,7 @@ export async function migrateExistingItems(): Promise<MigrationSummary> {
} catch (e: any) {
// Si le nouveau dedupKey existe déjà → cet article est un doublon, le supprimer
if (e?.code === "ER_DUP_ENTRY" || e?.cause?.code === "ER_DUP_ENTRY" || e?.cause?.message?.includes("Duplicate entry")) {
await removeArticleReadRecords("aap", [row.id]);
await db.delete(aapItems).where(eq(aapItems.id, row.id));
aapMerged++;
} else {
@@ -684,6 +927,7 @@ export async function migrateExistingItems(): Promise<MigrationSummary> {
.where(eq(aapItems.id, primary.id));
for (const dup of duplicates) {
await removeArticleReadRecords("aap", [dup.id]);
await db.delete(aapItems).where(eq(aapItems.id, dup.id));
aapMerged++;
}
@@ -692,6 +936,7 @@ export async function migrateExistingItems(): Promise<MigrationSummary> {
// Si le newDedupKey existe déjà → supprimer tout le groupe
if (e?.code === "ER_DUP_ENTRY" || e?.cause?.code === "ER_DUP_ENTRY" || e?.cause?.message?.includes("Duplicate entry")) {
for (const row of sorted) {
await removeArticleReadRecords("aap", [row.id]);
await db.delete(aapItems).where(eq(aapItems.id, row.id));
aapMerged++;
}

View File

@@ -1,4 +1,18 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
// Les tests de contrat tRPC ne doivent jamais dépendre d'une base ou d'une migration
// disponible dans l'environnement CI. Les quatre lectures publiques sont donc isolées.
vi.mock("./db", async () => {
const actual = await vi.importActual<typeof import("./db")>("./db");
return {
...actual,
getVeilleItems: vi.fn(async () => ({ items: [], total: 0 })),
getVeilleDistinctValues: vi.fn(async () => ({ categories: [], niveaux: [], territoires: [] })),
getAapItems: vi.fn(async () => ({ items: [], total: 0 })),
getAapDistinctValues: vi.fn(async () => ({ regions: [], departements: [] })),
};
});
import { appRouter } from "./routers";
import type { TrpcContext } from "./_core/context";
@@ -108,6 +122,12 @@ describe("protection admin", () => {
const caller = appRouter.createCaller(ctx);
await expect(caller.users.list()).rejects.toThrow();
});
it("refuse le rapport derreurs de classification pour un non admin", async () => {
const ctx = makeUserCtx();
const caller = appRouter.createCaller(ctx);
await expect(caller.rss.classificationErrors({ page: 1, pageSize: 25 })).rejects.toThrow();
});
});
// ─── Tests accès public ───────────────────────────────────────────────────────

54
todo.md
View File

@@ -150,3 +150,57 @@
- [x] VeilleDashboard.tsx : supprimer tout filtre ou affichage lié à iaCategorie
- [x] Tester visuellement en sandbox
- [x] Checkpoint
## Amélioration prompt IA — 5 secteurs d'activité
- [x] aiClassifier.ts : remplacer le prompt de pertinence générique ESMS par une définition explicite des 5 secteurs (handicap, précarité/logement, protection de l'enfance, personnes âgées, sanitaire SMR)
- [x] 0 erreur TypeScript, 21 tests passés
- [x] Checkpoint
## Récupération du 1er paragraphe pour la classification IA
- [x] Créer server/articleFetcher.ts : fetch HTTP avec timeout 5s, extraction des 500 premiers mots du contenu textuel (balises p, h1-h3), fallback silencieux si erreur
- [x] Intégrer articleFetcher dans rssEngine.ts : enrichir le contenu envoyé à classifyArticle avec le 1er paragraphe récupéré
- [x] Mettre à jour la signature de classifyArticle pour accepter un paramètre optionnel contenuPage
- [x] 0 erreur TypeScript, tests passés
- [x] Checkpoint
## Filtre de pertinence strict à l'insertion + mise à jour prompt IA
- [x] aiClassifier.ts : mettre à jour le prompt avec la formulation exacte fournie (expert ESMS, 5 secteurs, liste noire, "doute = false")
- [x] rssEngine.ts : ajouter filtre `if (!aiResult.relevant) { skip; continue; }` avant insertion veille_items
- [x] rssEngine.ts : même filtre avant insertion aap_items
- [x] 21 tests passés, 0 erreur TypeScript
- [x] Déployer en recette
- [x] Déployer en production (en attente validation recette — intentionnel)
- [x] Mode vignette par défaut
- [x] Alternance de couleurs en mode liste (pair=blanc, impair=gris clair)
- [x] Couleur de sélection au clic sur une ligne (bg-primary/10)
- [x] Filtre Lu/Non lu avec bouton segmenté (Non lu par défaut)
- [x] Message vide adapté selon le filtre actif
## Paramétrage fréquence des mises à jour
- [x] BDD : fetch_mode et fetch_interval_minutes stockés comme clés/valeurs dans app_settings (pas de migration nécessaire)
- [x] Backend index.ts : lire fetch_mode et fetch_interval_minutes pour construire l'expression cron
- [x] Backend routers.ts : exposer et sauvegarder ces deux nouveaux paramètres
- [x] Frontend Settings.tsx : sélecteur mode (heure fixe / intervalle) + sélecteur intervalle (1h, 2h, 4h, 6h, 12h, 24h)
- [x] Déployer en recette
## Évolutions planification et rétention
- [x] Afficher la prochaine date d'exécution dynamique dans les Paramètres (selon mode/heure/jour choisi)
- [x] Ajouter la règle de rétention configurable dans les Paramètres (supprimer articles > N mois)
- [x] Implémenter la purge automatique au démarrage du serveur selon la règle de rétention
- [x] Déployer en recette
- [x] Répliquer BDD recette vers production et déployer le code en production
## Audit de robustesse et nettoyage technique
- [x] Remplacer les captures derreur silencieuses sur le marquage lu par une gestion explicite des doublons et des erreurs SQL.
- [x] Centraliser le comportement du marquage lu pour les flux Veille et AAP afin déviter les divergences fonctionnelles.
- [x] Ajouter des commentaires techniques sur les invariants métier sensibles (lectures, classification IA et déduplication RSS).
- [x] Ajouter des tests unitaires couvrant la persistance et la restitution des articles lus.
- [x] Supprimer les composants non référencés et les artefacts de développement du dépôt applicatif.
- [x] Vérifier TypeScript, Vitest et le build de production après refactoring.
## Déploiement recette et supervision de classification
- [x] Empêcher le workflow de production de sexécuter depuis le Gitea et le runner de recette.
- [x] Déployer le refactoring et appliquer la migration dunicité des lectures en recette.
- [ ] Tester en recette la persistance des articles lus après déconnexion et reconnexion.
- [x] Ajouter un rapport administrateur des erreurs de classification RSS avec date, flux, article et cause.
- [ ] Corriger le découpage frontend qui empêchait React de safficher après le build de recette.