Compare commits

...

29 Commits

Author SHA1 Message Date
Manus Automation
290e3709e2 feat(ci): ajouter validation et garde-fou de déploiement
All checks were successful
Deploy Production / deploy (push) Has been skipped
Validation applicative / TypeScript, tests et build (push) Successful in 4m19s
Deploy Recette / deploy (push) Successful in 4m59s
2026-08-20 16:40:12 +00:00
Manus
fcafbf1965 fix: restore React rendering in production bundle
All checks were successful
Deploy Production / deploy (push) Has been skipped
Deploy Recette / deploy (push) Successful in 10m34s
2026-08-18 11:51:11 +00:00
Manus
1821937106 ci: isolate recette and production deployment workflows
All checks were successful
Deploy Production / deploy (push) Has been skipped
Deploy Recette / deploy (push) Successful in 1m9s
2026-08-18 11:37:10 +00:00
Manus
720d42cf71 Merge remote-tracking branch 'recette/main'
Some checks failed
Deploy Recette / deploy (push) Failing after 6s
Deploy Production / deploy (push) Successful in 1m5s
2026-08-18 07:50:50 +00:00
Manus
9acc52da7d Checkpoint: Préparation recette : ajout du journal classification_errors, exposition de la cause technique des fallbacks IA, enregistrement non bloquant depuis le moteur RSS, écran d’administration Erreurs IA, route protégée et migration 0013. Validation locale : 28 tests, TypeScript et build de production réussis. 2026-08-18 07:49:11 +00:00
Manus
6f201dd498 chore: versionner le manifeste de déploiement
All checks were successful
Deploy Production / deploy (push) Successful in 56s
Deploy Recette / deploy (push) Successful in 1m12s
2026-08-17 22:38:22 +02:00
Manus
e4a389cc67 Checkpoint: Audit et refactoring : centralisation robuste du suivi lu/non lu, suppression des captures SQL silencieuses, migration d’unicité avec déduplication des lectures existantes, correction du reclassement AAP via classifyAap, nettoyage de cinq composants template non utilisés, nettoyage des lectures lors des purges/fusions RSS et découpage dynamique du frontend. 27 tests Vitest, TypeScript et build de production validés. 2026-08-17 20:21:45 +00:00
Manus
1344d3c060 Checkpoint: Remplacement du prompt PROMPT_CLASSIFICATION_AAP : approche par bénéficiaires principaux et utilité pour un directeur d'ESMS. Ajout des actions transversales (accès aux soins, inclusion numérique, lutte contre l'isolement). Suppression de la liste noire.
All checks were successful
Deploy Production / deploy (push) Successful in 56s
Deploy Recette / deploy (push) Successful in 58s
2026-07-17 03:57:33 -04:00
Manus
727b7c6436 Checkpoint: Remplacement du prompt PROMPT_CLASSIFICATION : approche par impact métier (directeur d'ESMS) au lieu de liste noire de sujets interdits. Suppression de la liste noire, ajout du critère d'utilité professionnelle directe.
All checks were successful
Deploy Production / deploy (push) Successful in 1m5s
Deploy Recette / deploy (push) Successful in 48s
2026-07-17 03:53:23 -04:00
Manus
4b493bc67c CI/CD: test pipeline prod (has_actions=true)
All checks were successful
Deploy Production / deploy (push) Successful in 57s
Deploy Recette / deploy (push) Successful in 47s
2026-07-10 22:34:27 -04:00
Manus
fac964e0ba CI/CD: test pipeline prod (après resync hooks) 2026-07-10 22:31:51 -04:00
Manus
5203458865 CI/CD: test pipeline prod (Actions unit 16 activé) 2026-07-10 22:29:48 -04:00
Manus
cb3f36699b CI/CD: test pipeline prod final (runner #7 online) 2026-07-10 22:28:29 -04:00
Manus
ae4953cc82 CI/CD: test pipeline prod (Gitea 1.25.5 + runner #6) 2026-07-10 22:26:28 -04:00
Manus
cc00b2263a CI/CD: force Gitea Actions workflow detection 2026-07-10 22:18:36 -04:00
Manus
80e93aa194 CI/CD: test pipeline prod (runner IP interne) 2026-07-10 22:13:57 -04:00
Manus
9164ceb22f CI/CD: test pipeline prod (workflows en place) 2026-07-10 22:09:00 -04:00
Manus
0f0e40b149 CI/CD: test pipeline prod après ré-enregistrement runner 2026-07-10 21:57:59 -04:00
Manus
62751cd4ad CI/CD: test pipeline production 2026-07-10 21:55:55 -04:00
Manus
24b7dbf128 CI/CD: trigger pipeline après fix git clean recette
All checks were successful
Deploy Production / deploy (push) Successful in 1m1s
Deploy Recette / deploy (push) Successful in 1m0s
2026-07-10 21:48:45 -04:00
Manus
0a63cc6797 Fix: labels Traefik recette uniques (veille-recette-secure/svc)
Some checks failed
Deploy Production / deploy (push) Failing after 1s
Deploy Recette / deploy (push) Failing after 1s
2026-07-10 21:46:12 -04:00
Manus
9b11f0e998 CI/CD: Suppression actions/checkout (runner host sans Node.js)
Some checks failed
Deploy Production / deploy (push) Successful in 48s
Deploy Recette / deploy (push) Failing after 56s
2026-07-10 21:40:37 -04:00
Manus
f6aa445732 CI/CD: Ajout workflows Gitea Actions (deploy-recette + deploy-prod)
Some checks failed
Deploy Production / deploy (push) Failing after 17s
Deploy Recette / deploy (push) Failing after 1s
2026-07-10 21:37:47 -04:00
Manus
d283675230 Checkpoint: Purge automatique des tombstones (processed_dedup_keys) de plus de 6 mois :
- purgeOldArticles dans db.ts : suppression des entrées processedDedupKeys.processedAt <= cutoff 6 mois
- Logs cron et init mis à jour pour afficher le nombre de tombstones supprimés
- 0 erreur TypeScript
2026-07-10 21:14:55 -04:00
Manus
8782883bea Checkpoint: Persistance "marquer comme lu" entre sessions :
- Ajout de veille.getReadIds et aap.getReadIds dans routers.ts (procédures protectedProcedure)
- VeilleDashboard.tsx : chargement des IDs lus depuis la DB au montage (useEffect sur readIdsQuery), invalidation getReadIds dans markAsRead et markAllAsRead
- AAPDashboard.tsx : même pattern
- 0 erreur TypeScript
2026-07-10 21:08:26 -04:00
Manus
465e33c3b8 Checkpoint: Connexion Microsoft 365 (Azure AD OAuth2) : migration DB azureAdId, helpers backend, route callback, bouton Login, page AzureCallback, tests OK 2026-07-07 10:29:03 -04:00
Manus
2d0de2a30d feat: prompt IA dédié AAP avec classification par secteur (Handicap/PA/Enfance/Précarité/Sanitaire) 2026-07-06 10:24:40 -04:00
Manus
b3d1e9e6ed fix: supprimer les numéros de département des regex géographiques 2026-07-06 10:08:23 -04:00
Manus
7261a7d33e Checkpoint: reclassifyWithAI traite désormais NULL + rules, supprime les non pertinents et conserve les tombstones 2026-07-06 08:31:02 -04:00
42 changed files with 4797 additions and 2473 deletions

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

View File

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

View File

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

View File

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

1
.gitignore vendored
View File

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

16
app.json Normal file
View File

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

View File

@@ -1,4 +1,4 @@
{
"version": "2bfe4358",
"timestamp": 1783252262810
"timestamp": 1787039351851,
"version": "85944a51"
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import { useState, useMemo } from "react";
import { useState, useMemo, useEffect } from "react";
import { useLocalAuth } from "@/contexts/LocalAuthContext";
import { toast } from "sonner";
import { trpc } from "@/lib/trpc";
@@ -117,13 +117,23 @@ export default function AAPDashboard() {
// Marquage lu/non lu
const [readIds, setReadIds] = useState<Set<number>>(new Set());
const readIdsQuery = trpc.aap.getReadIds.useQuery(undefined, { enabled: !!user });
useEffect(() => {
if (readIdsQuery.data?.ids) {
setReadIds(new Set(readIdsQuery.data.ids));
}
}, [readIdsQuery.data]);
const markAsReadMutation = trpc.aap.markAsRead.useMutation({
onSuccess: (_, vars) => {
setReadIds((prev) => { const next = new Set(prev); next.add(vars.articleId); return next; });
utils.aap.getReadIds.invalidate();
},
});
const markAllAsReadMutation = trpc.aap.markAllAsRead.useMutation({
onSuccess: () => { utils.aap.unreadCount.invalidate(); },
onSuccess: () => {
utils.aap.unreadCount.invalidate();
utils.aap.getReadIds.invalidate();
},
});
const unreadCountQuery = trpc.aap.unreadCount.useQuery();
const unreadCount = unreadCountQuery.data?.count ?? 0;

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,4 +1,4 @@
import { useState, useMemo } from "react";
import { useState, useMemo, useEffect } from "react";
import { useLocalAuth } from "@/contexts/LocalAuthContext";
import { toast } from "sonner";
import { trpc } from "@/lib/trpc";
@@ -234,14 +234,22 @@ export default function VeilleDashboard() {
// Marquage lu/non lu
const [readIds, setReadIds] = useState<Set<number>>(new Set());
const readIdsQuery = trpc.veille.getReadIds.useQuery(undefined, { enabled: !!user });
useEffect(() => {
if (readIdsQuery.data?.ids) {
setReadIds(new Set(readIdsQuery.data.ids));
}
}, [readIdsQuery.data]);
const markAsReadMutation = trpc.veille.markAsRead.useMutation({
onSuccess: (_, vars) => {
setReadIds((prev) => { const next = new Set(prev); next.add(vars.articleId); return next; });
utils.veille.getReadIds.invalidate();
},
});
const markAllAsReadMutation = trpc.veille.markAllAsRead.useMutation({
onSuccess: () => {
utils.veille.unreadCount.invalidate();
utils.veille.getReadIds.invalidate();
},
});
const unreadCountQuery = trpc.veille.unreadCount.useQuery();

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

106
pnpm-lock.yaml generated
View File

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

View File

@@ -11,8 +11,9 @@ import { serveStatic, setupVite } from "./vite";
import { runFullImport } from "../importer";
import uploadRoutes from "../uploadRoutes";
import scheduledRoutes from "../scheduledRoutes";
import { ensureAdminExists } from "../localAuth";
import { getSetting, 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> {
@@ -97,8 +98,8 @@ export async function scheduleDailyImport() {
const retentionMonths = retentionStr ? parseInt(retentionStr, 10) : 0;
if (retentionMonths > 0) {
const purged = await purgeOldArticles(retentionMonths);
if (purged.veille > 0 || purged.aap > 0) {
console.log(`[Cron] Purge rétention (${retentionMonths} mois) — Veille: -${purged.veille} | AAP: -${purged.aap}`);
if (purged.veille > 0 || purged.aap > 0 || purged.tombstones > 0) {
console.log(`[Cron] Purge rétention (${retentionMonths} mois) — Veille: -${purged.veille} | AAP: -${purged.aap} | Tombstones: -${purged.tombstones}`);
}
}
} catch (e) {
@@ -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 })
@@ -154,8 +236,8 @@ async function startServer() {
const retentionMonths = retentionStr ? parseInt(retentionStr, 10) : 0;
if (retentionMonths > 0) {
const purged = await purgeOldArticles(retentionMonths);
if (purged.veille > 0 || purged.aap > 0) {
console.log(`[Init] Purge rétention (${retentionMonths} mois) — Veille: -${purged.veille} | AAP: -${purged.aap}`);
if (purged.veille > 0 || purged.aap > 0 || purged.tombstones > 0) {
console.log(`[Init] Purge rétention (${retentionMonths} mois) — Veille: -${purged.veille} | AAP: -${purged.aap} | Tombstones: -${purged.tombstones}`);
}
}
} catch (e) {
@@ -164,4 +246,8 @@ async function startServer() {
});
}
startServer().catch(console.error);
// Les routeurs importent certaines fonctions de ce module. Vitest ne doit jamais
// démarrer un serveur HTTP à cet effet, sinon les suites parallèles se disputent un port.
if (!process.env.VITEST) {
startServer().catch(console.error);
}

View File

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

View File

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

View File

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

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

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

76
server/azureAuth.ts Normal file
View File

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

View File

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

View File

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

View File

@@ -14,7 +14,7 @@
*/
import { XMLParser } from "fast-xml-parser";
import * as crypto from "crypto";
import { getDb } from "./db";
import { getDb, recordClassificationError, removeArticleReadRecords } from "./db";
import {
rssFeeds,
veilleItems,
@@ -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é" },
];
/**
@@ -563,6 +563,16 @@ async function processFeed(feed: RssFeed): Promise<FetchResult> {
},
contenuPage
);
if (aiResult.technicalError) {
await recordClassificationError({
feedId: feed.id,
feedName: feed.name,
feedType: "veille",
articleTitle: title,
articleUrl: link || null,
errorMessage: aiResult.technicalError,
});
}
const typeVeille = (aiResult.typeVeille ?? feed.defaultTypeVeille ?? "informationnelle") as
"reglementaire" | "concurrentielle" | "technologique" | "informationnelle";
@@ -630,16 +640,23 @@ 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
if (aiResult.technicalError) {
await recordClassificationError({
feedId: feed.id,
feedName: feed.name,
feedType: "aap",
articleTitle: title,
articleUrl: link || null,
errorMessage: aiResult.technicalError,
});
}
const categorie = (aiResult.categorieAap ?? feed.defaultCategorieAap ?? "Autre") as
"Handicap" | "PA" | "Enfance" | "Précarité" | "Sanitaire" | "Autre";
// ── Filtre de pertinence : ne pas insérer les AAP non pertinents ─────────
@@ -778,6 +795,7 @@ export async function migrateExistingItems(): Promise<MigrationSummary> {
} catch (e: any) {
// Si le nouveau dedupKey existe déjà → cet article est un doublon, le supprimer
if (e?.code === "ER_DUP_ENTRY" || e?.cause?.code === "ER_DUP_ENTRY" || e?.cause?.message?.includes("Duplicate entry")) {
await removeArticleReadRecords("veille", [row.id]);
await db.delete(veilleItems).where(eq(veilleItems.id, row.id));
veilleMerged++;
} else {
@@ -820,6 +838,7 @@ export async function migrateExistingItems(): Promise<MigrationSummary> {
// Supprimer les doublons
for (const dup of duplicates) {
await removeArticleReadRecords("veille", [dup.id]);
await db.delete(veilleItems).where(eq(veilleItems.id, dup.id));
veilleMerged++;
}
@@ -828,6 +847,7 @@ export async function migrateExistingItems(): Promise<MigrationSummary> {
// Si le newDedupKey existe déjà → supprimer tout le groupe
if (e?.code === "ER_DUP_ENTRY" || e?.cause?.code === "ER_DUP_ENTRY" || e?.cause?.message?.includes("Duplicate entry")) {
for (const row of sorted) {
await removeArticleReadRecords("veille", [row.id]);
await db.delete(veilleItems).where(eq(veilleItems.id, row.id));
veilleMerged++;
}
@@ -871,6 +891,7 @@ export async function migrateExistingItems(): Promise<MigrationSummary> {
} catch (e: any) {
// Si le nouveau dedupKey existe déjà → cet article est un doublon, le supprimer
if (e?.code === "ER_DUP_ENTRY" || e?.cause?.code === "ER_DUP_ENTRY" || e?.cause?.message?.includes("Duplicate entry")) {
await removeArticleReadRecords("aap", [row.id]);
await db.delete(aapItems).where(eq(aapItems.id, row.id));
aapMerged++;
} else {
@@ -906,6 +927,7 @@ export async function migrateExistingItems(): Promise<MigrationSummary> {
.where(eq(aapItems.id, primary.id));
for (const dup of duplicates) {
await removeArticleReadRecords("aap", [dup.id]);
await db.delete(aapItems).where(eq(aapItems.id, dup.id));
aapMerged++;
}
@@ -914,6 +936,7 @@ export async function migrateExistingItems(): Promise<MigrationSummary> {
// Si le newDedupKey existe déjà → supprimer tout le groupe
if (e?.code === "ER_DUP_ENTRY" || e?.cause?.code === "ER_DUP_ENTRY" || e?.cause?.message?.includes("Duplicate entry")) {
for (const row of sorted) {
await removeArticleReadRecords("aap", [row.id]);
await db.delete(aapItems).where(eq(aapItems.id, row.id));
aapMerged++;
}

View File

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

19
todo.md
View File

@@ -187,5 +187,20 @@
- [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
## Audit de robustesse et nettoyage technique
- [x] Remplacer les captures derreur silencieuses sur le marquage lu par une gestion explicite des doublons et des erreurs SQL.
- [x] Centraliser le comportement du marquage lu pour les flux Veille et AAP afin déviter les divergences fonctionnelles.
- [x] Ajouter des commentaires techniques sur les invariants métier sensibles (lectures, classification IA et déduplication RSS).
- [x] Ajouter des tests unitaires couvrant la persistance et la restitution des articles lus.
- [x] Supprimer les composants non référencés et les artefacts de développement du dépôt applicatif.
- [x] Vérifier TypeScript, Vitest et le build de production après refactoring.
## Déploiement recette et supervision de classification
- [x] Empêcher le workflow de production de sexécuter depuis le Gitea et le runner de recette.
- [x] Déployer le refactoring et appliquer la migration dunicité des lectures en recette.
- [ ] Tester en recette la persistance des articles lus après déconnexion et reconnexion.
- [x] Ajouter un rapport administrateur des erreurs de classification RSS avec date, flux, article et cause.
- [ ] Corriger le découpage frontend qui empêchait React de safficher après le build de recette.