Compare commits
47 Commits
dcb726832c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
290e3709e2 | ||
|
|
fcafbf1965 | ||
|
|
1821937106 | ||
|
|
720d42cf71 | ||
|
|
9acc52da7d | ||
|
|
6f201dd498 | ||
|
|
e4a389cc67 | ||
|
|
1344d3c060 | ||
|
|
727b7c6436 | ||
|
|
4b493bc67c | ||
|
|
fac964e0ba | ||
|
|
5203458865 | ||
|
|
cb3f36699b | ||
|
|
ae4953cc82 | ||
|
|
cc00b2263a | ||
|
|
80e93aa194 | ||
|
|
9164ceb22f | ||
|
|
0f0e40b149 | ||
|
|
62751cd4ad | ||
|
|
24b7dbf128 | ||
|
|
0a63cc6797 | ||
|
|
9b11f0e998 | ||
|
|
f6aa445732 | ||
|
|
d283675230 | ||
|
|
8782883bea | ||
|
|
465e33c3b8 | ||
|
|
2d0de2a30d | ||
|
|
b3d1e9e6ed | ||
|
|
7261a7d33e | ||
|
|
55f3d5a5d4 | ||
|
|
f0be15d432 | ||
|
|
b0550f3298 | ||
|
|
326e8b7bcf | ||
|
|
b6d89e1087 | ||
|
|
c6b89c5b46 | ||
|
|
53514b0540 | ||
|
|
1d7fa332ea | ||
|
|
288bcf982c | ||
|
|
cffb3d4201 | ||
|
|
1f1a479029 | ||
|
|
9eb5849504 | ||
|
|
6b8b3ce9d7 | ||
|
|
1c425f25ad | ||
|
|
a7cf918fa8 | ||
|
|
3613e26034 | ||
|
|
dde3d8b6b8 | ||
|
|
165599bc83 |
0
.gitea/workflows/.keep
Normal file
0
.gitea/workflows/.keep
Normal file
31
.gitea/workflows/deploy-prod.yml
Normal file
31
.gitea/workflows/deploy-prod.yml
Normal 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"
|
||||
31
.gitea/workflows/deploy-recette.yml
Normal file
31
.gitea/workflows/deploy-recette.yml
Normal 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"
|
||||
43
.gitea/workflows/validate.yml
Normal file
43
.gitea/workflows/validate.yml
Normal 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
1
.gitignore
vendored
@@ -108,3 +108,4 @@ temp/
|
||||
|
||||
# Webdev artifacts (checkpoint zips, migrations, etc.)
|
||||
.webdev/
|
||||
.project-config.json
|
||||
|
||||
162
.manus/db/db-query-1781872697281.json
Normal file
162
.manus/db/db-query-1781872697281.json
Normal 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
|
||||
}
|
||||
40
.manus/db/db-query-1781872703449.json
Normal file
40
.manus/db/db-query-1781872703449.json
Normal 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 d’experts-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 2023–2028 - Publication de l’arrêté portant révision du PRS le 25 février 2026",
|
||||
"resume_rss": "Le Projet Régional de Santé (PRS) 2023-2028 de l’Occitanie 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 d’experts-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 2023–2028 - Publication de l’arrêté portant révision du PRS le 25 février 2026\tLe Projet Régional de Santé (PRS) 2023-2028 de l’Occitanie 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
|
||||
}
|
||||
9
.manus/db/db-query-1781872709420.json
Normal file
9
.manus/db/db-query-1781872709420.json
Normal 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
|
||||
}
|
||||
282
.manus/db/db-query-1781872715777.json
Normal file
282
.manus/db/db-query-1781872715777.json
Normal file
File diff suppressed because one or more lines are too long
8
.manus/db/db-query-error-1781872691396.json
Normal file
8
.manus/db/db-query-error-1781872691396.json
Normal 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
16
app.json
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"version": "7708bf54",
|
||||
"timestamp": 1781683337686
|
||||
"timestamp": 1787039351851,
|
||||
"version": "85944a51"
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)} />
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
40
client/src/pages/AzureCallback.tsx
Normal file
40
client/src/pages/AzureCallback.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
145
client/src/pages/ClassificationErrors.tsx
Normal file
145
client/src/pages/ClassificationErrors.tsx
Normal 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 été 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
@@ -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>
|
||||
|
||||
@@ -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 d’import 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 l’heure, rendez-vous dans <strong>Paramètres → Import & 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>
|
||||
);
|
||||
|
||||
@@ -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 (1–28)</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>
|
||||
|
||||
|
||||
@@ -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 été 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">
|
||||
|
||||
67
docker-compose.recette.yml
Normal file
67
docker-compose.recette.yml
Normal 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
|
||||
8
drizzle/0010_graceful_nova.sql
Normal file
8
drizzle/0010_graceful_nova.sql
Normal 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`)
|
||||
);
|
||||
2
drizzle/0011_sharp_gambit.sql
Normal file
2
drizzle/0011_sharp_gambit.sql
Normal 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`);
|
||||
10
drizzle/0012_fluffy_boomer.sql
Normal file
10
drizzle/0012_fluffy_boomer.sql
Normal 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`);
|
||||
11
drizzle/0013_odd_grim_reaper.sql
Normal file
11
drizzle/0013_odd_grim_reaper.sql
Normal 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`)
|
||||
);
|
||||
1038
drizzle/meta/0010_snapshot.json
Normal file
1038
drizzle/meta/0010_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1051
drizzle/meta/0011_snapshot.json
Normal file
1051
drizzle/meta/0011_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1061
drizzle/meta/0012_snapshot.json
Normal file
1061
drizzle/meta/0012_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1135
drizzle/meta/0013_snapshot.json
Normal file
1135
drizzle/meta/0013_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
106
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
94
server/articleFetcher.ts
Normal 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(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/ /g, " ")
|
||||
.replace(/"/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;
|
||||
}
|
||||
}
|
||||
44
server/articleReads.test.ts
Normal file
44
server/articleReads.test.ts
Normal 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
47
server/azureAuth.test.ts
Normal 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 lorsqu’une configuration complète est fournie", async () => {
|
||||
const { isAzureAdConfigured } = await import("./azureAuth");
|
||||
const configured = isAzureAdConfigured();
|
||||
expect(configured).toBe(true);
|
||||
});
|
||||
|
||||
it("génère une URL d’autorisation 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
76
server/azureAuth.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
229
server/db.ts
229
server/db.ts
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}),
|
||||
}),
|
||||
|
||||
@@ -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++;
|
||||
}
|
||||
|
||||
@@ -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 d’erreurs 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
54
todo.md
@@ -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 d’erreur 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 s’exécuter depuis le Gitea et le runner de recette.
|
||||
- [x] Déployer le refactoring et appliquer la migration d’unicité 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 s’afficher après le build de recette.
|
||||
|
||||
Reference in New Issue
Block a user