Compare commits
4 Commits
55f3d5a5d4
...
465e33c3b8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
465e33c3b8 | ||
|
|
2d0de2a30d | ||
|
|
b3d1e9e6ed | ||
|
|
7261a7d33e |
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"version": "2bfe4358",
|
||||
"timestamp": 1783252262810
|
||||
"version": "f8f8affc",
|
||||
"timestamp": 1783434543359
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
>
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
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`);
|
||||
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
@@ -78,6 +78,13 @@
|
||||
"when": 1782978530081,
|
||||
"tag": "0010_graceful_nova",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "5",
|
||||
"when": 1783432258494,
|
||||
"tag": "0011_sharp_gambit",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
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, 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 })
|
||||
|
||||
@@ -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
21
server/azureAuth.test.ts
Normal 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
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,
|
||||
};
|
||||
}
|
||||
45
server/db.ts
45
server/db.ts
@@ -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 {
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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 ─────────
|
||||
|
||||
4
todo.md
4
todo.md
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user