Compare commits

..

3 Commits

25 changed files with 2772 additions and 2350 deletions

1
.gitignore vendored
View File

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

View File

@@ -1,4 +1,4 @@
{ {
"version": "fd4e8bc0", "timestamp": 1787039351851,
"timestamp": 1784275053491 "version": "85944a51"
} }

View File

@@ -1,22 +1,27 @@
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip"; import { TooltipProvider } from "@/components/ui/tooltip";
import NotFound from "@/pages/NotFound"; import { lazy, Suspense } from "react";
import { Route, Switch, Redirect } from "wouter"; import { Route, Switch, Redirect } from "wouter";
import ErrorBoundary from "./components/ErrorBoundary"; import ErrorBoundary from "./components/ErrorBoundary";
import { ThemeProvider } from "./contexts/ThemeContext"; import { ThemeProvider } from "./contexts/ThemeContext";
import { LocalAuthProvider, useLocalAuth } from "./contexts/LocalAuthContext"; import { LocalAuthProvider, useLocalAuth } from "./contexts/LocalAuthContext";
import { AppLayout } from "./components/AppLayout"; 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 AzureCallback from "@/pages/AzureCallback";
import { Loader2 } from "lucide-react"; 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 ───────────────────────────────────────────────── // ─── Guard d'authentification ─────────────────────────────────────────────────
function AuthGuard({ children }: { children: React.ReactNode }) { function AuthGuard({ children }: { children: React.ReactNode }) {
@@ -100,6 +105,16 @@ function LogsPage() {
); );
} }
function ClassificationErrorsPage() {
return (
<AuthGuard>
<DashboardWrapper>
<ClassificationErrors />
</DashboardWrapper>
</AuthGuard>
);
}
function BoiteAIdeesPage() { function BoiteAIdeesPage() {
return ( return (
<AuthGuard> <AuthGuard>
@@ -135,6 +150,7 @@ function Router() {
<Route path="/admin/settings" component={SettingsPage} /> <Route path="/admin/settings" component={SettingsPage} />
<Route path="/admin/users" component={UsersPage} /> <Route path="/admin/users" component={UsersPage} />
<Route path="/admin/logs" component={LogsPage} /> <Route path="/admin/logs" component={LogsPage} />
<Route path="/admin/classification-errors" component={ClassificationErrorsPage} />
<Route path="/boite-a-idees" component={BoiteAIdeesPage} /> <Route path="/boite-a-idees" component={BoiteAIdeesPage} />
<Route path="/admin/rss" component={RssFeedsPage} /> <Route path="/admin/rss" component={RssFeedsPage} />
<Route path="/404" component={NotFound} /> <Route path="/404" component={NotFound} />
@@ -150,7 +166,15 @@ function App() {
<LocalAuthProvider> <LocalAuthProvider>
<TooltipProvider> <TooltipProvider>
<Toaster richColors position="top-right" /> <Toaster richColors position="top-right" />
<Router /> <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> </TooltipProvider>
</LocalAuthProvider> </LocalAuthProvider>
</ThemeProvider> </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, X,
Lightbulb, Lightbulb,
Rss, Rss,
AlertTriangle,
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@@ -64,6 +65,7 @@ const NAV_GROUPS: NavGroup[] = [
defaultOpen: false, defaultOpen: false,
items: [ items: [
{ label: "Logs d'import", href: "/admin/logs", icon: <Activity size={16} />, adminOnly: true }, { 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: "Utilisateurs", href: "/admin/users", icon: <Users size={16} />, adminOnly: true },
{ label: "Flux RSS", href: "/admin/rss", icon: <Rss 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 }, { 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

@@ -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

@@ -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

View File

@@ -85,6 +85,20 @@
"when": 1783432258494, "when": 1783432258494,
"tag": "0011_sharp_gambit", "tag": "0011_sharp_gambit",
"breakpoints": true "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, mysqlTable,
text, text,
timestamp, timestamp,
uniqueIndex,
varchar, varchar,
json, json,
} from "drizzle-orm/mysql-core"; } from "drizzle-orm/mysql-core";
@@ -136,6 +137,25 @@ export const importLogs = mysqlTable("import_logs", {
export type ImportLog = typeof importLogs.$inferSelect; export type ImportLog = typeof importLogs.$inferSelect;
export type InsertImportLog = typeof importLogs.$inferInsert; 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 ─────────────────────────────────────────────────────────── // ─── Boîte à idées ───────────────────────────────────────────────────────────
export const ideas = mysqlTable("ideas", { export const ideas = mysqlTable("ideas", {
@@ -204,13 +224,20 @@ export type InsertRssSettings = typeof rssSettings.$inferInsert;
// ─── Suivi de lecture des articles ────────────────────────────────────────── // ─── Suivi de lecture des articles ──────────────────────────────────────────
export const articleReads = mysqlTable("article_reads", { export const articleReads = mysqlTable(
id: int("id").autoincrement().primaryKey(), "article_reads",
userId: int("userId").notNull(), {
articleType: mysqlEnum("articleType", ["veille", "aap"]).notNull(), id: int("id").autoincrement().primaryKey(),
articleId: int("articleId").notNull(), userId: int("userId").notNull(),
readAt: timestamp("readAt").defaultNow().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 ArticleRead = typeof articleReads.$inferSelect;
export type InsertArticleRead = typeof articleReads.$inferInsert; export type InsertArticleRead = typeof articleReads.$inferInsert;

View File

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

View File

@@ -38,6 +38,13 @@ export interface AiClassificationResult {
reason: string; reason: string;
/** Indique si la classification a été faite par l'IA ou par les règles (fallback) */ /** Indique si la classification a été faite par l'IA ou par les règles (fallback) */
classifiedBy: "ia" | "rules"; classifiedBy: "ia" | "rules";
/** Cause technique du fallback, réservée au journal d'administration. */
technicalError: string | null;
}
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 ───────────────── // ─── Prompt veille stratégique : pertinence + type de veille ─────────────────
@@ -194,9 +201,11 @@ export async function classifyArticle(
categorieAap: null, categorieAap: null,
reason: parsed.raison, reason: parsed.raison,
classifiedBy: "ia", classifiedBy: "ia",
technicalError: null,
}; };
} catch (e) { } catch (e) {
console.error("[AI Classifier] Erreur classification veille:", (e as Error).message); const technicalError = getTechnicalErrorMessage(e);
console.error("[AI Classifier] Erreur classification veille:", technicalError);
const fb = fallbackFn(); const fb = fallbackFn();
return { return {
relevant: true, relevant: true,
@@ -204,6 +213,7 @@ export async function classifyArticle(
categorieAap: null, categorieAap: null,
reason: "Classification par règles (erreur LLM)", reason: "Classification par règles (erreur LLM)",
classifiedBy: "rules", classifiedBy: "rules",
technicalError,
}; };
} }
} }
@@ -265,15 +275,18 @@ export async function classifyAap(
categorieAap: parsed.pertinent ? (parsed.categorie ?? "Autre") : null, categorieAap: parsed.pertinent ? (parsed.categorie ?? "Autre") : null,
reason: parsed.raison, reason: parsed.raison,
classifiedBy: "ia", classifiedBy: "ia",
technicalError: null,
}; };
} catch (e) { } catch (e) {
console.error("[AI Classifier] Erreur classification AAP:", (e as Error).message); const technicalError = getTechnicalErrorMessage(e);
console.error("[AI Classifier] Erreur classification AAP:", technicalError);
return { return {
relevant: true, relevant: true,
typeVeille: null, typeVeille: null,
categorieAap: fallbackCategorie, categorieAap: fallbackCategorie,
reason: "Classification par règles (erreur LLM)", reason: "Classification par règles (erreur LLM)",
classifiedBy: "rules", 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]);
});
});

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 { drizzle } from "drizzle-orm/mysql2";
import mysql from "mysql2/promise"; import mysql from "mysql2/promise";
import { import {
@@ -15,9 +15,12 @@ import {
rssFeeds, rssFeeds,
rssSettings, rssSettings,
processedDedupKeys, processedDedupKeys,
articleReads,
classificationErrors,
type InsertRssFeed, type InsertRssFeed,
type InsertRssSettings, type InsertRssSettings,
type ImportLog, type ImportLog,
type ClassificationError,
type RssFeed, type RssFeed,
type RssSettings, type RssSettings,
} from "../drizzle/schema"; } from "../drizzle/schema";
@@ -44,6 +47,114 @@ export async function getDb() {
return _db; 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) ───────────────────────────────────────────────────── // ─── Users (Manus OAuth) ─────────────────────────────────────────────────────
export async function upsertUser(user: InsertUser): Promise<void> { export async function upsertUser(user: InsertUser): Promise<void> {
@@ -380,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 ──────────────────────────────────────────────────────────── // ─── Boîte à idées ────────────────────────────────────────────────────────────
export async function createIdea(data: InsertIdea) { export async function createIdea(data: InsertIdea) {
@@ -495,6 +649,13 @@ export async function purgeOldArticles(retentionMonths: number): Promise<{ veill
if (!db) throw new Error("Database not available"); if (!db) throw new Error("Database not available");
const cutoff = new Date(); const cutoff = new Date();
cutoff.setMonth(cutoff.getMonth() - retentionMonths); 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 veilleResult = await db.delete(veilleItems).where(lte(veilleItems.importedAt, cutoff));
const aapResult = await db.delete(aapItems).where(lte(aapItems.importedAt, cutoff)); const aapResult = await db.delete(aapItems).where(lte(aapItems.importedAt, cutoff));
// Purge des tombstones (processed_dedup_keys) de plus de 6 mois // Purge des tombstones (processed_dedup_keys) de plus de 6 mois
@@ -511,6 +672,7 @@ export async function purgeOldArticles(retentionMonths: number): Promise<{ veill
export async function purgeVeilleItems(): Promise<number> { export async function purgeVeilleItems(): Promise<number> {
const db = await getDb(); const db = await getDb();
if (!db) throw new Error("Database not available"); if (!db) throw new Error("Database not available");
await db.delete(articleReads).where(eq(articleReads.articleType, "veille"));
const result = await db.delete(veilleItems); const result = await db.delete(veilleItems);
return (result as any).affectedRows ?? 0; return (result as any).affectedRows ?? 0;
} }
@@ -518,6 +680,7 @@ export async function purgeVeilleItems(): Promise<number> {
export async function purgeAapItems(): Promise<number> { export async function purgeAapItems(): Promise<number> {
const db = await getDb(); const db = await getDb();
if (!db) throw new Error("Database not available"); if (!db) throw new Error("Database not available");
await db.delete(articleReads).where(eq(articleReads.articleType, "aap"));
const result = await db.delete(aapItems); const result = await db.delete(aapItems);
return (result as any).affectedRows ?? 0; return (result as any).affectedRows ?? 0;
} }

View File

@@ -15,6 +15,7 @@ import {
setSettings, setSettings,
getImportLogs, getImportLogs,
getImportStats, getImportStats,
getClassificationErrors,
getLocalUsers, getLocalUsers,
createLocalUser, createLocalUser,
updateLocalUser, updateLocalUser,
@@ -31,15 +32,19 @@ import {
deleteRssFeed, deleteRssFeed,
getRssSettings, getRssSettings,
saveRssSettings, saveRssSettings,
getDb,
getReadArticleIds,
getUnreadArticleCount,
markArticlesAsRead,
removeArticleReadRecords,
} from "./db"; } from "./db";
import { importVeille, importAAP, runFullImport, getImportConfig } from "./importer"; import { importVeille, importAAP, runFullImport, getImportConfig } from "./importer";
import { scheduleDailyImport } from "./_core/index"; import { scheduleDailyImport } from "./_core/index";
import { loginLocalUser, hashPassword, ensureAdminExists } from "./localAuth"; import { loginLocalUser, hashPassword, ensureAdminExists } from "./localAuth";
import { isAzureAdConfigured, getAzureAuthUrl } from "./azureAuth"; import { isAzureAdConfigured, getAzureAuthUrl } from "./azureAuth";
import { classifyArticle } from "./aiClassifier"; import { classifyAap, classifyArticle } from "./aiClassifier";
import { getDb } from "./db"; import { veilleItems, aapItems, processedDedupKeys } from "../drizzle/schema";
import { veilleItems, aapItems, articleReads, processedDedupKeys } from "../drizzle/schema"; import { isNull, or, eq as eqDrizzle } from "drizzle-orm";
import { isNull, or, eq as eqDrizzle, and, inArray, count } from "drizzle-orm";
// ─── Middleware admin ───────────────────────────────────────────────────────── // ─── Middleware admin ─────────────────────────────────────────────────────────
@@ -148,6 +153,7 @@ export const appRouter = router({
); );
if (!aiResult.relevant) { if (!aiResult.relevant) {
// Supprimer l'article non pertinent // Supprimer l'article non pertinent
await removeArticleReadRecords("veille", [row.id]);
await db.delete(veilleItems).where(eqDrizzle(veilleItems.id, row.id)); await db.delete(veilleItems).where(eqDrizzle(veilleItems.id, row.id));
// Conserver le tombstone pour éviter la réinsertion // Conserver le tombstone pour éviter la réinsertion
if (row.dedupKey) { if (row.dedupKey) {
@@ -176,64 +182,31 @@ export const appRouter = router({
return { processed, deleted, errors, total: rows.length }; return { processed, deleted, errors, total: rows.length };
}), }),
// ─── Marquage lu/non lu ────────────────────────────────────────────────────────────────────── // ─── Marquage lu/non lu ─────────────────────────────────────────────────
markAsRead: protectedProcedure markAsRead: protectedProcedure
.input(z.object({ articleId: z.number().int().positive() })) .input(z.object({ articleId: z.number().int().positive() }))
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const db = await getDb(); const marked = await markArticlesAsRead(ctx.user.id, "veille", [input.articleId]);
if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB indisponible" }); return { success: true, marked };
// 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 };
}), }),
markAllAsRead: protectedProcedure.mutation(async ({ ctx }) => { markAllAsRead: protectedProcedure.mutation(async ({ ctx }) => {
const db = await getDb(); const db = await getDb();
if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB indisponible" }); 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 allItems = await db.select({ id: veilleItems.id }).from(veilleItems);
const allIds = allItems.map((r: { id: number }) => r.id); const marked = await markArticlesAsRead(ctx.user.id, "veille", allItems.map((item: { id: number }) => item.id));
// Trouver ceux déjà lus return { success: true, marked };
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 };
}), }),
unreadCount: protectedProcedure.query(async ({ ctx }) => { unreadCount: protectedProcedure.query(async ({ ctx }) => {
const db = await getDb(); const db = await getDb();
if (!db) return { count: 0 }; if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB indisponible" });
const totalRows = await db.select({ cnt: count() }).from(veilleItems); const totalRows = await db.select({ id: veilleItems.id }).from(veilleItems);
const total = totalRows[0]?.cnt ?? 0; return { count: await getUnreadArticleCount(ctx.user.id, "veille", totalRows.length) };
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) };
}), }),
getReadIds: protectedProcedure.query(async ({ ctx }) => { getReadIds: protectedProcedure.query(async ({ ctx }) => {
const db = await getDb(); return { ids: await getReadArticleIds(ctx.user.id, "veille") };
if (!db) return { ids: [] as number[] };
const rows = await db
.select({ articleId: articleReads.articleId })
.from(articleReads)
.where(and(eqDrizzle(articleReads.userId, ctx.user.id), eqDrizzle(articleReads.articleType, "veille")));
return { ids: rows.map((r: { articleId: number }) => r.articleId) };
}), }),
}), }),
// ─── AAPP ──────────────────────────────────────────────────────────────────── // ─── AAPP ────────────────────────────────────────────────────────────────────
@@ -283,12 +256,13 @@ export const appRouter = router({
for (const row of rows) { for (const row of rows) {
try { try {
const aiResult = await classifyArticle( const aiResult = await classifyAap(
row.titre || "", row.titre || "",
row.resume || "", row.resume || "",
() => ({ typeVeille: "informationnelle" as const }) row.categorie
); );
if (!aiResult.relevant) { if (!aiResult.relevant) {
await removeArticleReadRecords("aap", [row.id]);
await db.delete(aapItems).where(eqDrizzle(aapItems.id, row.id)); await db.delete(aapItems).where(eqDrizzle(aapItems.id, row.id));
if (row.dedupKey) { if (row.dedupKey) {
await db.insert(processedDedupKeys) await db.insert(processedDedupKeys)
@@ -300,7 +274,9 @@ export const appRouter = router({
await db await db
.update(aapItems) .update(aapItems)
.set({ .set({
categorie: aiResult.categorieAap ?? row.categorie,
iaRelevant: aiResult.relevant, iaRelevant: aiResult.relevant,
iaCategorie: aiResult.categorieAap,
iaClassifiedBy: aiResult.classifiedBy, iaClassifiedBy: aiResult.classifiedBy,
iaReason: aiResult.reason, iaReason: aiResult.reason,
}) })
@@ -315,61 +291,30 @@ export const appRouter = router({
return { processed, deleted, errors, total: rows.length }; return { processed, deleted, errors, total: rows.length };
}), }),
// ─── Marquage lu/non lu AAP ────────────────────────────────────────────────────────────────────── // ─── Marquage lu/non lu AAP ───────────────────────────────────────────────
markAsRead: protectedProcedure markAsRead: protectedProcedure
.input(z.object({ articleId: z.number().int().positive() })) .input(z.object({ articleId: z.number().int().positive() }))
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const db = await getDb(); const marked = await markArticlesAsRead(ctx.user.id, "aap", [input.articleId]);
if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB indisponible" }); return { success: true, marked };
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 };
}), }),
markAllAsRead: protectedProcedure.mutation(async ({ ctx }) => { markAllAsRead: protectedProcedure.mutation(async ({ ctx }) => {
const db = await getDb(); const db = await getDb();
if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB indisponible" }); if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB indisponible" });
const allItems = await db.select({ id: aapItems.id }).from(aapItems); const allItems = await db.select({ id: aapItems.id }).from(aapItems);
const allIds = allItems.map((r: { id: number }) => r.id); const marked = await markArticlesAsRead(ctx.user.id, "aap", allItems.map((item: { id: number }) => item.id));
const alreadyRead = await db return { success: true, marked };
.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 };
}), }),
unreadCount: protectedProcedure.query(async ({ ctx }) => { unreadCount: protectedProcedure.query(async ({ ctx }) => {
const db = await getDb(); const db = await getDb();
if (!db) return { count: 0 }; if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB indisponible" });
const totalRows = await db.select({ cnt: count() }).from(aapItems); const totalRows = await db.select({ id: aapItems.id }).from(aapItems);
const total = totalRows[0]?.cnt ?? 0; return { count: await getUnreadArticleCount(ctx.user.id, "aap", totalRows.length) };
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) };
}), }),
getReadIds: protectedProcedure.query(async ({ ctx }) => { getReadIds: protectedProcedure.query(async ({ ctx }) => {
const db = await getDb(); return { ids: await getReadArticleIds(ctx.user.id, "aap") };
if (!db) return { ids: [] as number[] };
const rows = await db
.select({ articleId: articleReads.articleId })
.from(articleReads)
.where(and(eqDrizzle(articleReads.userId, ctx.user.id), eqDrizzle(articleReads.articleType, "aap")));
return { ids: rows.map((r: { articleId: number }) => r.articleId) };
}), }),
}), }),
// ─── Import ───────────────────────────────────────────────────────────────── // ─── Import ─────────────────────────────────────────────────────────────────
@@ -576,6 +521,11 @@ export const appRouter = router({
return getRssFeeds(); 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 // Créer un flux
create: adminProcedure create: adminProcedure
.input(z.object({ .input(z.object({

View File

@@ -14,7 +14,7 @@
*/ */
import { XMLParser } from "fast-xml-parser"; import { XMLParser } from "fast-xml-parser";
import * as crypto from "crypto"; import * as crypto from "crypto";
import { getDb } from "./db"; import { getDb, recordClassificationError, removeArticleReadRecords } from "./db";
import { import {
rssFeeds, rssFeeds,
veilleItems, veilleItems,
@@ -563,6 +563,16 @@ async function processFeed(feed: RssFeed): Promise<FetchResult> {
}, },
contenuPage 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 const typeVeille = (aiResult.typeVeille ?? feed.defaultTypeVeille ?? "informationnelle") as
"reglementaire" | "concurrentielle" | "technologique" | "informationnelle"; "reglementaire" | "concurrentielle" | "technologique" | "informationnelle";
@@ -636,6 +646,16 @@ async function processFeed(feed: RssFeed): Promise<FetchResult> {
(feed.defaultCategorieAap ?? "Autre") as "Handicap" | "PA" | "Enfance" | "Précarité" | "Sanitaire" | "Autre", (feed.defaultCategorieAap ?? "Autre") as "Handicap" | "PA" | "Enfance" | "Précarité" | "Sanitaire" | "Autre",
contenuPageAap contenuPageAap
); );
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 const categorie = (aiResult.categorieAap ?? feed.defaultCategorieAap ?? "Autre") as
"Handicap" | "PA" | "Enfance" | "Précarité" | "Sanitaire" | "Autre"; "Handicap" | "PA" | "Enfance" | "Précarité" | "Sanitaire" | "Autre";
@@ -775,6 +795,7 @@ export async function migrateExistingItems(): Promise<MigrationSummary> {
} catch (e: any) { } catch (e: any) {
// Si le nouveau dedupKey existe déjà → cet article est un doublon, le supprimer // 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")) { 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)); await db.delete(veilleItems).where(eq(veilleItems.id, row.id));
veilleMerged++; veilleMerged++;
} else { } else {
@@ -817,6 +838,7 @@ export async function migrateExistingItems(): Promise<MigrationSummary> {
// Supprimer les doublons // Supprimer les doublons
for (const dup of duplicates) { for (const dup of duplicates) {
await removeArticleReadRecords("veille", [dup.id]);
await db.delete(veilleItems).where(eq(veilleItems.id, dup.id)); await db.delete(veilleItems).where(eq(veilleItems.id, dup.id));
veilleMerged++; veilleMerged++;
} }
@@ -825,6 +847,7 @@ export async function migrateExistingItems(): Promise<MigrationSummary> {
// Si le newDedupKey existe déjà → supprimer tout le groupe // 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")) { if (e?.code === "ER_DUP_ENTRY" || e?.cause?.code === "ER_DUP_ENTRY" || e?.cause?.message?.includes("Duplicate entry")) {
for (const row of sorted) { for (const row of sorted) {
await removeArticleReadRecords("veille", [row.id]);
await db.delete(veilleItems).where(eq(veilleItems.id, row.id)); await db.delete(veilleItems).where(eq(veilleItems.id, row.id));
veilleMerged++; veilleMerged++;
} }
@@ -868,6 +891,7 @@ export async function migrateExistingItems(): Promise<MigrationSummary> {
} catch (e: any) { } catch (e: any) {
// Si le nouveau dedupKey existe déjà → cet article est un doublon, le supprimer // 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")) { 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)); await db.delete(aapItems).where(eq(aapItems.id, row.id));
aapMerged++; aapMerged++;
} else { } else {
@@ -903,6 +927,7 @@ export async function migrateExistingItems(): Promise<MigrationSummary> {
.where(eq(aapItems.id, primary.id)); .where(eq(aapItems.id, primary.id));
for (const dup of duplicates) { for (const dup of duplicates) {
await removeArticleReadRecords("aap", [dup.id]);
await db.delete(aapItems).where(eq(aapItems.id, dup.id)); await db.delete(aapItems).where(eq(aapItems.id, dup.id));
aapMerged++; aapMerged++;
} }
@@ -911,6 +936,7 @@ export async function migrateExistingItems(): Promise<MigrationSummary> {
// Si le newDedupKey existe déjà → supprimer tout le groupe // 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")) { if (e?.code === "ER_DUP_ENTRY" || e?.cause?.code === "ER_DUP_ENTRY" || e?.cause?.message?.includes("Duplicate entry")) {
for (const row of sorted) { for (const row of sorted) {
await removeArticleReadRecords("aap", [row.id]);
await db.delete(aapItems).where(eq(aapItems.id, row.id)); await db.delete(aapItems).where(eq(aapItems.id, row.id));
aapMerged++; aapMerged++;
} }

View File

@@ -108,6 +108,12 @@ describe("protection admin", () => {
const caller = appRouter.createCaller(ctx); const caller = appRouter.createCaller(ctx);
await expect(caller.users.list()).rejects.toThrow(); 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 ─────────────────────────────────────────────────────── // ─── Tests accès public ───────────────────────────────────────────────────────

13
todo.md
View File

@@ -189,3 +189,16 @@
- [x] Implémenter la purge automatique au démarrage du serveur selon la règle de rétention - [x] Implémenter la purge automatique au démarrage du serveur selon la règle de rétention
- [x] Déployer en recette - [x] Déployer en recette
- [x] Répliquer BDD recette vers production et déployer le code en production - [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
- [ ] 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.

View File

@@ -167,6 +167,19 @@ export default defineConfig({
build: { build: {
outDir: path.resolve(import.meta.dirname, "dist/public"), outDir: path.resolve(import.meta.dirname, "dist/public"),
emptyOutDir: true, emptyOutDir: true,
// Conserver un socle initial léger : les bibliothèques d'interface et de données
// sont séparées pour être mises en cache indépendamment des écrans métier.
rollupOptions: {
output: {
manualChunks(id) {
if (!id.includes("node_modules")) return undefined;
if (id.includes("/@trpc/") || id.includes("/@tanstack/")) return "vendor-data";
if (id.includes("/react/") || id.includes("/react-dom/") || id.includes("/scheduler/")) return "vendor-react";
if (id.includes("/@radix-ui/") || id.includes("/lucide-react/")) return "vendor-ui";
return "vendor";
},
},
},
}, },
server: { server: {
host: true, host: true,