Compare commits

...

4 Commits

19 changed files with 1763 additions and 113 deletions

View File

@@ -1,4 +1,4 @@
{
"version": "2bfe4358",
"timestamp": 1783252262810
"version": "f8f8affc",
"timestamp": 1783434543359
}

View File

@@ -14,6 +14,7 @@ import UsersAdmin from "./pages/UsersAdmin";
import ImportLogs from "./pages/ImportLogs";
import BoiteAIdees from "@/pages/BoiteAIdees";
import RssFeeds from "@/pages/RssFeeds";
import AzureCallback from "@/pages/AzureCallback";
import { Loader2 } from "lucide-react";
// ─── Guard d'authentification ─────────────────────────────────────────────────
@@ -125,6 +126,7 @@ function Router() {
return (
<Switch>
<Route path="/login" component={Login} />
<Route path="/azure-callback" component={AzureCallback} />
<Route path="/">
<Redirect to="/veille" />
</Route>

View File

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

View File

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

View File

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

View File

@@ -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`);

File diff suppressed because it is too large Load Diff

View File

@@ -78,6 +78,13 @@
"when": 1782978530081,
"tag": "0010_graceful_nova",
"breakpoints": true
},
{
"idx": 11,
"version": "5",
"when": 1783432258494,
"tag": "0011_sharp_gambit",
"breakpoints": true
}
]
}

View File

@@ -36,6 +36,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"),

View File

@@ -15,6 +15,7 @@
"dependencies": {
"@aws-sdk/client-s3": "^3.693.0",
"@aws-sdk/s3-request-presigner": "^3.693.0",
"@azure/msal-node": "^5.3.1",
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15",

106
pnpm-lock.yaml generated
View File

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

View File

@@ -11,8 +11,9 @@ import { serveStatic, setupVite } from "./vite";
import { runFullImport } from "../importer";
import uploadRoutes from "../uploadRoutes";
import scheduledRoutes from "../scheduledRoutes";
import { ensureAdminExists } from "../localAuth";
import { getSetting, purgeOldArticles } 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> {
@@ -126,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 })

View File

@@ -19,18 +19,28 @@ 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";
}
// ─── Prompt unique : pertinence + type de veille ─────────────────────────────
// ─── Prompt veille stratégique : pertinence + type de veille ─────────────────
const PROMPT_CLASSIFICATION = `Tu es un expert des établissements et services sociaux et médico-sociaux (ESMS) en France.
Analyse attentivement le texte fourni. Détermine si le contenu est pertinent au regard du périmètre d'intervention de l'association, s'il a un impact professionnel direct ou indirect sur les activités, les services, la gestion, la réglementation, le financement, les professionnels, les usagers ou l'environnement stratégique de l'association.
@@ -67,6 +77,46 @@ 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.
Analyse attentivement le texte fourni. Détermine si le contenu est pertinent au regard du périmètre d'intervention de l'association, s'il a un impact professionnel direct ou indirect sur les activités, les services, la gestion, la réglementation, le financement, les professionnels, les usagers ou l'environnement stratégique de l'association.
Un article est pertinent UNIQUEMENT s'il traite explicitement d'un sujet en lien avec l'un des 5 secteurs d'activité de l'association :
1. 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
2. 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, expulsions
3. 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
4. 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
5. 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
Un article est NON PERTINENT s'il porte principalement sur l'un des sujets suivants, même s'il mentionne des termes administratifs ou régionaux :
- Sylviculture, forêts, gestion forestière, bois, chasse, pêche
- Agriculture, élevage, viticulture, pêche maritime, filières agroalimentaires
- Urbanisme, construction, permis de construire, plans locaux d'urbanisme (PLU), logement neuf (hors hébergement social)
- Transports, mobilité, infrastructures routières, ferroviaires ou aéroportuaires
- Environnement, biodiversité, eau, énergie, transition écologique (sauf si lien direct avec les ESMS)
- Économie générale, fiscalité des entreprises, commerce, industrie, artisanat
- Défense nationale, sécurité intérieure, police, justice (sauf protection de l'enfance)
- Sport, culture, tourisme (sauf si lien direct avec des usagers ESMS)
- Enseignement supérieur, recherche (sauf si lien direct avec le secteur médico-social)
- Nominations administratives sans lien avec les secteurs ci-dessus
En cas de doute, réponds pertinent: false — il vaut mieux rejeter un article ambigu que d'en laisser passer un hors périmètre.
Si l'article est pertinent, classe-le dans l'une des 5 catégories suivantes (utilise exactement ces valeurs) :
- "Handicap" : lien avec le secteur handicap (enfant ou adulte)
- "Précarité" : lien avec la précarité, l'exclusion, le logement social ou l'insertion
- "Enfance" : lien avec la protection de l'enfance
- "PA" : lien avec les personnes âgées ou la perte d'autonomie
- "Sanitaire" : lien avec la santé, les soins ou le sanitaire
Si l'article est pertinent mais ne correspond clairement à aucune des 5 catégories, 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).
@@ -100,14 +150,8 @@ 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
* @param contenuPage Premiers paragraphes de l'article complet (optionnel, améliore la précision)
*/
export async function classifyArticle(
titre: string,
@@ -115,7 +159,6 @@ export async function classifyArticle(
fallbackFn: () => { typeVeille: AiTypeVeille },
contenuPage?: string | null
): Promise<AiClassificationResult> {
// Construire le contenu utilisateur en enrichissant avec le contenu de la page si disponible
const contenuSection = contenuPage
? `\n\nExtrait de l'article (premiers paragraphes) : ${contenuPage}`
: "";
@@ -138,10 +181,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" },
],
},
@@ -156,29 +196,94 @@ 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",
};
} catch (e) {
console.error("[AI Classifier] Erreur classification:", (e as Error).message);
// Fallback sur les règles
console.error("[AI Classifier] Erreur classification veille:", (e as Error).message);
const fb = fallbackFn();
return {
relevant: true,
typeVeille: fb.typeVeille,
categorieAap: null,
reason: "Classification par règles (erreur LLM)",
classifiedBy: "rules",
};
}
}
/**
* 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",
};
} catch (e) {
console.error("[AI Classifier] Erreur classification AAP:", (e as Error).message);
return {
relevant: true,
typeVeille: null,
categorieAap: fallbackCategorie,
reason: "Classification par règles (erreur LLM)",
classifiedBy: "rules",
};

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

@@ -0,0 +1,21 @@
import { describe, it, expect } from "vitest";
import { isAzureAdConfigured, getAzureAuthUrl } from "./azureAuth";
describe("Azure AD configuration", () => {
it("should detect Azure AD as configured when env vars are set", () => {
// Les variables sont injectées via webdev_request_secrets
const configured = isAzureAdConfigured();
expect(configured).toBe(true);
});
it("should generate a valid Azure AD auth URL", async () => {
if (!isAzureAdConfigured()) {
console.warn("Azure AD not configured, skipping URL test");
return;
}
const url = await getAzureAuthUrl();
expect(url).toContain("login.microsoftonline.com");
expect(url).toContain("oauth2/v2.0/authorize");
expect(url).toContain("f496da82-e18f-4567-bf05-8551ae6669b2"); // client_id
});
});

76
server/azureAuth.ts Normal file
View File

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

View File

@@ -116,6 +116,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 {

View File

@@ -35,9 +35,10 @@ import {
import { importVeille, importAAP, runFullImport, getImportConfig } from "./importer";
import { scheduleDailyImport } from "./_core/index";
import { loginLocalUser, hashPassword, ensureAdminExists } from "./localAuth";
import { isAzureAdConfigured, getAzureAuthUrl } from "./azureAuth";
import { classifyArticle } from "./aiClassifier";
import { getDb } from "./db";
import { veilleItems, aapItems, articleReads } from "../drizzle/schema";
import { veilleItems, aapItems, articleReads, processedDedupKeys } from "../drizzle/schema";
import { isNull, or, eq as eqDrizzle, and, inArray, count } from "drizzle-orm";
// ─── Middleware admin ─────────────────────────────────────────────────────────
@@ -80,6 +81,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 +128,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,22 +146,34 @@ export const appRouter = router({
row.resume || "",
() => ({ typeVeille: (row.typeVeille || "informationnelle") as "reglementaire" | "concurrentielle" | "technologique" | "informationnelle" })
);
await db
.update(veilleItems)
.set({
typeVeille: (aiResult.typeVeille ?? row.typeVeille ?? "informationnelle") as any,
iaRelevant: aiResult.relevant,
iaClassifiedBy: aiResult.classifiedBy,
iaReason: aiResult.reason,
})
.where(eqDrizzle(veilleItems.id, row.id));
processed++;
if (!aiResult.relevant) {
// Supprimer l'article non pertinent
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({
typeVeille: (aiResult.typeVeille ?? row.typeVeille ?? "informationnelle") as any,
iaRelevant: aiResult.relevant,
iaClassifiedBy: aiResult.classifiedBy,
iaReason: aiResult.reason,
})
.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 ──────────────────────────────────────────────────────────────────────
@@ -240,34 +265,45 @@ 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(
row.titre || "",
"",
row.resume || "",
() => ({ typeVeille: "informationnelle" as const })
);
await db
.update(aapItems)
.set({
iaRelevant: aiResult.relevant,
iaClassifiedBy: aiResult.classifiedBy,
iaReason: aiResult.reason,
})
.where(eqDrizzle(aapItems.id, row.id));
processed++;
if (!aiResult.relevant) {
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({
iaRelevant: aiResult.relevant,
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 ──────────────────────────────────────────────────────────────────────

View File

@@ -23,7 +23,7 @@ import {
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 ───────────────────────────────────────────────────────────
@@ -59,65 +59,65 @@ interface FetchResult {
const AURA_DEPARTMENTS: Array<{ pattern: RegExp; name: string; num: string; region: string }> = [
// ─── AUVERGNE-RHÔNE-ALPES ─────────────────────────────────────────────────────────────────
{ pattern: /haute-savoie|haute savoie|(?<!\d)74(?!\d)|\(74\)/i, name: "Haute-Savoie", num: "74", region: "Auvergne-Rhône-Alpes" },
{ pattern: /haute-loire|haute loire|(?<!\d)43(?!\d)|\(43\)/i, name: "Haute-Loire", num: "43", region: "Auvergne-Rhône-Alpes" },
{ pattern: /puy-de-d[oô]me|puy de d[oô]me|(?<!\d)63(?!\d)|\(63\)/i, name: "Puy-de-Dôme", num: "63", region: "Auvergne-Rhône-Alpes" },
{ pattern: /\bain\b|(?<!\d)01(?!\d)|\(01\)/i, name: "Ain", num: "01", region: "Auvergne-Rhône-Alpes" },
{ pattern: /\ballier\b|(?<!\d)03(?!\d)|\(03\)/i, name: "Allier", num: "03", region: "Auvergne-Rhône-Alpes" },
{ pattern: /\bard[eè]che\b|(?<!\d)07(?!\d)|\(07\)/i, name: "Ardèche", num: "07", region: "Auvergne-Rhône-Alpes" },
{ pattern: /\bcantal\b|(?<!\d)15(?!\d)|\(15\)/i, name: "Cantal", num: "15", region: "Auvergne-Rhône-Alpes" },
{ pattern: /\bdr[oô]me\b|(?<!\d)26(?!\d)|\(26\)/i, name: "Drôme", num: "26", region: "Auvergne-Rhône-Alpes" },
{ pattern: /\bis[eè]re\b|(?<!\d)38(?!\d)|\(38\)/i, name: "Isère", num: "38", region: "Auvergne-Rhône-Alpes" },
{ pattern: /(?<!haute-)\bloire\b|(?<!\d)42(?!\d)|\(42\)/i, name: "Loire", num: "42", region: "Auvergne-Rhône-Alpes" },
{ pattern: /\brhone\b|\brhône\b|(?<!\d)69(?!\d)|\(69\)|m[eé]tropole\s+de\s+lyon/i, name: "Rhône", num: "69", region: "Auvergne-Rhône-Alpes" },
{ pattern: /\bsavoie\b(?!.*haute)|(?<!\d)73(?!\d)|\(73\)/i, name: "Savoie", num: "73", region: "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|(?<!\d)04(?!\d)|\(04\)/i, name: "Alpes-de-Haute-Provence", num: "04", region: "Provence-Alpes-Côte d'Azur" },
{ pattern: /hautes-alpes|hautes alpes|(?<!\d)05(?!\d)|\(05\)/i, name: "Hautes-Alpes", num: "05", region: "Provence-Alpes-Côte d'Azur" },
{ pattern: /alpes-maritimes|alpes maritimes|(?<!\d)06(?!\d)|\(06\)/i, name: "Alpes-Maritimes", num: "06", region: "Provence-Alpes-Côte d'Azur" },
{ pattern: /bouches-du-rh[oô]ne|bouches du rh[oô]ne|(?<!\d)13(?!\d)|\(13\)|marseille/i, name: "Bouches-du-Rhône", num: "13", region: "Provence-Alpes-Côte d'Azur" },
{ pattern: /\bvar\b|(?<!\d)83(?!\d)|\(83\)/i, name: "Var", num: "83", region: "Provence-Alpes-Côte d'Azur" },
{ pattern: /\bvaucluse\b|(?<!\d)84(?!\d)|\(84\)/i, name: "Vaucluse", num: "84", region: "Provence-Alpes-Côte d'Azur" },
{ 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|(?<!\d)17(?!\d)|\(17\)/i, name: "Charente-Maritime", num: "17", region: "Nouvelle-Aquitaine" },
{ pattern: /\bcharente\b|(?<!\d)16(?!\d)|\(16\)/i, name: "Charente", num: "16", region: "Nouvelle-Aquitaine" },
{ pattern: /\bcorr[eè]ze\b|(?<!\d)19(?!\d)|\(19\)/i, name: "Corrèze", num: "19", region: "Nouvelle-Aquitaine" },
{ pattern: /\bcreuse\b|(?<!\d)23(?!\d)|\(23\)/i, name: "Creuse", num: "23", region: "Nouvelle-Aquitaine" },
{ pattern: /\bdordogne\b|(?<!\d)24(?!\d)|\(24\)/i, name: "Dordogne", num: "24", region: "Nouvelle-Aquitaine" },
{ pattern: /\bgironde\b|(?<!\d)33(?!\d)|\(33\)|bordeaux/i, name: "Gironde", num: "33", region: "Nouvelle-Aquitaine" },
{ pattern: /\blandes\b|(?<!\d)40(?!\d)|\(40\)/i, name: "Landes", num: "40", region: "Nouvelle-Aquitaine" },
{ pattern: /lot-et-garonne|lot et garonne|(?<!\d)47(?!\d)|\(47\)/i, name: "Lot-et-Garonne", num: "47", region: "Nouvelle-Aquitaine" },
{ pattern: /pyr[eé]n[eé]es-atlantiques|pyr[eé]n[eé]es atlantiques|(?<!\d)64(?!\d)|\(64\)/i, name: "Pyrénées-Atlantiques", num: "64", region: "Nouvelle-Aquitaine" },
{ pattern: /deux-s[eè]vres|deux s[eè]vres|(?<!\d)79(?!\d)|\(79\)/i, name: "Deux-Sèvres", num: "79", region: "Nouvelle-Aquitaine" },
{ pattern: /haute-vienne|haute vienne|(?<!\d)87(?!\d)|\(87\)/i, name: "Haute-Vienne", num: "87", region: "Nouvelle-Aquitaine" },
{ pattern: /\bvienne\b|(?<!\d)86(?!\d)|\(86\)/i, name: "Vienne", num: "86", region: "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|(?<!\d)09(?!\d)|\(09\)/i, name: "Ariège", num: "09", region: "Occitanie" },
{ pattern: /\baude\b|(?<!\d)11(?!\d)|\(11\)/i, name: "Aude", num: "11", region: "Occitanie" },
{ pattern: /\baveyron\b|(?<!\d)12(?!\d)|\(12\)/i, name: "Aveyron", num: "12", region: "Occitanie" },
{ pattern: /\bgard\b|(?<!\d)30(?!\d)|\(30\)/i, name: "Gard", num: "30", region: "Occitanie" },
{ pattern: /haute-garonne|haute garonne|(?<!\d)31(?!\d)|\(31\)|toulouse/i, name: "Haute-Garonne", num: "31", region: "Occitanie" },
{ pattern: /\bgers\b|(?<!\d)32(?!\d)|\(32\)/i, name: "Gers", num: "32", region: "Occitanie" },
{ pattern: /\bh[eé]rault\b|(?<!\d)34(?!\d)|\(34\)|montpellier/i, name: "Hérault", num: "34", region: "Occitanie" },
{ pattern: /\blot\b|(?<!\d)46(?!\d)|\(46\)/i, name: "Lot", num: "46", region: "Occitanie" },
{ pattern: /\bloz[eè]re\b|(?<!\d)48(?!\d)|\(48\)/i, name: "Lozère", num: "48", region: "Occitanie" },
{ pattern: /hautes-pyr[eé]n[eé]es|hautes pyr[eé]n[eé]es|(?<!\d)65(?!\d)|\(65\)/i, name: "Hautes-Pyrénées", num: "65", region: "Occitanie" },
{ pattern: /pyr[eé]n[eé]es-orientales|pyr[eé]n[eé]es orientales|(?<!\d)66(?!\d)|\(66\)/i, name: "Pyrénées-Orientales", num: "66", region: "Occitanie" },
{ pattern: /tarn-et-garonne|tarn et garonne|(?<!\d)82(?!\d)|\(82\)/i, name: "Tarn-et-Garonne", num: "82", region: "Occitanie" },
{ pattern: /\btarn\b|(?<!\d)81(?!\d)|\(81\)/i, name: "Tarn", num: "81", region: "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|(?<!\d)21(?!\d)|\(21\)|dijon/i, name: "Côte-d'Or", num: "21", region: "Bourgogne-Franche-Comté" },
{ pattern: /\bdoubs\b|(?<!\d)25(?!\d)|\(25\)|besan[cç]on/i, name: "Doubs", num: "25", region: "Bourgogne-Franche-Comté" },
{ pattern: /\bjura\b|(?<!\d)39(?!\d)|\(39\)/i, name: "Jura", num: "39", region: "Bourgogne-Franche-Comté" },
{ pattern: /\bni[eè]vre\b|(?<!\d)58(?!\d)|\(58\)/i, name: "Nièvre", num: "58", region: "Bourgogne-Franche-Comté" },
{ pattern: /haute-sa[oô]ne|haute sa[oô]ne|(?<!\d)70(?!\d)|\(70\)/i, name: "Haute-Saône", num: "70", region: "Bourgogne-Franche-Comté" },
{ pattern: /sa[oô]ne-et-loire|sa[oô]ne et loire|(?<!\d)71(?!\d)|\(71\)/i, name: "Saône-et-Loire", num: "71", region: "Bourgogne-Franche-Comté" },
{ pattern: /\byonne\b|(?<!\d)89(?!\d)|\(89\)/i, name: "Yonne", num: "89", region: "Bourgogne-Franche-Comté" },
{ pattern: /territoire-de-belfort|territoire de belfort|belfort|(?<!\d)90(?!\d)|\(90\)/i, name: "Territoire de Belfort", num: "90", region: "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é" },
];
/**
@@ -630,16 +630,13 @@ async function processFeed(feed: RssFeed): Promise<FetchResult> {
? await fetchArticleFirstParagraph(link)
: null;
const aiResult = await classifyArticle(
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
const categorie = (aiResult.categorieAap ?? feed.defaultCategorieAap ?? "Autre") as
"Handicap" | "PA" | "Enfance" | "Précarité" | "Sanitaire" | "Autre";
// ── Filtre de pertinence : ne pas insérer les AAP non pertinents ─────────

View File

@@ -187,5 +187,5 @@
- [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
- [ ] Déployer en recette
- [ ] Répliquer BDD recette vers production et déployer le code en production
- [x] Déployer en recette
- [x] Répliquer BDD recette vers production et déployer le code en production